Compare commits

..

15 Commits

Author SHA1 Message Date
nntrivi2001 3daf7ed4cb chore: v2.8.70 — select combobox, chat turn end without Stop, no Stop flicker 2026-11-03 14:09:39 +07:00
nntrivi2001 f9869ada6d fix(chat): a finished turn ends without a Stop hook; Stop no longer flickers
Claude Code skips the Stop hook in roughly one turn in fifteen (only the
prompt-suggestion helper's SubagentStop arrives), so the main agent stayed
"working" until the idle Notification a minute later and the chat kept
showing Stop on a finished answer. The transcript's turn_duration entry is
now an end of turn too: one newer than the session's last prompt, read
while main still works with no tool open, lands it in Waiting like Stop.

The chat's Stop button now drops to Send only after every busy signal has
been quiet for 2.5 s (Stop pressed still drops at once), so a spinner line
that comes and goes while the CLI redraws no longer flips it back and forth,
and only the newest "working" read is applied.
2026-11-03 14:02:33 +07:00
nntrivi2001 0fc436dc0c chore(select): no search box for the hour pickers and the webhook type
24 fixed hours (reports, backups) are found by type-ahead and the webhook
providers are a short fixed list; a search box there is only noise.
Branches, models, AI proxies and webhook field options keep theirs.
2026-11-03 13:33:12 +07:00
nntrivi2001 cd25791160 feat(select): long lists become a combobox; the branch switcher uses it (#88)
Select opens with a focused search box when it has more than 8 options
(or searchable): typing narrows by label/value, ↑/↓ move, Enter picks,
Esc closes; role=combobox drives the listbox via aria-activedescendant,
and options may carry a group heading. The git bar's native branch
select (and the separate search box added for #88) is replaced by it,
remote branches under their own heading.
2026-11-03 13:25:16 +07:00
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
66 changed files with 1649 additions and 5340 deletions
+3 -3
View File
File diff suppressed because one or more lines are too long
-108
View File
@@ -1,108 +0,0 @@
/**
* @file ChatDock.tsx
* @description Chat between users for someone who hid the pet (server mode):
* a round 💬 button in the bottom-right corner, above the status bar, with
* the unread count, that opens the same chat window the pet has (`ChatHub.tsx`
* — rooms rail without the pet, a room, New chat). A new message from someone
* shows as the same card above the button while the window is closed. Mounted
* by `SagiMount` only while the pet is off, so the two never both run.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { MessagesSquare } from "lucide-react";
import { ChatNotice, ChatRail, ChatRoomView, NewChat } from "./ChatHub";
import { useChat } from "./useChat";
export function ChatDock() {
const { t } = useTranslation("settings");
const chat = useChat();
const [open, setOpen] = useState(false);
const [pane, setPane] = useState<"new" | number>("new");
const [settingsAsk, setSettingsAsk] = useState(0);
const room = typeof pane === "number" ? chat.rooms.find((r) => r.id === pane) : undefined;
// Opening with nothing picked: the newest chat, else New chat.
useEffect(() => {
if (!open) return;
if (typeof pane === "number" && !room) setPane(chat.rooms[0]?.id ?? "new");
}, [open, pane, room, chat.rooms]);
useEffect(() => {
if (open && pane === "new" && chat.rooms[0]) setPane(chat.rooms[0].id);
// Only when the window opens.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const { setActive } = chat;
useEffect(() => {
setActive(open && typeof pane === "number" ? pane : null);
}, [open, pane, setActive]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
if (!chat.enabled) return null;
return (
<div className="fixed bottom-10 right-4 z-40 flex flex-col items-end gap-2">
{open && (
<div className="flex items-start gap-2">
<ChatRail
chat={chat}
active={pane}
onSelect={(v) => setPane(v === "pet" ? "new" : v)}
onSettings={(id) => {
setPane(id);
setSettingsAsk((n) => n + 1);
}}
/>
{room ? (
<ChatRoomView
key={`${room.id}:${room.clearedId ?? 0}`}
chat={chat}
room={room}
onClose={() => setOpen(false)}
onLeft={() => setPane("new")}
onOpenRoom={setPane}
showSettings={settingsAsk}
/>
) : (
<NewChat chat={chat} onOpen={setPane} onClose={() => setOpen(false)} />
)}
</div>
)}
{!open && chat.notice && (
<ChatNotice
notice={chat.notice}
onDismiss={chat.dismissNotice}
onOpen={(roomId) => {
chat.dismissNotice();
setPane(roomId);
setOpen(true);
}}
/>
)}
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="relative flex h-11 w-11 items-center justify-center rounded-full bg-accent-solid text-white shadow-lg transition-opacity hover:opacity-90"
aria-label={
chat.unread ? t("sagi.people.dockUnread", { n: chat.unread }) : t("sagi.people.dock")
}
title={t("sagi.people.dock")}
aria-expanded={open}
>
<MessagesSquare size={20} />
{chat.unread > 0 && (
<span className="absolute -right-1 -top-1 min-w-[1.25rem] rounded-full bg-status-danger px-1 text-center text-[10px] font-semibold leading-5 text-white">
{chat.unread > 99 ? "99+" : chat.unread}
</span>
)}
</button>
</div>
);
}
File diff suppressed because it is too large Load Diff
-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>
);
}
+40 -161
View File
@@ -5,12 +5,8 @@
* 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
* shares the single WebSocket.
* Clicking the pet opens its chat window (`PetChat`), which is its whole UI:
* the status strip (`SagiInfo`) sits on top, a question the pet can answer
* itself is answered there, anything else goes to the AI. In server mode a
* rail beside it (`ChatHub.tsx`, `useChat.ts`) switches to direct chats and
* groups with other users, and a blue dot on the pet counts their unread
* messages.
* Clicking the pet opens its window (`PetPanel`): its name, a pat, and the
* status strip (`SagiInfo`). The pet no longer chats (v2.8.68).
* Owns the open/closed chat state, the ⌘B / Esc shortcuts, reduced-motion
* detection, and route navigation. Reactive personality + status/Ask come
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
@@ -18,10 +14,7 @@
* bubble/panel render in a self-clamping flyout so
* 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
* 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
* ⌘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).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -94,17 +87,12 @@ import { PetNameButton, SagiInfo } from "./SagiInfo";
import { useSagiBrain } from "./useSagiBrain";
import { useSagiPosition } from "./useSagiPosition";
import { useSagiEnabled } from "./SagiMount";
import { matchIntent } from "./intents";
import { petName, sagiPrefs } from "./prefs";
import type { SagiSubject } from "./brain";
import { api } from "../../lib/api";
import { readDefaultTool } from "../../lib/sessionDefaults";
import { startInFreeTabDetailed } from "../lanes/startSession";
import { workspaceHref } from "../SessionRowMenu";
import { Heart } from "lucide-react";
import { PetChat, petChatTool, type PetLocalReply } from "./PetChat";
import { ChatNotice, ChatRail, ChatRoomView, NewChat } from "./ChatHub";
import { useChat } from "./useChat";
import { PetPanel } from "./PetPanel";
// 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.
@@ -234,7 +222,7 @@ function SagiBody() {
// The name it goes by: the user's nickname, the pet's own, else Sagi.
const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
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);
// The right-click pet menu (`PetMenu.tsx`).
const [menu, setMenu] = useState(false);
@@ -243,21 +231,6 @@ function SagiBody() {
const navigate = useNavigate();
const href = useSettingsHref();
const brain = useSagiBrain();
// Server mode: chat with other users beside the pet's own (ChatHub.tsx).
const people = useChat();
const [pane, setPane] = useState<"pet" | "new" | number>("pet");
// Bumped by the rail's "Group settings": the room opens on its settings.
const [settingsAsk, setSettingsAsk] = useState(0);
const room = typeof pane === "number" ? people.rooms.find((r) => r.id === pane) : undefined;
// The room on screen: no notice for its messages.
const { setActive } = people;
useEffect(() => {
setActive(open && typeof pane === "number" ? pane : null);
}, [open, pane, setActive]);
// A room that went away (left, removed): back to the pet.
useEffect(() => {
if (typeof pane === "number" && people.rooms.length && !room) setPane("pet");
}, [pane, room, people.rooms.length]);
const place = useSagiPosition(size);
// Keep the pet and its size in sync with Settings / other tabs (enabled is
@@ -360,43 +333,6 @@ function SagiBody() {
[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.
const stroke = useRef({ dir: 0, x: 0, turns: [] as number[], until: 0 });
const onStroke = (e: React.PointerEvent) => {
@@ -418,8 +354,6 @@ function SagiBody() {
s.dir = dir;
};
const onBusy = useCallback((b: boolean) => brain.setThinking(b), [brain.setThinking]);
const anchor: Anchor = {
left: place.left,
top: place.top,
@@ -432,80 +366,44 @@ function SagiBody() {
return (
<>
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
it 1:1). Closing unmounts it; its chat is remembered, so reopening
re-attaches the same session. */}
it 1:1). */}
{open && (
<SagiFlyout anchor={anchor} beside>
<div className="flex items-start gap-2">
{people.enabled && (
<ChatRail
chat={people}
active={pane}
onSelect={setPane}
petName={name}
onSettings={(id) => {
setPane(id);
setSettingsAsk((n) => n + 1);
}}
/>
)}
{people.enabled && pane === "new" ? (
<NewChat chat={people} onOpen={setPane} onClose={() => setOpen(false)} />
) : people.enabled && room ? (
<ChatRoomView
// Cleared for me: read the (now shorter) history again.
key={`${room.id}:${room.clearedId ?? 0}`}
chat={people}
room={room}
onClose={() => setOpen(false)}
onLeft={() => setPane("pet")}
onOpenRoom={setPane}
showSettings={settingsAsk}
/>
) : (
<PetChat
<PetPanel
name={name}
title={
<PetNameButton
name={name}
title={
<PetNameButton
name={name}
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
nickname={sagiPrefs.getNickname(pet.id)}
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
/>
}
headerActions={
<button
type="button"
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
onClick={brain.pat}
aria-label={t("sagi.panel.pat", { name })}
title={t("sagi.panel.pat", { name })}
>
<Heart size={15} />
</button>
}
info={
<SagiInfo
attention={brain.attention}
onOpenSession={openSession}
status={brain.status}
muted={brain.muted}
onToggleMute={brain.toggleMute}
onClearAlerts={brain.clearAlerts}
onNavigate={onNavigate}
defaultOpen={brain.attention.length > 0}
/>
}
onLocal={onLocal}
onClose={() => setOpen(false)}
onOpenWorkspace={(laneId, runId) => {
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
setOpen(false);
}}
onBusy={onBusy}
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
nickname={sagiPrefs.getNickname(pet.id)}
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
/>
)}
</div>
}
headerActions={
<button
type="button"
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
onClick={brain.pat}
aria-label={t("sagi.panel.pat", { name })}
title={t("sagi.panel.pat", { name })}
>
<Heart size={15} />
</button>
}
info={
<SagiInfo
attention={brain.attention}
onOpenSession={openSession}
status={brain.status}
muted={brain.muted}
onToggleMute={brain.toggleMute}
onClearAlerts={brain.clearAlerts}
onNavigate={onNavigate}
defaultOpen={brain.attention.length > 0}
/>
}
onClose={() => setOpen(false)}
/>
</SagiFlyout>
)}
@@ -523,21 +421,7 @@ function SagiBody() {
</SagiFlyout>
)}
{!open && !menu && people.notice && (
<SagiFlyout anchor={anchor}>
<ChatNotice
notice={people.notice}
onDismiss={people.dismissNotice}
onOpen={(roomId) => {
people.dismissNotice();
setPane(roomId);
setOpen(true);
}}
/>
</SagiFlyout>
)}
{!open && !menu && !people.notice && brain.bubble && (
{!open && !menu && brain.bubble && (
<SagiFlyout anchor={anchor}>
<SpeechBubble
bubble={brain.bubble}
@@ -613,11 +497,6 @@ function SagiBody() {
</span>
</span>
)}
{people.unread > 0 && (
<span className="sagi-chat-dot" aria-hidden>
{people.unread > 9 ? "9+" : people.unread}
</span>
)}
{brain.status.errorCount > 0 ? (
<span className="sagi-error-dot" aria-hidden>
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
+3 -3
View File
@@ -1,6 +1,6 @@
/**
* @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
* (`SagiInfo`) — live / waiting / errored counts and the connection state
* 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"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-label={t("sagi.chat.status")}
title={t("sagi.chat.status")}
aria-label={t("sagi.panel.status")}
title={t("sagi.panel.status")}
>
<StatChip
icon={Radio}
+2 -14
View File
@@ -3,17 +3,14 @@
* @description Gate for the Sagi mascot: follows the enabled preference
* (`useSagiEnabled`) and only while it is on lazy-loads and mounts `Sagi` — so
* a disabled mascot costs no chunk download, no brain, no 1 Hz tick and no
* WebSocket reducer. While it is off, the chat between users still has a
* way in: `ChatDock` (its own chunk, server mode only).
* WebSocket reducer.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { lazy, Suspense, useEffect, useState } from "react";
import { sagiPrefs } from "./prefs";
import { useAuth } from "../AuthGate";
const Sagi = lazy(() => import("./Sagi").then((m) => ({ default: m.Sagi })));
const ChatDock = lazy(() => import("./ChatDock").then((m) => ({ default: m.ChatDock })));
/** The mascot's enabled preference, followed live (Settings, other tabs). */
export function useSagiEnabled(): boolean {
@@ -23,16 +20,7 @@ export function useSagiEnabled(): boolean {
}
export function SagiMount() {
const { state } = useAuth();
if (!useSagiEnabled()) {
// The pet hidden: chat with other users from a button of its own.
if (state.mode !== "server") return null;
return (
<Suspense fallback={null}>
<ChatDock />
</Suspense>
);
}
if (!useSagiEnabled()) return null;
return (
<Suspense fallback={null}>
<Sagi />
@@ -1,69 +0,0 @@
/**
* @file ChatDock.test.tsx
* @description The chat button shown while the pet is hidden (`ChatDock.tsx`,
* mounted by `SagiMount`): server mode only, the unread count on it, and a
* click opens the newest chat with the rooms rail beside it (no pet item).
* @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";
const auth = vi.hoisted(() => ({
state: { mode: "server", user: { id: 1, username: "me", role: "developer" } } as unknown,
}));
const api = vi.hoisted(() => ({
chat: {
rooms: vi.fn(),
people: vi.fn(),
messages: vi.fn(),
read: vi.fn(),
fileUrl: () => "",
},
users: { avatarUrl: () => null },
me: { avatarUrl: () => null },
}));
vi.mock("../../AuthGate", () => ({ useAuth: () => ({ state: auth.state }) }));
vi.mock("../../../lib/api", async (orig) => ({ ...(await orig<Record<string, unknown>>()), api }));
import { ChatDock } from "../ChatDock";
const room = {
id: 7,
kind: "dm" as const,
name: "",
createdBy: 1,
createdAt: "2026-11-03T01:00:00Z",
lastMessageAt: null,
members: [1, 2],
lastMessage: null,
lastReadId: 0,
unread: 3,
};
beforeEach(() => {
vi.clearAllMocks();
auth.state = { mode: "server", user: { id: 1, username: "me", role: "developer" } };
api.chat.rooms.mockResolvedValue({ rooms: [room] });
api.chat.people.mockResolvedValue({
people: [{ id: 2, username: "an", displayName: "An", avatarAt: null }],
});
api.chat.messages.mockResolvedValue({ messages: [], hasMore: false });
api.chat.read.mockResolvedValue({ lastReadId: 0 });
});
describe("ChatDock", () => {
it("shows the unread count and opens the newest chat", async () => {
render(<ChatDock />);
const btn = await screen.findByRole("button", { name: "Chats, 3 unread" });
fireEvent.click(btn);
await waitFor(() => expect(screen.getByRole("dialog", { name: "Chat with An" })).toBeTruthy());
expect(screen.queryByText("🐾")).toBeNull();
});
it("is not there in local mode", () => {
auth.state = { mode: "local" };
render(<ChatDock />);
expect(screen.queryByRole("button", { name: /Chats/ })).toBeNull();
});
});
@@ -1,389 +0,0 @@
/**
* @file ChatHub.test.tsx
* @description The pet window's chat between users (`ChatHub.tsx`): the rail
* lists the pet then the rooms with their unread counts; New chat opens a
* direct chat for one person and makes a group for several; a room reads its
* messages (a group names who wrote each), marks them read, appends one that
* arrives live, and sends text with the attached files; the group settings
* scroll only one way; a new message raises a notice that opens its room; and
* the right-click menus of a room, a message, a file and a group member —
* deleting a chat for yourself and disbanding a group (its creator) included;
* "Seen" under your newest message; a disabled account never offered for a
* new chat, a deleted one named as such; and the rail without the pet.
* @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 type { ChatMessage, ChatRoom } from "../../../lib/api";
import type { ChatState } from "../useChat";
const api = vi.hoisted(() => ({
chat: {
openDm: vi.fn(),
createGroup: vi.fn(),
messages: vi.fn(),
post: vi.fn(),
read: vi.fn(),
updateGroup: vi.fn(),
leave: vi.fn(),
deleteMessage: vi.fn(),
clear: vi.fn(),
disband: vi.fn(),
fileUrl: (r: number, f: string) => `/api/chat/rooms/${r}/files/${f}`,
},
users: { avatarUrl: () => null },
me: { avatarUrl: () => null },
}));
vi.mock("../../../lib/api", async (orig) => ({
...(await orig<Record<string, unknown>>()),
api,
}));
const clip = vi.hoisted(() => ({ copyText: vi.fn(async () => true) }));
vi.mock("../../../lib/clipboard", () => clip);
const confirm = vi.hoisted(() => ({ confirmDialog: vi.fn(async () => true) }));
vi.mock("../../../lib/confirm", () => confirm);
vi.mock("../../AuthGate", () => ({
useAuth: () => ({
state: { mode: "server", user: { id: 1, username: "me", role: "developer" } },
}),
}));
import { ChatNotice, ChatRail, ChatRoomView, NewChat } from "../ChatHub";
const msg = (id: number, userId: number, body: string): ChatMessage => ({
id,
roomId: 7,
userId,
kind: "text",
body,
createdAt: "2026-11-03T02:00:00Z",
files: [],
});
const group: ChatRoom = {
id: 7,
kind: "group",
name: "Team",
createdBy: 1,
createdAt: "2026-11-03T01:00:00Z",
lastMessageAt: null,
members: [1, 2, 3],
lastMessage: null,
lastReadId: 0,
unread: 2,
};
const dm: ChatRoom = { ...group, id: 8, kind: "dm", name: "", members: [1, 2], unread: 0 };
function state(over: Partial<ChatState> = {}): ChatState {
return {
enabled: true,
me: 1,
rooms: [group, dm],
people: [
{ id: 2, username: "an", displayName: "An", avatarAt: null },
{ id: 3, username: "binh", displayName: null, avatarAt: null },
],
unread: 2,
reload: vi.fn(async () => {}),
upsert: vi.fn(),
onMessage: vi.fn(),
markRead: vi.fn(),
setActive: vi.fn(),
notice: null,
dismissNotice: vi.fn(),
onDeleted: vi.fn(),
muted: new Set<number>(),
toggleMute: vi.fn(),
reloadPeople: vi.fn(async () => {}),
...over,
};
}
beforeEach(() => vi.clearAllMocks());
describe("ChatHub", () => {
it("lists the pet, then each room with its unread count", () => {
const onSelect = vi.fn();
render(<ChatRail chat={state()} active="pet" onSelect={onSelect} petName="Sagi" />);
expect(screen.getByRole("button", { name: "Sagi" })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByRole("button", { name: "Team, 2 unread" }));
expect(onSelect).toHaveBeenCalledWith(7);
// A direct chat is named after the other person.
expect(screen.getByRole("button", { name: "An" })).toBeTruthy();
});
it("opens a direct chat for one person and a group for several", async () => {
const chat = state();
const onOpen = vi.fn();
api.chat.openDm.mockResolvedValue({ room: dm });
render(<NewChat chat={chat} onOpen={onOpen} onClose={() => {}} />);
fireEvent.click(screen.getByRole("checkbox", { name: /An/ }));
fireEvent.click(screen.getByRole("button", { name: "Start chat" }));
await waitFor(() => expect(onOpen).toHaveBeenCalledWith(8));
expect(api.chat.openDm).toHaveBeenCalledWith(2);
api.chat.createGroup.mockResolvedValue({ room: group });
fireEvent.click(screen.getByRole("checkbox", { name: /binh/ }));
fireEvent.change(screen.getByLabelText("Group name (optional)"), { target: { value: "Team" } });
fireEvent.click(screen.getByRole("button", { name: "Create group (2 people)" }));
await waitFor(() => expect(api.chat.createGroup).toHaveBeenCalledWith("Team", [2, 3]));
});
it("reads the room, names group senders, marks it read and sends with files", async () => {
const chat = state();
api.chat.messages.mockResolvedValue({
messages: [msg(1, 2, "chào"), msg(2, 1, "hi")],
hasMore: false,
});
api.chat.read.mockResolvedValue({ lastReadId: 2 });
render(
<ChatRoomView
chat={chat}
room={group}
onClose={() => {}}
onLeft={() => {}}
onOpenRoom={() => {}}
/>
);
expect(await screen.findByText("chào")).toBeTruthy();
expect(screen.getByText("An")).toBeTruthy(); // sender of a group message
expect(api.chat.read).toHaveBeenCalledWith(7, 2);
expect(chat.markRead).toHaveBeenCalledWith(7, 2);
// A message arriving live is appended.
const calls = vi.mocked(chat.onMessage).mock.calls;
const push = calls[calls.length - 1]![0]!;
push(msg(3, 3, "mới"));
expect(await screen.findByText("mới")).toBeTruthy();
const file = new File(["x"], "a.png", { type: "image/png" });
const box = screen.getByLabelText("Message Team");
fireEvent.paste(box, { clipboardData: { files: [file] } });
expect(screen.getByText("a.png")).toBeTruthy();
fireEvent.change(box, { target: { value: "xem nè" } });
api.chat.post.mockResolvedValue({ message: { ...msg(4, 1, "xem nè") } });
fireEvent.click(screen.getByRole("button", { name: /send/i }));
await waitFor(() => expect(api.chat.post).toHaveBeenCalledWith(7, "xem nè", [file]));
expect(await screen.findByText("xem nè")).toBeTruthy();
});
it("keeps the group settings to one vertical scroll, the name field shrinking", async () => {
api.chat.messages.mockResolvedValue({ messages: [], hasMore: false });
render(
<ChatRoomView
chat={state()}
room={group}
onClose={() => {}}
onLeft={() => {}}
onOpenRoom={() => {}}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Group settings" }));
const name = screen.getAllByLabelText("Group name (optional)")[0]!;
expect(name.className).toContain("min-w-0");
const panel = name.closest(".overflow-y-auto")!;
expect(panel.className).toContain("overflow-x-hidden");
// The add-people list grows inside that panel instead of scrolling itself.
const lists = panel.querySelectorAll("ul.overflow-y-auto");
expect(lists.length).toBe(0);
});
it("shows a new message as a card that opens its room", () => {
const onOpen = vi.fn();
const onDismiss = vi.fn();
render(
<ChatNotice
notice={{ key: 1, roomId: 7, title: "An · Team", text: "họp lúc 2h nhé" }}
onOpen={onOpen}
onDismiss={onDismiss}
/>
);
expect(screen.getByText("họp lúc 2h nhé")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Open the chat with An · Team" }));
expect(onOpen).toHaveBeenCalledWith(7);
});
it("right-click on a room: mark read, mute, copy its name, settings and leave", async () => {
const chat = state({ rooms: [{ ...group, lastMessage: msg(5, 2, "x") }, dm] });
const onSelect = vi.fn();
const onSettings = vi.fn();
api.chat.read.mockResolvedValue({ lastReadId: 5 });
api.chat.leave.mockResolvedValue({ ok: true });
render(
<ChatRail chat={chat} active={7} onSelect={onSelect} petName="Sagi" onSettings={onSettings} />
);
const open = () =>
fireEvent.contextMenu(screen.getByRole("button", { name: "Team, 2 unread" }));
open();
fireEvent.click(screen.getByRole("menuitem", { name: "Mark as read" }));
expect(chat.markRead).toHaveBeenCalledWith(7, 5);
expect(api.chat.read).toHaveBeenCalledWith(7, 5);
open();
fireEvent.click(screen.getByRole("menuitem", { name: "Mute notifications" }));
expect(chat.toggleMute).toHaveBeenCalledWith(7);
open();
fireEvent.click(screen.getByRole("menuitem", { name: "Copy name" }));
expect(clip.copyText).toHaveBeenCalledWith("Team");
open();
fireEvent.click(screen.getByRole("menuitem", { name: "Group settings" }));
expect(onSettings).toHaveBeenCalledWith(7);
open();
fireEvent.click(screen.getByRole("menuitem", { name: "Leave group" }));
await waitFor(() => expect(api.chat.leave).toHaveBeenCalledWith(7));
await waitFor(() => expect(onSelect).toHaveBeenCalledWith("pet"));
// A direct chat has no group items.
fireEvent.contextMenu(screen.getByRole("button", { name: "An" }));
expect(screen.queryByRole("menuitem", { name: "Leave group" })).toBeNull();
});
it("right-click on a message: copy, reply, message the sender, delete your own", async () => {
const chat = state();
const onOpenRoom = vi.fn();
api.chat.messages.mockResolvedValue({
messages: [
msg(1, 2, "của An"),
{
...msg(2, 1, "của tôi"),
files: [{ id: "f1", name: "a.pdf", type: "application/pdf", size: 9 }],
},
],
hasMore: false,
});
api.chat.read.mockResolvedValue({ lastReadId: 2 });
api.chat.openDm.mockResolvedValue({ room: dm });
api.chat.deleteMessage.mockResolvedValue({ ok: true });
render(
<ChatRoomView
chat={chat}
room={group}
onClose={() => {}}
onLeft={() => {}}
onOpenRoom={onOpenRoom}
/>
);
fireEvent.contextMenu(await screen.findByText("của An"));
expect(screen.queryByRole("menuitem", { name: "Delete message" })).toBeNull();
fireEvent.click(screen.getByRole("menuitem", { name: "Copy text" }));
expect(clip.copyText).toHaveBeenCalledWith("của An");
fireEvent.contextMenu(screen.getByText("của An"));
fireEvent.click(screen.getByRole("menuitem", { name: "Reply" }));
expect((screen.getByLabelText("Message Team") as HTMLTextAreaElement).value).toBe(
"An:\n> của An\n"
);
fireEvent.contextMenu(screen.getByText("của An"));
fireEvent.click(screen.getByRole("menuitem", { name: "Message An privately" }));
await waitFor(() => expect(onOpenRoom).toHaveBeenCalledWith(8));
// A file has its own items.
fireEvent.contextMenu(screen.getByText("a.pdf"));
expect(screen.getByRole("menuitem", { name: "Download" })).toBeTruthy();
fireEvent.click(screen.getByRole("menuitem", { name: "Copy file name" }));
expect(clip.copyText).toHaveBeenCalledWith("a.pdf");
fireEvent.contextMenu(screen.getByText("của tôi"));
fireEvent.click(screen.getByRole("menuitem", { name: "Delete message" }));
await waitFor(() => expect(api.chat.deleteMessage).toHaveBeenCalledWith(7, 2));
await waitFor(() => expect(screen.queryByText("của tôi")).toBeNull());
});
it("right-click on a group member: message them; the creator may remove them", async () => {
const onOpenRoom = vi.fn();
api.chat.messages.mockResolvedValue({ messages: [], hasMore: false });
api.chat.openDm.mockResolvedValue({ room: dm });
api.chat.updateGroup.mockResolvedValue({ room: { ...group, members: [1, 3] } });
render(
<ChatRoomView
chat={state()}
room={group}
onClose={() => {}}
onLeft={() => {}}
onOpenRoom={onOpenRoom}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Group settings" }));
const row = screen
.getAllByText("An")
.find((el) => el.closest("li"))!
.closest("li")!;
fireEvent.contextMenu(row);
fireEvent.click(screen.getByRole("menuitem", { name: "Remove An" }));
await waitFor(() => expect(api.chat.updateGroup).toHaveBeenCalledWith(7, { remove: [2] }));
fireEvent.contextMenu(row);
fireEvent.click(screen.getByRole("menuitem", { name: "Message An privately" }));
await waitFor(() => expect(onOpenRoom).toHaveBeenCalledWith(8));
});
it("deletes a chat for yourself, and lets the creator disband a group", async () => {
const chat = state();
const onSelect = vi.fn();
api.chat.clear.mockResolvedValue({ hidden: true });
api.chat.disband.mockResolvedValue({ ok: true });
render(<ChatRail chat={chat} active={8} onSelect={onSelect} petName="Sagi" />);
fireEvent.contextMenu(screen.getByRole("button", { name: "An" }));
expect(screen.queryByRole("menuitem", { name: "Disband group" })).toBeNull();
fireEvent.click(screen.getByRole("menuitem", { name: "Delete chat" }));
await waitFor(() => expect(api.chat.clear).toHaveBeenCalledWith(8));
await waitFor(() => expect(onSelect).toHaveBeenCalledWith("pet"));
expect(confirm.confirmDialog).toHaveBeenCalled();
fireEvent.contextMenu(screen.getByRole("button", { name: "Team, 2 unread" }));
expect(screen.getByRole("menuitem", { name: "Delete chat history" })).toBeTruthy();
fireEvent.click(screen.getByRole("menuitem", { name: "Disband group" }));
await waitFor(() => expect(api.chat.disband).toHaveBeenCalledWith(7));
});
it("offers Disband only to the group's creator", () => {
render(<ChatRail chat={state({ me: 2 })} active="pet" onSelect={() => {}} petName="Sagi" />);
fireEvent.contextMenu(screen.getByRole("button", { name: /Team/ }));
expect(screen.queryByRole("menuitem", { name: "Disband group" })).toBeNull();
expect(screen.getByRole("menuitem", { name: "Leave group" })).toBeTruthy();
});
it("shows who has seen your newest message", async () => {
api.chat.messages.mockResolvedValue({
messages: [msg(1, 2, "của An"), msg(2, 1, "của tôi")],
hasMore: false,
});
api.chat.read.mockResolvedValue({ lastReadId: 2 });
const view = (room: ChatRoom) => (
<ChatRoomView
chat={state()}
room={room}
onClose={() => {}}
onLeft={() => {}}
onOpenRoom={() => {}}
/>
);
// Direct chat, not read yet: nothing; read: "Seen".
const { rerender } = render(view({ ...dm, id: 7, reads: { 1: 2, 2: 1 } }));
await screen.findByText("của tôi");
expect(screen.queryByTestId("chat-seen")).toBeNull();
rerender(view({ ...dm, id: 7, reads: { 1: 2, 2: 2 } }));
expect(screen.getByTestId("chat-seen").textContent).toBe("Seen");
// Group: by name, then everyone.
rerender(view({ ...group, reads: { 1: 2, 2: 2, 3: 1 } }));
expect(screen.getByTestId("chat-seen").textContent).toBe("Seen by An");
rerender(view({ ...group, reads: { 1: 2, 2: 2, 3: 2 } }));
expect(screen.getByTestId("chat-seen").textContent).toBe("Seen by everyone");
});
it("never offers a disabled account, and names a deleted one", () => {
const chat = state({
people: [
{ id: 2, username: "an", displayName: "An", avatarAt: null },
{ id: 3, username: "binh", displayName: null, avatarAt: null, disabled: true },
],
// A direct chat whose other person's account was deleted.
rooms: [{ ...dm, members: [1] }],
});
render(<NewChat chat={chat} onOpen={() => {}} onClose={() => {}} />);
expect(screen.queryByRole("checkbox", { name: /binh/ })).toBeNull();
expect(chat.reloadPeople).toHaveBeenCalled();
render(<ChatRail chat={chat} active="new" onSelect={() => {}} />);
expect(screen.getByRole("button", { name: "Deleted user" })).toBeTruthy();
// No pet name: no pet item.
expect(screen.queryByText("🐾")).toBeNull();
});
});
@@ -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([]);
});
});
@@ -261,9 +261,7 @@ describe("PetMenu", () => {
renderMenu();
fireEvent.click(screen.getByTestId("pet-choose"));
fireEvent.click(await screen.findByTestId("pet-featured-goku"));
await waitFor(() =>
expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" })
);
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" }));
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
});
@@ -315,9 +313,7 @@ describe("PetSettings", () => {
it("downloads a featured pet on its first pick, wears an own one, deletes it back to Sagi", async () => {
render(<PetSettings />);
fireEvent.click(await screen.findByTestId("pet-card-featured-goku"));
await waitFor(() =>
expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" })
);
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" }));
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
@@ -1,6 +1,6 @@
/**
* @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>
*/
@@ -18,7 +18,6 @@ import { useSagiPosition } from "../useSagiPosition";
import { MemoryRouter } from "react-router-dom";
import { Sagi } from "../Sagi";
import { eventBus } from "../../../lib/eventBus";
import { api } from "../../../lib/api";
import type { WSMessage, Session } from "../../../lib/types";
function renderSagi() {
@@ -55,16 +54,15 @@ describe("Sagi widget", () => {
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();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
expect(panel).toBeInTheDocument();
const input = within(panel).getByLabelText(/ask sagi anything/i);
fireEvent.change(input, { target: { value: "status" } });
fireEvent.submit(input.closest("form")!);
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
const panel = screen.getByRole("dialog", { name: /pet sagi/i });
expect(within(panel).getByText("live")).toBeInTheDocument();
expect(within(panel).queryByRole("textbox")).toBeNull();
expect(within(panel).queryByRole("button", { name: /new chat/i })).toBeNull();
fireEvent.click(within(panel).getByRole("button", { name: /close sagi/i }));
expect(screen.queryByRole("dialog")).toBeNull();
});
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();
});
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();
act(() => {
eventBus.publish({
@@ -102,7 +100,7 @@ describe("Sagi widget", () => {
expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
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).getByRole("button", { name: /Fix login/ })).toBeInTheDocument();
});
@@ -122,54 +120,17 @@ describe("Sagi widget", () => {
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();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
const input = screen.getByLabelText("Pet name");
fireEvent.change(input, { target: { value: "Mochi" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByRole("dialog", { name: /chat with mochi/i })).toBeInTheDocument();
expect(screen.getByLabelText(/ask mochi anything/i)).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: /pet mochi/i })).toBeInTheDocument();
});
it("hands a question it cannot answer to the AI", () => {
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", () => {
it("a pat shows its reaction on the pet even while its window is open", () => {
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
fireEvent.click(screen.getByRole("button", { name: /pat sagi/i }));
@@ -187,14 +148,14 @@ describe("Sagi widget", () => {
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();
act(() => {
eventBus.publish(sessionMsg("a", "active"));
eventBus.publish(sessionMsg("b", "active"));
});
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.
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
expect(within(liveChip).getByText("2")).toBeInTheDocument();
@@ -208,7 +169,7 @@ describe("Sagi widget", () => {
eventBus.publish({ ...sessionMsg("theirs-err", "error"), mine: false });
});
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
const 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();
@@ -220,7 +181,7 @@ describe("Sagi widget", () => {
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();
const btn = screen.getByRole("button", { name: /open sagi companion/i });
act(() => {
@@ -228,7 +189,7 @@ describe("Sagi widget", () => {
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
});
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", () => {
@@ -257,7 +218,7 @@ describe("Sagi widget", () => {
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
});
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.
expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull();
});
@@ -1,137 +0,0 @@
/**
* @file useChat.test.tsx
* @description `useChat`'s live side: a message from someone else raises a
* notice (sender, room) and bumps the room's unread count, plays the sound
* only with notifications on, and stays quiet for the room on screen and for
* the user's own messages; a `chat_read` frame moves that member's read mark
* ("Seen"); a group change is no unread message; with several tabs only the
* first to see a message plays its sound; nothing runs in local mode.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { eventBus } from "../../../lib/eventBus";
import type { ChatMessage } from "../../../lib/api";
const auth = vi.hoisted(() => ({
state: { mode: "server", user: { id: 1, username: "me", role: "developer" } } as unknown,
}));
const api = vi.hoisted(() => ({
chat: { rooms: vi.fn(), people: vi.fn() },
}));
const sound = vi.hoisted(() => ({ playSound: vi.fn() }));
vi.mock("../../AuthGate", () => ({ useAuth: () => ({ state: auth.state }) }));
vi.mock("../../../lib/api", async (orig) => ({ ...(await orig<Record<string, unknown>>()), api }));
vi.mock("../../../lib/notify", () => sound);
import { useChat } from "../useChat";
const room = {
id: 7,
kind: "group" as const,
name: "Team",
createdBy: 1,
createdAt: "2026-11-03T01:00:00Z",
lastMessageAt: null,
members: [1, 2],
lastMessage: null,
lastReadId: 0,
unread: 0,
};
const msg = (id: number, userId: number): ChatMessage => ({
id,
roomId: 7,
userId,
kind: "text",
body: `tin ${id}`,
createdAt: `2026-11-03T02:00:0${id}Z`,
files: [],
});
const frame = (m: ChatMessage) =>
eventBus.publish({ type: "chat_message", data: { roomId: 7, message: m }, timestamp: "t" });
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
auth.state = { mode: "server", user: { id: 1, username: "me", role: "developer" } };
api.chat.rooms.mockResolvedValue({ rooms: [room] });
api.chat.people.mockResolvedValue({
people: [{ id: 2, username: "an", displayName: "An", avatarAt: null }],
});
});
describe("useChat", () => {
it("raises a notice for someone else's message in a room not on screen", async () => {
const { result } = renderHook(() => useChat());
await waitFor(() => expect(result.current.people.length).toBe(1));
act(() => frame(msg(1, 2)));
expect(result.current.notice).toMatchObject({ roomId: 7, title: "An · Team", text: "tin 1" });
expect(result.current.unread).toBe(1);
expect(sound.playSound).not.toHaveBeenCalled(); // notifications off by default
});
it("plays the sound when notifications are on", async () => {
localStorage.setItem(
"agent-monitor-notifications",
JSON.stringify({ enabled: true, sound: "pop" })
);
const { result } = renderHook(() => useChat());
await waitFor(() => expect(result.current.rooms.length).toBe(1));
act(() => frame(msg(2, 2)));
expect(sound.playSound).toHaveBeenCalledWith("pop");
});
it("stays quiet for the room on screen and for your own messages", async () => {
const { result } = renderHook(() => useChat());
await waitFor(() => expect(result.current.rooms.length).toBe(1));
act(() => result.current.setActive(7));
act(() => frame(msg(3, 2)));
act(() => frame(msg(4, 1)));
expect(result.current.notice).toBeNull();
});
it("does nothing in local mode", () => {
auth.state = { mode: "local" };
const { result } = renderHook(() => useChat());
expect(result.current.enabled).toBe(false);
expect(api.chat.rooms).not.toHaveBeenCalled();
});
it("moves a member's read mark on a chat_read frame", async () => {
const { result } = renderHook(() => useChat());
await waitFor(() => expect(result.current.rooms.length).toBe(1));
act(() =>
eventBus.publish({
type: "chat_read",
data: { roomId: 7, userId: 2, lastReadId: 9 },
timestamp: "t",
})
);
expect(result.current.rooms[0]!.reads?.[2]).toBe(9);
});
it("does not count a group change as unread", async () => {
const { result } = renderHook(() => useChat());
await waitFor(() => expect(result.current.rooms.length).toBe(1));
act(() => frame({ ...msg(5, 2), kind: "system", body: "renamed" }));
expect(result.current.unread).toBe(0);
expect(result.current.notice).toBeNull();
});
it("plays a message's sound in one tab only", async () => {
localStorage.setItem(
"agent-monitor-notifications",
JSON.stringify({ enabled: true, sound: "pop" })
);
const a = renderHook(() => useChat());
const b = renderHook(() => useChat());
await waitFor(() => expect(a.result.current.rooms.length).toBe(1));
await waitFor(() => expect(b.result.current.rooms.length).toBe(1));
act(() => frame(msg(6, 2)));
expect(sound.playSound).toHaveBeenCalledTimes(1);
// Both still show the card.
expect(a.result.current.notice).not.toBeNull();
expect(b.result.current.notice).not.toBeNull();
});
});
+4 -82
View File
@@ -6,8 +6,7 @@
* infinite animation makes the browser draw a new frame 60 times a second for
* as long as the page is open (the drop-shadow filter re-rendered each time),
* which made the whole desktop stutter. All motion is disabled under
* [data-reduced="1"] and prefers-reduced-motion. The blue dot counts unread
* chat messages from other users (server mode).
* [data-reduced="1"] and prefers-reduced-motion.
* Transform origins are the delicate part - see the comments on .sagi-avatar
* and .sagi-eye-blink for which reference box each one resolves against.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -407,25 +406,6 @@
outline: 2px solid #818cf8;
outline-offset: 3px;
}
/* Unread chat messages from other users: top-left, opposite the error dot. */
.sagi-chat-dot {
position: absolute;
top: 2px;
left: 2px;
min-width: 14px;
height: 14px;
padding: 0 2px;
border-radius: 9999px;
background: #3b82f6;
color: #fff;
font-size: 9px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #0c0c14;
}
.sagi-error-dot {
position: absolute;
top: 2px;
@@ -549,13 +529,10 @@
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
colour: a tint of the accent at the top fading into the surface, so it
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). */
/* A tint of the theme's accent at the top fading into the surface, so it
reads in every light and dark theme. */
.pet-chat {
background: linear-gradient(
180deg,
@@ -567,61 +544,6 @@
.pet-chat-head {
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 ════════ */
-305
View File
@@ -1,305 +0,0 @@
/**
* @file useChat.ts
* @description The pet window's chat between users (server mode only): the
* signed-in user's rooms (`GET /api/chat/rooms`), the people they can start a
* chat with (`GET /api/chat/people`, read once), and the total unread count
* for the badge on the pet. A `chat_message` frame updates its room's newest
* message and unread count in place and is handed to the open room's view
* (`onMessage`); a `chat_room` frame (made, renamed, members changed, left)
* re-reads the list. A message from someone else in a room that is not open
* on screen raises a `notice` (the pet shows it as a card that opens the
* room), plays the user's notification sound when notifications are on, and —
* while the tab is hidden and the browser allows it — shows a local system
* notification (never through the server's push relay, which would reach
* other users' devices). A room can be muted (no notice, no sound — its
* unread count still shows), kept per browser in `agenthub.chat.muted`; a
* `chat_deleted` frame drops a message from the open view and re-reads the
* list; a `chat_read` frame moves that member's read mark (`reads`, what the
* room shows as Seen). With several tabs open, only the first to see a
* message plays its sound / system notification (`agenthub.chat.notified`).
* Off in local mode — one person has no one to chat with.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api, type ChatMessage, type ChatPerson, type ChatRoom } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { useAuth } from "../AuthGate";
import { loadNotifPrefs } from "../../lib/notifPrefs";
import { playSound } from "../../lib/notify";
import type { WSMessage } from "../../lib/types";
/** A new message to show on the pet. */
export type ChatNotice = { key: number; roomId: number; title: string; text: string };
export type ChatState = {
/** Server mode with a login: the chat exists. */
enabled: boolean;
me: number | null;
rooms: ChatRoom[];
people: ChatPerson[];
unread: number;
reload: () => Promise<void>;
/** A room changed here (opened, renamed, read): put it in the list now. */
upsert: (room: ChatRoom) => void;
/** The open room's view, told of each new message for it. */
onMessage: (fn: ((m: ChatMessage) => void) | null) => void;
/** Mark a room read locally (the view also tells the server). */
markRead: (roomId: number, upTo: number) => void;
/** The room on screen now (null: none, or the window closed) — no notice for it. */
setActive: (roomId: number | null) => void;
notice: ChatNotice | null;
dismissNotice: () => void;
/** The open room's view, told when a message of it is deleted. */
onDeleted: (fn: ((roomId: number, messageId: number) => void) | null) => void;
/** Read the people list again (New chat, a room change). */
reloadPeople: () => Promise<void>;
/** Rooms whose new messages raise no notice or sound. */
muted: Set<number>;
toggleMute: (roomId: number) => void;
};
const MUTED_KEY = "agenthub.chat.muted";
const NOTIFIED_KEY = "agenthub.chat.notified";
/** True for the first tab of this browser to claim message `id`'s sound / notification. */
function claimNotice(id: number): boolean {
try {
if (Number(localStorage.getItem(NOTIFIED_KEY) || 0) >= id) return false;
localStorage.setItem(NOTIFIED_KEY, String(id));
} catch {
// No storage: every tab plays it.
}
return true;
}
function readMuted(): Set<number> {
try {
const v = JSON.parse(localStorage.getItem(MUTED_KEY) || "[]");
return new Set(Array.isArray(v) ? v.filter((x) => Number.isInteger(x)) : []);
} catch {
return new Set();
}
}
/** What a notice says of a message: its text, else its files. */
function preview(m: ChatMessage): string {
const text = m.body.trim().replace(/\s+/g, " ");
if (text) return text.length > 140 ? `${text.slice(0, 139)}…` : text;
return m.files.map((f) => `📎 ${f.name}`).join(", ");
}
/** A system notification from this tab only (the service worker's, else the page's). */
async function localNotification(title: string, body: string) {
try {
if (!("Notification" in window) || Notification.permission !== "granted") return;
if ("serviceWorker" in navigator) {
const reg = await navigator.serviceWorker.getRegistration();
if (reg) {
await reg.showNotification(title, { body, icon: "/favicon.svg", tag: `chat-${title}` });
return;
}
}
new Notification(title, { body, icon: "/favicon.svg" });
} catch {
// A browser that refuses: the card on the pet still shows it.
}
}
export function useChat(): ChatState {
const { state } = useAuth();
const me = state.mode === "server" && "user" in state && state.user ? state.user.id : null;
const enabled = me != null;
const [rooms, setRooms] = useState<ChatRoom[]>([]);
const [people, setPeople] = useState<ChatPerson[]>([]);
const listener = useRef<((m: ChatMessage) => void) | null>(null);
const active = useRef<number | null>(null);
const deletedListener = useRef<((roomId: number, messageId: number) => void) | null>(null);
const [muted, setMuted] = useState<Set<number>>(readMuted);
const mutedRef = useRef(muted);
mutedRef.current = muted;
const [notice, setNotice] = useState<ChatNotice | null>(null);
// Names for a notice, read inside the socket handler.
const lookup = useRef({ rooms, people });
lookup.current = { rooms, people };
const reload = useCallback(async () => {
if (!enabled) return;
try {
setRooms((await api.chat.rooms()).rooms);
} catch {
// The pet's chat with the AI still works; the list retries on the next frame.
}
}, [enabled]);
const reloadPeople = useCallback(async () => {
if (!enabled) return;
try {
setPeople((await api.chat.people()).people);
} catch {
// Names fall back until the next read.
}
}, [enabled]);
useEffect(() => {
if (!enabled) return;
void reload();
void reloadPeople();
}, [enabled, reload, reloadPeople]);
useEffect(() => {
if (!enabled) return;
const off = eventBus.subscribe((msg: WSMessage) => {
if (msg.type === "chat_room") {
void reload();
void reloadPeople();
return;
}
if (msg.type === "chat_read") {
const d = msg.data as { roomId: number; userId?: number; lastReadId?: number };
if (d.userId == null || d.lastReadId == null) return;
setRooms((prev) =>
prev.map((r) =>
r.id === d.roomId
? {
...r,
reads: {
...r.reads,
[d.userId!]: Math.max(r.reads?.[d.userId!] ?? 0, d.lastReadId!),
},
}
: r
)
);
return;
}
if (msg.type === "chat_deleted") {
const d = msg.data as { roomId: number; messageId?: number };
if (d.messageId != null) deletedListener.current?.(d.roomId, d.messageId);
void reload();
return;
}
if (msg.type !== "chat_message") return;
const m = (msg.data as { message?: ChatMessage }).message;
if (!m) return;
let known = false;
setRooms((prev) => {
const next = prev.map((r) => {
if (r.id !== m.roomId) return r;
known = true;
const own = m.userId === me;
return {
...r,
lastMessage: m,
lastMessageAt: m.createdAt,
// A group change (system line) is not a message to read.
unread: own ? 0 : m.kind === "text" ? r.unread + 1 : r.unread,
lastReadId: own ? m.id : r.lastReadId,
};
});
return next.sort((a, b) =>
(b.lastMessageAt ?? b.createdAt).localeCompare(a.lastMessageAt ?? a.createdAt)
);
});
// A room this list has not seen yet (added a moment ago): read it all.
if (!known) void reload();
listener.current?.(m);
if (m.userId === me || m.kind !== "text") return;
const hidden = document.visibilityState !== "visible";
if (active.current === m.roomId && !hidden) return;
if (mutedRef.current.has(m.roomId)) return;
const sender = lookup.current.people.find((p) => p.id === m.userId);
const from = sender ? sender.displayName || sender.username : "?";
const room = lookup.current.rooms.find((r) => r.id === m.roomId);
const title = room && room.kind === "group" ? `${from} · ${room.name || "…"}` : from;
const text = preview(m);
setNotice({ key: m.id, roomId: m.roomId, title, text });
// Several tabs open: only the first to see a message makes a sound or a
// system notification (message ids only grow).
if (!claimNotice(m.id)) return;
const prefs = loadNotifPrefs();
if (prefs.enabled) playSound(prefs.sound);
if (hidden) void localNotification(title, text);
});
// Reconnected: frames may have been missed.
const offConn = eventBus.onConnection((up) => {
if (up) void reload();
});
return () => {
off();
offConn();
};
}, [enabled, me, reload, reloadPeople]);
const upsert = useCallback((room: ChatRoom) => {
setRooms((prev) => [room, ...prev.filter((r) => r.id !== room.id)]);
}, []);
const onMessage = useCallback((fn: ((m: ChatMessage) => void) | null) => {
listener.current = fn;
}, []);
const markRead = useCallback(
(roomId: number, upTo: number) => {
setRooms((prev) =>
prev.map((r) =>
r.id === roomId
? {
...r,
unread: 0,
lastReadId: Math.max(r.lastReadId, upTo),
reads:
me != null ? { ...r.reads, [me]: Math.max(r.reads?.[me] ?? 0, upTo) } : r.reads,
}
: r
)
);
},
[me]
);
const setActive = useCallback((roomId: number | null) => {
active.current = roomId;
if (roomId != null) setNotice((n) => (n && n.roomId === roomId ? null : n));
}, []);
const dismissNotice = useCallback(() => setNotice(null), []);
const onDeleted = useCallback((fn: ((roomId: number, messageId: number) => void) | null) => {
deletedListener.current = fn;
}, []);
const toggleMute = useCallback((roomId: number) => {
setMuted((prev) => {
const next = new Set(prev);
if (next.has(roomId)) next.delete(roomId);
else next.add(roomId);
try {
localStorage.setItem(MUTED_KEY, JSON.stringify([...next]));
} catch {
// private mode: muted for this tab only
}
return next;
});
}, []);
// A notice goes away by itself.
useEffect(() => {
if (!notice) return;
const id = window.setTimeout(() => setNotice(null), 10000);
return () => window.clearTimeout(id);
}, [notice]);
const unread = useMemo(() => rooms.reduce((n, r) => n + r.unread, 0), [rooms]);
return {
enabled,
me,
rooms,
people,
unread,
reload,
upsert,
onMessage,
markRead,
setActive,
notice,
dismissNotice,
onDeleted,
reloadPeople,
muted,
toggleMute,
};
}
+129 -52
View File
@@ -15,6 +15,14 @@
* the next option whose label starts with it. Focus returns to the trigger on
* every close except Tab.
*
* ## Search — a combobox once the list is long
* With more than `SEARCH_FROM` options (or `searchable`), the popup opens with
* a search box on top, focused: typing narrows the list to options whose
* label or value contains the text (case-insensitive), ↑/↓ move, Enter picks,
* Escape closes. The box is `role="combobox"` controlling the listbox through
* `aria-activedescendant`. Options may carry a `group`: a heading is drawn
* where the group changes.
*
* ## Consumers
* Used on the Run Claude page and webhook settings form wherever a compact
* enum picker is needed.
@@ -73,7 +81,11 @@
* ----------------------------------------------------------------------------- */
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { ChevronDown, Check } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ChevronDown, Check, Search } from "lucide-react";
/** A list longer than this gets a search box (unless `searchable` says otherwise). */
export const SEARCH_FROM = 8;
/** Room above and below `r` (the trigger's rect) for a menu it opens: inside
* the nearest `data-menu-bounds` box — a split pane, whose neighbour paints
@@ -95,6 +107,8 @@ export interface SelectOption<T extends string> {
icon?: ReactNode;
/** Draw a rule above this option: it starts a group of its own. */
divider?: boolean;
/** The group this option belongs to: a heading is drawn where it changes. */
group?: string;
}
/** Props for {@link Select}. */
@@ -111,6 +125,10 @@ export interface SelectProps<T extends string> {
variant?: "field" | "ghost";
/** Accessible name and tooltip of the trigger (a ghost one has no label). */
label?: string;
/** Show the search box: default only for lists longer than `SEARCH_FROM`. */
searchable?: boolean;
/** The search box's name and placeholder (default: "Search…"). */
searchLabel?: string;
}
/**
@@ -124,15 +142,26 @@ export function Select<T extends string>({
disabled,
variant = "field",
label,
searchable,
searchLabel,
}: SelectProps<T>) {
const { t } = useTranslation("common");
const ghost = variant === "ghost";
const canSearch = searchable ?? options.length > SEARCH_FROM;
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const [openUp, setOpenUp] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
const searchRef = useRef<HTMLInputElement | null>(null);
const typed = useRef({ text: "", at: 0 });
// The options shown: all of them, or those the search text matches.
const q = canSearch ? query.trim().toLowerCase() : "";
const shown = q
? options.filter((o) => `${o.label} ${o.value}`.toLowerCase().includes(q))
: options;
const id = useId();
const optionId = (idx: number) => `${id}-opt-${idx}`;
@@ -150,6 +179,7 @@ export function Select<T extends string>({
// above the trigger (288 px = `max-h-72`), and move focus into the list.
useEffect(() => {
if (!open) return;
setQuery("");
const idx = options.findIndex((o) => o.value === value);
setActive(idx >= 0 ? idx : 0);
if (buttonRef.current) {
@@ -159,14 +189,14 @@ export function Select<T extends string>({
);
setOpenUp(below < 288 && above > below);
}
listRef.current?.focus({ preventScroll: true });
(canSearch ? searchRef.current : listRef.current)?.focus({ preventScroll: true });
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on open
}, [open]);
useEffect(() => {
if (open) document.getElementById(optionId(active))?.scrollIntoView?.({ block: "nearest" });
// eslint-disable-next-line react-hooks/exhaustive-deps -- id is stable
}, [open, active]);
}, [open, active, q]);
const close = (refocus = true) => {
setOpen(false);
@@ -185,8 +215,12 @@ export function Select<T extends string>({
}
};
const onListKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
const last = options.length - 1;
const onListKey = (e: React.KeyboardEvent<HTMLElement>) => {
const last = shown.length - 1;
// In the search box, keys that edit text stay the box's own.
const inSearch = e.currentTarget === searchRef.current;
if (inSearch && (e.key === " " || e.key === "Home" || e.key === "End")) return;
if (inSearch && e.key === "Enter" && e.nativeEvent.isComposing) return;
const move: Record<string, (a: number) => number> = {
ArrowDown: (a) => Math.min(last, a + 1),
ArrowUp: (a) => Math.max(0, a - 1),
@@ -200,7 +234,7 @@ export function Select<T extends string>({
setActive(move[e.key]!);
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
const opt = options[active];
const opt = shown[active];
if (opt) choose(opt);
} else if (e.key === "Escape") {
// Stop here: a Select inside a dialog must not close the dialog too.
@@ -209,7 +243,7 @@ export function Select<T extends string>({
close();
} else if (e.key === "Tab") {
close(false);
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
} else if (!inSearch && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// Type-ahead: keys typed within 500 ms build one prefix; a repeated single
// character cycles through the options that start with it.
const now = Date.now();
@@ -258,57 +292,100 @@ export function Select<T extends string>({
</button>
{open && (
<div
ref={listRef}
id={`${id}-list`}
role="listbox"
tabIndex={-1}
aria-label={label}
aria-activedescendant={options[active] ? optionId(active) : undefined}
onKeyDown={onListKey}
onBlur={(e) => {
if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
}}
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 outline-none ${
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} flex max-h-72 flex-col overflow-hidden rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 ${
openUp ? "bottom-full mb-1" : "top-full mt-1"
}`}
>
{options.map((opt, idx) => {
const isSelected = opt.value === value;
const isActive = idx === active;
return (
<div
key={opt.value || "__default__"}
id={optionId(idx)}
role="option"
aria-selected={isSelected}
onMouseDown={(e) => e.preventDefault()}
onClick={() => choose(opt)}
onMouseEnter={() => setActive(idx)}
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
opt.divider ? "mt-1 border-t border-border pt-2" : ""
} ${
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
}`}
>
<div className="flex items-center gap-2">
{opt.icon}
<span
className={`text-[11px] flex-1 truncate ${
isSelected ? "text-accent font-medium" : "text-fg-secondary"
{canSearch && (
<div className="flex shrink-0 items-center gap-1.5 border-b border-border px-2.5 py-1.5">
<Search className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
<input
ref={searchRef}
role="combobox"
aria-label={searchLabel ?? t("select.search")}
aria-expanded
aria-controls={`${id}-list`}
aria-autocomplete="list"
aria-activedescendant={shown[active] ? optionId(active) : undefined}
placeholder={searchLabel ?? t("select.search")}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActive(0);
}}
onKeyDown={onListKey}
onBlur={(e) => {
if (!containerRef.current?.contains(e.relatedTarget as Node | null))
setOpen(false);
}}
className="min-w-0 flex-1 bg-transparent text-[11px] text-fg-primary placeholder:text-fg-muted focus:outline-none"
/>
</div>
)}
<div
ref={listRef}
id={`${id}-list`}
role="listbox"
tabIndex={-1}
aria-label={label}
aria-activedescendant={!canSearch && shown[active] ? optionId(active) : undefined}
onKeyDown={onListKey}
onBlur={(e) => {
if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
}}
className="min-h-0 flex-1 overflow-auto py-1 outline-none"
>
{canSearch && shown.length === 0 && (
<div className="px-3 py-1.5 text-[11px] text-fg-muted">{t("select.noMatch")}</div>
)}
{shown.map((opt, idx) => {
const isSelected = opt.value === value;
const isActive = idx === active;
const heading = opt.group && opt.group !== shown[idx - 1]?.group ? opt.group : null;
return (
<div key={opt.value || "__default__"} role="none">
{heading && (
<div
role="presentation"
className="mt-1 border-t border-border px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-fg-muted"
>
{heading}
</div>
)}
<div
id={optionId(idx)}
role="option"
aria-selected={isSelected}
onMouseDown={(e) => e.preventDefault()}
onClick={() => choose(opt)}
onMouseEnter={() => setActive(idx)}
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
opt.divider ? "mt-1 border-t border-border pt-2" : ""
} ${
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
}`}
>
{opt.label}
</span>
{isSelected && (
<Check className="w-3 h-3 text-accent flex-shrink-0" aria-hidden />
)}
<div className="flex items-center gap-2">
{opt.icon}
<span
className={`text-[11px] flex-1 truncate ${
isSelected ? "text-accent font-medium" : "text-fg-secondary"
}`}
>
{opt.label}
</span>
{isSelected && (
<Check className="w-3 h-3 text-accent flex-shrink-0" aria-hidden />
)}
</div>
{opt.hint && (
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
)}
</div>
</div>
{opt.hint && (
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
)}
</div>
);
})}
);
})}
</div>
</div>
)}
</div>
@@ -559,6 +559,8 @@ export function WebhookSettings() {
headerRows: [],
})
}
// A short, fixed list of providers: no search box.
searchable={false}
options={providers.map((p) => ({ value: p.type, label: p.label }))}
/>
</div>
@@ -105,7 +105,61 @@ describe("Select (listbox button)", () => {
vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(360, 380));
vi.stubGlobal("innerHeight", 2000);
fireEvent.click(button);
expect(screen.getByRole("listbox").className).toContain("bottom-full");
expect(screen.getByRole("listbox").parentElement!.className).toContain("bottom-full");
vi.unstubAllGlobals();
});
it("turns a long list into a combobox: type to narrow, Enter picks, groups keep headings", async () => {
const onChange = vi.fn();
const options = [
{ value: "", label: "Switch branch…" },
...["develop", "main", "feat/campaign-pause", "fix/campaign-step", "chore/a", "chore/b"].map(
(v) => ({ value: v, label: v })
),
...["origin/feat/campaign-statistic", "origin/x", "origin/y"].map((v) => ({
value: v,
label: v,
group: "On the remote",
})),
];
render(<Select value="" onChange={onChange} options={options} label="Switch branch…" />);
fireEvent.click(screen.getByRole("button", { name: "Switch branch…" }));
const search = screen.getByRole("combobox", { name: "Search…" });
expect(search).toHaveFocus();
expect(screen.getByText("On the remote")).toBeInTheDocument();
fireEvent.change(search, { target: { value: "CAMPAIGN" } });
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
"feat/campaign-pause",
"fix/campaign-step",
"origin/feat/campaign-statistic",
]);
fireEvent.keyDown(search, { key: "ArrowDown" });
fireEvent.keyDown(search, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith("fix/campaign-step");
expect(screen.queryByRole("listbox")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Switch branch…" }));
fireEvent.change(screen.getByRole("combobox"), { target: { value: "zzz" } });
expect(screen.getByText("No match")).toBeInTheDocument();
fireEvent.keyDown(screen.getByRole("combobox"), { key: "Escape" });
expect(screen.queryByRole("listbox")).toBeNull();
});
it("keeps a short list a plain listbox (no search box)", () => {
render(
<Select
value="a"
onChange={vi.fn()}
options={[
{ value: "a", label: "A" },
{ value: "b", label: "B" },
]}
label="Pick"
/>
);
fireEvent.click(screen.getByRole("button", { name: "Pick" }));
expect(screen.queryByRole("combobox")).toBeNull();
expect(screen.getByRole("listbox")).toHaveFocus();
});
});
+27 -27
View File
@@ -57,6 +57,7 @@ import { isSubmitEnter } from "../../lib/keys";
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
import { restoreShelf } from "./LaneShelves";
import { BranchesButton } from "./git/BranchesButton";
import { Select } from "../Select";
type Action =
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
@@ -526,33 +527,32 @@ export function LaneGitBar({
{switching && (
<div className="flex flex-wrap items-center gap-1.5">
<select
aria-label={t("gitBar.switchLabel")}
value={picked}
disabled={!!busy}
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]"
>
<option value="">{t("gitBar.switchLabel")}</option>
{(branches || []).map((b) => {
const held = heldBy.get(b);
return (
<option key={b} value={b}>
{b}
{b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : ""}
</option>
);
})}
{remoteBranches.length > 0 && (
<optgroup label={t("gitBar.remoteBranches")}>
{remoteBranches.map((b) => (
<option key={`remote:${b}`} value={b}>
{b}
</option>
))}
</optgroup>
)}
</select>
{/* A combobox once the list is long (Select's search box, #88):
type to narrow the local and remote branches. */}
<div className="min-w-[8rem] flex-1 font-mono">
<Select
label={t("gitBar.switchLabel")}
searchLabel={t("gitBar.branchSearch")}
searchable
value={picked}
disabled={!!busy}
onChange={setPicked}
options={[
{ value: "", label: t("gitBar.switchLabel") },
...(branches || []).map((b) => {
const held = heldBy.get(b);
const note =
b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : "";
return { value: b, label: `${b}${note}` };
}),
...remoteBranches.map((b) => ({
value: b,
label: b,
group: t("gitBar.remoteBranches"),
})),
]}
/>
</div>
<Btn
label={t("gitBar.switchApply")}
busy={busy === "switch"}
@@ -171,21 +171,23 @@ beforeEach(() => {
vi.mocked(h.commit).mockReset().mockResolvedValue({ available: false });
vi.mocked(h.sequence).mockReset().mockResolvedValue({ available: true, active: false });
vi.mocked(h.pick).mockReset();
vi.mocked(h.pickPlan).mockReset().mockImplementation(async (_t, op, shas) => ({
available: true,
op,
commits: shas.map((sha) => ({
sha,
short: sha.slice(0, 7),
subject: "feat: the commit",
author: "t",
date: "2026-09-22T01:00:00Z",
})),
branch: "feat/thing",
clean: true,
repoRoot: true,
busy: false,
}));
vi.mocked(h.pickPlan)
.mockReset()
.mockImplementation(async (_t, op, shas) => ({
available: true,
op,
commits: shas.map((sha) => ({
sha,
short: sha.slice(0, 7),
subject: "feat: the commit",
author: "t",
date: "2026-09-22T01:00:00Z",
})),
branch: "feat/thing",
clean: true,
repoRoot: true,
busy: false,
}));
vi.mocked(h.fileHistory)
.mockReset()
.mockResolvedValue({
@@ -538,7 +540,8 @@ describe("LaneChangesPanel", () => {
await screen.findByTitle("only-on-thing.ts");
await user.click(screen.getByRole("button", { name: /^Branch$/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "other");
await user.click(await screen.findByRole("button", { name: "Switch branch…" }));
await user.click(screen.getByRole("option", { name: "other" }));
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
@@ -1344,9 +1347,7 @@ describe("LaneChangesPanel", () => {
await user.click(within(await commitMenu(user)).getByText("Cherry-pick onto feat/thing"));
await user.click(await screen.findByTestId("pick-confirm"));
await waitFor(() =>
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", [
"a".repeat(40),
])
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", ["a".repeat(40)])
);
});
@@ -13,13 +13,22 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LaneGitBar } from "../LaneGitBar";
import { api } from "../../../lib/api";
import { confirmDialog } from "../../../lib/confirm";
import type { LaneGitFacts } from "../../../lib/types";
/** Picks `name` in the branch switcher — a Select combobox (#88). */
async function pickBranch(user: ReturnType<typeof userEvent.setup>, name: string) {
await user.click(await screen.findByRole("button", { name: "Switch branch…" }));
const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
await user.click(
within(screen.getByRole("listbox")).getByRole("option", { name: new RegExp(`^${esc}( —|$)`) })
);
}
// A merge asks through the themed confirm (its host lives in App): yes.
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
vi.mock("../../../lib/api", () => ({
@@ -210,7 +219,7 @@ describe("LaneGitBar", () => {
await user.click(screen.getByRole("button", { name: /Branch/ }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
// Nothing is sent before the user picks what happens to the changes.
expect(api.lanes.gitAction).not.toHaveBeenCalled();
@@ -235,7 +244,7 @@ describe("LaneGitBar", () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await user.click(screen.getByRole("button", { name: "Bring my changes" }));
expect(await screen.findByTestId("lane-git-carry-conflicts")).toHaveTextContent(
@@ -258,7 +267,7 @@ describe("LaneGitBar", () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await user.click(screen.getByRole("button", { name: "Shelve and switch" }));
await waitFor(() =>
@@ -291,7 +300,7 @@ describe("LaneGitBar", () => {
const onChanged = vi.fn();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={onChanged} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "unshelve", { sha: "abc1234def" })
@@ -319,8 +328,8 @@ describe("LaneGitBar", () => {
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…");
await user.selectOptions(select, "development");
await screen.findByRole("button", { name: "Switch branch…" });
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() =>
@@ -332,11 +341,42 @@ describe("LaneGitBar", () => {
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/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
});
it("opens the branch switcher as a combobox: type to narrow, Enter picks (#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 trigger = await screen.findByRole("button", { name: "Switch branch…" });
await user.click(trigger);
const search = screen.getByRole("combobox", { name: "Search branches…" });
expect(search).toHaveFocus();
await user.keyboard("CAMPAIGN");
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
"feat/campaign-pause",
"fix/campaign-step",
"origin/feat/campaign-statistic",
]);
await user.keyboard("{Enter}");
expect(trigger).toHaveTextContent("feat/campaign-pause");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", {
branch: "feat/campaign-pause",
})
);
});
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({
available: true,
@@ -355,11 +395,12 @@ describe("LaneGitBar", () => {
/>
);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
await user.click(await screen.findByRole("button", { name: "Switch branch…" }));
expect(
screen.getByRole("group", { name: "On the remote — switching creates the local branch" })
screen.getByText("On the remote — switching creates the local branch")
).toBeInTheDocument();
await user.selectOptions(select, "origin/feat/new");
await user.keyboard("{Escape}");
await pickBranch(user, "origin/feat/new");
// Not a local branch yet: nothing to delete, no worktree to start on it.
expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
expect(screen.getByRole("button", { name: /New workspace here/ })).toBeDisabled();
@@ -395,7 +436,7 @@ describe("LaneGitBar", () => {
})
);
await user.selectOptions(screen.getByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "New workspace here" }));
expect(onNewLane).toHaveBeenCalledWith("development");
});
@@ -413,18 +454,18 @@ describe("LaneGitBar", () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
await screen.findByRole("button", { name: "Switch branch…" });
const newLane = () => screen.getByRole("button", { name: "New workspace here" });
// This lane's own branch: its worktree is this lane.
await user.selectOptions(select, "feat/thing");
await pickBranch(user, "feat/thing");
expect(newLane()).toBeDisabled();
// Held by another worktree: git allows one worktree per branch (EBRANCHBUSY).
await user.selectOptions(select, "development");
await pickBranch(user, "development");
expect(newLane()).toBeDisabled();
expect(newLane()).toHaveAttribute("title", expect.stringContaining("/repo/dev"));
await user.selectOptions(select, "feat/free");
await pickBranch(user, "feat/free");
expect(newLane()).toBeEnabled();
});
@@ -443,13 +484,15 @@ describe("LaneGitBar", () => {
const user = userEvent.setup();
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} />);
expect(screen.queryByLabelText("Switch branch…")).toBeNull();
await user.click(screen.getByRole("button", { name: /Branch/ }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
expect(await screen.findByLabelText("Switch branch…")).toHaveValue("");
expect(await screen.findByRole("button", { name: "Switch branch…" })).toHaveTextContent(
"Switch branch…"
);
});
it("sends one branch-create for a double Enter", async () => {
@@ -476,7 +519,7 @@ describe("LaneGitBar", () => {
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
await screen.findByRole("button", { name: "Switch branch…" });
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
// Lane 3's answer lands late: its worktree held `development`.
resolveOld({
@@ -485,7 +528,7 @@ describe("LaneGitBar", () => {
current: "feat/thing",
worktrees: [{ path: "/repo/dev", branch: "development" }],
});
await user.selectOptions(select, "development");
await pickBranch(user, "development");
const newLane = screen.getByRole("button", { name: "New workspace here" });
expect(newLane).toBeEnabled();
expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev"));
@@ -572,7 +615,7 @@ describe("merge (issue #45)", () => {
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/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "Merge into current" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" })
+33 -6
View File
@@ -124,6 +124,11 @@ const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v ==
const OUTPUT_FETCH_MS = 1000;
/** A tool with no known busy line counts as at work this long after its pane last drew. */
const OUTPUT_BUSY_MS = 2000;
/** How long Stop stays up after every busy signal dropped: the screen's busy
* line comes and goes while the CLI redraws, and without a hold the button
* flickered between Stop and Send mid-turn. An explicit end (Stop pressed)
* is not held. */
const BUSY_HOLD_MS = 2500;
const SIDE_KEY = "agenthub.chat.panelWidth";
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
const outlineKey = (sessionId: string) => `agenthub.chat.panel.${sessionId}`;
@@ -140,6 +145,23 @@ const storedSide = () => {
* older; every Claude model since (Sonnet/Opus 4.6+, the 5 family) has 1M
* (LiteLLM's list, 2026-10). Null for another vendor's model. */
// ponytail: a fixed rule, not a table; read LiteLLM's max_input_tokens if a new 200K model ships.
/** `value`, except that a drop to false shows only once it has lasted
* `holdMs`; a change of `release` (Stop pressed) lets it drop at once. */
export function useHeldTrue(value: boolean, holdMs: number, release: unknown): boolean {
const [held, setHeld] = useState(value);
const released = useRef(release);
useEffect(() => {
if (value) return setHeld(true);
if (released.current !== release) {
released.current = release;
return setHeld(false);
}
const timer = window.setTimeout(() => setHeld(false), holdMs);
return () => window.clearTimeout(timer);
}, [value, holdMs, release]);
return value || held;
}
export function contextWindow(model: string | null): number | null {
// A Codex/omp chat reports its own window (ContextMeter's `window`).
if (!model?.startsWith("claude-")) return null;
@@ -622,13 +644,18 @@ export const PaneChat = memo(function PaneChat({
const [waiting, setWaiting] = useState(false);
// Stop presses, for the conversation's live line (issue #74).
const [stops, setStops] = useState(0);
const readWorking = useCallback(
() =>
// Reads overlap (every throttled transcript change starts one); only the
// newest may land, or a slow older answer put back a state already left.
const readSeq = useRef(0);
const readWorking = useCallback(() => {
const seq = ++readSeq.current;
return (
api.agents
.list({ session_id: sessionId, status: "working" })
// With session_id the server answers every agent of the session, its
// status filter ignored: check the status here.
.then((r) => {
if (seq !== readSeq.current) return;
const main = r.agents.filter((a) => a.type === "main");
// A fresh start, resume or `/clear` leaves main "working" at an
// empty prompt (SessionStart's flag) until the first prompt.
@@ -637,9 +664,9 @@ export const PaneChat = memo(function PaneChat({
);
setWaiting(main.some((a) => agentAwaitingReason(a) === "notification"));
})
.catch(() => {}),
[sessionId]
);
.catch(() => {})
);
}, [sessionId]);
useEffect(() => {
void readWorking();
}, [readWorking]);
@@ -714,7 +741,7 @@ export const PaneChat = memo(function PaneChat({
}, 700);
return () => window.clearInterval(timer);
}, [runId, onScreen]);
const busy = working || screenBusy;
const busy = useHeldTrue(working || screenBusy, BUSY_HOLD_MS, stops);
const asking = waiting || menu !== null;
// Gone until the pane redraws: no second press on a stale menu.
const choose = (index: number) => {
@@ -215,6 +215,10 @@ describe("PaneChat — Stop", () => {
await act(async () => {
await vi.advanceTimersByTimeAsync(5000);
});
// Send comes back once nothing has said busy for BUSY_HOLD_MS.
await act(async () => {
await vi.advanceTimersByTimeAsync(2600);
});
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
} finally {
vi.useRealTimers();
@@ -233,6 +237,10 @@ describe("PaneChat — Stop", () => {
await act(async () => {
await vi.advanceTimersByTimeAsync(800);
});
// Send comes back once nothing has said busy for BUSY_HOLD_MS.
await act(async () => {
await vi.advanceTimersByTimeAsync(2600);
});
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
} finally {
vi.useRealTimers();
@@ -240,6 +248,32 @@ describe("PaneChat — Stop", () => {
}
});
it("does not flicker to Send when the busy line drops out for a moment", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.mocked(readRunBusy).mockReturnValue(true);
try {
renderChat();
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
// A redraw: the spinner line is gone for a poll or two.
vi.mocked(readRunBusy).mockReturnValue(false);
await act(async () => {
await vi.advanceTimersByTimeAsync(1500);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(50);
});
expect(screen.getByTestId("pane-chat-stop")).toBeInTheDocument();
vi.mocked(readRunBusy).mockReturnValue(true);
await act(async () => {
await vi.advanceTimersByTimeAsync(800);
});
expect(screen.getByTestId("pane-chat-stop")).toBeInTheDocument();
} finally {
vi.useRealTimers();
vi.mocked(readRunBusy).mockReturnValue(false);
}
});
it("counts a tool with no known busy line as working while its pane draws", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.mocked(readRunBusy).mockReturnValue(null);
@@ -261,6 +295,10 @@ describe("PaneChat — Stop", () => {
await act(async () => {
await vi.advanceTimersByTimeAsync(2800);
});
// Send comes back once nothing has said busy for BUSY_HOLD_MS.
await act(async () => {
await vi.advanceTimersByTimeAsync(2600);
});
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
} finally {
vi.useRealTimers();
@@ -131,6 +131,8 @@ export function BackupSettings() {
onChange={(v) => setHour(Number(v))}
label={t("backup.hour")}
disabled={!state || owned?.hour}
// 24 fixed hours: type-ahead finds one; a search box would only add noise.
searchable={false}
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
/>
</div>
@@ -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>
);
}
@@ -222,6 +222,8 @@ export function ReportSettings() {
value={String(form.value.hour)}
onChange={(v) => set({ hour: Number(v) })}
label={t("reports.hour")}
// 24 fixed hours: type-ahead finds one; a search box would only add noise.
searchable={false}
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
/>
</div>
@@ -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();
});
});
+4
View File
@@ -223,5 +223,9 @@
"errors": "Steps that failed. A few are normal; many may mean the AI got stuck.",
"tokenFlow": "Input = sent to the AI, Output = written by the AI, Cache = reused from memory (cheaper)."
}
},
"select": {
"search": "Search…",
"noMatch": "No match"
}
}
+1
View File
@@ -195,6 +195,7 @@
"gitBar.pushHint": "git push -u origin HEAD",
"gitBar.switch": "Branch",
"gitBar.switchApply": "Switch branch",
"gitBar.branchSearch": "Search branches…",
"gitBar.switchCurrent": "current",
"gitBar.remoteBranches": "On the remote — switching creates the local branch",
"gitBar.switchDirty": "Commit, discard or shelve the uncommitted changes first",
+18 -132
View File
@@ -45,7 +45,6 @@
"mute": "Mute",
"unmute": "Unmute",
"clearAlerts": "Clear alerts",
"send": "Send",
"rename": "Click to rename {{name}}",
"renameLabel": "Pet name",
"renameHint": "Enter to save · empty = default name",
@@ -53,13 +52,15 @@
"attention": "Needs you",
"attentionError": "errored",
"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",
"open": "Open {{name}} companion",
"closeAvatar": "Close {{name}}",
"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",
"bubbleMore": "{{list}} +{{count}} more",
"notify": {
@@ -77,21 +78,13 @@
"run_done_other": "{{count}} runs finished 🐾"
},
"greet": {
"morning": [
"good morning! ☀️",
"morning! coffee first? ☕",
"rise and shine 🌤️"
],
"morning": ["good morning! ☀️", "morning! coffee first? ☕", "rise and shine 🌤️"],
"afternoon": [
"good afternoon! 🐾",
"hey, welcome back 😺",
"afternoon! ready when you are ✨"
],
"evening": [
"good evening! 🌆",
"evening shift? I'm in 😼",
"hey there, night owl-to-be 🌙"
],
"evening": ["good evening! 🌆", "evening shift? I'm in 😼", "hey there, night owl-to-be 🌙"],
"night": [
"burning the midnight oil? 🌙",
"late one, huh? I'll keep watch 🦉",
@@ -114,11 +107,7 @@
"help": "I'm {{name}} - I watch your sessions. Try \"what's running\", \"any errors\", \"what's waiting\" or \"status\". Anything else, I'll hand to Claude.",
"whoami": "I'm {{name}}, your session-watching buddy 🐾 Right-click me to change my look, or click my name in the panel to rename me.",
"hello": "hi there! 😺 {{summary}}.",
"thanks": [
"anytime! 💜",
"happy to help 😸",
"you got it 🐾"
],
"thanks": ["anytime! 💜", "happy to help 😸", "you got it 🐾"],
"errors_one": "{{count}} session errored{{list}} - open the panel to jump to it.",
"errors_other": "{{count}} sessions errored{{list}} - open the panel to jump to them.",
"errorsNone": "no errors - all clean 🐾",
@@ -175,125 +164,17 @@
"can't reach the server 📡",
"no signal - taking a nap 💤"
],
"worried": [
"that didn't look right 😟",
"keeping an eye out 👀",
"hmm, something's off 🫣"
],
"worried": ["that didn't look right 😟", "keeping an eye out 👀", "hmm, something's off 🫣"],
"stuck": [
"a session's been quiet a while… 🤔",
"is something stuck? ⏳",
"still chewing on it… 😾"
],
"happy": [
"great run! 😻",
"love a tidy finish ✨",
"purrfect 💜"
],
"thinking": [
"hmm, let me look… 🤔",
"sniffing around… 🐾",
"one sec, checking 🔍"
],
"watching": [
"on the prowl 👀",
"watching your sessions 😼",
"eyes peeled 🐾"
],
"sleeping": [
"zzz… 💤",
"wake me if something happens 😴",
"curled up, all calm 🐈"
],
"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."
},
"people": {
"rail": "Chats",
"new": "New chat",
"newHint": "Pick one person for a direct chat, or several for a group.",
"search": "Search people",
"none": "No one found",
"groupName": "Group name (optional)",
"createGroup": "Create group ({{n}} people)",
"startDm": "Start chat",
"close": "Close",
"unreadIn": "{{name}}, {{n}} unread",
"roomLabel": "Chat with {{name}}",
"members": "{{n}} members",
"settings": "Group settings",
"back": "Back to the chat",
"rename": "Rename",
"creator": "· created the group",
"remove": "Remove {{name}}",
"add": "Add people",
"addN": "Add {{n}}",
"leave": "Leave group",
"leaveTitle": "Leave this group?",
"leaveBody": "You will no longer see {{name}}. Someone in it can add you again.",
"older": "Load older messages",
"empty": "No messages yet. Say hello to {{name}}.",
"placeholder": "Message {{name}}",
"attach": "Attach files",
"unattach": "Remove {{name}}",
"tooBig": "{{name}} is over 25 MB",
"sysCreated": "{{who}} created the group",
"sysRenamed": "{{who}} renamed the group",
"sysLeft": "{{who}} left",
"sysAdded": "{{who}} added {{names}}",
"sysRemoved": "{{who}} removed {{names}}",
"openFrom": "Open the chat with {{name}}",
"menuOpen": "Open",
"menuRead": "Mark as read",
"menuReadAll": "Mark all chats as read",
"menuMute": "Mute notifications",
"menuUnmute": "Unmute notifications",
"menuCopyName": "Copy name",
"menuPet": "Chat with {{name}}",
"menuDm": "Message {{name}} privately",
"menuOpenFile": "Open in a new tab",
"menuDownload": "Download",
"menuCopyFileName": "Copy file name",
"menuCopyText": "Copy text",
"menuReply": "Reply",
"menuDelete": "Delete message",
"deleteTitle": "Delete this message?",
"deleteBody": "It is removed for everyone in the chat, with its files. This cannot be undone.",
"menuClearDm": "Delete chat",
"menuClearGroup": "Delete chat history",
"clearDmTitle": "Delete this chat?",
"clearDmBody": "The chat with {{name}} and its history are deleted for you only — they keep theirs. It comes back, without the old messages, if either of you writes again.",
"clearGroupTitle": "Delete this group's history?",
"clearGroupBody": "Every message in {{name}} so far is deleted for you only. You stay in the group; the others keep theirs.",
"disband": "Disband group",
"disbandTitle": "Disband this group?",
"disbandBody": "{{name}} and all its messages and files are deleted for every member. This cannot be undone.",
"seen": "Seen",
"seenAll": "Seen by everyone",
"seenBy": "Seen by {{names}}",
"deletedUser": "Deleted user",
"dock": "Chats",
"dockUnread": "Chats, {{n}} unread"
"happy": ["great run! 😻", "love a tidy finish ✨", "purrfect 💜"],
"thinking": ["hmm, let me look… 🤔", "sniffing around… 🐾", "one sec, checking 🔍"],
"watching": ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
"sleeping": ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
"idle": ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"]
}
},
"pricing": {
@@ -1272,6 +1153,11 @@
"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": {
"title": "Oh My Pi login sharing",
"switch": "Share this server's Oh My Pi login",
+4
View File
@@ -223,5 +223,9 @@
"errors": "Các bước bị lỗi. Vài lỗi là bình thường; nhiều lỗi có thể nghĩa là AI bị kẹt.",
"tokenFlow": "Input = gửi cho AI, Output = AI viết ra, Cache = dùng lại từ bộ nhớ đệm (rẻ hơn)."
}
},
"select": {
"search": "Tìm…",
"noMatch": "Không có kết quả"
}
}
+1
View File
@@ -195,6 +195,7 @@
"gitBar.pushHint": "git push -u origin HEAD",
"gitBar.switch": "Nhánh",
"gitBar.switchApply": "Chuyển nhánh",
"gitBar.branchSearch": "Tìm nhánh…",
"gitBar.switchCurrent": "hiện tại",
"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",
+21 -147
View File
@@ -45,7 +45,6 @@
"mute": "Tắt tiếng",
"unmute": "Bật tiếng",
"clearAlerts": "Xoá cảnh báo",
"send": "Gửi",
"rename": "Bấm để đổi tên {{name}}",
"renameLabel": "Tên pet",
"renameHint": "Enter để lưu · để trống = tên mặc định",
@@ -53,13 +52,15 @@
"attention": "Cần bạn xem",
"attentionError": "bị lỗi",
"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",
"open": "Mở trợ lý {{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",
"startFailed": "Không khởi động được phiên: {{error}}",
"bubbleOpen": "Mở",
"bubbleMore": "{{list}} và {{count}} phiên khác",
"notify": {
@@ -77,26 +78,10 @@
"run_done_other": "{{count}} lượt chạy vừa xong 🐾"
},
"greet": {
"morning": [
"chào buổi sáng! ☀️",
"sáng rồi! cà phê chưa? ☕",
"ngày mới tốt lành nha 🌤️"
],
"afternoon": [
"chào buổi chiều! 🐾",
"bạn quay lại rồi 😺",
"chiều nay làm gì đây? ✨"
],
"evening": [
"chào buổi tối! 🌆",
"ca tối hả? có mình đây 😼",
"tối rồi, cố lên nhé 🌙"
],
"night": [
"khuya rồi mà vẫn làm à? 🌙",
"để mình canh giúp cho 🦉",
"nhớ nghỉ ngơi nha 💤"
],
"morning": ["chào buổi sáng! ☀️", "sáng rồi! cà phê chưa? ☕", "ngày mới tốt lành nha 🌤️"],
"afternoon": ["chào buổi chiều! 🐾", "bạn quay lại rồi 😺", "chiều nay làm gì đây? ✨"],
"evening": ["chào buổi tối! 🌆", "ca tối hả? có mình đây 😼", "tối rồi, cố lên nhé 🌙"],
"night": ["khuya rồi mà vẫn làm à? 🌙", "để mình canh giúp cho 🦉", "nhớ nghỉ ngơi nha 💤"],
"summary_one": "{{count}} phiên đang chạy",
"summary_other": "{{count}} phiên đang chạy",
"summaryWaiting": "{{count}} phiên chờ bạn",
@@ -114,11 +99,7 @@
"help": "Mình là {{name}} - mình canh các phiên của bạn. Thử hỏi \"đang chạy gì\", \"có lỗi không\", \"cái gì đang chờ\" hoặc \"tình hình\". Câu khác mình sẽ chuyển cho Claude.",
"whoami": "Mình là {{name}}, bạn đồng hành canh phiên của bạn 🐾 Chuột phải vào mình để đổi ngoại hình, hoặc bấm vào tên trong bảng để đặt tên mới.",
"hello": "chào bạn! 😺 {{summary}}.",
"thanks": [
"có gì đâu! 💜",
"rất vui được giúp 😸",
"chuyện nhỏ 🐾"
],
"thanks": ["có gì đâu! 💜", "rất vui được giúp 😸", "chuyện nhỏ 🐾"],
"errors_one": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
"errors_other": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
"errorsNone": "không có lỗi nào - sạch sẽ 🐾",
@@ -175,125 +156,13 @@
"không gọi được máy chủ 📡",
"mất sóng - đi ngủ chút 💤"
],
"worried": [
"có vẻ không ổn 😟",
"mình đang để mắt đây 👀",
"hừm, có gì đó lạ lạ 🫣"
],
"stuck": [
"một phiên im lặng lâu rồi… 🤔",
"có gì bị kẹt không? ⏳",
"vẫn đang nhai… 😾"
],
"happy": [
"tuyệt vời! 😻",
"kết thúc gọn ghê ✨",
"hoàn hảo 💜"
],
"thinking": [
"để mình xem… 🤔",
"đang đánh hơi… 🐾",
"chờ chút, đang kiểm tra 🔍"
],
"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 🐈"
],
"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."
},
"people": {
"rail": "Trò chuyện",
"new": "Chat mới",
"newHint": "Chọn 1 người để chat riêng, hoặc nhiều người để tạo nhóm.",
"search": "Tìm người",
"none": "Không tìm thấy ai",
"groupName": "Tên nhóm (không bắt buộc)",
"createGroup": "Tạo nhóm ({{n}} người)",
"startDm": "Bắt đầu chat",
"close": "Đóng",
"unreadIn": "{{name}}, {{n}} tin chưa đọc",
"roomLabel": "Chat với {{name}}",
"members": "{{n}} thành viên",
"settings": "Cài đặt nhóm",
"back": "Quay lại cuộc chat",
"rename": "Đổi tên",
"creator": "· người tạo nhóm",
"remove": "Xoá {{name}} khỏi nhóm",
"add": "Thêm người",
"addN": "Thêm {{n}} người",
"leave": "Rời nhóm",
"leaveTitle": "Rời nhóm này?",
"leaveBody": "Bạn sẽ không còn thấy {{name}}. Người trong nhóm có thể thêm bạn lại.",
"older": "Xem tin cũ hơn",
"empty": "Chưa có tin nhắn. Hãy chào {{name}}.",
"placeholder": "Nhắn cho {{name}}",
"attach": "Đính kèm file",
"unattach": "Bỏ {{name}}",
"tooBig": "{{name}} lớn hơn 25 MB",
"sysCreated": "{{who}} đã tạo nhóm",
"sysRenamed": "{{who}} đã đổi tên nhóm",
"sysLeft": "{{who}} đã rời nhóm",
"sysAdded": "{{who}} đã thêm {{names}}",
"sysRemoved": "{{who}} đã xoá {{names}}",
"openFrom": "Mở chat với {{name}}",
"menuOpen": "Mở",
"menuRead": "Đánh dấu đã đọc",
"menuReadAll": "Đánh dấu đã đọc tất cả",
"menuMute": "Tắt thông báo",
"menuUnmute": "Bật lại thông báo",
"menuCopyName": "Sao chép tên",
"menuPet": "Chat với {{name}}",
"menuDm": "Nhắn riêng cho {{name}}",
"menuOpenFile": "Mở trong tab mới",
"menuDownload": "Tải về",
"menuCopyFileName": "Sao chép tên file",
"menuCopyText": "Sao chép nội dung",
"menuReply": "Trả lời",
"menuDelete": "Xoá tin nhắn",
"deleteTitle": "Xoá tin nhắn này?",
"deleteBody": "Tin nhắn và file đính kèm sẽ bị xoá với mọi người trong cuộc chat. Không thể hoàn tác.",
"menuClearDm": "Xoá cuộc trò chuyện",
"menuClearGroup": "Xoá lịch sử chat",
"clearDmTitle": "Xoá cuộc trò chuyện này?",
"clearDmBody": "Cuộc trò chuyện với {{name}} và lịch sử của nó chỉ bị xoá ở phía bạn — người kia vẫn giữ. Nếu một trong hai nhắn tiếp, cuộc chat sẽ hiện lại nhưng không còn tin cũ.",
"clearGroupTitle": "Xoá lịch sử của nhóm này?",
"clearGroupBody": "Mọi tin nhắn trong {{name}} đến giờ chỉ bị xoá ở phía bạn. Bạn vẫn ở trong nhóm; người khác vẫn giữ tin của họ.",
"disband": "Giải tán nhóm",
"disbandTitle": "Giải tán nhóm này?",
"disbandBody": "{{name}} cùng toàn bộ tin nhắn và file sẽ bị xoá với mọi thành viên. Không thể hoàn tác.",
"seen": "Đã xem",
"seenAll": "Mọi người đã xem",
"seenBy": "{{names}} đã xem",
"deletedUser": "Người dùng đã xoá",
"dock": "Trò chuyện",
"dockUnread": "Trò chuyện, {{n}} tin chưa đọc"
"worried": ["có vẻ không ổn 😟", "mình đang để mắt đây 👀", "hừm, có gì đó lạ lạ 🫣"],
"stuck": ["một phiên im lặng lâu rồi… 🤔", "có gì bị kẹt không? ⏳", "vẫn đang nhai… 😾"],
"happy": ["tuyệt vời! 😻", "kết thúc gọn ghê ✨", "hoàn hảo 💜"],
"thinking": ["để mình xem… 🤔", "đang đánh hơi… 🐾", "chờ chút, đang kiểm tra 🔍"],
"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 🐈"],
"idle": ["yên ắng quá 😺", "sẵn sàng khi bạn cần 🐾", "chill thôi ✨"]
}
},
"pricing": {
@@ -1272,6 +1141,11 @@
"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": {
"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ủ",
+15 -95
View File
@@ -547,44 +547,6 @@ function ccQuery(scope?: CcScope, cwd?: string): string {
/** Fired on `window` when a request answers 401 `EAUTH` (server mode). */
export const AUTH_REQUIRED_EVENT = "agenthub:auth-required";
/** Someone to chat with (`GET /api/chat/people`). */
export type ChatPerson = {
id: number;
username: string;
displayName: string | null;
avatarAt: string | null;
/** A disabled account: named in old chats, never offered for a new one. */
disabled?: boolean;
};
/** A file of a chat message (`GET /api/chat/rooms/:id/files/:fileId`). */
export type ChatFile = { id: string; name: string; type: string; size: number };
/** One chat message; `system` bodies: created, renamed, left, added:<ids>, removed:<ids>. */
export type ChatMessage = {
id: number;
roomId: number;
userId: number | null;
kind: "text" | "system";
body: string;
createdAt: string;
files: ChatFile[];
};
/** A direct chat or a group, as the caller sees it. */
export type ChatRoom = {
id: number;
kind: "dm" | "group";
name: string;
createdBy: number | null;
createdAt: string;
lastMessageAt: string | null;
members: number[];
lastMessage: ChatMessage | null;
lastReadId: number;
/** Messages up to this id were deleted by the viewer for themselves. */
clearedId?: number;
/** Every member's read mark by user id ("Seen"); absent from an older server. */
reads?: Record<string, number>;
unread: number;
};
/** One account in Settings → Users (`GET /api/users`). */
export type ServerUserUsage = AuthUser & {
disabled: boolean;
@@ -1605,63 +1567,6 @@ export const api = {
request<HostHistory>(`/host/history?range=${range}`),
},
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
/** `/api/chat` — chat between users in server mode (the pet's window). */
chat: {
people: () => request<{ people: ChatPerson[] }>("/chat/people"),
rooms: () => request<{ rooms: ChatRoom[] }>("/chat/rooms"),
/** The one direct chat with `userId`, made on first use. */
openDm: (userId: number) =>
request<{ room: ChatRoom }>("/chat/rooms", {
method: "POST",
body: JSON.stringify({ kind: "dm", userId }),
}),
createGroup: (name: string, userIds: number[]) =>
request<{ room: ChatRoom }>("/chat/rooms", {
method: "POST",
body: JSON.stringify({ kind: "group", name, userIds }),
}),
/** The group's creator only. */
updateGroup: (id: number, patch: { name?: string; add?: number[]; remove?: number[] }) =>
request<{ room: ChatRoom }>(`/chat/rooms/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
}),
leave: (id: number) => request<{ ok: true }>(`/chat/rooms/${id}/leave`, { method: "POST" }),
messages: (id: number, before?: number) =>
request<{ messages: ChatMessage[]; hasMore: boolean }>(
`/chat/rooms/${id}/messages${before ? `?before=${before}` : ""}`
),
/** Text and files (multipart when there are files). */
post: (id: number, body: string, files: File[] = []) => {
if (!files.length)
return request<{ message: ChatMessage }>(`/chat/rooms/${id}/messages`, {
method: "POST",
body: JSON.stringify({ body }),
});
const form = new FormData();
form.append("body", body);
for (const f of files) form.append("files", f, f.name);
return request<{ message: ChatMessage }>(`/chat/rooms/${id}/messages`, {
method: "POST",
body: form,
});
},
read: (id: number, upTo: number) =>
request<{ lastReadId: number }>(`/chat/rooms/${id}/read`, {
method: "POST",
body: JSON.stringify({ upTo }),
}),
/** Delete the chat for yourself only (a direct chat leaves your list). */
clear: (id: number) =>
request<{ hidden: boolean }>(`/chat/rooms/${id}/clear`, { method: "POST" }),
/** The group's creator deletes it for everyone. */
disband: (id: number) => request<{ ok: true }>(`/chat/rooms/${id}`, { method: "DELETE" }),
/** Your own message only (403 otherwise). */
deleteMessage: (roomId: number, messageId: number) =>
request<{ ok: true }>(`/chat/rooms/${roomId}/messages/${messageId}`, { method: "DELETE" }),
fileUrl: (roomId: number, fileId: string) =>
`${BASE}/chat/rooms/${roomId}/files/${encodeURIComponent(fileId)}`,
},
users: {
/** URL of one account's avatar (any signed-in account), versioned by `avatarAt` (null = none). */
avatarUrl: (id: number, avatarAt?: string | null) =>
@@ -3877,6 +3782,14 @@ export const api = {
method: "PUT",
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. */
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). */
@@ -5369,6 +5282,13 @@ export interface ArtifactCandidate {
}
/** `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 {
/** Server mode with per-user Linux accounts (S3): the only place it applies. */
available: boolean;
+2 -17
View File
@@ -1917,16 +1917,10 @@ export interface WSMessage {
* alert_triggered/alert_updated → AlertEvent; workflow_upserted → WorkflowRun;
* remote_source.status → RemoteSourceStatusPayload;
* remote_data.updated → RemoteDataUpdatedPayload;
* lane_update → { lane?: Lane; removed?: number };
* chat_message → { roomId, message: ChatMessage, to }; chat_room → { roomId, to };
* chat_deleted → { roomId, messageId, to }; chat_read → { roomId, userId, lastReadId, to }. */
* lane_update → { lane?: Lane; removed?: number }. */
type:
| "session_created"
| "session_updated"
| "chat_message"
| "chat_room"
| "chat_deleted"
| "chat_read"
| "agent_created"
| "agent_updated"
| "new_event"
@@ -1973,16 +1967,7 @@ export interface WSMessage {
| { lane?: Lane; removed?: number }
| LaneHookOutputPayload
| LaneRuntimePayload
| LaneHookResultPayload
/** chat_message / chat_room (lib/api.ts ChatMessage). */
| {
roomId: number;
message?: unknown;
messageId?: number;
userId?: number;
lastReadId?: number;
to?: number[];
};
| LaneHookResultPayload;
/** ISO timestamp the server broadcast this message (not necessarily the
* same instant the underlying event occurred). */
timestamp: string;
+3
View File
@@ -72,6 +72,7 @@ import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings";
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
import { MemberPolicySettings } from "../components/settings/MemberPolicySettings";
import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings";
import { PoolAccountSettings } from "../components/settings/PoolAccountSettings";
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
@@ -317,6 +318,8 @@ export function SettingsHub({ section }: { section?: string } = {}) {
<ArtifactsHubSettings />
{/* The host's omp login for members: server mode + S3, an admin's. */}
<OmpSharingSettings />
{/* Members' test runs on this box: server mode + S3, an admin's. */}
<MemberPolicySettings />
</div>
),
},
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.66 (rebuild client to match)
UI build v2.8.70 (rebuild client to match)
</span>
</p>
</div>
+7 -26
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/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.
@@ -197,9 +197,11 @@ POST /api/lanes/ensure
```
`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
adopts its dedicated workspace without the client needing to know an absolute
path. Existing cwd values still pass through unchanged. `pipeline` is
`os.homedir()` (the user's home directory) — or, for a server-mode member
with an own folder, `<their folder>/sagi`, made as them on first use (the
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**
— an existing workspace is returned untouched. Omitted, the workspace gets `default`.
`POST /api/lanes/worktree` and `POST /api/lanes` take the same optional
@@ -3491,33 +3493,12 @@ 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)
```
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.
**Featured pets are downloaded, never bundled.** The three offered (Goku, Border Collie, 11) are other people's uploads to codex-pets.net with no licence to redistribute them, so AgentHub ships none of their art: `POST /api/pets/featured/:id` takes only an id from the fixed list in `server/lib/pets.js` (`404 ENOPET` for any other, with no request made) and fetches `https://codex-pets.net/api/pets/<id>` and `/spritesheet` — no URL from the request, no redirects followed. A pet id is always made by the server (`p-<12 hex>` for an upload, `cp-<featured id>`), every write is same-origin (`403 EBADORIGIN`), and at most 50 pets are kept (`409 ETOOMANY`). Other errors: `413 ETOOBIG`, `502 EFETCH` (codex-pets.net unreachable or refusing).
### Chat between users (server mode)
The pet's window has a rail of chats beside the chat with the pet: direct chats (one per pair of users) and groups. Only a room's MEMBERS read or write it — an admin or analyst included; another user's room answers `404 ENOTFOUND` like a missing one. Local mode answers `400 ELOCALMODE`. Code: `server/lib/chat.js`, `server/routes/chat.js`, client `client/src/components/Sagi/ChatHub.tsx` + `useChat.ts`.
```
GET /api/chat/people {people: [{id, username, displayName, avatarAt, disabled}]} — every account but you; a disabled one is named in old chats, never offered for a new one
GET /api/chat/rooms {rooms} — yours, newest first: members, lastMessage, lastReadId, clearedId, reads (every member's read mark — "Seen"), unread
POST /api/chat/rooms {kind: "dm", userId} → the pair's one direct chat (201 new / 200 existing); {kind: "group", name?, userIds} → 201
PATCH /api/chat/rooms/:id {name?, add?, remove?} — the group's creator only (403 EFORBIDDEN)
DELETE /api/chat/rooms/:id Disband a group — its creator only, for every member, files included
POST /api/chat/rooms/:id/clear Delete the chat for YOURSELF: {hidden, purged} — see below
POST /api/chat/rooms/:id/leave Leave a group; the last one out deletes it and its files
GET /api/chat/rooms/:id/messages?before=&limit= {messages (oldest first), hasMore} — 50 by default, ≤ 200
POST /api/chat/rooms/:id/messages JSON {body} or multipart `body` + ≤ 10 `files` of ≤ 25 MB; body ≤ 8000 chars
DELETE /api/chat/rooms/:id/messages/:messageId Delete your OWN text message and its files (403 EFORBIDDEN otherwise, an admin included)
POST /api/chat/rooms/:id/read {upTo} — read mark, never moves back nor past the newest message; a move is sent as `chat_read`
GET /api/chat/rooms/:id/files/:fileId The file, members only, `sandbox` CSP + `nosniff`; PNG/JPEG/GIF/WebP inline, else attachment
```
Files are stored under `<data dir>/chat/<room id>/<32 hex id>` — a name the server makes. Right-click menus in the window: a room (open, mark read, mute notifications — per browser —, copy name, group settings, leave), the rail's pet and **+** (mark all read), a message (copy text, reply, message the sender privately, delete your own), a file (open, download, copy name) and a group member (message privately, remove — the creator). **Deleting a chat** is per person: the messages so far vanish for the caller only (`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 — the messages up to the lowest `cleared_id` and their files on disk, and a direct chat both people deleted, whole (`purge`, also run when a member leaves or is removed). A group whose creator left or was deleted passes to its longest-standing member. Group changes (`system` lines) are sent live as `chat_message` and never count as unread. An upload is written to `<data dir>/chat/.incoming/` and moved into place — never held in memory whole. In the browser, only the first tab to see a message plays its sound or shows a system notification; a message from someone in a room not on screen shows as a card on the pet. With the pet hidden, a 💬 button in the bottom-right corner (`ChatDock.tsx`) opens the same chat window. **Seen**: the room shows under your newest message "Seen" (direct chat), "Seen by …" or "Seen by everyone" (group), from `reads`. A group change adds a `system` message (`created`, `renamed`, `left`, `added:<ids>`, `removed:<ids>`). Every change is sent over `/ws` as `chat_message` (`{roomId, message, to}`), `chat_room` (`{roomId, to}`) or `chat_deleted` (`{roomId, messageId, to}`) or `chat_read` (`{roomId, userId, lastReadId, to}`) to exactly the user ids in `to` (`scope.exactAudience`), whatever their role.
### Session snapshots
```
+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) |
| **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` |
| **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 list is a combobox: it opens with a **Search branches…** box focused, typing narrows the local and remote branches to names containing the text, ↑/↓ move, Enter picks, Esc closes (#88 — every `Select` longer than 8 options gets the same search box); `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** | `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 |
+46
View File
@@ -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
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
Users page → Edit → **Delete user** (the admin's own password, then a red
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.66",
"version": "2.8.70",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.66",
"version": "2.8.70",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.66",
"version": "2.8.70",
"bin": {
"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/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/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
# /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)
# /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>
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/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/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
# ── 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: "*" } : {}),
hooks: [{ type: "command", command: `/opt/agenthub/libexec/member-hook ${e}`, timeout: 5,
...(["PreToolUse","PostToolUse"].includes(e) ? { async: true } : {}) }] }];
process.stdout.write(JSON.stringify({ hooks: Object.fromEntries(ev.map((e) => [e, hook(e)])) }, null, 2) + "\n");
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"
chmod 0644 "$MANAGED"
sha256sum "$MANAGED" >"$MANAGED_MARK"
@@ -203,6 +213,14 @@ ExecStart=/usr/bin/env \${AGENTHUB_NODE} \${AGENTHUB_ROOT}/server/index.js
Restart=always
RestartSec=3
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]
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 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>
set -eu
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; }
export "$kv"
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}")
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[@]}"
+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"
-306
View File
@@ -1,306 +0,0 @@
/**
* @file chat.test.js
* @description Chat between users (server/lib/chat.js, server/routes/chat.js):
* a direct chat is one per pair, a group's creator alone changes it, only a
* room's members read it — an admin included —, unread counts and read marks,
* files served only to members under a sandbox CSP, a message deleted only by
* its author, "delete chat" for the caller alone, a group disbanded only by its
* creator, local mode refused, and
* every chat frame reaches exactly the users in its `to` (scope.exactAudience).
* @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(), "chat-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_DATA_DIR = TMP;
const prevMode = process.env.AGENTHUB_MODE;
process.env.AGENTHUB_MODE = "server";
const { db } = require("../db");
const scope = require("../lib/scope");
const USERS = {
1: { id: 1, username: "boss", role: "admin" },
2: { id: 2, username: "an", role: "developer" },
3: { id: 3, username: "binh", role: "developer" },
4: { id: 4, username: "chi", role: "analyst" },
};
let srv;
let base;
before(async () => {
const ins = db.prepare(
"INSERT INTO users (id, username, role, password_hash) VALUES (?, ?, ?, 'x')"
);
for (const u of Object.values(USERS)) ins.run(u.id, u.username, u.role);
const express = require("express");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.user = USERS[req.headers["x-user"]];
next();
});
app.use("/api/chat", require("../routes/chat"));
await new Promise((r) => (srv = app.listen(0, r)));
base = `http://127.0.0.1:${srv.address().port}/api/chat`;
});
after(() => {
srv?.close();
if (prevMode === undefined) delete process.env.AGENTHUB_MODE;
else process.env.AGENTHUB_MODE = prevMode;
fs.rmSync(TMP, { recursive: true, force: true });
});
async function call(user, method, p, body) {
const init = { method, headers: { "x-user": String(user) } };
if (body instanceof FormData) init.body = body;
else if (body !== undefined) {
init.body = JSON.stringify(body);
init.headers["content-type"] = "application/json";
}
const r = await fetch(base + p, init);
const type = r.headers.get("content-type") || "";
return {
status: r.status,
headers: r.headers,
body: type.includes("json") ? await r.json() : await r.text(),
};
}
describe("chat", () => {
let dm;
let group;
it("lists the other enabled users to chat with", async () => {
const r = await call(2, "GET", "/people");
assert.deepEqual(r.body.people.map((p) => p.username).sort(), ["binh", "boss", "chi"]);
assert.ok(r.body.people.every((p) => p.disabled === false));
});
it("opens one direct chat per pair, whoever starts it", async () => {
const a = await call(2, "POST", "/rooms", { kind: "dm", userId: 3 });
assert.equal(a.status, 201);
const b = await call(3, "POST", "/rooms", { kind: "dm", userId: 2 });
assert.equal(b.status, 200);
assert.equal(b.body.room.id, a.body.room.id);
dm = a.body.room.id;
assert.equal((await call(2, "POST", "/rooms", { kind: "dm", userId: 2 })).status, 400);
});
it("keeps a chat to its members — an admin gets 404 like a missing room", async () => {
const m = await call(2, "POST", `/rooms/${dm}/messages`, { body: "chào Bình" });
assert.equal(m.status, 201);
assert.equal((await call(1, "GET", `/rooms/${dm}/messages`)).status, 404);
assert.equal((await call(1, "POST", `/rooms/${dm}/messages`, { body: "x" })).status, 404);
assert.deepEqual((await call(1, "GET", "/rooms")).body.rooms, []);
const seen = await call(3, "GET", `/rooms/${dm}/messages`);
assert.deepEqual(
seen.body.messages.map((x) => x.body),
["chào Bình"]
);
});
it("counts unread messages until they are marked read", async () => {
let rooms = (await call(3, "GET", "/rooms")).body.rooms;
assert.equal(rooms[0].unread, 1);
assert.equal((await call(2, "GET", "/rooms")).body.rooms[0].unread, 0); // your own
await call(3, "POST", `/rooms/${dm}/read`, { upTo: rooms[0].lastMessage.id });
rooms = (await call(3, "GET", "/rooms")).body.rooms;
assert.equal(rooms[0].unread, 0);
});
it("lets a group's creator alone change it, and any member leave", async () => {
const g = await call(2, "POST", "/rooms", { kind: "group", name: "Team", userIds: [3] });
assert.equal(g.status, 201);
group = g.body.room.id;
assert.deepEqual(g.body.room.members, [2, 3]);
assert.equal((await call(3, "PATCH", `/rooms/${group}`, { add: [4] })).status, 403);
const up = await call(2, "PATCH", `/rooms/${group}`, { add: [4], name: "Team A" });
assert.deepEqual(up.body.room.members, [2, 3, 4]);
assert.equal(up.body.room.name, "Team A");
assert.equal((await call(4, "POST", `/rooms/${group}/leave`)).status, 200);
assert.equal((await call(4, "GET", `/rooms/${group}/messages`)).status, 404);
assert.equal((await call(2, "PATCH", `/rooms/${group}`, { add: [99] })).status, 400);
});
it("stores files and serves them only to members, sandboxed", async () => {
const fd = new FormData();
fd.append("body", "ảnh nè");
fd.append("files", new Blob([Buffer.from("PNGDATA")], { type: "image/png" }), "màn hình.png");
fd.append("files", new Blob(["<script>x</script>"], { type: "text/html" }), "a.html");
const m = await call(3, "POST", `/rooms/${group}/messages`, fd);
assert.equal(m.status, 201);
const [img, page] = m.body.message.files;
assert.equal(img.name, "màn hình.png");
const got = await call(2, "GET", `/rooms/${group}/files/${img.id}`);
assert.equal(got.status, 200);
assert.equal(got.body, "PNGDATA");
assert.match(got.headers.get("content-security-policy"), /sandbox/);
assert.equal(got.headers.get("x-content-type-options"), "nosniff");
assert.match(got.headers.get("content-disposition"), /^inline/);
const html = await call(2, "GET", `/rooms/${group}/files/${page.id}`);
assert.match(html.headers.get("content-disposition"), /^attachment/);
assert.equal((await call(1, "GET", `/rooms/${group}/files/${img.id}`)).status, 404);
assert.equal(
(await call(2, "GET", `/rooms/${group}/files/..%2f..%2fdashboard.db`)).status,
404
);
});
it("lets only the author delete a message, its files too", async () => {
const fd = new FormData();
fd.append("body", "xoá tôi");
fd.append("files", new Blob(["x"], { type: "text/plain" }), "a.txt");
const m = (await call(2, "POST", `/rooms/${group}/messages`, fd)).body.message;
assert.equal((await call(3, "DELETE", `/rooms/${group}/messages/${m.id}`)).status, 403);
assert.equal((await call(1, "DELETE", `/rooms/${group}/messages/${m.id}`)).status, 404);
assert.equal((await call(2, "DELETE", `/rooms/${group}/messages/${m.id}`)).status, 200);
const left = (await call(2, "GET", `/rooms/${group}/messages`)).body.messages;
assert.ok(!left.some((x) => x.id === m.id));
assert.equal((await call(2, "GET", `/rooms/${group}/files/${m.files[0].id}`)).status, 404);
// A system line is nobody's to delete.
const sys = left.find((x) => x.kind === "system");
assert.equal((await call(2, "DELETE", `/rooms/${group}/messages/${sys.id}`)).status, 403);
});
it("deletes a direct chat for the caller only, until a new message", async () => {
await call(3, "POST", `/rooms/${dm}/messages`, { body: "cũ" });
const c = await call(2, "POST", `/rooms/${dm}/clear`);
assert.deepEqual(c.body, { hidden: true, purged: false });
assert.ok(!(await call(2, "GET", "/rooms")).body.rooms.some((r) => r.id === dm));
assert.deepEqual((await call(2, "GET", `/rooms/${dm}/messages`)).body.messages, []);
// The other person keeps everything.
assert.ok((await call(3, "GET", `/rooms/${dm}/messages`)).body.messages.length >= 2);
await call(3, "POST", `/rooms/${dm}/messages`, { body: "mới" });
const back = (await call(2, "GET", "/rooms")).body.rooms.find((r) => r.id === dm);
assert.equal(back.unread, 1);
assert.deepEqual(
(await call(2, "GET", `/rooms/${dm}/messages`)).body.messages.map((m) => m.body),
["mới"]
);
});
it("hides a cleared message's files from the one who cleared it", async () => {
const fd = new FormData();
fd.append("files", new Blob(["x"], { type: "text/plain" }), "b.txt");
const m = (await call(3, "POST", `/rooms/${group}/messages`, fd)).body.message;
await call(2, "POST", `/rooms/${group}/clear`);
assert.equal((await call(2, "GET", `/rooms/${group}/files/${m.files[0].id}`)).status, 404);
assert.equal((await call(3, "GET", `/rooms/${group}/files/${m.files[0].id}`)).status, 200);
// A group stays in the list after its history is cleared.
assert.ok((await call(2, "GET", "/rooms")).body.rooms.some((r) => r.id === group));
});
it("lets only a group's creator disband it, for everyone", async () => {
const g = (await call(3, "POST", "/rooms", { kind: "group", name: "Tạm", userIds: [2] })).body
.room.id;
assert.equal((await call(2, "DELETE", `/rooms/${g}`)).status, 403);
assert.equal((await call(3, "DELETE", `/rooms/${g}`)).status, 200);
assert.equal((await call(2, "GET", `/rooms/${g}/messages`)).status, 404);
assert.equal((await call(3, "DELETE", `/rooms/${dm}`)).status, 400);
});
it("deletes for real what everyone deleted, and a direct chat both deleted whole", async () => {
const { db } = require("../db");
const fd = new FormData();
fd.append("body", "bí mật");
fd.append("files", new Blob(["y"], { type: "text/plain" }), "c.txt");
const room = (await call(4, "POST", "/rooms", { kind: "dm", userId: 3 })).body.room.id;
const m = (await call(4, "POST", `/rooms/${room}/messages`, fd)).body.message;
const onDisk = path.join(TMP, "chat", String(room), m.files[0].id);
assert.ok(fs.existsSync(onDisk));
await call(4, "POST", `/rooms/${room}/clear`);
// One side only: still stored for the other.
assert.ok(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id));
assert.ok(fs.existsSync(onDisk));
const both = await call(3, "POST", `/rooms/${room}/clear`);
assert.equal(both.body.purged, true);
assert.equal(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id), undefined);
assert.equal(db.prepare("SELECT 1 FROM chat_rooms WHERE id = ?").get(room), undefined);
assert.ok(!fs.existsSync(onDisk));
// Opening it again starts afresh.
const again = (await call(4, "POST", "/rooms", { kind: "dm", userId: 3 })).body.room;
assert.equal(again.lastMessage, null);
});
it("purges a group's history once every member cleared it", async () => {
const { db } = require("../db");
const g = (await call(2, "POST", "/rooms", { kind: "group", userIds: [3] })).body.room.id;
const m = (await call(2, "POST", `/rooms/${g}/messages`, { body: "x" })).body.message;
await call(2, "POST", `/rooms/${g}/clear`);
assert.ok(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id));
await call(3, "POST", `/rooms/${g}/clear`);
assert.equal(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id), undefined);
// The group itself stays.
assert.ok((await call(2, "GET", "/rooms")).body.rooms.some((r) => r.id === g));
});
it("tells every member's read mark (seen), never past the newest message", async () => {
const r = await call(3, "POST", `/rooms/${group}/read`, { upTo: 1e9 });
const room = (await call(2, "GET", "/rooms")).body.rooms.find((x) => x.id === group);
assert.equal(room.reads[3], r.body.lastReadId);
assert.equal(room.reads[3], room.lastMessage ? room.lastMessage.id : room.reads[3]);
assert.ok(r.body.lastReadId < 1e9);
assert.deepEqual([...scope.exactAudience("chat_read", { to: [2, 3] })], [2, 3]);
});
it("hands a group to its longest-standing member when the creator leaves", async () => {
const g = (await call(2, "POST", "/rooms", { kind: "group", name: "Kế", userIds: [3, 4] })).body
.room.id;
await call(2, "POST", `/rooms/${g}/leave`);
const room = (await call(3, "GET", "/rooms")).body.rooms.find((r) => r.id === g);
assert.equal(room.createdBy, 3);
assert.equal((await call(3, "PATCH", `/rooms/${g}`, { name: "Kế 2" })).status, 200);
// The "left" and "renamed" lines never count as unread.
const four = (await call(4, "GET", "/rooms")).body.rooms.find((r) => r.id === g);
assert.equal(four.unread, 0);
});
it("still names a disabled user, and never offers them for a new chat", async () => {
const { db } = require("../db");
db.prepare("UPDATE users SET disabled = 1 WHERE id = 4").run();
try {
const chi = (await call(2, "GET", "/people")).body.people.find((p) => p.id === 4);
assert.equal(chi.disabled, true);
assert.equal((await call(2, "POST", "/rooms", { kind: "dm", userId: 4 })).status, 400);
} finally {
db.prepare("UPDATE users SET disabled = 0 WHERE id = 4").run();
}
});
it("leaves nothing behind in the upload folder", async () => {
const fd = new FormData();
fd.append("files", new Blob(["z"], { type: "text/plain" }), "d.txt");
assert.equal((await call(2, "POST", `/rooms/${group}/messages`, fd)).status, 201);
const fd2 = new FormData();
fd2.append("files", new Blob(["z"], { type: "text/plain" }), "e.txt");
assert.equal((await call(1, "POST", `/rooms/${group}/messages`, fd2)).status, 404);
assert.deepEqual(fs.readdirSync(path.join(TMP, "chat", ".incoming")), []);
});
it("refuses an empty message", async () => {
assert.equal((await call(2, "POST", `/rooms/${dm}/messages`, { body: " " })).status, 400);
});
it("is refused in local mode", async () => {
delete process.env.AGENTHUB_MODE;
try {
assert.equal((await call(2, "GET", "/rooms")).body.error.code, "ELOCALMODE");
} finally {
process.env.AGENTHUB_MODE = "server";
}
});
it("sends a chat frame to exactly its `to`, an admin not included", () => {
const only = scope.exactAudience("chat_message", { roomId: 1, to: [2, 3] });
assert.deepEqual([...only], [2, 3]);
assert.deepEqual([...scope.exactAudience("chat_room", {})], []);
});
});
+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);
});
});
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");
});
});
@@ -0,0 +1,106 @@
/**
* @file Regression test: a turn the transcript says has ended lands the main
* agent in Waiting even when Claude Code never sends the Stop hook (it skips
* it in some turns — only the prompt-suggestion helper's SubagentStop
* arrives). Before, the agent stayed `working` until the idle Notification a
* minute later and the chat showed Stop on a finished answer. A turn end OLDER
* than the last prompt, or one read on new work (UserPromptSubmit), must not.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const http = require("http");
const STAMP = `turn-end-${Date.now()}-${process.pid}`;
const TMP = path.join(os.tmpdir(), STAMP);
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, "home");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
const { createApp, startServer } = require("../index");
const { stmts } = require("../db");
let server;
let BASE;
function post(body) {
return new Promise((resolve, reject) => {
const url = new URL("/api/hooks/event", BASE);
const payload = JSON.stringify(body);
const r = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: "POST",
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) },
},
(res) => {
res.resume();
res.on("end", () => resolve(res.statusCode));
}
);
r.on("error", reject);
r.write(payload);
r.end();
});
}
const line = (o) => JSON.stringify(o) + "\n";
const user = (text) => ({ type: "user", message: { role: "user", content: text } });
const reply = (id) => ({
type: "assistant",
message: { id, model: "claude-sonnet-5", content: [{ type: "text", text: "ok" }], usage: { input_tokens: 1, output_tokens: 1 } },
});
const turnEnd = (ts) => ({ type: "system", subtype: "turn_duration", durationMs: 900, timestamp: ts });
before(async () => {
fs.mkdirSync(TMP, { recursive: true });
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("a finished turn without a Stop hook", () => {
it("lands in Waiting on the next hook that reads the turn's end", async () => {
const cwd = path.join(TMP, "work");
fs.mkdirSync(cwd, { recursive: true });
const sid = "12121212-3434-5656-7878-909090909090";
const tpath = path.join(TMP, `${sid}.jsonl`);
const event = (extra = {}) => ({ session_id: sid, cwd, transcript_path: tpath, ...extra });
const main = () => stmts.getAgent.get(`${sid}-main`);
fs.writeFileSync(tpath, line(user("hello")));
assert.equal(await post({ hook_type: "UserPromptSubmit", data: event({ prompt: "hello" }) }), 200);
assert.equal(main().status, "working");
// The turn ends; Claude Code writes turn_duration but sends no Stop —
// only a helper agent's SubagentStop arrives.
fs.appendFileSync(tpath, line(reply("m1")) + line(turnEnd(new Date().toISOString())));
assert.equal(
await post({ hook_type: "SubagentStop", data: event({ agent_id: "a1", agent_type: "" }) }),
200
);
assert.equal(main().status, "waiting");
assert.equal(stmts.getSession.get(sid).awaiting_reason, "stop");
// A new prompt: that turn's old end must not end the new one.
await new Promise((r) => setTimeout(r, 5));
fs.appendFileSync(tpath, line(user("again")));
assert.equal(await post({ hook_type: "UserPromptSubmit", data: event({ prompt: "again" }) }), 200);
assert.equal(main().status, "working");
assert.equal(
await post({ hook_type: "SubagentStop", data: event({ agent_id: "a2", agent_type: "" }) }),
200
);
assert.equal(main().status, "working", "no turn end newer than the prompt yet");
});
});
+16 -50
View File
@@ -1854,6 +1854,22 @@ try {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN chat_token_hash TEXT").run();
}
// v2.8.65–2.8.66 shipped a chat between users (chat_* tables, uploads under
// <data dir>/chat/); it was removed in v2.8.67, and its data goes with it.
try {
db.exec(`
DROP TABLE IF EXISTS chat_files;
DROP TABLE IF EXISTS chat_messages;
DROP TABLE IF EXISTS chat_members;
DROP TABLE IF EXISTS chat_rooms;
`);
if (!process.env.DASHBOARD_DB_PATH) {
fs.rmSync(path.join(getDataDir(), "chat"), { recursive: true, force: true });
}
} catch {
/* best effort: a leftover is unused */
}
// Server mode: whose browser a push subscription is (`users.id`); NULL in
// local mode. A user's notifications go only to their own subscriptions.
try {
@@ -1912,56 +1928,6 @@ db.exec(`
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
`);
// Chat between users (server mode, server/lib/chat.js): a direct chat (`dm`,
// one per pair — `dm_key` is "<low id>:<high id>") or a group. Only a room's
// members read it, an admin included. Files live under <data dir>/chat/.
db.exec(`
CREATE TABLE IF NOT EXISTS chat_rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL CHECK (kind IN ('dm', 'group')),
name TEXT NOT NULL DEFAULT '',
dm_key TEXT UNIQUE,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
last_message_at TEXT
);
CREATE TABLE IF NOT EXISTS chat_members (
room_id INTEGER NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
last_read_id INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (room_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_chat_members_user ON chat_members(user_id);
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
kind TEXT NOT NULL DEFAULT 'text' CHECK (kind IN ('text', 'system')),
body TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_chat_messages_room ON chat_messages(room_id, id);
CREATE TABLE IF NOT EXISTS chat_files (
id TEXT PRIMARY KEY,
message_id INTEGER NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
room_id INTEGER NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL,
size INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_chat_files_message ON chat_files(message_id);
`);
// A member's own "delete this chat": messages up to cleared_id are gone for
// them (not for anyone else), and a hidden direct chat leaves their list until
// a new message arrives. Additive.
try {
db.prepare("SELECT cleared_id, hidden FROM chat_members LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE chat_members ADD COLUMN cleared_id INTEGER NOT NULL DEFAULT 0").run();
db.prepare("ALTER TABLE chat_members ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0").run();
}
// An account's own agent instructions, put before every workspace's in each
// pane it owns (server/lib/lane-sources.js, issue #67). Additive.
try {
-1
View File
@@ -237,7 +237,6 @@ function createApp() {
app.use("/api/projects", projectsRouter);
app.use("/api/locks", locksRouter);
app.use("/api/pets", petsRouter);
app.use("/api/chat", require("./routes/chat"));
app.use("/api/snapshots", snapshotsRouter);
app.use("/api/artifacts", require("./routes/artifacts"));
app.use("/api/alerts", alertsRouter);
+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 };
-567
View File
@@ -1,567 +0,0 @@
/**
* @file chat.js
* @description Chat between the users of a server-mode install, shown in the
* pet's window beside the chat with the pet: a direct chat (`dm`, at most one
* per pair of users) or a group anyone may start and name. Only a room's
* MEMBERS read or write it — an admin or analyst included, and there is no
* route that lists another user's rooms. A group's creator adds, removes and
* renames; any member may leave; anyone deletes their own messages (only).
* "Delete chat" is the caller's alone: the messages so far vanish for THEM
* (`cleared_id`) and a direct chat leaves their list (`hidden`) until a new
* message arrives — the other members keep everything. Only a group's creator
* disbands it, for everyone, its files included. Once EVERY member has
* deleted a stretch of history, it is deleted for real (`purge`): the
* messages up to the lowest `cleared_id` and their files, and a direct chat
* both people deleted, whole. "Seen": each room carries every member's read
* mark (`reads`), and a read that moves is told to the members (`chat_read`) —
* a mark never passes the room's newest message, so it cannot claim to have
* seen one not yet written. A group whose creator left or was deleted passes
* to its longest-standing member (`ensureOwner`). Group changes add `system`
* lines that are sent live like messages and never count as unread. An
* upload arrives on disk (`<data dir>/chat/.incoming/`) and is renamed into
* place — never held in memory whole. A message is text (≤ 8000 chars) and up to
* `MAX_FILES` files of ≤ `MAX_FILE_BYTES` each, stored once under
* `<data dir>/chat/<room id>/<random id>` — a name the server makes, never one
* from the request — and served back only to the room's members (the route
* adds a `sandbox` CSP and `nosniff`; only raster images are shown inline).
* Every change is told to exactly the users concerned
* (`chat_message` / `chat_room` frames, `to` = their ids, see
* `scope.exactAudience`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");
const { getDataDir } = require("./claude-home");
const db = () => require("../db").db;
const sql = (q) => db().prepare(q);
const MAX_BODY = 8000;
const MAX_NAME = 80;
const MAX_FILES = 10;
const MAX_FILE_BYTES = 25 * 1024 * 1024;
const MAX_GROUP = 200;
/** Raster types shown inline; anything else is a download. */
const INLINE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
function err(code, message, status = 400) {
return Object.assign(new Error(message), { code, status });
}
const chatDir = () => path.join(getDataDir(), "chat");
const fileDir = (roomId) => path.join(chatDir(), String(Number(roomId)));
/** A user who may be chatted with: exists and is not disabled. */
function activeUser(id) {
const n = Number(id);
if (!Number.isSafeInteger(n) || n <= 0) return null;
return sql("SELECT id FROM users WHERE id = ? AND COALESCE(disabled, 0) = 0").get(n) || null;
}
/** Every account but the caller, `disabled` marked — names for old chats, the enabled ones to start a new one with. */
function people(me) {
return sql(
`SELECT id, username, display_name, avatar_at, COALESCE(disabled, 0) AS disabled FROM users
WHERE id != ? ORDER BY COALESCE(display_name, username)`
)
.all(me)
.map((u) => ({
id: u.id,
username: u.username,
displayName: u.display_name ?? null,
avatarAt: u.avatar_at ?? null,
// Still named in old chats; never offered for a new one.
disabled: Boolean(u.disabled),
}));
}
function memberIds(roomId) {
return sql("SELECT user_id FROM chat_members WHERE room_id = ? ORDER BY user_id")
.all(roomId)
.map((r) => r.user_id);
}
/** The room, when `me` is one of its members; else ENOTFOUND (404) — never "forbidden". */
function roomFor(me, roomId) {
const id = Number(roomId);
const row =
Number.isSafeInteger(id) && id > 0
? sql(
`SELECT r.* FROM chat_rooms r JOIN chat_members m ON m.room_id = r.id
WHERE r.id = ? AND m.user_id = ?`
).get(id, me)
: null;
if (!row) throw err("ENOTFOUND", "no such chat", 404);
return ensureOwner(row);
}
function filesOf(messageIds) {
if (!messageIds.length) return new Map();
const rows = sql(
`SELECT id, message_id, name, type, size FROM chat_files
WHERE message_id IN (${messageIds.map(() => "?").join(",")}) ORDER BY rowid`
).all(...messageIds);
const out = new Map();
for (const f of rows) {
if (!out.has(f.message_id)) out.set(f.message_id, []);
out.get(f.message_id).push({ id: f.id, name: f.name, type: f.type, size: f.size });
}
return out;
}
function shapeMessages(rows) {
const files = filesOf(rows.map((r) => r.id));
return rows.map((r) => ({
id: r.id,
roomId: r.room_id,
userId: r.user_id,
kind: r.kind,
body: r.body,
createdAt: r.created_at,
files: files.get(r.id) || [],
}));
}
/**
* A group's creator who left or whose account was deleted hands it to the
* longest-standing member, so somebody can still manage it. Answers the room.
*/
function ensureOwner(r) {
if (r.kind !== "group") return r;
const holds =
r.created_by != null &&
sql("SELECT 1 FROM chat_members WHERE room_id = ? AND user_id = ?").get(r.id, r.created_by);
if (holds) return r;
const next = sql(
"SELECT user_id FROM chat_members WHERE room_id = ? ORDER BY joined_at, rowid LIMIT 1"
).get(r.id);
if (!next) return r;
sql("UPDATE chat_rooms SET created_by = ? WHERE id = ?").run(next.user_id, r.id);
return { ...r, created_by: next.user_id };
}
/** One room as `me` sees it: members, newest message, unread count. */
function shapeRoom(me, r) {
r = ensureOwner(r);
const mine = sql(
"SELECT last_read_id, cleared_id FROM chat_members WHERE room_id = ? AND user_id = ?"
).get(r.id, me);
const cleared = mine ? mine.cleared_id : 0;
const last = sql(
"SELECT * FROM chat_messages WHERE room_id = ? AND id > ? ORDER BY id DESC LIMIT 1"
).get(r.id, cleared);
const unread = sql(
"SELECT COUNT(*) AS n FROM chat_messages WHERE room_id = ? AND id > ? AND kind = 'text' AND COALESCE(user_id, -1) != ?"
).get(r.id, Math.max(mine ? mine.last_read_id : 0, cleared), me).n;
return {
id: r.id,
kind: r.kind,
name: r.name,
createdBy: r.created_by,
createdAt: r.created_at,
lastMessageAt: r.last_message_at,
members: memberIds(r.id),
lastMessage: last ? shapeMessages([last])[0] : null,
lastReadId: mine ? mine.last_read_id : 0,
clearedId: cleared,
reads: Object.fromEntries(
sql("SELECT user_id, last_read_id FROM chat_members WHERE room_id = ?")
.all(r.id)
.map((x) => [x.user_id, x.last_read_id])
),
unread,
};
}
/** `me`'s rooms, the most recently active first. */
function listRooms(me) {
return sql(
`SELECT r.* FROM chat_rooms r JOIN chat_members m ON m.room_id = r.id
WHERE m.user_id = ? AND m.hidden = 0
ORDER BY COALESCE(r.last_message_at, r.created_at) DESC`
)
.all(me)
.map((r) => shapeRoom(me, r));
}
function cleanName(name) {
if (name === undefined || name === null) return "";
if (typeof name !== "string") throw err("EBADFIELD", "name: text");
return name.trim().slice(0, MAX_NAME);
}
function userList(value, field) {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length > MAX_GROUP)
throw err("EBADFIELD", `${field}: a list of user ids`);
const ids = [...new Set(value.map(Number))];
for (const id of ids) if (!activeUser(id)) throw err("ENOUSER", `no such user: ${id}`);
return ids;
}
/** The direct chat between `me` and `other`, made on first use. `created` says which. */
function openDm(me, other) {
const them = activeUser(other);
if (!them || them.id === me) throw err("ENOUSER", "pick another user");
const key = [me, them.id].sort((a, b) => a - b).join(":");
const found = sql("SELECT * FROM chat_rooms WHERE dm_key = ?").get(key);
if (found) {
// Opened again on purpose: back in the list (its cleared history stays gone).
sql("UPDATE chat_members SET hidden = 0 WHERE room_id = ? AND user_id = ?").run(found.id, me);
return { room: shapeRoom(me, found), created: false };
}
const id = db().transaction(() => {
const { lastInsertRowid } = sql(
"INSERT INTO chat_rooms (kind, dm_key, created_by) VALUES ('dm', ?, ?)"
).run(key, me);
const add = sql("INSERT INTO chat_members (room_id, user_id) VALUES (?, ?)");
add.run(lastInsertRowid, me);
add.run(lastInsertRowid, them.id);
return lastInsertRowid;
})();
return {
room: shapeRoom(me, sql("SELECT * FROM chat_rooms WHERE id = ?").get(id)),
created: true,
};
}
/** Adds a `system` line; answers its id (the caller sends it live). */
function systemLine(roomId, me, body) {
const { lastInsertRowid } = sql(
"INSERT INTO chat_messages (room_id, user_id, kind, body) VALUES (?, ?, 'system', ?)"
).run(roomId, me, body);
sql(
"UPDATE chat_rooms SET last_message_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
).run(roomId);
return lastInsertRowid;
}
/** The messages of these ids, shaped, to send live. */
function linesOf(ids) {
if (!ids.length) return [];
return shapeMessages(
sql(
`SELECT * FROM chat_messages WHERE id IN (${ids.map(() => "?").join(",")}) ORDER BY id`
).all(...ids)
);
}
/** A new group of `me` plus `userIds`. */
function createGroup(me, { name, userIds }) {
const title = cleanName(name);
const ids = userList(userIds, "userIds").filter((id) => id !== me);
if (!ids.length) throw err("EBADFIELD", "a group needs at least one other member");
const id = db().transaction(() => {
const { lastInsertRowid } = sql(
"INSERT INTO chat_rooms (kind, name, created_by) VALUES ('group', ?, ?)"
).run(title, me);
const add = sql("INSERT INTO chat_members (room_id, user_id) VALUES (?, ?)");
for (const uid of [me, ...ids]) add.run(lastInsertRowid, uid);
systemLine(lastInsertRowid, me, "created");
return lastInsertRowid;
})();
return shapeRoom(me, sql("SELECT * FROM chat_rooms WHERE id = ?").get(id));
}
/**
* A group's creator renames it, adds or removes members. Answers the room and
* `notify` = every user to tell (removed ones included, so they drop it).
*/
function updateGroup(me, roomId, { name, add, remove }) {
const room = roomFor(me, roomId);
if (room.kind !== "group") throw err("EBADFIELD", "a direct chat has no members to change");
if (room.created_by !== me) throw err("EFORBIDDEN", "only the group's creator changes it", 403);
const before = memberIds(room.id);
const adds = userList(add, "add").filter((id) => !before.includes(id));
const drops = userList(remove, "remove").filter((id) => id !== me && before.includes(id));
const ids = [];
db().transaction(() => {
if (name !== undefined) {
const title = cleanName(name);
if (title !== room.name) {
sql("UPDATE chat_rooms SET name = ? WHERE id = ?").run(title, room.id);
ids.push(systemLine(room.id, me, "renamed"));
}
}
const ins = sql("INSERT OR IGNORE INTO chat_members (room_id, user_id) VALUES (?, ?)");
for (const uid of adds) ins.run(room.id, uid);
const del = sql("DELETE FROM chat_members WHERE room_id = ? AND user_id = ?");
for (const uid of drops) del.run(room.id, uid);
if (adds.length) ids.push(systemLine(room.id, me, `added:${adds.join(",")}`));
if (drops.length) ids.push(systemLine(room.id, me, `removed:${drops.join(",")}`));
})();
// A removed member's mark no longer holds history back.
if (drops.length) purge(room.id);
return {
room: shapeRoom(me, sql("SELECT * FROM chat_rooms WHERE id = ?").get(room.id)),
notify: [...new Set([...before, ...adds])],
lines: linesOf(ids),
};
}
/** `me` leaves a group; the last one out deletes it and its files. Answers who to tell. */
function leaveGroup(me, roomId) {
const room = roomFor(me, roomId);
if (room.kind !== "group") throw err("EBADFIELD", "a direct chat cannot be left");
const before = memberIds(room.id);
let line = null;
db().transaction(() => {
sql("DELETE FROM chat_members WHERE room_id = ? AND user_id = ?").run(room.id, me);
if (before.length > 1) line = systemLine(room.id, me, "left");
})();
const gone = purge(room.id);
const rest = sql("SELECT * FROM chat_rooms WHERE id = ?").get(room.id);
if (rest) ensureOwner(rest);
return { notify: before, lines: gone || line == null ? [] : linesOf([line]) };
}
/** A page of messages, oldest first: the newest `limit`, or those before `before`. */
function messages(me, roomId, { before, limit } = {}) {
const room = roomFor(me, roomId);
const cleared = sql("SELECT cleared_id FROM chat_members WHERE room_id = ? AND user_id = ?").get(
room.id,
me
).cleared_id;
const n = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);
const b = parseInt(before, 10);
const rows = Number.isSafeInteger(b)
? sql(
"SELECT * FROM chat_messages WHERE room_id = ? AND id > ? AND id < ? ORDER BY id DESC LIMIT ?"
).all(room.id, cleared, b, n)
: sql("SELECT * FROM chat_messages WHERE room_id = ? AND id > ? ORDER BY id DESC LIMIT ?").all(
room.id,
cleared,
n
);
return { messages: shapeMessages(rows.reverse()), hasMore: rows.length === n };
}
/** A file's media type, kept only when it is a plain `type/subtype`. */
function cleanType(t) {
return typeof t === "string" && /^[a-z0-9.+-]+\/[a-z0-9.+-]+$/i.test(t)
? t.toLowerCase()
: "application/octet-stream";
}
function cleanFileName(n) {
const base = path.basename(String(n || "file")).replace(/[\u0000-\u001f\u007f]/g, "");
return base.slice(0, 200) || "file";
}
/**
* `me` posts to a room: `body` text and `files` ([{name, type, buffer}]).
* Answers the message and who to tell (every member).
*/
function post(me, roomId, { body, files = [] }) {
const room = roomFor(me, roomId);
const text = typeof body === "string" ? body.replace(/\s+$/, "") : "";
if (text.length > MAX_BODY)
throw err("ETOOBIG", `a message is at most ${MAX_BODY} characters`, 413);
if (files.length > MAX_FILES) throw err("ETOOBIG", `at most ${MAX_FILES} files`, 413);
const sizeOf = (f) => (f.path ? fs.statSync(f.path).size : f.buffer.length);
for (const f of files)
if (sizeOf(f) > MAX_FILE_BYTES) throw err("ETOOBIG", "a file is at most 25 MB", 413);
if (!text.trim() && !files.length) throw err("EBADFIELD", "an empty message");
const dir = fileDir(room.id);
const stored = files.map((f) => ({
id: crypto.randomBytes(16).toString("hex"),
name: cleanFileName(f.name),
type: cleanType(f.type),
size: sizeOf(f),
from: f.path || null,
buffer: f.buffer,
}));
if (stored.length) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
for (const f of stored) {
const dest = path.join(dir, f.id);
// An upload already on disk (multer) is moved, never read into memory.
if (f.from) {
fs.renameSync(f.from, dest);
fs.chmodSync(dest, 0o600);
} else fs.writeFileSync(dest, f.buffer, { mode: 0o600 });
}
}
let id;
try {
id = db().transaction(() => {
const { lastInsertRowid } = sql(
"INSERT INTO chat_messages (room_id, user_id, body) VALUES (?, ?, ?)"
).run(room.id, me, text);
const ins = sql(
"INSERT INTO chat_files (id, message_id, room_id, name, type, size) VALUES (?, ?, ?, ?, ?, ?)"
);
for (const f of stored) ins.run(f.id, lastInsertRowid, room.id, f.name, f.type, f.size);
sql(
"UPDATE chat_rooms SET last_message_at = (SELECT created_at FROM chat_messages WHERE id = ?) WHERE id = ?"
).run(lastInsertRowid, room.id);
// A new message brings the chat back to everyone who deleted it.
sql("UPDATE chat_members SET hidden = 0 WHERE room_id = ? AND hidden = 1").run(room.id);
// Your own message is read.
sql("UPDATE chat_members SET last_read_id = ? WHERE room_id = ? AND user_id = ?").run(
lastInsertRowid,
room.id,
me
);
return lastInsertRowid;
})();
} catch (e) {
for (const f of stored) fs.rmSync(path.join(dir, f.id), { force: true });
throw e;
}
const [message] = shapeMessages([sql("SELECT * FROM chat_messages WHERE id = ?").get(id)]);
return { message, notify: memberIds(room.id) };
}
/** `me` has read the room up to message `upTo` (never moves back). */
function markRead(me, roomId, upTo) {
const room = roomFor(me, roomId);
const n = Number(upTo);
if (!Number.isSafeInteger(n) || n < 0) throw err("EBADFIELD", "upTo: a message id");
const top = sql("SELECT COALESCE(MAX(id), 0) AS id FROM chat_messages WHERE room_id = ?").get(
room.id
).id;
const { changes } = sql(
"UPDATE chat_members SET last_read_id = ? WHERE room_id = ? AND user_id = ? AND last_read_id < ?"
).run(Math.min(n, top), room.id, me, Math.min(n, top));
const now = sql("SELECT last_read_id FROM chat_members WHERE room_id = ? AND user_id = ?").get(
room.id,
me
).last_read_id;
return { lastReadId: now, moved: changes > 0, notify: memberIds(room.id) };
}
/**
* Delete for real what every member has deleted for themselves: messages up
* to the lowest `cleared_id` and their files; a direct chat both people
* deleted (hidden, nothing left) goes whole. Answers whether the room is gone.
*/
function purge(roomId) {
const room = sql("SELECT * FROM chat_rooms WHERE id = ?").get(roomId);
if (!room) return true;
const low = sql(
"SELECT MIN(cleared_id) AS low, MIN(hidden) AS allHidden FROM chat_members WHERE room_id = ?"
).get(room.id);
if (low.low == null) {
// Nobody left.
sql("DELETE FROM chat_rooms WHERE id = ?").run(room.id);
fs.rmSync(fileDir(room.id), { recursive: true, force: true });
return true;
}
if (low.low > 0) {
const files = sql("SELECT id FROM chat_files WHERE room_id = ? AND message_id <= ?").all(
room.id,
low.low
);
sql("DELETE FROM chat_messages WHERE room_id = ? AND id <= ?").run(room.id, low.low);
for (const f of files) fs.rmSync(path.join(fileDir(room.id), f.id), { force: true });
}
const left = sql("SELECT COUNT(*) AS n FROM chat_messages WHERE room_id = ?").get(room.id).n;
if (room.kind === "dm" && low.allHidden === 1 && left === 0) {
sql("DELETE FROM chat_rooms WHERE id = ?").run(room.id);
fs.rmSync(fileDir(room.id), { recursive: true, force: true });
return true;
}
return false;
}
/**
* `me` deletes their OWN text message (and its files) — never anyone else's,
* an admin included. Answers who to tell (every member).
*/
function removeMessage(me, roomId, messageId) {
const room = roomFor(me, roomId);
const id = Number(messageId);
const m = Number.isSafeInteger(id)
? sql("SELECT * FROM chat_messages WHERE id = ? AND room_id = ?").get(id, room.id)
: null;
if (!m) throw err("ENOTFOUND", "no such message", 404);
if (m.user_id !== me || m.kind !== "text")
throw err("EFORBIDDEN", "only your own messages can be deleted", 403);
const files = sql("SELECT id FROM chat_files WHERE message_id = ?").all(id);
db().transaction(() => {
sql("DELETE FROM chat_messages WHERE id = ?").run(id);
const last = sql("SELECT MAX(created_at) AS at FROM chat_messages WHERE room_id = ?").get(
room.id
);
sql("UPDATE chat_rooms SET last_message_at = ? WHERE id = ?").run(last.at ?? null, room.id);
})();
for (const f of files) fs.rmSync(path.join(fileDir(room.id), f.id), { force: true });
return { notify: memberIds(room.id) };
}
/**
* "Delete chat" for `me` only: every message so far is gone for them; a
* direct chat also leaves their list until a new message arrives. Nobody
* else's copy changes.
*/
function clearChat(me, roomId) {
const room = roomFor(me, roomId);
const top = sql("SELECT COALESCE(MAX(id), 0) AS id FROM chat_messages WHERE room_id = ?").get(
room.id
).id;
sql(
`UPDATE chat_members SET cleared_id = ?, last_read_id = MAX(last_read_id, ?), hidden = ?
WHERE room_id = ? AND user_id = ?`
).run(top, top, room.kind === "dm" ? 1 : 0, room.id, me);
const gone = purge(room.id);
return { hidden: room.kind === "dm", purged: gone };
}
/** The group's creator deletes it for everyone, files included. Answers who to tell. */
function disbandGroup(me, roomId) {
const room = roomFor(me, roomId);
if (room.kind !== "group") throw err("EBADFIELD", "only a group can be disbanded");
if (room.created_by !== me) throw err("EFORBIDDEN", "only the group's creator disbands it", 403);
const before = memberIds(room.id);
sql("DELETE FROM chat_rooms WHERE id = ?").run(room.id);
fs.rmSync(fileDir(room.id), { recursive: true, force: true });
return { notify: before };
}
/** A file of a room `me` is in: `{path, name, type, inline}`. */
function file(me, roomId, fileId) {
const room = roomFor(me, roomId);
if (typeof fileId !== "string" || !/^[0-9a-f]{32}$/.test(fileId))
throw err("ENOTFOUND", "no such file", 404);
const f = sql(
`SELECT f.* FROM chat_files f JOIN chat_members m ON m.room_id = f.room_id AND m.user_id = ?
WHERE f.id = ? AND f.room_id = ? AND f.message_id > m.cleared_id`
).get(me, fileId, room.id);
const p = f && path.join(fileDir(room.id), f.id);
if (!f || !fs.existsSync(p)) throw err("ENOTFOUND", "no such file", 404);
return { path: p, name: f.name, type: f.type, inline: INLINE_TYPES.has(f.type) };
}
/** Where multer writes an upload before `post` moves it into its room. */
function incomingDir() {
const d = path.join(chatDir(), ".incoming");
fs.mkdirSync(d, { recursive: true, mode: 0o700 });
return d;
}
module.exports = {
incomingDir,
MAX_FILES,
MAX_FILE_BYTES,
people,
listRooms,
openDm,
createGroup,
updateGroup,
leaveGroup,
messages,
post,
markRead,
purge,
removeMessage,
clearChat,
disbandGroup,
file,
roomFor,
shapeRoom,
};
+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;
}
/**
* 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) {
const err = new Error(message);
err.code = code;
@@ -475,6 +499,7 @@ function spawnRun(args) {
assertCwdAccount(laneId, cwd); // ECWDACCOUNT
// Server mode: an owner over their spend quota starts nothing new (user-quota.js).
require("./user-quota").assertSpawnAllowed(laneOwner); // EQUOTA
assertSessionRoom(laneId); // ESESSIONLIMIT
const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED
// 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
+1 -7
View File
@@ -340,16 +340,10 @@ function broadcastAudience(type, data) {
* the role-based rules above. Pure (no DB): asked for every client. A change
* to a member's OWN Claude Code config (`cc_config_changed` with `ownerId`,
* routes/cc-config.js) names their files: it reaches that member and nobody
* else, an admin or analyst included. A chat frame (`chat_message`,
* `chat_room`) reaches exactly the users in its `to`.
* else, an admin or analyst included.
*/
function exactAudience(type, data) {
const d = data && typeof data === "object" ? data : {};
// Chat (server/lib/chat.js): only the room's members, whatever their role —
// an admin reads no one else's chat. No list = nobody.
if (["chat_message", "chat_room", "chat_deleted", "chat_read"].includes(type)) {
return new Set(Array.isArray(d.to) ? d.to : []);
}
if (type === "cc_config_changed" && d.ownerId != null) return new Set([d.ownerId]);
return null;
}
+1 -2
View File
@@ -76,8 +76,7 @@ module.exports = combine(
overrideCore,
overridePricingAlerts,
overrideOps,
gitRefs,
require("./openapi-extra/chat")
gitRefs
);
// The branch & tag writes join the git action entry in place.
gitRefs.extend(module.exports);
+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}": {
put: {
tags: ["AI Tools"],
-352
View File
@@ -1,352 +0,0 @@
/**
* @file OpenAPI fragment for `/api/chat` (server/routes/chat.js): chat between
* the users of a server-mode install — direct chats and groups, text and
* files. Every route acts on the caller's own rooms only (404 otherwise, an
* admin included); local mode answers `400 ELOCALMODE`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const err = { $ref: "#/components/schemas/ErrorResponse" };
const json = (schema) => ({ content: { "application/json": { schema } } });
const fail = (codes) =>
Object.fromEntries(codes.map((c) => [String(c), { description: "Refused", ...json(err) }]));
const idParam = { name: "id", in: "path", required: true, schema: { type: "integer" } };
const tags = [
{
name: "Chat",
description:
"Chat between users (server mode): direct chats and groups in the pet's window. Only a room's members read it.",
},
];
const schemas = {
ChatFile: {
type: "object",
properties: {
id: { type: "string", example: "9f0c1b2a3d4e5f60718293a4b5c6d7e8" },
name: { type: "string", example: "screenshot.png" },
type: { type: "string", example: "image/png" },
size: { type: "integer", example: 20480 },
},
},
ChatMessage: {
type: "object",
properties: {
id: { type: "integer", example: 42 },
roomId: { type: "integer", example: 3 },
userId: { type: "integer", nullable: true, example: 2 },
kind: {
type: "string",
enum: ["text", "system"],
description:
"`system`: a group change — body `created`, `renamed`, `left`, `added:<ids>` or `removed:<ids>`.",
},
body: { type: "string", example: "Chào cả nhóm" },
createdAt: { type: "string", format: "date-time" },
files: { type: "array", items: { $ref: "#/components/schemas/ChatFile" } },
},
},
ChatRoom: {
type: "object",
properties: {
id: { type: "integer", example: 3 },
kind: { type: "string", enum: ["dm", "group"] },
name: { type: "string", description: "A group's name; empty for a direct chat." },
createdBy: { type: "integer", nullable: true },
createdAt: { type: "string", format: "date-time" },
lastMessageAt: { type: "string", format: "date-time", nullable: true },
members: { type: "array", items: { type: "integer" } },
lastMessage: { allOf: [{ $ref: "#/components/schemas/ChatMessage" }], nullable: true },
lastReadId: { type: "integer" },
clearedId: {
type: "integer",
description: "Messages up to this id were deleted by the caller for themselves.",
},
unread: { type: "integer", description: "Messages of others after `lastReadId`." },
reads: {
type: "object",
additionalProperties: { type: "integer" },
description: "Every member's read mark by user id — what the room shows as Seen.",
},
},
},
};
const roomResp = json({
type: "object",
properties: { room: { $ref: "#/components/schemas/ChatRoom" } },
});
const paths = {
"/api/chat/people": {
get: {
tags: ["Chat"],
summary: "Users the caller can chat with",
description:
"Every account but the caller: id, username, display name, avatar time, and `disabled` (not offered for a new chat).",
responses: {
200: {
description: "People",
...json({
type: "object",
properties: {
people: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "integer" },
username: { type: "string" },
displayName: { type: "string", nullable: true },
avatarAt: { type: "string", nullable: true },
disabled: {
type: "boolean",
description: "A disabled account: still named in old chats, never offered for a new one.",
},
},
},
},
},
}),
},
...fail([400]),
},
},
},
"/api/chat/rooms": {
get: {
tags: ["Chat"],
summary: "The caller's rooms",
description:
"Most recently active first, each with its members, newest message and unread count.",
responses: {
200: {
description: "Rooms",
...json({
type: "object",
properties: {
rooms: { type: "array", items: { $ref: "#/components/schemas/ChatRoom" } },
},
}),
},
...fail([400]),
},
},
post: {
tags: ["Chat"],
summary: "Open a direct chat or create a group",
description:
"`{kind: 'dm', userId}` answers the one direct chat of the pair (201 when made now, 200 when it existed); `{kind: 'group', name, userIds}` makes a group of the caller plus `userIds`. Broadcasts `chat_room` to the members.",
requestBody: {
required: true,
...json({
type: "object",
required: ["kind"],
properties: {
kind: { type: "string", enum: ["dm", "group"] },
userId: { type: "integer" },
name: { type: "string", maxLength: 80 },
userIds: { type: "array", items: { type: "integer" } },
},
}),
},
responses: {
200: { description: "The existing direct chat", ...roomResp },
201: { description: "Created", ...roomResp },
...fail([400]),
},
},
},
"/api/chat/rooms/{id}": {
delete: {
tags: ["Chat"],
summary: "Disband a group",
description:
"The group's creator only (`403 EFORBIDDEN`): deletes it for every member, its files included. Broadcasts `chat_room`.",
parameters: [idParam],
responses: {
200: {
description: "Disbanded",
...json({ type: "object", properties: { ok: { type: "boolean" } } }),
},
...fail([400, 403, 404]),
},
},
patch: {
tags: ["Chat"],
summary: "Rename a group, add or remove members",
description: "Only the group's creator (`403 EFORBIDDEN`). Removed users are told too.",
parameters: [idParam],
requestBody: {
required: true,
...json({
type: "object",
properties: {
name: { type: "string" },
add: { type: "array", items: { type: "integer" } },
remove: { type: "array", items: { type: "integer" } },
},
}),
},
responses: { 200: { description: "Updated", ...roomResp }, ...fail([400, 403, 404]) },
},
},
"/api/chat/rooms/{id}/clear": {
post: {
tags: ["Chat"],
summary: "Delete a chat for yourself",
description:
"Every message so far is gone for the caller only (`clearedId`); a direct chat also leaves their list until a new message arrives. Nobody else's copy changes — but what EVERY member has deleted is deleted for real (messages, files, and a direct chat both people deleted, whole).",
parameters: [idParam],
responses: {
200: {
description: "Cleared",
...json({
type: "object",
properties: {
hidden: { type: "boolean" },
purged: {
type: "boolean",
description: "Every member has now deleted it: the chat is gone from the server.",
},
},
}),
},
...fail([404]),
},
},
},
"/api/chat/rooms/{id}/leave": {
post: {
tags: ["Chat"],
summary: "Leave a group",
description: "The last member out deletes the group and its files.",
parameters: [idParam],
responses: {
200: {
description: "Left",
...json({ type: "object", properties: { ok: { type: "boolean" } } }),
},
...fail([400, 404]),
},
},
},
"/api/chat/rooms/{id}/messages": {
get: {
tags: ["Chat"],
summary: "A page of a room's messages",
description:
"Oldest first: the newest `limit` (default 50, max 200), or those before `before`.",
parameters: [
idParam,
{ name: "before", in: "query", schema: { type: "integer" } },
{ name: "limit", in: "query", schema: { type: "integer" } },
],
responses: {
200: {
description: "Messages",
...json({
type: "object",
properties: {
messages: { type: "array", items: { $ref: "#/components/schemas/ChatMessage" } },
hasMore: { type: "boolean" },
},
}),
},
...fail([400, 404]),
},
},
post: {
tags: ["Chat"],
summary: "Post a message",
description:
"JSON `{body}` or multipart `body` + up to 10 `files` of ≤ 25 MB. Broadcasts `chat_message` to the room's members.",
parameters: [idParam],
requestBody: {
required: true,
content: {
"application/json": {
schema: { type: "object", properties: { body: { type: "string", maxLength: 8000 } } },
},
"multipart/form-data": {
schema: {
type: "object",
properties: {
body: { type: "string" },
files: { type: "array", items: { type: "string", format: "binary" } },
},
},
},
},
},
responses: {
201: {
description: "Posted",
...json({
type: "object",
properties: { message: { $ref: "#/components/schemas/ChatMessage" } },
}),
},
...fail([400, 404, 413]),
},
},
},
"/api/chat/rooms/{id}/messages/{messageId}": {
delete: {
tags: ["Chat"],
summary: "Delete your own message",
description:
"Only the message's author (`403 EFORBIDDEN`, an admin included); its files go with it. Broadcasts `chat_deleted` to the members.",
parameters: [
idParam,
{ name: "messageId", in: "path", required: true, schema: { type: "integer" } },
],
responses: {
200: {
description: "Deleted",
...json({ type: "object", properties: { ok: { type: "boolean" } } }),
},
...fail([403, 404]),
},
},
},
"/api/chat/rooms/{id}/read": {
post: {
tags: ["Chat"],
summary: "Mark a room read up to a message",
description:
"Never moves back, never past the room's newest message. A mark that moved is told to the members as `chat_read` (`{roomId, userId, lastReadId}`).",
parameters: [idParam],
requestBody: {
required: true,
...json({ type: "object", required: ["upTo"], properties: { upTo: { type: "integer" } } }),
},
responses: {
200: {
description: "Read mark",
...json({ type: "object", properties: { lastReadId: { type: "integer" } } }),
},
...fail([400, 404]),
},
},
},
"/api/chat/rooms/{id}/files/{fileId}": {
get: {
tags: ["Chat"],
summary: "One file of a room",
description:
"Members only. Served under a `sandbox` CSP and `nosniff`; PNG/JPEG/GIF/WebP inline, anything else as an attachment.",
parameters: [
idParam,
{ name: "fileId", in: "path", required: true, schema: { type: "string" } },
],
responses: {
200: { description: "The file's bytes", content: { "application/octet-stream": {} } },
...fail([400, 404]),
},
},
},
};
module.exports = { tags, schemas, paths };
+29
View File
@@ -66,6 +66,35 @@ router.put("/omp-sharing", sameOriginGuard, async (req, res) => {
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) => {
const { enabled, default: makeDefault, defaultModel } = req.body || {};
// Every field checked before anything is written: a bad one saves nothing.
-215
View File
@@ -1,215 +0,0 @@
/**
* @file chat.js (routes)
* @description `/api/chat` — chat between users in server mode (the pet's
* window, `client/src/components/Sagi/ChatHub.tsx`): people to chat with,
* the caller's rooms, a direct chat or a group, messages (text + files, a
* multipart `POST`), read marks and a room's files. Everything acts on the
* CALLER's own rooms (`server/lib/chat.js`): a room they are not in is 404,
* whatever their role. Local mode has one person and no chat (`400 ELOCALMODE`).
* Each change is broadcast as `chat_message` / `chat_room` / `chat_deleted` to exactly the
* users concerned (`to`, `scope.exactAudience`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
const express = require("express");
const chat = require("../lib/chat");
const { isServerMode } = require("../lib/auth");
const { sameOriginGuard } = require("../lib/same-origin");
const router = express.Router();
function send(res, e) {
const status = e.status || (e.code === "ENOUSER" || e.code === "EBADFIELD" ? 400 : 500);
if (status === 500) console.error("[chat]", e);
res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
}
/** Run `fn(userId)`; refuses local mode and an anonymous request. */
const handle = (fn) => (req, res) => {
if (!isServerMode() || !req.user) {
return res
.status(400)
.json({ error: { code: "ELOCALMODE", message: "chat is for a shared server" } });
}
try {
fn(req.user.id, req, res);
} catch (e) {
send(res, e);
}
};
function tell(type, data, to) {
try {
require("../websocket").broadcast(type, { ...data, to });
} catch {
// no socket server (tests): nothing to tell
}
}
/** Multipart `files` + `body`, written to disk (never held whole in memory), within the chat's limits. */
function upload(req, res, next) {
if (!req.is("multipart/form-data")) return next();
const multer = require("multer");
multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => cb(null, chat.incomingDir()),
// A name of ours; the browser's goes only into the database.
filename: (_req, _file, cb) =>
cb(null, require("node:crypto").randomBytes(16).toString("hex")),
}),
limits: { fileSize: chat.MAX_FILE_BYTES, files: chat.MAX_FILES },
}).array("files")(req, res, (e) => {
if (!e) return next();
const big = e.code === "LIMIT_FILE_SIZE" || e.code === "LIMIT_FILE_COUNT";
return res.status(big ? 413 : 400).json({
error: { code: big ? "ETOOBIG" : "EBADUPLOAD", message: e.message },
});
});
}
router.get(
"/people",
handle((me, _req, res) => res.json({ people: chat.people(me) }))
);
router.get(
"/rooms",
handle((me, _req, res) => res.json({ rooms: chat.listRooms(me) }))
);
router.post(
"/rooms",
sameOriginGuard,
handle((me, req, res) => {
const b = req.body || {};
if (b.kind === "dm") {
const { room, created } = chat.openDm(me, b.userId);
if (created) tell("chat_room", { roomId: room.id }, room.members);
return res.status(created ? 201 : 200).json({ room });
}
if (b.kind === "group") {
const room = chat.createGroup(me, { name: b.name, userIds: b.userIds });
tell("chat_room", { roomId: room.id }, room.members);
return res.status(201).json({ room });
}
throw Object.assign(new Error("kind: dm or group"), { code: "EBADFIELD" });
})
);
router.patch(
"/rooms/:id",
sameOriginGuard,
handle((me, req, res) => {
const b = req.body || {};
const { room, notify, lines } = chat.updateGroup(me, req.params.id, {
name: b.name,
add: b.add,
remove: b.remove,
});
tell("chat_room", { roomId: room.id }, notify);
// The "X added Y" lines, live in every open view.
for (const message of lines) tell("chat_message", { roomId: room.id, message }, room.members);
res.json({ room });
})
);
router.post(
"/rooms/:id/leave",
sameOriginGuard,
handle((me, req, res) => {
const { notify, lines } = chat.leaveGroup(me, req.params.id);
tell("chat_room", { roomId: Number(req.params.id) }, notify);
const stay = notify.filter((id) => id !== me);
for (const message of lines)
tell("chat_message", { roomId: Number(req.params.id), message }, stay);
res.json({ ok: true });
})
);
// Delete the chat for the caller only (their history; a direct chat leaves their list).
router.post(
"/rooms/:id/clear",
sameOriginGuard,
handle((me, req, res) => res.json(chat.clearChat(me, req.params.id)))
);
// The group's creator deletes it for everyone.
router.delete(
"/rooms/:id",
sameOriginGuard,
handle((me, req, res) => {
const { notify } = chat.disbandGroup(me, req.params.id);
tell("chat_room", { roomId: Number(req.params.id) }, notify);
res.json({ ok: true });
})
);
router.get(
"/rooms/:id/messages",
handle((me, req, res) =>
res.json(chat.messages(me, req.params.id, { before: req.query.before, limit: req.query.limit }))
)
);
router.post("/rooms/:id/messages", sameOriginGuard, upload, (req, res) =>
handle((me) => {
const files = (req.files || []).map((f) => ({
// multer reads names as latin1; browsers send UTF-8.
name: Buffer.from(f.originalname, "latin1").toString("utf8"),
type: f.mimetype,
path: f.path,
}));
let posted;
try {
posted = chat.post(me, req.params.id, { body: req.body?.body, files });
} finally {
// Moved into place on success; anything left over is a refused upload.
for (const f of files) require("node:fs").rmSync(f.path, { force: true });
}
const { message, notify } = posted;
tell("chat_message", { roomId: message.roomId, message }, notify);
res.status(201).json({ message });
})(req, res)
);
router.delete(
"/rooms/:id/messages/:messageId",
sameOriginGuard,
handle((me, req, res) => {
const { notify } = chat.removeMessage(me, req.params.id, req.params.messageId);
const roomId = Number(req.params.id);
tell("chat_deleted", { roomId, messageId: Number(req.params.messageId) }, notify);
res.json({ ok: true });
})
);
router.post(
"/rooms/:id/read",
sameOriginGuard,
handle((me, req, res) => {
const { lastReadId, moved, notify } = chat.markRead(me, req.params.id, req.body?.upTo);
// "Seen": the other members' views move their read marks.
if (moved) tell("chat_read", { roomId: Number(req.params.id), userId: me, lastReadId }, notify);
res.json({ lastReadId });
})
);
router.get(
"/rooms/:id/files/:fileId",
handle((me, req, res) => {
const f = chat.file(me, req.params.id, req.params.fileId);
const ascii = f.name.replace(/[^\x20-\x7e]|["\\]/g, "_");
res.set({
"Content-Type": f.type,
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "sandbox; default-src 'none'",
"Cache-Control": "private, max-age=86400",
"Content-Disposition": `${f.inline ? "inline" : "attachment"}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(f.name)}`,
});
require("../lib/send-file").sendFile(res, f.path);
})
);
module.exports = router;
+32
View File
@@ -294,6 +294,10 @@ const apiErrorSummariesStmt = db.prepare(
const turnDurationStampsStmt = db.prepare(
"SELECT created_at FROM events WHERE session_id = ? AND event_type = 'TurnDuration'"
);
// When the session's current turn began: the newest UserPromptSubmit.
const lastPromptAtStmt = db.prepare(
"SELECT MAX(created_at) AS at FROM events WHERE session_id = ? AND event_type = 'UserPromptSubmit'"
);
// What this process already handled of each session's transcript parse.
// `transcriptCache.extract` returns the SAME result object while the file is
@@ -1202,6 +1206,7 @@ const processEventTx = db.transaction((hookType, data, tool = "claude", peer = n
? null
: tailAfter(result.turnDurations, mark?.lastTurn, (a, b) => a === b);
if (freshTurns && freshTurns.length) {
endTurnFromTranscript(sessionId, mainAgentId, hookType, freshTurns);
// `turnDurations` is every turn in the transcript (up to 1000), on
// every hook: dedupe against ONE read of the recorded timestamps (a
// covering-index scan) instead of a query per turn — 23 ms per hook on
@@ -1327,6 +1332,33 @@ const processEventTx = db.transaction((hookType, data, tool = "claude", peer = n
* that transaction COMMITTED — in order, in the same tick. A frame sent from
* inside it described a row a later throw could still roll back.
*/
/**
* A turn the transcript says has ended is over, Stop hook or not. Claude Code
* writes a `turn_duration` entry when a turn finishes, but in roughly one turn
* in fifteen it never sends the Stop hook (seen on 2.1.31x: only the
* prompt-suggestion helper's SubagentStop arrives), and the main agent then
* stayed `working` until the idle Notification a minute later — the chat
* showed Stop on a finished answer. So a turn end NEWER than the session's
* last prompt, read while the main agent is still working with no tool open,
* lands it in Waiting exactly like Stop does. A UserPromptSubmit/PreToolUse is
* new work and never ends a turn: its own read may still carry the previous
* turn's entry.
*/
function endTurnFromTranscript(sessionId, mainAgentId, hookType, turns) {
if (!mainAgentId || hookType === "UserPromptSubmit" || hookType === "PreToolUse") return;
const main = stmts.getAgent.get(mainAgentId);
if (!main || main.status !== "working" || main.current_tool) return;
const prompt = lastPromptAtStmt.get(sessionId)?.at;
const end = turns.reduce((m, td) => (td.timestamp && td.timestamp > m ? td.timestamp : m), "");
if (!end || (prompt && end <= prompt)) return;
const now = new Date().toISOString();
stmts.updateAgent.run(null, "waiting", null, null, null, null, mainAgentId);
stmts.setSessionAwaitingInput.run(now, "stop", sessionId);
stmts.setAgentAwaitingInput.run(now, "stop", mainAgentId);
broadcast("session_updated", stmts.getSession.get(sessionId));
broadcast("agent_updated", stmts.getAgent.get(mainAgentId));
}
function processEvent(hookType, data, tool, peer = null) {
const outer = pendingMarks;
pendingMarks = [];
+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
* 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
// path and lane uniqueness stays meaningful. Callers that do care pass one
// 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.
const cwd = lanesLib.canonicalCwd(
typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir()
);
let cwd;
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)) {
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.
// 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.
// ESESSIONLIMIT (S3): the member already runs the most AI sessions allowed.
const status = /^(EBAD|EUNSUPPORTED$)/.test(err.code || "")
? 400
: ["ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code)
? 409
: err.code === "EQUOTA"
: err.code === "EQUOTA" || err.code === "ESESSIONLIMIT"
? 429
: 500;
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 } });
}
// 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 } });
}
// Nor is a host missing tmux: the request was fine, the dependency is not