Compare commits

...

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

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

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

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

Only a room's members read or write it, an admin included (roomFor → 404).
Files are stored under server-made names in <data dir>/chat/<room>/ and
served to members only under a sandbox CSP + nosniff. chat_message /
chat_room frames reach exactly the ids in `to` (scope.exactAudience).
New tables chat_rooms / chat_members / chat_messages / chat_files (additive).
2026-11-03 09:33:33 +07:00
nntrivi2001 85edc88a6d fix(status-bar): running / need-you count only the viewer's own sessions
An admin's status bar and the Dashboard's Needs-you strip counted every
user's waiting sessions; both now ask GET /api/sessions with mine=1, like
Today's spend already did.
2026-11-03 09:07:06 +07:00
nntrivi2001 c229b4b79b fix(pet): each user's pet tells only of their own sessions
An admin/analyst's pet counted every user's sessions (live, waiting, errors)
because their WebSocket carries everyone's. Server mode now marks their
session/agent/event frames `mine: true|false` (scope.broadcastAudience owners,
worked out once per broadcast), and GET /api/sessions?mine=1 lists only the
caller's own sessions (ownSessionWhere). The pet seeds from mine=1 and skips
frames marked mine: false. Developers and local mode are unchanged.
2026-11-03 09:01:57 +07:00
nntrivi2001 9721941962 feat(terminal): scroll 30% slower
lib/wheelReports.ts SCROLL_SPEED = 0.7: a touchpad's travel is scaled, and a
wheel notch's reports are carried as a fraction (rounded, so the first notch
still moves; reset when the direction turns). xterm's own scrollback gets the
same scrollSensitivity.
2026-11-03 08:52:43 +07:00
nntrivi2001 b777e08371 fix(health): the user hover card leaves out the login name
It now opens only once the users list is read (admin/analyst): role, today's
spend, workspaces, sessions and last sign-in. A developer gets no card.
2026-11-03 08:50:26 +07:00
nntrivi2001 d0b4f8aaf6 fix(health): the user hover card sits on the name row only
Over the whole tile it opened together with the CPU bar's own help tip and
the two overlapped.
2026-11-03 08:47:13 +07:00
nntrivi2001 916505fbac chore: v2.8.64 — user hover card on System Health 2026-11-03 08:38:51 +07:00
nntrivi2001 75a4693f27 feat(health): hovering a user tile shows their login, role and today's spend
System Health's Users · CPU / RAM card: each tile's hover card names the login
name; for an admin or analyst it adds role, $ spent today, workspaces,
sessions and last sign-in from GET /api/users (staff-only, re-read every 60 s).
A developer's card shows the login name alone and never calls /api/users.
2026-11-03 08:38:48 +07:00
nntrivi2001 cd6a1d36ff chore: v2.8.63 — commit signing per workspace 2026-11-02 16:50:15 +07:00
nntrivi2001 2342f54179 feat(git-identity): commit signing per workspace, and Delete in the signing card
Signing was account-wide (gpg.format/user.signingkey/commit.gpgsign in
~/.gitconfig), so every repository was signed. Now Settings → Git →
Commit signing lists the user's workspaces, each with its own key picker
("Don't sign" by default). PUT /api/me/git/signing/lane {laneId, key}
writes a small file of ours (lane-<id>.gitconfig beside the account's
keys; a member's through write-as) and includes it from ~/.gitconfig
with includeIf "gitdir:<the workspace's git dir>" and
"gitdir:<common dir>/worktrees/ah-<id>-*" (its sessions' worktrees) —
only that workspace signs, from the terminal, the Changes panel or an
agent, and nothing is written into the repository. The git dirs come
from git run in the workspace's folder as its account; a workspace that
runs as another account is EFOREIGN, a folder that is not a repo
ENOREPO. Stopping needs no workspace, so one removed from the dashboard
while it signed is still listed and can be turned off.

The card now lists the keys that can sign, each with Delete (confirm;
deleting a key stops every workspace signing with it), a way out of the
old account-wide setting, and Remove for a signing setup the user made
themselves (PUT /signing {clearForeign: true}: the three settings only,
never their key file). Importing a key no longer turns on signing.

Verified with real signatures: the picked workspace's commits are G,
another repo's and an unrelated worktree's N, a session worktree's G.
2026-11-02 16:49:27 +07:00
73 changed files with 2721 additions and 1212 deletions
+4 -3
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -1,7 +1,8 @@
/**
* @file NeedsYouStrip.tsx
* @description The Dashboard's pinned "Needs you" strip: every active session
* blocked on the user (`awaiting_input_since`), each linking straight to its
* of the viewer's OWN blocked on them (`awaiting_input_since`, `mine=1` — an
* admin's lists no other user's), each linking straight to its
* workspace pane (or replay), else its analysis page. Renders nothing while no
* session is waiting. `compact` is the one-line form shown above Analytics.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -27,7 +28,7 @@ export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
const load = useCallback(() => {
api.sessions
.list({ awaiting: true, limit: 20 })
.list({ awaiting: true, limit: 20, mine: true })
.then((r) => {
setSessions(r.sessions);
setTotal(r.total);
-595
View File
@@ -1,595 +0,0 @@
/**
* @file PetChat.tsx
* @description The pet's chat window — its whole UI: clicking the pet opens
* it. Above the conversation sits what the pet knows (`info`: live / waiting /
* errored, the sessions that need you, Mute, Clear alerts — `SagiInfo.tsx`).
* Before a chat runs, a question the pet can answer itself (`onLocal`: status,
* who are you…) is answered right here; anything else — and every message once
* a chat runs — goes to the AI: the first message starts a
* session of the user's DEFAULT AI tool (`agenthub.defaultTool`, Claude or
* Codex — the two with a readable transcript) in the "Sagi" workspace, in its
* first free tab (`startInFreeTabDetailed`), and the conversation is shown as
* chat bubbles read from the session's transcript. Follow-ups are typed into
* that session's pane through a terminal attached OFF-SCREEN (`TerminalView`
* in a fixed box left of the viewport — `submitToRun` needs a mounted,
* attached pane, exactly like the workspace's own Chat view), so nothing new
* reaches the server. A menu the CLI asks with (a permission or trust prompt)
* is read off that pane and answered with `PaneMenuCard`. The chat is kept in
* `localStorage` (`agenthub.pet.chat`) while its pane lives, so closing and
* reopening the window continues it; **New chat** STOPS that session (kills
* its pane and archives it, asking first only mid-reply), **Open in workspace**
* opens its pane there. Opening never resumes an ended session: once the pane
* is gone, the next message starts a new one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
import { api } from "../../lib/api";
import type { TranscriptMessage } from "../../lib/types";
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
import {
answerRunMenu,
chooseRunOption,
interruptRun,
readRunMenu,
stepRunMenu,
submitToRun,
type PaneMenu,
} from "../../lib/panePaste";
import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
import { startInFreeTabDetailed } from "../lanes/startSession";
import { TerminalView } from "../run/TerminalView";
import { PaneMenuCard } from "../run/PaneChat";
import { MarkdownContent } from "../conversation/MarkdownContent";
/** Tools whose session the chat can read back (a transcript AgentHub parses). */
export const PET_CHAT_TOOLS = new Set(["claude", "codex"]);
const CHAT_KEY = "agenthub.pet.chat";
const POLL_MS = 1500;
const MENU_POLL_MS = 700;
// A follow-up waits this long for the off-screen pane to attach.
const SEND_TRIES = 20;
const SEND_RETRY_MS = 250;
/** The session the chat is talking to. */
export interface PetChatRef {
laneId: number;
runId: string;
tool: string;
}
export function readPetChat(): PetChatRef | null {
try {
const v = JSON.parse(localStorage.getItem(CHAT_KEY) ?? "null") as Partial<PetChatRef> | null;
if (
v &&
typeof v.laneId === "number" &&
typeof v.runId === "string" &&
typeof v.tool === "string"
)
return { laneId: v.laneId, runId: v.runId, tool: v.tool };
} catch {
/* unreadable: no chat */
}
return null;
}
function writePetChat(ref: PetChatRef | null) {
try {
if (ref) localStorage.setItem(CHAT_KEY, JSON.stringify(ref));
else localStorage.removeItem(CHAT_KEY);
} catch {
/* best-effort */
}
}
/** The tool a new chat starts: the user's default, when the chat can show it. */
export function petChatTool(): string | null {
const tool = readDefaultTool() ?? "claude";
return PET_CHAT_TOOLS.has(tool) ? tool : null;
}
type Line =
| { key: string; role: "user" | "assistant"; text: string }
| { key: string; role: "tools"; names: string[] };
/** A human line's text: Claude Code's own injected lines (`<command-…>`,
* `<system-reminder>`…) are not something the user said. */
const humanText = (m: TranscriptMessage) =>
m.content
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text!.trim())
.filter((t) => t && !t.startsWith("<"))
.join("\n\n");
/** The transcript as chat lines: the user's words, the assistant's prose, and
* each run of tool calls between them folded into one chip. */
export function chatLines(messages: TranscriptMessage[]): Line[] {
const out: Line[] = [];
messages.forEach((m, i) => {
const sender = m.sender ?? m.type;
if (sender === "user") {
const text = humanText(m);
if (text) out.push({ key: `u${i}`, role: "user", text });
return;
}
if (m.type !== "assistant") return;
m.content.forEach((c, j) => {
if (c.type === "text" && c.text?.trim()) {
out.push({ key: `a${i}-${j}`, role: "assistant", text: c.text.trim() });
} else if (c.type === "tool_use" && c.name) {
const last = out[out.length - 1];
if (last?.role === "tools") last.names.push(c.name);
else out.push({ key: `t${i}-${j}`, role: "tools", names: [c.name] });
}
});
});
return out;
}
/** Still working: the last line is the user's, or an assistant turn not ended. */
function isBusy(messages: TranscriptMessage[]): boolean {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
const sender = m.sender ?? m.type;
if (m.type === "assistant") return m.stop_reason !== undefined && m.stop_reason !== "end_turn";
if (sender === "user" && humanText(m)) return true;
}
return false;
}
/** "Read ×2 · Bash" */
function toolSummary(names: string[]): string {
const counts = new Map<string, number>();
names.forEach((n) => counts.set(n, (counts.get(n) ?? 0) + 1));
return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · ");
}
/** What the container made of a message: an answer the pet gives itself, or
* `handled` (it took the message elsewhere); null sends it to the AI. */
export type PetLocalReply = { answer: string } | { handled: true } | null;
interface PetChatProps {
/** The pet's shown name, for the header and greeting. */
name: string;
/** Shown in place of the plain name (the rename button). */
title?: ReactNode;
/** Extra header buttons, before Open in workspace (the pat heart). */
headerActions?: ReactNode;
/** Under the header: what the pet knows (status, sessions that need you). */
info?: ReactNode;
/** Asked first while no chat runs. */
onLocal?: (text: string) => PetLocalReply;
onClose: () => void;
/** Opens the chat's pane in its workspace. */
onOpenWorkspace: (laneId: number, runId: string) => void;
/** The thinking mood while a reply is being written. */
onBusy?: (busy: boolean) => void;
}
export function PetChat({
name,
title,
headerActions,
info,
onLocal,
onClose,
onOpenWorkspace,
onBusy,
}: PetChatProps) {
const { t } = useTranslation("settings");
const [chat, setChat] = useState<PetChatRef | null>(readPetChat);
const [sessionId, setSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
// Sent but not in the transcript yet.
const [pending, setPending] = useState<string | null>(null);
const [starting, setStarting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [menu, setMenu] = useState<PaneMenu | null>(null);
// The pet's own answers, given before any chat ran.
const [local, setLocal] = useState<Line[]>([]);
const listRef = useRef<HTMLDivElement>(null);
const remember = (ref: PetChatRef | null) => {
writePetChat(ref);
setChat(ref);
setSessionId(null);
setMessages([]);
setMenu(null);
};
// A remembered chat whose pane is gone is over: forget it, the next message starts anew.
useEffect(() => {
if (!chat) return;
let live = true;
const check = () =>
api.run
.get(chat.runId)
.then((h) => {
if (!live) return;
if (h.status !== "running") remember(null);
else if (h.sessionId) setSessionId(h.sessionId);
})
.catch(() => live && remember(null));
void check();
// Codex learns its session only after a while; Claude's is known at start.
const timer = window.setInterval(() => {
if (!sessionId) void check();
}, POLL_MS);
return () => {
live = false;
window.clearInterval(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chat?.runId, sessionId]);
// The conversation, polled while the window is open.
useEffect(() => {
if (!sessionId) return;
let live = true;
const load = () =>
api.sessions
.transcript(sessionId, { limit: 60 })
.then((r) => {
if (!live) return;
setMessages((prev) =>
prev.length === r.messages.length &&
JSON.stringify(prev[prev.length - 1]) ===
JSON.stringify(r.messages[r.messages.length - 1])
? prev
: r.messages
);
})
.catch(() => undefined); // not started yet: the next poll asks again
void load();
const timer = window.setInterval(load, POLL_MS);
return () => {
live = false;
window.clearInterval(timer);
};
}, [sessionId]);
// A menu the CLI shows in the pane (permission, trust, a question).
useEffect(() => {
if (!chat) return;
const read = () =>
setMenu((prev) => {
const next = readRunMenu(chat.runId);
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
});
const timer = window.setInterval(read, MENU_POLL_MS);
return () => window.clearInterval(timer);
}, [chat]);
const lines = [...local, ...chatLines(messages)];
// The pending line shows until the transcript has it.
useEffect(() => {
if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim()))
setPending(null);
}, [lines, pending]);
const busy = starting || !!pending || isBusy(messages);
useEffect(() => onBusy?.(busy), [busy, onBusy]);
useEffect(() => () => onBusy?.(false), [onBusy]);
// Keep the newest in sight.
useEffect(() => {
const el = listRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [lines.length, pending, busy, menu]);
const send = useCallback(
async (text: string) => {
const prompt = text.trim();
if (!prompt || starting) return;
setError(null);
if (!chat && onLocal) {
const reply = onLocal(prompt);
if (reply && "handled" in reply) return;
if (reply) {
const n = Date.now();
setLocal((prev) => [
...prev,
{ key: `lu${n}`, role: "user", text: prompt },
{ key: `la${n}`, role: "assistant", text: reply.answer },
]);
return;
}
}
setPending(prompt);
if (chat) {
// Typed into the attached pane; it may still be attaching.
for (let i = 0; i < SEND_TRIES; i++) {
if (submitToRun(chat.runId, prompt)) return;
await new Promise((r) => setTimeout(r, SEND_RETRY_MS));
}
setPending(null);
setDraft(prompt);
setError(t("sagi.chat.notSent"));
return;
}
const tool = petChatTool() ?? "claude";
setStarting(true);
try {
const { lane } = await api.lanes.ensure({ title: "Sagi" });
const r = await startInFreeTabDetailed(lane.id, {
prompt,
...(tool !== lane.tool ? { tool } : {}),
...(tool === "claude" ? { permissionMode: readDefaultMode({ safe: true }) } : {}),
});
remember({ laneId: r.laneId, runId: r.runId, tool });
} catch (err) {
setPending(null);
setDraft(prompt);
setError(
t("sagi.startFailed", { error: err instanceof Error ? err.message : String(err) })
);
} finally {
setStarting(false);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[chat, starting, t, onLocal]
);
// New chat: the old one's session is stopped, not left running unseen —
// asking first only while it is still writing a reply.
const newChat = async () => {
if (chat) {
if (
busy &&
!(await confirmDialog({
title: t("sagi.chat.endTitle"),
message: t("sagi.chat.endBody"),
confirmLabel: t("sagi.chat.endConfirm"),
destructive: true,
}))
)
return;
try {
await api.run.kill(chat.runId, { archive: true });
} catch (err) {
toast.error(err);
return;
}
}
remember(null);
setLocal([]);
setPending(null);
setError(null);
};
const submit = (e?: FormEvent) => {
e?.preventDefault();
if (!draft.trim()) return;
const text = draft;
setDraft("");
void send(text);
};
const toolLabel = chat?.tool ?? petChatTool() ?? "claude";
const empty = lines.length === 0 && !pending;
return (
<div
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-80 flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
role="dialog"
aria-label={t("sagi.chat.label", { name })}
>
{/* header */}
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
<span className="pl-1.5 text-base leading-none" aria-hidden>
🐾
</span>
<div className="min-w-0 flex-1 px-1">
{title ?? <div className="truncate text-sm font-semibold text-fg-primary">{name}</div>}
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-fg-muted">
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${
busy
? "animate-pulse bg-status-warning"
: chat
? "bg-status-success"
: "bg-fg-muted"
}`}
aria-hidden
/>
{busy ? t("sagi.chat.typing") : toolLabel}
</div>
</div>
{headerActions}
{chat && (
<IconButton
label={t("sagi.chat.openWorkspace")}
onClick={() => onOpenWorkspace(chat.laneId, chat.runId)}
>
<ExternalLink size={15} />
</IconButton>
)}
<IconButton
label={t("sagi.chat.new")}
onClick={() => void newChat()}
disabled={(!chat && !local.length) || starting}
>
<SquarePen size={15} />
</IconButton>
<IconButton label={t("sagi.chat.close", { name })} onClick={onClose}>
<X size={16} />
</IconButton>
</div>
{info}
{/* conversation */}
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
{empty && (
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center">
<span className="text-3xl" aria-hidden>
🐾
</span>
<p className="text-sm font-medium text-fg-primary">
{t("sagi.chat.emptyTitle", { name })}
</p>
<p className="text-xs text-fg-muted">{t("sagi.chat.emptyHint", { tool: toolLabel })}</p>
</div>
)}
{lines.map((l) =>
l.role === "tools" ? (
<div key={l.key} className="flex justify-start">
<span className="pet-chat-tools inline-flex max-w-[85%] items-center gap-1 truncate rounded-full px-2 py-0.5 text-[10px] text-fg-muted">
<Wrench size={10} className="shrink-0" aria-hidden />
<span className="truncate">{toolSummary(l.names)}</span>
</span>
</div>
) : l.role === "user" ? (
<div key={l.key} className="flex justify-end">
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white shadow-sm">
{l.text}
</div>
</div>
) : (
<div key={l.key} className="flex justify-start">
<div className="pet-chat-reply max-w-[90%] break-words rounded-2xl rounded-bl-md px-3 py-2 text-sm text-fg-primary shadow-sm">
<MarkdownContent text={l.text} dense prose />
</div>
</div>
)
)}
{pending && (
<div className="flex justify-end">
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white opacity-70 shadow-sm">
{pending}
</div>
</div>
)}
{busy && !menu && (
<div className="flex justify-start" aria-label={t("sagi.chat.typing")}>
<div className="pet-chat-reply flex gap-1 rounded-2xl rounded-bl-md px-3 py-3 shadow-sm">
<span className="pet-chat-dot" />
<span className="pet-chat-dot" />
<span className="pet-chat-dot" />
</div>
</div>
)}
{starting && (
<p className="flex items-center justify-center gap-1.5 text-[11px] text-fg-muted">
<Loader2 size={12} className="animate-spin" aria-hidden />
{t("sagi.chat.starting", { tool: toolLabel })}
</p>
)}
{chat && !sessionId && !starting && (
<p className="text-center text-[11px] text-fg-muted">{t("sagi.chat.notStarted")}</p>
)}
</div>
{error && (
<p className="mx-3 mb-1 rounded-lg bg-status-danger/10 px-2 py-1 text-[11px] text-status-danger">
{error}
</p>
)}
{/* prompt, or the question the CLI asks */}
<div className="pet-chat-foot px-2.5 py-2.5">
{menu && chat ? (
<div className="max-h-56 overflow-y-auto">
<PaneMenuCard
menu={menu}
onChoose={(i) => {
if (chooseRunOption(chat.runId, menu, i)) setMenu(null);
else setError(t("sagi.chat.notSent"));
}}
onAnswer={(text) => {
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
else setError(t("sagi.chat.notSent"));
}}
onStep={(dir) => {
if (!stepRunMenu(chat.runId, dir)) setError(t("sagi.chat.notSent"));
}}
onCancel={() => {
if (interruptRun(chat.runId)) setMenu(null);
else setError(t("sagi.chat.notSent"));
}}
/>
</div>
) : (
<form onSubmit={submit} className="flex items-end gap-1.5">
<textarea
rows={1}
autoFocus
className="max-h-28 min-h-[2.25rem] flex-1 resize-none rounded-2xl border border-border bg-surface-1 px-3.5 py-2 text-sm text-fg-primary placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
placeholder={t("sagi.chat.placeholder", { name })}
aria-label={t("sagi.chat.placeholder", { name })}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e) && !e.shiftKey) {
e.preventDefault();
submit();
} else if (e.key === "Escape") {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
onClose();
}
}}
/>
<button
type="submit"
disabled={!draft.trim() || starting}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent-solid text-white shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40"
aria-label={t("sagi.panel.send")}
>
<ArrowUp size={16} />
</button>
</form>
)}
</div>
{/* The chat's pane, attached off-screen: the only way a follow-up or a
menu answer reaches it. A real size, so tmux does not shrink the pane. */}
{chat && (
<div className="pet-chat-term" aria-hidden>
<TerminalView
runId={chat.runId}
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
autoFocus={false}
// The pane ended: the conversation stays on screen, the next
// message starts a new session.
onExit={() => {
writePetChat(null);
setChat(null);
}}
/>
</div>
)}
</div>
);
}
function IconButton({
label,
onClick,
disabled,
children,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
>
{children}
</button>
);
}
+62
View File
@@ -0,0 +1,62 @@
/**
* @file PetPanel.tsx
* @description The pet's window — clicking the pet opens it: its name (which
* the user can rename), a pat button, and what the pet knows (`info`: live /
* waiting / errored, the sessions that need you, Mute, Clear alerts —
* `SagiInfo.tsx`). The pet no longer chats: it started a full AI session per
* question, a heavy process on a shared server, so the chat was removed
* (v2.8.68); this window is where the pet's own care comes next.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
export function PetPanel({
name,
title,
headerActions,
info,
onClose,
}: {
name: string;
/** The name line (a rename button); plain text without it. */
title?: ReactNode;
/** Buttons before Close (the pat). */
headerActions?: ReactNode;
/** What the pet knows (`SagiInfo`). */
info: ReactNode;
onClose: () => void;
}) {
const { t } = useTranslation("settings");
return (
<div
className="pet-chat flex max-h-[calc(100vh-6rem)] w-[22rem] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
role="dialog"
aria-label={t("sagi.panel.label", { name })}
>
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
<span className="pl-1.5 text-base leading-none" aria-hidden>
🐾
</span>
<div className="min-w-0 flex-1 px-1">
{title ?? <div className="truncate text-sm font-semibold text-fg-primary">{name}</div>}
</div>
{headerActions}
<button
type="button"
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
onClick={onClose}
aria-label={t("sagi.panel.close", { name })}
title={t("sagi.panel.close", { name })}
>
<X size={16} />
</button>
</div>
{/* Vertical only: an auto overflow-y makes x scrollable too, and the
status chips' row then showed a horizontal scrollbar. */}
<div className="min-w-0 overflow-y-auto overflow-x-hidden">{info}</div>
</div>
);
}
+7 -60
View File
@@ -5,9 +5,8 @@
* enabled; `Sagi` gates on the same preference itself, and the body (brain,
* 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.
* 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
@@ -15,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>
*/
@@ -91,15 +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 { 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.
@@ -229,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);
@@ -340,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) => {
@@ -398,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,
@@ -412,11 +366,10 @@ 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>
<PetChat
<PetPanel
name={name}
title={
<PetNameButton
@@ -449,13 +402,7 @@ function SagiBody() {
defaultOpen={brain.attention.length > 0}
/>
}
onLocal={onLocal}
onClose={() => setOpen(false)}
onOpenWorkspace={(laneId, runId) => {
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
setOpen(false);
}}
onBusy={onBusy}
/>
</SagiFlyout>
)}
+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}
@@ -1,46 +0,0 @@
/**
* @file PetChat.test.ts
* @description Unit tests for the pet chat's transcript-to-bubbles folding (`chatLines`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { chatLines } from "../PetChat";
import type { TranscriptMessage } from "../../../lib/types";
const msg = (m: Partial<TranscriptMessage>): TranscriptMessage =>
({ type: "user", timestamp: null, content: [], ...m }) as TranscriptMessage;
describe("chatLines", () => {
it("keeps the user's words and the assistant's prose, folding tool calls into one chip", () => {
const lines = chatLines([
msg({ type: "user", sender: "user", content: [{ type: "text", text: "hi" }] }),
msg({
type: "assistant",
content: [
{ type: "thinking", text: "hmm" },
{ type: "tool_use", name: "Read" },
],
}),
msg({ type: "user", sender: "tool", content: [{ type: "tool_result", output: "x" }] }),
msg({
type: "assistant",
content: [
{ type: "tool_use", name: "Read" },
{ type: "text", text: "hello!" },
],
}),
]);
expect(lines.map((l) => (l.role === "tools" ? l.names : l.text))).toEqual([
"hi",
["Read", "Read"],
"hello!",
]);
});
it("drops Claude Code's own injected lines", () => {
const lines = chatLines([
msg({ sender: "user", content: [{ type: "text", text: "<command-name>/clear</command-name>" }] }),
]);
expect(lines).toEqual([]);
});
});
@@ -1,6 +1,6 @@
/**
* @file Sagi.test.tsx
* @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,26 +148,40 @@ 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();
});
it("counts only the viewer's own sessions (another user's arrive marked mine: false)", () => {
renderSagi();
act(() => {
eventBus.publish({ ...sessionMsg("mine", "active"), mine: true });
eventBus.publish({ ...sessionMsg("theirs", "active"), mine: false });
eventBus.publish({ ...sessionMsg("theirs-err", "error"), mine: false });
});
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const panel = screen.getByRole("dialog", { name: /pet sagi/i });
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
expect(within(liveChip).getByText("1")).toBeInTheDocument();
expect(screen.queryByText(/theirs/)).toBeNull();
});
it("respects the enabled preference", () => {
localStorage.setItem("agent-dashboard-sagi-enabled", "false");
renderSagi();
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(() => {
@@ -214,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", () => {
@@ -243,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();
});
+3 -61
View File
@@ -529,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,
@@ -547,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 ════════ */
+6 -2
View File
@@ -7,7 +7,8 @@
* burst of the same event into one count), the pet's one-shot animation
* "moment", and imperative controls (mute, clear alerts, pat, set thinking)
* for the UI shell. It also greets once per browser session and makes an
* idle pet fidget now and then.
* idle pet fidget now and then. It only ever counts the viewer's OWN sessions
* (`mine=1` seed, and frames marked `mine: false` skipped).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -278,7 +279,7 @@ export function useSagiBrain(): SagiBrain {
}, GREET_DELAY_MS);
};
api.sessions
.list({ status: "active", limit: 100 })
.list({ status: "active", limit: 100, mine: true })
.then((res) => {
if (cancelled) return;
update((prev) => seedSessions(prev, res.sessions, Date.now()));
@@ -294,6 +295,9 @@ export function useSagiBrain(): SagiBrain {
// Subscribe to the live stream and connection status.
useEffect(() => {
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
// Each user's pet tells only of their own sessions: an admin/analyst
// gets every user's, marked `mine: false` when not theirs.
if (msg.mine === false) return;
const prev = stateRef.current;
const { state: next, pulse, subject } = reduceSagi(prev, msg, Date.now());
// Most frames only move lastActivityAt, which the moods read at a 1 s
+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>
+4 -3
View File
@@ -4,7 +4,8 @@
* which the sidebar owns and which also carries the update check), the version
* — with a green dot while a newer release is out —, how many SESSIONS are
* running and how many need the user (`GET /api/sessions?status=active` and
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
* `?awaiting=1` — the Dashboard's Needs-you rule —, both `&mine=1`: the user's
* OWN sessions even for an admin —, re-read on session
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>&mine=1`, the
* user's OWN spend even for an admin — and, on the
* Workspace page, the open workspace's total (`?lane=` on the same call: the
@@ -388,11 +389,11 @@ export function StatusBar({
const loadCounts = useCallback(() => {
api.sessions
.list({ status: "active", limit: 1 })
.list({ status: "active", limit: 1, mine: true })
.then((r) => setActive(r.total))
.catch(() => undefined);
api.sessions
.list({ awaiting: true, limit: 1 })
.list({ awaiting: true, limit: 1, mine: true })
.then((r) => setNeedYou(r.total))
.catch(() => undefined);
}, []);
@@ -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();
});
});
@@ -183,6 +183,9 @@ describe("StatusBar", () => {
);
await screen.findByText("1 need you");
expect(screen.getByTestId("status-bar-counts").textContent).toBe("2 running · 1 need you");
// The user's OWN sessions, even for an admin who sees every user's rows.
for (const [p] of vi.mocked(api.sessions.list).mock.calls)
expect(p).toMatchObject({ mine: true });
await user.click(conn);
expect(onConnectionClick).toHaveBeenCalled();
// Polite live region: the connection and who is waiting, nothing that ticks.
+73 -22
View File
@@ -11,7 +11,9 @@
* GPUs), disks (how full, read / write rate) with network interfaces, and the
* busiest processes with who each counts for (history, CPU threads and
* processes are Linux-only reads, so off Linux those cards are left out); and — server mode — each user's
* live CPU / RAM share (a tile per user; click for their busiest processes). Also exports `useHostStats` (a poll every `pollMs` while
* live CPU / RAM share (a tile per user; click for their busiest processes; hover
* for — admin/analyst, from `GET /api/users` — their role, $ spent today,
* workspaces, sessions and last sign-in). Also exports `useHostStats` (a poll every `pollMs` while
* the tab is visible; 0 = read once), `Meter`, `pct` and `fmtPct`, which the
* Administration users table shares for each home's share of the homes disk.
* Read-only.
@@ -38,14 +40,17 @@ import {
type HostHistoryRange,
type HostStats,
type HostUsageGroup,
type ServerUserUsage,
} from "../../lib/api";
import { formatBytes, formatDateTime } from "../../lib/format";
import { fmt, fmtCost, formatBytes, formatDateTime } from "../../lib/format";
import { usePermissions } from "../AuthGate";
import { Tip } from "../Tip";
import { MiniChart } from "../admin/MiniChart";
import { UserAvatar } from "../users/UserAvatar";
const POLL_MS = 5000;
const HISTORY_POLL_MS = 30000;
const USERS_POLL_MS = 60000;
const RANGES: HostHistoryRange[] = ["1h", "6h", "24h"];
export const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
@@ -324,6 +329,25 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
const [history, setHistory] = useState<HostHistory | null>(null);
// The user tile whose busiest processes are shown.
const [open, setOpen] = useState<string | null>(null);
// Each account's role and spend for the tiles' hover card. `GET /api/users`
// is admin/analyst only, so a developer gets no card.
const { seesAll, local } = usePermissions();
const { t: tu } = useTranslation("users");
const [accounts, setAccounts] = useState<Map<number, ServerUserUsage>>(new Map());
const hasPeople = (stats?.usage?.groups ?? []).some((g) => g.kind === "user");
useEffect(() => {
if (!seesAll || local || !hasPeople) return;
const load = () =>
api.users
.list()
.then((r) => setAccounts(new Map(r.users.map((u) => [u.id, u]))))
.catch(() => {});
void load();
const id = setInterval(() => {
if (document.visibilityState === "visible") void load();
}, USERS_POLL_MS);
return () => clearInterval(id);
}, [seesAll, local, hasPeople]);
const loadHistory = useCallback(
() =>
@@ -365,6 +389,29 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
if (key === "system") return t("host.systemGroup");
return key;
};
const userTip = (g: HostUsageGroup) => {
const u = g.userId != null ? accounts.get(g.userId) : undefined;
// No account row (a developer, or not read yet): no card at all.
if (!u) return "";
const lines = [groupName(g)];
{
lines.push(
t("host.userTipRole", { v: tu(`roles.${u.role}`) }) +
(u.disabled ? ` · ${t("host.userTipDisabled")}` : "")
);
if (u.today)
lines.push(
t("host.userTipToday", { cost: fmtCost(u.today.cost), tokens: fmt(u.today.tokens) })
);
lines.push(t("host.userTipWork", { w: u.workspaces, s: u.sessions }));
lines.push(
t("host.userTipLastLogin", {
v: u.lastLoginAt ? formatDateTime(u.lastLoginAt) : t("host.userTipNever"),
})
);
}
return lines.join("\n");
};
const perSec = (v: number) => t("host.perSecond", { v: formatBytes(v) });
const timeLabel = (iso: string) =>
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
@@ -676,26 +723,30 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
const isOpen = open === g.key;
return (
<div key={g.key} className="space-y-2" data-testid="server-user">
<button
type="button"
onClick={() => setOpen(isOpen ? null : g.key)}
disabled={!g.top.length}
aria-expanded={isOpen}
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
>
<UserAvatar
name={name}
userId={g.userId}
avatarAt={g.avatarAt}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
{name}
</span>
<span className="text-[10px] font-mono text-fg-muted">
{t("host.processCount", { n: g.processes })}
</span>
</button>
{/* The hover card sits on the name row only: over the whole
tile it stacked on the CPU bar's own help tip. */}
<Tip raw={userTip(g)} block>
<button
type="button"
onClick={() => setOpen(isOpen ? null : g.key)}
disabled={!g.top.length}
aria-expanded={isOpen}
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
>
<UserAvatar
name={name}
userId={g.userId}
avatarAt={g.avatarAt}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
{name}
</span>
<span className="text-[10px] font-mono text-fg-muted">
{t("host.processCount", { n: g.processes })}
</span>
</button>
</Tip>
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
{isOpen && (
@@ -7,7 +7,8 @@
* rate and each network interface, the CPU / memory history with its range
* switch (`GET /api/host/history`), and the busiest processes with who each
* counts for, the processes of no user in a short list of their own, and
* each user's live CPU / RAM as a tile (their busiest processes on click);
* each user's live CPU / RAM as a tile (their busiest processes on click; on
* hover, for staff, their role and $ spent today);
* off Linux the cards it can never fill are left out.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -69,8 +70,10 @@ const stats: HostStats = {
const api = vi.hoisted(() => ({
host: { stats: vi.fn(), history: vi.fn() },
me: { avatarUrl: () => null },
users: { avatarUrl: () => null },
users: { avatarUrl: () => null, list: vi.fn() },
}));
const perms = vi.hoisted(() => ({ seesAll: true, local: true, isAdmin: true }));
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api };
@@ -105,6 +108,7 @@ const full: HostStats = {
beforeEach(() => {
vi.clearAllMocks();
Object.assign(perms, { seesAll: true, local: true, isAdmin: true });
api.host.stats.mockResolvedValue(full);
api.host.history.mockResolvedValue({
range: "1h",
@@ -182,6 +186,50 @@ describe("HostHealth", () => {
expect(within(tile).getByText("claude")).toBeTruthy();
});
it("names the role and today's spend when a user tile is hovered", async () => {
Object.assign(perms, { local: false });
api.users.list.mockResolvedValue({
users: [
{
id: 2,
username: "mia",
role: "developer",
disabled: false,
workspaces: 2,
sessions: 5,
today: { cost: 1.5, tokens: 12000 },
last7d: { cost: 0, tokens: 0 },
last30d: { cost: 0, tokens: 0 },
lastLoginAt: null,
},
],
});
render(<Tab />);
const tile = await screen.findByTestId("server-user");
// The card exists only once the users list has arrived.
const nameRow = () => within(tile).getByRole("button").parentElement!;
const tip = await vi.waitFor(() => {
fireEvent.mouseEnter(nameRow());
return screen.getByText(/Role: Developer/);
});
expect(tip.textContent).not.toContain("Login");
expect(tip.textContent).toMatch(/Today: \$1\.5/);
expect(tip.textContent).toContain("2 workspaces · 5 sessions");
// Only the name row carries it: the CPU bar keeps its own help tip alone.
fireEvent.mouseLeave(within(tile).getByRole("button").parentElement!);
fireEvent.mouseEnter(within(tile).getByRole("meter", { name: "CPU" }));
expect(screen.queryByText(/Role: Developer/)).toBeNull();
});
it("gives a developer no hover card, without reading the users list", async () => {
Object.assign(perms, { seesAll: false, local: false, isAdmin: false });
render(<Tab />);
const tile = await screen.findByTestId("server-user");
fireEvent.mouseEnter(within(tile).getByRole("button").parentElement!);
expect(screen.queryByText(/Today:/)).toBeNull();
expect(api.users.list).not.toHaveBeenCalled();
});
it("leaves out what Windows cannot report instead of empty cards", async () => {
api.host.stats.mockResolvedValue({
...stats,
+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) => {
+11 -3
View File
@@ -86,7 +86,7 @@ import {
sameFilesHere,
} from "../../lib/promptAttach";
import { attachImeInput } from "../../lib/imeInput";
import { wheelReports, type WheelState } from "../../lib/wheelReports";
import { SCROLL_SPEED, wheelReports, type WheelState } from "../../lib/wheelReports";
import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
import { usePermissions } from "../AuthGate";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
@@ -455,6 +455,8 @@ export const TerminalView = memo(function TerminalView({
fontWeightBold: 700,
theme: paletteRef.current,
screenReaderMode: readFlag(SCREEN_READER_KEY),
// xterm's own scrollback scrolls at the same share as the reports below.
scrollSensitivity: SCROLL_SPEED,
});
termRef.current = term;
const fit = new FitAddon();
@@ -538,8 +540,14 @@ export const TerminalView = memo(function TerminalView({
const ws = wsRef.current;
if (!n || !ws || ws.readyState !== WebSocket.OPEN) return;
const clamp = (v: number, max: number) => Math.min(max, Math.max(1, v));
const col = clamp(Math.floor(((ev.clientX - box.left) / box.width) * term.cols) + 1, term.cols);
const row = clamp(Math.floor(((ev.clientY - box.top) / box.height) * term.rows) + 1, term.rows);
const col = clamp(
Math.floor(((ev.clientX - box.left) / box.width) * term.cols) + 1,
term.cols
);
const row = clamp(
Math.floor(((ev.clientY - box.top) / box.height) * term.rows) + 1,
term.rows
);
const button = (n < 0 ? 64 : 65) | (ev.altKey ? 8 : 0) | (ev.ctrlKey ? 16 : 0);
ws.send(`\x1b[<${button};${col};${row}M`.repeat(Math.abs(n)));
};
@@ -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();
@@ -329,7 +329,8 @@ describe("TerminalView", () => {
/** Gives the mocked terminal an on-screen box: 10px per column and per row. */
const screenBox = () => {
const term = vi.mocked(Terminal).mock.results[vi.mocked(Terminal).mock.results.length - 1]!.value as { element: HTMLElement };
const term = vi.mocked(Terminal).mock.results[vi.mocked(Terminal).mock.results.length - 1]!
.value as { element: HTMLElement };
const screen = document.createElement("div");
screen.className = "xterm-screen";
screen.getBoundingClientRect = () =>
@@ -350,9 +351,11 @@ describe("TerminalView", () => {
// A notch of 6 rows: one report, at the cell under the pointer.
expect(wheel(new WheelEvent("wheel", { deltaY: -60, clientX: 45, clientY: 72 }))).toBe(false);
expect(ws.sent).toEqual(["\x1b[<64;5;8M"]);
// A touchpad: 4 steps of 25px = 10 rows = two reports, sent as they add up.
// A touchpad: 6 steps of 25px = 15 rows, ×0.7 scroll speed = 10.5 rows =
// two reports, sent as they add up.
ws.sent.length = 0;
for (let i = 0; i < 4; i++) wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 }));
for (let i = 0; i < 6; i++)
wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 }));
expect(ws.sent.join("")).toBe("\x1b[<65;1;1M".repeat(2));
});
@@ -523,9 +526,7 @@ describe("TerminalView", () => {
// wrong. The WebGL renderer draws each cell at a fixed position instead
// (xterm 6 removed the canvas one).
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(
true
);
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(true);
});
it("falls back to the DOM renderer when the browser takes the WebGL context back", () => {
@@ -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>
@@ -20,10 +20,11 @@
* a user with their own Linux account (S3), their own HTTPS
* logins. The host account's HTTPS logins are the shared Git credentials
* table above it, so this lists them only for a member. **Commit signing**
* (under the author): one picker signs every commit with one of the keys
* (`PUT /api/me/git/signing`), and **Import a key you have** takes a private
* key file or paste (`POST /api/me/git/ssh-key/import`) and, ticked by
* default, signs with it in the same step. The private key and
* (under the author) is PER WORKSPACE: one picker per workspace of the user's
* (`PUT /api/me/git/signing/lane`, "Don't sign" by default), the keys that can
* sign with Delete, a way out of the old account-wide setting and of a
* signing setup the user made themselves, and **Import a key you have** takes
* a private key file or paste (`POST /api/me/git/ssh-key/import`). The private key and
* every token are write-only: the server never returns them. A host identity
* seen by a non-admin is read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -44,6 +45,7 @@ import {
Upload,
} from "lucide-react";
import { api, type GitIdentity } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { toast } from "../../lib/toast";
@@ -233,9 +235,13 @@ function GitHostForm({
}
/**
* Commit signing: the key every commit is signed with (one picker — choosing
* a key turns signing on, "Don't sign" off) and Import a key the user already
* has, which signs with it in the same step unless unticked.
* Commit signing, PER WORKSPACE: one picker per workspace of the user's
* (`PUT /api/me/git/signing/lane` — "Don't sign" by default, so only the
* workspaces that need it sign), the keys that can sign, each with Delete,
* and Import a key the user already has. The old account-wide setting
* (every repo signed) only shows when it is on, with a button that turns it
* off; a signing setup the user made themselves in `~/.gitconfig` can be
* taken out (their key file stays).
*/
function SigningCard({
id,
@@ -251,51 +257,90 @@ function SigningCard({
run: (fn: () => Promise<GitIdentity>, done?: string) => Promise<boolean>;
}) {
const { t } = useTranslation("settings");
const auth = useAuth().state;
const me = auth.mode === "server" ? auth.user : null;
const signing = id.signing ?? { state: "off" as const, key: null, path: null };
const current = signing.state === "on" ? keys.find((k) => k.id === signing.key) : undefined;
const [form, setForm] = useState<{ text: string; name: string; sign: boolean } | null>(null);
const laneSigning = id.laneSigning ?? [];
const [form, setForm] = useState<{ text: string; name: string } | null>(null);
const [lanes, setLanes] = useState<Lane[] | null>(null);
/** Sign with `keyId` (null = stop), asking before taking over the user's own setup. */
const choose = async (keyId: string | null) => {
if (keyId && signing.state === "foreign") {
if (
!(await confirmDialog({
title: t("gitIdentity.signReplaceConfirm", {
path: signing.path,
key: keyLabel(keyId),
}),
confirmLabel: t("gitIdentity.signReplace"),
}))
)
return;
}
useEffect(() => {
let live = true;
api.lanes
.list()
.then((r) => live && setLanes(r.lanes))
.catch(() => live && setLanes([]));
return () => {
live = false;
};
}, []);
// The user's OWN workspaces: an admin sees everyone's, but signs only theirs.
const mine = (lanes ?? []).filter((l) => !me || l.owner_id == null || l.owner_id === me.id);
const laneName = (l: Lane) => l.title || l.cwd;
// Still signing, but its workspace is gone from the list (removed): the
// folder keeps signing until it is turned off here.
const orphans =
lanes === null
? []
: laneSigning
.filter((x) => !lanes.some((l) => l.id === x.laneId))
.map(
(x) =>
({
id: x.laneId,
title: t("gitIdentity.signLaneGone", { id: x.laneId }),
cwd: "",
}) as Lane
);
const signedWith = (laneId: number) => laneSigning.find((x) => x.laneId === laneId);
const chooseLane = async (lane: Lane, keyId: string | null) =>
run(
() => api.gitIdentity.setLaneSigning(lane.id, keyId),
keyId
? t("gitIdentity.signLaneSaved", { name: laneName(lane), key: keyLabel(keyId) })
: t("gitIdentity.signLaneStopped", { name: laneName(lane) })
);
const clearForeign = async () => {
if (
!(await confirmDialog({
title: t("gitIdentity.signForeignConfirm", { path: signing.path }),
confirmLabel: t("gitIdentity.signForeignRemove"),
}))
)
return;
await run(
() => api.gitIdentity.setSigning(keyId, signing.state === "foreign"),
keyId ? t("gitIdentity.signSaved", { key: keyLabel(keyId) }) : t("gitIdentity.signStopped")
() => api.gitIdentity.setSigning(null, false, true),
t("gitIdentity.signForeignRemoved")
);
};
const deleteKey = async (k: Key) => {
if (
!(await confirmDialog({
title: t("gitIdentity.signKeyDeleteConfirm", { key: keyLabel(k.id) }),
confirmLabel: t("gitIdentity.remove"),
}))
)
return;
await run(
() => api.gitIdentity.removeKeyById(k.id!),
t("gitIdentity.signKeyDeleted", { key: keyLabel(k.id) })
);
};
const doImport = async () => {
if (!form) return;
let imported: string | null = null;
let foreign = false;
const ok = await run(
async () => {
const r = await api.gitIdentity.importKey({
() =>
api.gitIdentity.importKey({
privateKey: form.text,
...(form.name.trim() ? { name: form.name.trim() } : {}),
sign: form.sign,
});
imported = r.imported?.id ?? null;
foreign = r.signError?.code === "EFOREIGNSIGN";
return r;
},
}),
t("gitIdentity.imported", { name: form.name.trim() || "signing" })
);
if (!ok) return;
setForm(null);
// Saved, but the user's own signing setup was left alone: ask once.
if (imported && foreign) await choose(imported);
if (ok) setForm(null);
};
const readFile = async (file: File | undefined) => {
@@ -306,6 +351,8 @@ function SigningCard({
setForm({ ...form, text });
};
const usable = keys.filter((k) => k.id);
return (
<section
className="space-y-2 rounded-lg border border-border p-3"
@@ -321,7 +368,7 @@ function SigningCard({
type="button"
className={BTN}
disabled={busy}
onClick={() => setForm({ text: "", name: "", sign: true })}
onClick={() => setForm({ text: "", name: "" })}
>
<Upload className="h-3.5 w-3.5" />
{t("gitIdentity.importKey")}
@@ -330,52 +377,138 @@ function SigningCard({
</div>
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signIntro")}</p>
<div className="flex flex-wrap items-center gap-2">
<span
data-testid="git-signing-state"
className={`min-w-0 flex-1 text-[11px] ${
signing.state === "on" ? "text-status-success" : "text-fg-secondary"
}`}
{signing.state === "on" && (
<div
data-testid="git-signing-legacy"
className="flex flex-wrap items-center gap-2 rounded border border-status-warning/40 bg-status-warning/10 px-2.5 py-1.5"
>
{signing.state === "on"
? t("gitIdentity.signOn", { key: keyLabel(signing.key) })
: signing.state === "foreign"
? t("gitIdentity.signForeign", { path: signing.path })
: t("gitIdentity.signOff")}
</span>
{keys.length > 0 && (
<select
aria-label={t("gitIdentity.signKey")}
value={signing.state === "on" ? (signing.key ?? "") : ""}
disabled={busy || !id.writable}
onChange={(e) => void choose(e.target.value || null)}
className="select !py-1 !text-xs"
>
<option value="">
{signing.state === "foreign" ? signing.path : t("gitIdentity.signNone")}
</option>
{keys
.filter((k) => k.id)
.map((k) => (
<option key={k.id} value={k.id}>
{keyLabel(k.id)}
</option>
))}
</select>
<span className="min-w-0 flex-1 text-[11px] text-fg-primary">
{t("gitIdentity.signLegacy", { key: keyLabel(signing.key) })}
</span>
{id.writable && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() =>
void run(() => api.gitIdentity.setSigning(null), t("gitIdentity.signLegacyStopped"))
}
>
{t("gitIdentity.signLegacyOff")}
</button>
)}
</div>
)}
{signing.state === "foreign" && (
<div
data-testid="git-signing-foreign"
className="flex flex-wrap items-center gap-2 rounded border border-border px-2.5 py-1.5"
>
<span className="min-w-0 flex-1 text-[11px] text-fg-secondary [overflow-wrap:anywhere]">
{t("gitIdentity.signForeign", { path: signing.path })}
</span>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
onClick={() => void clearForeign()}
>
<Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.signForeignRemove")}
</button>
)}
</div>
)}
<div className="space-y-1" data-testid="git-signing-lanes">
<div className="text-[11px] font-medium text-fg-secondary">
{t("gitIdentity.signLanes")}
</div>
{usable.length === 0 ? (
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signNeedKey")}</p>
) : lanes === null ? null : mine.length + orphans.length === 0 ? (
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signLanesEmpty")}</p>
) : (
[...mine, ...orphans].map((l) => {
const on = signedWith(l.id);
return (
<div key={l.id} className="flex items-center gap-2">
<span
className={`min-w-0 flex-1 truncate text-xs ${
on ? "text-status-success" : "text-fg-primary"
}`}
title={l.cwd || undefined}
>
{laneName(l)}
</span>
<select
aria-label={t("gitIdentity.signLaneKey", { name: laneName(l) })}
value={on?.key ?? ""}
disabled={busy || !id.writable}
onChange={(e) => void chooseLane(l, e.target.value || null)}
className="select !w-48 !py-1 !text-xs"
>
<option value="">{t("gitIdentity.signNone")}</option>
{usable.map((k) => (
<option key={k.id} value={k.id}>
{keyLabel(k.id)}
</option>
))}
</select>
</div>
);
})
)}
</div>
{current && (
<Step n={1}>
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1">{t("gitIdentity.signHostStep")}</span>
<button type="button" className={BTN} onClick={() => void copyText(current.publicKey)}>
<Copy className="h-3.5 w-3.5" />
{t("gitIdentity.copy")}
</button>
{usable.length > 0 && (
<div className="space-y-1" data-testid="git-signing-keys">
<div className="text-[11px] font-medium text-fg-secondary">
{t("gitIdentity.signKeys")}
</div>
<div className="truncate font-mono text-[10px] text-fg-muted">{current.fingerprint}</div>
</Step>
{usable.map((k) => {
const used = laneSigning.filter((x) => x.key === k.id).length;
return (
<div
key={k.id}
className="flex items-center gap-2 rounded border border-border px-2 py-1"
>
<FileKey className="h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
<div className="min-w-0 flex-1">
<div className="truncate text-xs text-fg-primary">{keyLabel(k.id)}</div>
<div className="truncate font-mono text-[10px] text-fg-muted">
{k.fingerprint}
{used > 0 ? ` · ${t("gitIdentity.signKeyUsed", { count: used })}` : ""}
</div>
</div>
<button
type="button"
className={BTN}
onClick={() => void copyText(k.publicKey)}
aria-label={t("gitIdentity.signKeyCopy", { key: keyLabel(k.id) })}
title={t("gitIdentity.signHostStep")}
>
<Copy className="h-3.5 w-3.5" />
</button>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={t("gitIdentity.removeKeyNamed", { name: keyLabel(k.id) })}
onClick={() => void deleteKey(k)}
>
<Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.remove")}
</button>
)}
</div>
);
})}
<p className="text-[10px] text-fg-muted">{t("gitIdentity.signHostStep")}</p>
</div>
)}
{form && (
@@ -407,24 +540,14 @@ function SigningCard({
onChange={(e) => setForm({ ...form, text: e.target.value })}
className={`${INPUT} resize-y font-mono text-[10px]`}
/>
<div className="flex flex-wrap items-center gap-3">
<input
aria-label={t("gitIdentity.importName")}
placeholder={t("gitIdentity.importName")}
value={form.name}
autoComplete="off"
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase() })}
className={`${INPUT} !w-40 font-mono`}
/>
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
<input
type="checkbox"
checked={form.sign}
onChange={(e) => setForm({ ...form, sign: e.target.checked })}
/>
{t("gitIdentity.importSign")}
</label>
</div>
<input
aria-label={t("gitIdentity.importName")}
placeholder={t("gitIdentity.importName")}
value={form.name}
autoComplete="off"
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase() })}
className={`${INPUT} !w-40 font-mono`}
/>
<p className="text-[10px] text-fg-muted">{t("gitIdentity.importNote")}</p>
<div className="flex gap-2">
<button
@@ -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>
@@ -8,8 +8,10 @@
* the sections never mix; "Add another key" makes a named key as one more
* card in its own tab, a second AWS account is saved from that card under
* the key's name with that key, and a second GitHub key is tied to its host;
* commit signing: a key picked signs commits, an imported key signs in the
* same step, and taking over the user's own signing key asks first.
* commit signing PER WORKSPACE: an imported key signs nothing by itself, one
* workspace's picker signs that workspace only, a signing key is deleted from
* the card after a confirm, and the old account-wide setting and a setup the
* user made themselves each have a way out.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -35,6 +37,16 @@ vi.mock("../../../lib/api", () => ({
removeHttps: vi.fn(),
importKey: vi.fn(),
setSigning: vi.fn(),
setLaneSigning: vi.fn(),
},
lanes: {
list: vi.fn(async () => ({
lanes: [
{ id: 1, title: "shop-api", cwd: "/w/shop-api" },
{ id: 2, title: "", cwd: "/w/docs" },
],
counts: {},
})),
},
},
}));
@@ -42,6 +54,7 @@ vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn()
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(() => Promise.resolve(true)) }));
vi.mock("../../../lib/clipboard", () => ({ copyText: vi.fn(() => Promise.resolve(true)) }));
const OFF = { state: "off" as const, key: null, path: null };
const KEY = { publicKey: "ssh-ed25519 AAAAC3Nz an@agenthub", fingerprint: "SHA256:abc" };
const view = (over: Partial<GitIdentity> = {}): GitIdentity => ({
account: "member",
@@ -340,7 +353,7 @@ describe("GitIdentitySettings", () => {
expect(await within(work).findByText("ssh://git-2/ → git@github.com")).toBeInTheDocument();
});
it("signs commits with an imported key in one step", async () => {
it("imports a key without signing anything by itself", async () => {
const signingKey = {
...KEY,
id: "id_ed25519_signing",
@@ -348,17 +361,15 @@ describe("GitIdentitySettings", () => {
name: "signing",
};
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ sshKeys: [], signing: { state: "off", key: null, path: null } })
view({ sshKeys: [], signing: { state: "off", key: null, path: null }, laneSigning: [] })
);
vi.mocked(api.gitIdentity.importKey).mockResolvedValue({
...view({
sshKeys: [signingKey],
signing: { state: "on", key: "id_ed25519_signing", path: null },
}),
...view({ sshKeys: [signingKey], signing: OFF, laneSigning: [] }),
imported: signingKey,
signError: null,
});
render(<GitIdentitySettings />);
expect(await screen.findByText(/Import a key you have, or make one/)).toBeInTheDocument();
await userEvent.click(await screen.findByRole("button", { name: "Import a key you have" }));
const form = screen.getByRole("form", { name: "Import an existing private key" });
await userEvent.type(
@@ -368,26 +379,103 @@ describe("GitIdentitySettings", () => {
await userEvent.click(within(form).getByRole("button", { name: "Import" }));
expect(api.gitIdentity.importKey).toHaveBeenCalledWith({
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----x",
sign: true,
});
expect(await screen.findByTestId("git-signing-state")).toHaveTextContent(/signed with signing/);
expect(confirmDialog).not.toHaveBeenCalled();
// Now there is a key: each workspace gets its own picker, all "Don't sign".
expect(
await screen.findByLabelText("Signing key for shop-api", {}, { timeout: 5000 })
).toHaveValue("");
expect(screen.getByLabelText("Signing key for /w/docs")).toHaveValue("");
expect(api.gitIdentity.setSigning).not.toHaveBeenCalled();
});
it("asks before taking over a signing key the user set up themselves", async () => {
const k = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
it("signs one workspace only, and stops it", async () => {
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ sshKeys: [k], signing: { state: "foreign", key: null, path: "~/.ssh/id_ed25519" } })
view({ sshKeys: [k], signing: { state: "off", key: null, path: null }, laneSigning: [] })
);
vi.mocked(api.gitIdentity.setSigning).mockResolvedValue(
view({ sshKeys: [k], signing: { state: "on", key: "id_ed25519", path: null } })
vi.mocked(api.gitIdentity.setLaneSigning)
.mockResolvedValueOnce(
view({ sshKeys: [k], signing: OFF, laneSigning: [{ laneId: 1, key: k.id }] })
)
.mockResolvedValueOnce(view({ sshKeys: [k], signing: OFF, laneSigning: [] }));
render(<GitIdentitySettings />);
const pick = await screen.findByLabelText("Signing key for shop-api", {}, { timeout: 5000 });
await userEvent.selectOptions(pick, k.id);
expect(api.gitIdentity.setLaneSigning).toHaveBeenCalledWith(1, k.id);
await waitFor(() =>
expect(screen.getByLabelText("Signing key for shop-api")).toHaveValue(k.id)
);
expect(screen.getByLabelText("Signing key for /w/docs")).toHaveValue("");
expect(
within(screen.getByTestId("git-signing-keys")).getByText(/signs 1 workspace/)
).toBeTruthy();
await userEvent.selectOptions(screen.getByLabelText("Signing key for shop-api"), "");
expect(api.gitIdentity.setLaneSigning).toHaveBeenLastCalledWith(1, null);
expect(api.gitIdentity.setSigning).not.toHaveBeenCalled();
});
it("lists a removed workspace that still signs, so it can be turned off", async () => {
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ sshKeys: [k], signing: OFF, laneSigning: [{ laneId: 9, key: k.id }] })
);
vi.mocked(api.gitIdentity.setLaneSigning).mockResolvedValue(
view({ sshKeys: [k], signing: OFF, laneSigning: [] })
);
render(<GitIdentitySettings />);
expect(await screen.findByTestId("git-signing-state")).toHaveTextContent("~/.ssh/id_ed25519");
await userEvent.selectOptions(screen.getByLabelText("Signing key"), "id_ed25519");
expect(confirmDialog).toHaveBeenCalled();
const pick = await screen.findByLabelText(
"Signing key for Workspace #9 (removed)",
{},
{ timeout: 5000 }
);
expect(pick).toHaveValue(k.id);
await userEvent.selectOptions(pick, "");
expect(api.gitIdentity.setLaneSigning).toHaveBeenCalledWith(9, null);
await waitFor(() =>
expect(api.gitIdentity.setSigning).toHaveBeenCalledWith("id_ed25519", true)
expect(screen.queryByLabelText("Signing key for Workspace #9 (removed)")).toBeNull()
);
});
it("deletes a signing key from the signing card, after asking", async () => {
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({
sshKeys: [k],
signing: { state: "off", key: null, path: null },
laneSigning: [{ laneId: 1, key: k.id }],
})
);
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
view({ sshKeys: [], signing: OFF, laneSigning: [] })
);
render(<GitIdentitySettings />);
const keys = await screen.findByTestId("git-signing-keys", {}, { timeout: 5000 });
await userEvent.click(within(keys).getByRole("button", { name: "Delete key signing" }));
expect(confirmDialog).toHaveBeenCalledWith(
expect.objectContaining({ title: expect.stringMatching(/every workspace signing with it/) })
);
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith(k.id);
await waitFor(() => expect(screen.queryByTestId("git-signing-keys")).toBeNull());
});
it("turns off the old account-wide signing and removes a setup the user made", async () => {
const k = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ sshKeys: [k], signing: { state: "on", key: "id_ed25519", path: null } })
);
vi.mocked(api.gitIdentity.setSigning).mockResolvedValue(
view({ sshKeys: [k], signing: { state: "foreign", key: null, path: "~/.ssh/mine" } })
);
render(<GitIdentitySettings />);
const legacy = await screen.findByTestId("git-signing-legacy");
await userEvent.click(within(legacy).getByRole("button", { name: "Sign per workspace only" }));
expect(api.gitIdentity.setSigning).toHaveBeenCalledWith(null);
const foreign = await screen.findByTestId("git-signing-foreign");
expect(foreign).toHaveTextContent("~/.ssh/mine");
await userEvent.click(within(foreign).getByRole("button", { name: "Remove" }));
expect(confirmDialog).toHaveBeenCalledWith(
expect.objectContaining({ title: expect.stringMatching(/key file itself is not deleted/) })
);
expect(api.gitIdentity.setSigning).toHaveBeenLastCalledWith(null, false, true);
});
});
@@ -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"
}
}
+7 -1
View File
@@ -182,6 +182,12 @@
"historyNone": "No history yet — the server keeps it in memory on Linux, from the moment it starts.",
"agenthub": "AgentHub",
"usersTitle": "Users · CPU / RAM now",
"usersCount": "{{n}} users"
"usersCount": "{{n}} users",
"userTipRole": "Role: {{v}}",
"userTipToday": "Today: {{cost}} · {{tokens}} tokens",
"userTipWork": "{{w}} workspaces · {{s}} sessions",
"userTipLastLogin": "Last sign-in: {{v}}",
"userTipNever": "never",
"userTipDisabled": "Disabled"
}
}
+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",
+30 -29
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": {
@@ -174,23 +175,6 @@
"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."
}
},
"pricing": {
@@ -884,23 +868,35 @@
"authorEmail": "Email",
"authorUnset": "Not set yet: commits from the dashboard and your agents fail until a name and email are saved. Filled in from your account — check and save.",
"signTitle": "Commit signing",
"signIntro": "Sign every commit — from your terminal, the Changes panel or an agent — with one of your SSH keys, so a host that requires signed commits accepts your push.",
"signOff": "Commits are not signed.",
"signOn": "Every commit is signed with {{key}}.",
"signForeign": "Commits are signed with a key you set up yourself: {{path}}.",
"signIntro": "Choose which workspaces sign their commits — from your terminal, the Changes panel or an agent — with one of your SSH keys. Every other repository stays unsigned.",
"signLanes": "Workspaces",
"signLanesEmpty": "You have no workspace yet.",
"signNeedKey": "Import a key you have, or make one below, to sign with.",
"signLaneGone": "Workspace #{{id}} (removed)",
"signLaneKey": "Signing key for {{name}}",
"signLaneSaved": "{{name}} now signs its commits with {{key}}.",
"signLaneStopped": "{{name}} no longer signs its commits.",
"signLegacy": "Every commit of your account is signed with {{key}} (the old account-wide setting).",
"signLegacyOff": "Sign per workspace only",
"signLegacyStopped": "Account-wide signing is off — each workspace keeps its own choice.",
"signForeign": "Your ~/.gitconfig signs every commit with a key you set up yourself: {{path}}.",
"signForeignRemove": "Remove",
"signForeignConfirm": "Take {{path}} out of your ~/.gitconfig? Commits stop being signed with it; the key file itself is not deleted.",
"signForeignRemoved": "The old signing setup was taken out of ~/.gitconfig.",
"signKeys": "Keys you can sign with",
"signKeyUsed_one": "signs {{count}} workspace",
"signKeyUsed_other": "signs {{count}} workspaces",
"signKeyCopy": "Copy the public key of {{key}}",
"signKeyDeleteConfirm": "Delete key {{key}}? Its private file is deleted, every workspace signing with it stops signing, and clones that use it can no longer fetch or push.",
"signKeyDeleted": "Key {{key}} deleted.",
"signKey": "Signing key",
"signNone": "Don't sign",
"signHostStep": "Add this public key to your account on the host as a SIGNING key (Gitea/GitHub: SSH / GPG Keys — on Gitea press Verify), and keep your commit email above the same as the host account's.",
"signReplaceConfirm": "Your git already signs with {{path}}. Switch to {{key}}?",
"signReplace": "Switch",
"signSaved": "Commits are now signed with {{key}}.",
"signStopped": "Commits are no longer signed.",
"importKey": "Import a key you have",
"importTitle": "Import an existing private key",
"importFile": "Choose the key file",
"importPaste": "…or paste the private key (-----BEGIN … PRIVATE KEY-----)",
"importName": "Name (optional)",
"importSign": "Sign my commits with it",
"importNote": "The key must have no passphrase (a dashboard commit has nowhere to type one). It is stored in your own account; only its public half is ever shown again.",
"importSubmit": "Import",
"imported": "Key {{name}} imported.",
@@ -1157,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ả"
}
}
+7 -1
View File
@@ -182,6 +182,12 @@
"historyNone": "Chưa có lịch sử — server chỉ giữ trong bộ nhớ trên Linux, tính từ lúc khởi động.",
"agenthub": "AgentHub",
"usersTitle": "Người dùng · CPU / RAM hiện tại",
"usersCount": "{{n}} người dùng"
"usersCount": "{{n}} người dùng",
"userTipRole": "Vai trò: {{v}}",
"userTipToday": "Hôm nay: {{cost}} · {{tokens}} token",
"userTipWork": "{{w}} workspace · {{s}} phiên",
"userTipLastLogin": "Đăng nhập gần nhất: {{v}}",
"userTipNever": "chưa bao giờ",
"userTipDisabled": "Đã vô hiệu hóa"
}
}
+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",
+30 -29
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": {
@@ -162,23 +163,6 @@
"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."
}
},
"pricing": {
@@ -872,23 +856,35 @@
"authorEmail": "Email",
"authorUnset": "Chưa đặt: commit từ dashboard và từ agent của bạn sẽ lỗi cho tới khi lưu tên và email. Đã điền sẵn từ tài khoản — kiểm tra rồi bấm Lưu.",
"signTitle": "Ký commit",
"signIntro": "Ký mọi commit — trong terminal, ở panel Thay đổi hay do agent tạo — bằng một SSH key của bạn, để host yêu cầu commit có chữ ký chấp nhận lần push của bạn.",
"signOff": "Commit chưa được ký.",
"signOn": "Mọi commit đang được ký bằng {{key}}.",
"signForeign": "Commit đang được ký bằng key bạn tự cấu hình: {{path}}.",
"signIntro": "Chọn workspace nào ký commit — từ terminal, bảng Changes hay agent — bằng một SSH key của bạn. Các repo khác không ký.",
"signLanes": "Workspace",
"signLanesEmpty": "Bạn chưa có workspace nào.",
"signNeedKey": "Hãy nhập key bạn có, hoặc tạo key ở phần dưới, để dùng ký.",
"signLaneGone": "Workspace #{{id}} (đã xóa)",
"signLaneKey": "Key ký cho {{name}}",
"signLaneSaved": "{{name}} giờ ký commit bằng {{key}}.",
"signLaneStopped": "{{name}} không còn ký commit.",
"signLegacy": "Mọi commit của tài khoản đang được ký bằng {{key}} (cài đặt cũ cho cả tài khoản).",
"signLegacyOff": "Chỉ ký theo workspace",
"signLegacyStopped": "Đã tắt ký cho cả tài khoản — mỗi workspace giữ lựa chọn riêng.",
"signForeign": "~/.gitconfig của bạn đang ký mọi commit bằng key bạn tự cài: {{path}}.",
"signForeignRemove": "Gỡ",
"signForeignConfirm": "Gỡ {{path}} khỏi ~/.gitconfig? Commit sẽ không còn ký bằng key này; file key không bị xóa.",
"signForeignRemoved": "Đã gỡ cấu hình ký cũ khỏi ~/.gitconfig.",
"signKeys": "Key dùng để ký",
"signKeyUsed_one": "ký {{count}} workspace",
"signKeyUsed_other": "ký {{count}} workspace",
"signKeyCopy": "Sao chép public key của {{key}}",
"signKeyDeleteConfirm": "Xóa key {{key}}? File khóa riêng sẽ bị xóa, các workspace đang ký bằng key này sẽ ngừng ký, và các bản clone dùng key này không fetch/push được nữa.",
"signKeyDeleted": "Đã xóa key {{key}}.",
"signKey": "Key dùng để ký",
"signNone": "Không ký",
"signHostStep": "Thêm public key này vào tài khoản trên host dưới dạng key KÝ (Gitea/GitHub: SSH / GPG Keys — trên Gitea bấm Verify), và giữ email commit ở trên trùng với email tài khoản trên host.",
"signReplaceConfirm": "Git của bạn đang ký bằng {{path}}. Chuyển sang {{key}}?",
"signReplace": "Chuyển",
"signSaved": "Commit giờ được ký bằng {{key}}.",
"signStopped": "Commit không còn được ký.",
"importKey": "Nhập key có sẵn",
"importTitle": "Nhập private key có sẵn",
"importFile": "Chọn file key",
"importPaste": "…hoặc dán private key (-----BEGIN … PRIVATE KEY-----)",
"importName": "Tên (không bắt buộc)",
"importSign": "Dùng key này để ký commit",
"importNote": "Key không được có passphrase (commit từ dashboard không có chỗ nhập). Key được lưu trong tài khoản của chính bạn; sau khi nhập chỉ public key được hiển thị.",
"importSubmit": "Nhập",
"imported": "Đã nhập key {{name}}.",
@@ -1145,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ủ",
+16 -6
View File
@@ -6,7 +6,7 @@
*/
import { describe, it, expect } from "vitest";
import { LINES_PER_REPORT, wheelReports, type WheelState } from "../wheelReports";
import { LINES_PER_REPORT, SCROLL_SPEED, wheelReports, type WheelState } from "../wheelReports";
const ROW = 20;
const px = (deltaY: number) => ({ deltaY, deltaMode: 0 });
@@ -15,16 +15,17 @@ describe("wheelReports", () => {
it("adds small touchpad steps up to one report per LINES_PER_REPORT rows", () => {
const s: WheelState = { partial: 0 };
let sent = 0;
// 30 steps of 10px = 300px = 15 rows.
for (let i = 0; i < 30; i++) sent += wheelReports(s, px(10), ROW, 40);
expect(sent).toBe(15 / LINES_PER_REPORT);
// 110 steps of 10px = 1100px = 55 rows, scrolled at SCROLL_SPEED.
for (let i = 0; i < 110; i++) sent += wheelReports(s, px(10), ROW, 40);
expect(sent).toBe(Math.trunc((55 * SCROLL_SPEED) / LINES_PER_REPORT));
});
it("drops the carried remainder when the swipe turns around", () => {
const s: WheelState = { partial: 0 };
wheelReports(s, px(40), ROW, 40); // 2 rows down, nothing sent yet
let sent = 0;
for (let i = 0; i < 5; i++) sent += wheelReports(s, px(-20), ROW, 40); // 5 rows up
// 8 rows up = 5.6 scrolled: one report, the 2 rows down forgotten.
for (let i = 0; i < 8; i++) sent += wheelReports(s, px(-20), ROW, 40);
expect(sent).toBe(-1);
});
@@ -32,7 +33,16 @@ describe("wheelReports", () => {
expect(wheelReports({ partial: 0 }, px(100), ROW, 40)).toBe(1);
expect(wheelReports({ partial: 0 }, px(-100), ROW, 40)).toBe(-1);
expect(wheelReports({ partial: 0 }, { deltaY: 3, deltaMode: 1 }, ROW, 40)).toBe(1);
expect(wheelReports({ partial: 0 }, { deltaY: 1, deltaMode: 2 }, ROW, 40)).toBe(8);
expect(wheelReports({ partial: 0 }, { deltaY: 1, deltaMode: 2 }, ROW, 40)).toBe(6); // 8 × 0.7
});
it("slows a run of wheel notches to SCROLL_SPEED of a report each", () => {
const s: WheelState = { partial: 0 };
let sent = 0;
for (let i = 0; i < 10; i++) sent += wheelReports(s, px(100), ROW, 40);
expect(sent).toBe(10 * SCROLL_SPEED);
// Turning around starts from zero: the first notch up moves at once.
expect(wheelReports(s, px(-100), ROW, 40)).toBe(-1);
});
it("sends nothing for a horizontal-only event or an unmeasured terminal", () => {
+36 -3
View File
@@ -1858,6 +1858,8 @@ export const api = {
archived?: boolean;
/** Pinned sessions first (by pin time), before the limit. */
pinnedFirst?: boolean;
/** Server mode: the caller's OWN sessions, even for an admin/analyst (`mine=1`). */
mine?: boolean;
/** Only active sessions waiting on the user (`awaiting=1`). */
awaiting?: boolean;
/** ISO bounds on `started_at`. */
@@ -1878,6 +1880,7 @@ export const api = {
if (params?.archived !== undefined) qs.set("archived", params.archived ? "1" : "0");
if (params?.pinnedFirst) qs.set("pinned_first", "1");
if (params?.awaiting) qs.set("awaiting", "1");
if (params?.mine) qs.set("mine", "1");
if (params?.from) qs.set("from", params.from);
if (params?.to) qs.set("to", params.to);
if (params?.sort_by) qs.set("sort_by", params.sort_by);
@@ -3528,11 +3531,21 @@ export const api = {
"/me/git/ssh-key/import",
{ method: "POST", body: JSON.stringify(body) }
),
/** PUT /api/me/git/signing — sign commits with key `key` (its id), or stop (null). */
setSigning: (key: string | null, replace = false) =>
/**
* PUT /api/me/git/signing — the account-wide setting: sign every commit
* with key `key` (its id), or stop (null); `clearForeign` also takes out a
* signing setup the user made themselves (their key file stays).
*/
setSigning: (key: string | null, replace = false, clearForeign = false) =>
request<GitIdentity>("/me/git/signing", {
method: "PUT",
body: JSON.stringify({ key, replace }),
body: JSON.stringify(clearForeign ? { key, replace, clearForeign } : { key, replace }),
}),
/** PUT /api/me/git/signing/lane — sign ONE workspace's commits with `key`, or stop (null). */
setLaneSigning: (laneId: number, key: string | null) =>
request<GitIdentity>("/me/git/signing/lane", {
method: "PUT",
body: JSON.stringify({ laneId, key }),
}),
/** PUT /api/me/git/ssh-terminal — use the key for git typed in the account's terminal. */
setTerminalSsh: (on: boolean) =>
@@ -3769,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). */
@@ -5261,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;
@@ -5450,6 +5478,11 @@ export interface GitIdentity {
* from an older server.
*/
signing?: { state: "on" | "off" | "foreign"; key: string | null; path: string | null };
/**
* The workspaces whose commits are signed (an `includeIf "gitdir:…"` each):
* `key` = the key's id, null when its file is gone. Absent from an older server.
*/
laneSigning?: { laneId: number; key: string | null }[];
/** The terminal switch (shared by both keys); null without a key. */
sshTerminal?: "on" | "off" | "foreign" | null;
/**
+4
View File
@@ -1971,6 +1971,10 @@ export interface WSMessage {
/** ISO timestamp the server broadcast this message (not necessarily the
* same instant the underlying event occurred). */
timestamp: string;
/** Server mode, an admin/analyst's session/agent/event frames only: whether
* the session is their OWN (they also get every other user's). Absent for a
* developer (who only ever gets their own) and in local mode. */
mine?: boolean;
}
// ───── Session stats ─────
+20 -3
View File
@@ -8,13 +8,19 @@
* swipe crawled. In our panes every report moves a fixed stretch (Claude's
* `CLAUDE_CODE_SCROLL_SPEED`, tmux's copy-mode `WHEEL_LINES`), so a report is
* sent per `LINES_PER_REPORT` rows of travel: a touchpad scrolls 1:1 with the
* finger, and a wheel notch always sends at least one.
* finger, and a wheel notch always sends at least one. `SCROLL_SPEED` then
* slows both down (the user asked for 30 % less): a touchpad's travel is
* scaled, and a notch's reports are carried as a fraction, rounded, so the
* first notch still moves and the average comes out at that share.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** Rows one report scrolls in a Claude pane (server/lib/pty-run.js CLAUDE_SCROLL_SPEED). */
export const LINES_PER_REPORT = 5;
/** Share of the travel a pane scrolls (0.7 = 30 % slower than 1:1). */
export const SCROLL_SPEED = 0.7;
/** A pixel delta this large or larger is a wheel notch, not a touchpad step (xterm.js's own cut). */
const NOTCH_PX = 50;
@@ -25,6 +31,10 @@ const DOM_DELTA_PAGE = 2;
export interface WheelState {
/** Touchpad travel not yet sent, in rows (signed). */
partial: number;
/** Wheel-notch reports owed but not sent yet (signed, a fraction). */
notch?: number;
/** Direction of the last notch (the carry above may round to either sign). */
notchDir?: number;
}
/**
@@ -42,18 +52,25 @@ export function wheelReports(
if (ev.deltaMode === DOM_DELTA_PIXEL && Math.abs(ev.deltaY) < NOTCH_PX) {
// Touchpad: carry the remainder, so slow swipes still add up.
if (Math.sign(state.partial) === -sign) state.partial = 0; // turned around
state.partial += ev.deltaY / rowPx;
state.partial += (ev.deltaY / rowPx) * SCROLL_SPEED;
const n = Math.trunc(state.partial / LINES_PER_REPORT);
state.partial -= n * LINES_PER_REPORT;
return n;
}
// A notch (or a fast flick): at least one report, more for a long one.
state.partial = 0;
if (state.notchDir !== sign) state.notch = 0; // turned around
state.notchDir = sign;
const lines =
ev.deltaMode === DOM_DELTA_LINE
? ev.deltaY
: ev.deltaMode === DOM_DELTA_PAGE
? ev.deltaY * rows
: ev.deltaY / rowPx;
return sign * Math.max(1, Math.round(Math.abs(lines) / LINES_PER_REPORT));
const owed =
(state.notch ?? 0) +
sign * Math.max(1, Math.round(Math.abs(lines) / LINES_PER_REPORT)) * SCROLL_SPEED;
const n = sign * Math.round(Math.abs(owed));
state.notch = owed - n;
return n;
}
+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.62 (rebuild client to match)
UI build v2.8.70 (rebuild client to match)
</span>
</p>
</div>
+12 -6
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
@@ -1563,7 +1565,8 @@ GET /api/me/git { account: "member"|"host", writable, ssh: {
POST /api/me/git/ssh-key Make an SSH key — Body: { replace?, type?: "ed25519" | "rsa", name? } — without `name` the DEFAULT key of that type (`id_ed25519`/`id_rsa`), with `name` (lowercase letters, digits, `-`, ≤ 32; 400 EBADKEYNAME) one more beside it, `id_<type>_<name>` — for another AWS account or a second GitHub user. 409 EEXISTS when that key exists without replace, or the name is taken by a key of the other type; 409 ETOOMANYKEYS past 10 keys → 201, same shape
DELETE /api/me/git/ssh-key Delete one key — `?id=<key id>` (400 EBADKEYNAME for a malformed id), or the default key of `?type=ed25519|rsa` — or every key without either (404 ENOTFOUND, 400 EBADKEYTYPE). A login naming a deleted key goes back to every key; our terminal `core.sshCommand` follows the keys and goes with the last one
POST /api/me/git/ssh-key/import Import a private key the user already has — Body: { privateKey, name?, sign?, replace? } — OpenSSH (`BEGIN OPENSSH PRIVATE KEY`) or PEM (PKCS#1/PKCS#8) text, ed25519 or RSA ≥ 2048; saved as a NAMED key (`name`, else `signing`, `signing-2`…) written like a generated one, so it serves clones too; only `imported` (its public half) comes back. 400 EBADKEY (not a key, a `.pub`, halves that do not match), EKEYPASSPHRASE (an encrypted key — a dashboard commit has nowhere to type the passphrase), EBADKEYTYPE; 409 EEXISTS (the same key already imported, or the name taken), ETOOMANYKEYS. With `sign: true` commits are signed with it at once; a refusal of that step comes back as `signError` (the key stays saved) → 201
PUT /api/me/git/signing Sign commits — Body: { key: <key id> | null, replace? } — writes `gpg.format ssh`, `user.signingkey <the key's PRIVATE file>` (ssh-keygen signs with it; a `.pub` would need an ssh-agent) and `commit.gpgsign true` to the account's `~/.gitconfig`, so terminal, dashboard and agent commits are all signed; `null` removes the three only when the key is ours. 409 EFOREIGNSIGN over a signing key the user set themselves, unless `replace: true`; deleting the signing key stops signing. `GET` answers `signing: { state: "on"|"off"|"foreign", key, path }` (`path` = the user's own key file when foreign)
PUT /api/me/git/signing Sign commits — Body: { key: <key id> | null, replace? } — writes `gpg.format ssh`, `user.signingkey <the key's PRIVATE file>` (ssh-keygen signs with it; a `.pub` would need an ssh-agent) and `commit.gpgsign true` to the account's `~/.gitconfig`, so terminal, dashboard and agent commits are all signed; `null` removes the three only when the key is ours. 409 EFOREIGNSIGN over a signing key the user set themselves, unless `replace: true`; deleting the signing key stops signing. `GET` answers `signing: { state: "on"|"off"|"foreign", key, path }` (`path` = the user's own key file when foreign) — the old ACCOUNT-WIDE setting; `clearForeign: true` (with `key: null`) also takes out a signing setup the user made themselves, never their key file
PUT /api/me/git/signing/lane Sign ONE workspace's commits — Body: { laneId, key: <key id> | null } — a file of ours `lane-<id>.gitconfig` (gpg.format ssh, user.signingkey, commit.gpgsign) included from `~/.gitconfig` by `includeIf "gitdir:<the workspace's git dir>"` and `"gitdir:<common dir>/worktrees/ah-<id>-*"` (its sessions' worktrees), so only that workspace signs — terminal, Changes panel and agents alike — and nothing is written into the repository. 404 for a workspace the caller cannot see, 403 EFOREIGN when its folder runs as another account, 409 ENOREPO when it is not a git repository. `GET` answers `laneSigning: [{ laneId, key }]`; deleting a key stops every workspace signing with it. Two workspaces on one folder share it; a workspace in a SUBFOLDER of a repo signs the whole repo
PUT /api/me/git/ssh-terminal Use the keys for git typed in the account's own terminal — Body: { on } — sets/unsets `core.sshCommand` in its `~/.gitconfig` (every AgentHub key first, then `~/.ssh/id_*`; rewritten as keys are made or deleted while on); on by default when a member makes a key; 409 EFOREIGNSSH over one the user wrote, which is never touched
PUT /api/me/git/ssh-user An SSH login — Body: { host, user, alias?, key?, allRegions? } — `allRegions: true` (a CodeCommit host, no alias — else 400 EBADHOST) writes the rule for EVERY CodeCommit region at once (the list is `codecommitRegions` in `GET /api/me/git`), replacing each region's own; `DELETE /api/me/git/ssh-user/<a CodeCommit host>?allRegions=1&user=<key ID>` removes them all. Otherwise it writes `url."ssh://<user>@<host>/".insteadOf "ssh://<from>/"` to the account's `~/.gitconfig`, `<from>` = `alias` or the host itself (replacing ours for that host/alias), so `ssh://<from>/…` logs in as `user` (AWS CodeCommit: the IAM SSH key ID `APKA…`). `alias` (lowercase letters, digits, `-`, NO dot — 400 EBADALIAS) is one more account on the same host: two AWS accounts clone as `ssh://aws-prod/v1/repos/x` and `ssh://aws-dev/v1/repos/x`. `key` (a key id, 400 ENOKEY) is stored as `agenthub.<from>.sshKey` there: a clone of a `ssh://<from>/…` URL then offers ONLY that key (`IdentitiesOnly`) and persists it into the clone's config; without it every key is offered. An IAM SSH key ID logs in to every region of its AWS account, so a Clone of `ssh://git-codecommit.<region>.amazonaws.com/…` tries every CodeCommit login saved — the region's own first, then other regions' and every named key's (another AWS account), each with its own key: the first the remote accepts (`git ls-remote`), or the only one, and the clone's `origin` then names that user. So a second account's key clones from a plain AWS URL too, with no `ssh://<alias>/` rewrite. 400 EBADHOST / EBADSSHUSER
DELETE /api/me/git/ssh-user/:from Drop the rule of that host or alias and its key link (404 ENOTFOUND); any other `insteadOf` the user wrote is never touched
@@ -2175,6 +2178,7 @@ Returns all sessions, ordered by most recent activity.
| `offset` | integer | 0 | Pagination offset |
| `status` | string | - | Filter by persisted status: `active`, `completed`, `error`, `abandoned`. The UI **Waiting** state is derived from the `awaiting_input_since` column and is not a `status` value — use `awaiting=1` |
| `awaiting` | `1` | - | Only sessions blocked on the user: `awaiting_input_since IS NOT NULL AND status = 'active'` (the UI's **Waiting** filter) |
| `mine` | `1` | - | Server mode: only the caller's OWN sessions (workspaces they own, or their Linux account's), even for an admin or analyst. The pet seeds its count from it. No-op in local mode |
| `from` / `to` | ISO 8601 | - | Inclusive bounds on `started_at`; an unparseable value is ignored. Like every filter here, applied before `limit`/`offset`, so `total` stays exact |
| `cwd` | string | - | Exact working directory |
| `tool` | string | - | One CLI's sessions: `claude`, `codex`, `antigravity`. Also accepted on `/api/stats`, `/api/analytics`, `/api/workflows` (and `/workflows/skill-usage`) and `/api/pricing/cost`, where a malformed id is `400 INVALID_INPUT` |
@@ -3489,7 +3493,7 @@ POST /api/pets/featured/:id Download a FEATURED pet from codex-pets.ne
DELETE /api/pets/:id Delete a pet (its folder under the data dir)
```
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.
@@ -3717,6 +3721,8 @@ stateDiagram-v2
Server broadcasts JSON messages to all connected clients:
Server mode: a developer gets only the frames of their own sessions and workspaces. An admin or analyst gets every user's, and each `session_created`, `session_updated`, `agent_created`, `agent_updated` and `new_event` frame they receive also carries `"mine": true|false`, which says whether the session is their own (the rule of `GET /api/sessions?mine=1`). The pet counts only frames with `mine` absent or true. Local mode never adds `mine`.
#### session.created
Sent when a new session is created.
+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 |
+49 -1
View File
@@ -188,7 +188,9 @@ Workspaces made before server mode, or by the host account, have none.
load, memory and swap, every real disk (HDD or SSD, how full; the one
holding the users' homes is marked), any NVIDIA GPU. Each user's live
share of it is a **Users · CPU / RAM now** card on the same tab (a tile per
user, their busiest processes on click; their home share stays in
user, their busiest processes on click; on hover, for an
admin or analyst — from `GET /api/users` — their role, $ spent today,
workspaces, sessions and last sign-in; their home share stays in
Administration's users table), and the processes of no user are listed apart — CPU % of the whole machine (100 % = every core busy) and
resident memory over a 1 s `/proc` sample, a process count, the busiest
processes by their kernel name (never argv or env), and their home's size
@@ -1154,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.62",
"version": "2.8.70",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.62",
"version": "2.8.70",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.62",
"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"
+129 -1
View File
@@ -5,7 +5,11 @@
* the same public half `ssh-keygen -y` prints, only that half returned, the
* private file 0600; a passphrase key, a `.pub`, an ecdsa key and the same key
* twice refused; a real commit signed and verified by git; a signing key the
* user set themselves kept unless replaced; deleting the key stops signing.
* user set themselves kept unless replaced, and taken out (its file kept) on
* `clearForeign`; deleting the key stops signing. Per workspace
* (`PUT /api/me/git/signing/lane`): only that workspace's commits — and its
* sessions' `ah-<id>-*` worktrees — are signed, never another repo's, the
* user's own includes stay, and deleting the key stops that workspace's too.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -204,3 +208,127 @@ describe("PUT /api/me/git/signing", () => {
await removeAll();
});
});
describe("PUT /api/me/git/signing/lane", () => {
const env = {
...process.env,
GIT_AUTHOR_NAME: "t",
GIT_AUTHOR_EMAIL: "me@x",
GIT_COMMITTER_NAME: "t",
GIT_COMMITTER_EMAIL: "me@x",
};
const commit = (dir, msg) =>
execFileSync("git", ["-C", dir, "commit", "-q", "--allow-empty", "-m", msg], { env });
/** `G` signed and verified, `N` not signed. */
const sig = (dir, allowed) =>
execFileSync(
"git",
["-C", dir, "-c", `gpg.ssh.allowedSignersFile=${allowed}`, "log", "-1", "--format=%G?"],
{ encoding: "utf8" }
).trim();
const lane = async (dir) => {
const r = await request("POST", "/api/lanes/ensure", { cwd: dir, exact: true });
return r.body.lane.id;
};
it("signs one workspace and its sessions' worktrees, never another repo", async () => {
const A = path.join(ROOT, "ws-a");
const B = path.join(ROOT, "ws-b");
const plain = path.join(ROOT, "ws-plain");
for (const d of [A, B]) {
execFileSync("git", ["init", "-q", d]);
commit(d, "first");
}
fs.mkdirSync(plain);
const [a, b, p] = [await lane(A), await lane(B), await lane(plain)];
// A user's own include stays whatever we do.
execFileSync("git", ["config", "--global", "includeIf.gitdir:/elsewhere/.path", "/mine"]);
const k = keygen("lane", ["-t", "ed25519"]);
const imp = await request("POST", "/api/me/git/ssh-key/import", {
privateKey: k.text,
name: "lane",
});
const key = imp.body.imported.id;
const allowed = path.join(ROOT, "allowed-lane");
fs.writeFileSync(allowed, `me@x ${k.pub}\n`);
const on = await request("PUT", "/api/me/git/signing/lane", { laneId: a, key });
assert.equal(on.status, 200);
assert.deepEqual(on.body.laneSigning, [{ laneId: a, key }]);
// Nothing account-wide: the global settings stay unset.
assert.equal(on.body.signing.state, "off");
assert.equal(gitGet("commit.gpgsign"), "");
commit(A, "in a");
commit(B, "in b");
assert.equal(sig(A, allowed), "G");
assert.equal(sig(B, allowed), "N");
// A session's worktree of A is signed; any other worktree of A is not.
const wt = path.join(A, ".claude", "worktrees", `ah-${a}-x1`);
const other = path.join(ROOT, "other-wt");
execFileSync("git", ["-C", A, "worktree", "add", "-q", "-b", "s1", wt]);
execFileSync("git", ["-C", A, "worktree", "add", "-q", "-b", "s2", other]);
commit(wt, "in session worktree");
commit(other, "in other worktree");
assert.equal(sig(wt, allowed), "G");
assert.equal(sig(other, allowed), "N");
// Nothing was written into the repository itself.
assert.doesNotMatch(fs.readFileSync(path.join(A, ".git", "config"), "utf8"), /gpgsign/);
// Not a repo: refused; an unknown workspace: 404.
const notRepo = await request("PUT", "/api/me/git/signing/lane", { laneId: p, key });
assert.equal(notRepo.status, 409);
assert.equal(notRepo.body.error.code, "ENOREPO");
const gone = await request("PUT", "/api/me/git/signing/lane", { laneId: 9999, key });
assert.equal(gone.status, 404);
// Off: unsigned again, our file gone, the user's include kept.
const off = await request("PUT", "/api/me/git/signing/lane", { laneId: a, key: null });
assert.deepEqual(off.body.laneSigning, []);
commit(A, "after off");
assert.equal(sig(A, allowed), "N");
assert.equal(gitGet("includeIf.gitdir:/elsewhere/.path"), "/mine");
// A workspace removed from the dashboard while it signed can still be turned off.
await request("PUT", "/api/me/git/signing/lane", { laneId: b, key });
await request("POST", `/api/lanes/${b}/remove`, {});
const orphan = await request("GET", "/api/me/git");
assert.deepEqual(orphan.body.laneSigning, [{ laneId: b, key }]);
const stopGone = await request("PUT", "/api/me/git/signing/lane", { laneId: b, key: null });
assert.equal(stopGone.status, 200);
assert.deepEqual(stopGone.body.laneSigning, []);
const b2 = await lane(B);
// Deleting the key stops every workspace that signs with it.
await request("PUT", "/api/me/git/signing/lane", { laneId: a, key });
await request("PUT", "/api/me/git/signing/lane", { laneId: b2, key });
const del = await request("DELETE", `/api/me/git/ssh-key?id=${key}`);
assert.equal(del.status, 200);
assert.deepEqual(del.body.laneSigning, []);
commit(A, "after the key went");
assert.equal(sig(A, allowed), "N");
assert.equal(gitGet("includeIf.gitdir:/elsewhere/.path"), "/mine");
assert.deepEqual(
fs
.readdirSync(path.join(ROOT, ".claude", "agenthub-signing"))
.filter((n) => n.endsWith(".gitconfig")),
[]
);
execFileSync("git", ["config", "--global", "--unset-all", "includeIf.gitdir:/elsewhere/.path"]);
await removeAll();
});
it("takes out a signing setup the user made themselves, keeping their key file", async () => {
const mine = path.join(ROOT, "my-own-key");
fs.writeFileSync(mine, "not ours");
execFileSync("git", ["config", "--global", "user.signingkey", mine]);
execFileSync("git", ["config", "--global", "commit.gpgsign", "true"]);
const kept = await request("PUT", "/api/me/git/signing", { key: null });
assert.equal(kept.body.signing.state, "foreign");
const r = await request("PUT", "/api/me/git/signing", { key: null, clearForeign: true });
assert.equal(r.status, 200);
assert.equal(r.body.signing.state, "off");
assert.equal(gitGet("user.signingkey"), "");
assert.equal(gitGet("commit.gpgsign"), "");
assert.equal(fs.readFileSync(mine, "utf8"), "not ours");
});
});
+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");
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* @file websocket-mine.test.js
* @description Server mode broadcasts (server/websocket.js `broadcast`): an
* admin/analyst's session, agent and event frames say whether the session is
* their OWN (`mine`), a developer's and local mode's never carry it, and
* `GET /api/sessions?mine=1` (server/routes/sessions.js) lists only the
* caller's own sessions whatever their role — the pet counts only those.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "ws-mine-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
const { db } = require("../db");
const ws = require("../websocket");
const prevMode = process.env.AGENTHUB_MODE;
before(() => {
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
lane.run("/w/boss", 99); // id 1, the admin's
lane.run("/w/dev", 2); // id 2, a developer's
const s = db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES (?, ?, 'active', '/w', ?)"
);
s.run("s-boss", "boss", 1);
s.run("s-dev", "dev", 2);
});
after(() => {
ws._setClientsForTest(null);
if (prevMode === undefined) delete process.env.AGENTHUB_MODE;
else process.env.AGENTHUB_MODE = prevMode;
fs.rmSync(TMP, { recursive: true, force: true });
});
function client(user) {
const got = [];
return { readyState: 1, user, got, send: (m) => got.push(JSON.parse(m)) };
}
describe("broadcast `mine`", () => {
it("marks an admin's frames by whose session it is; a developer gets only theirs, unmarked", () => {
process.env.AGENTHUB_MODE = "server";
const boss = client({ id: 99, username: "boss", role: "admin" });
const dev = client({ id: 2, username: "dev", role: "developer" });
ws._setClientsForTest(new Set([boss, dev]));
ws.broadcast("session_updated", { id: "s-boss", status: "active" });
ws.broadcast("new_event", { session_id: "s-dev", event_type: "PostToolUse" });
ws.broadcast("lane_update", { lane: { id: 2 } });
assert.deepEqual(
boss.got.map((m) => [m.type, m.mine]),
[
["session_updated", true],
["new_event", false],
["lane_update", undefined],
]
);
assert.deepEqual(
dev.got.map((m) => [m.type, m.mine]),
[
["new_event", undefined],
["lane_update", undefined],
]
);
});
it("adds nothing in local mode", () => {
delete process.env.AGENTHUB_MODE;
const me = client(null);
ws._setClientsForTest(new Set([me]));
ws.broadcast("session_updated", { id: "s-dev" });
assert.equal(me.got[0].mine, undefined);
});
});
describe("GET /api/sessions?mine=1", () => {
it("lists only the caller's own sessions, an admin's too", async () => {
const express = require("express");
const app = express();
app.use((req, _res, next) => {
req.user = { id: 99, username: "boss", role: "admin" };
next();
});
app.use("/api/sessions", require("../routes/sessions"));
const srv = app.listen(0);
try {
const base = `http://127.0.0.1:${srv.address().port}/api/sessions`;
const ids = async (qs) =>
(await (await fetch(base + qs)).json()).sessions.map((s) => s.id).sort();
assert.deepEqual(await ids("?mine=1"), ["s-boss"]);
assert.deepEqual(await ids(""), ["s-boss", "s-dev"]);
} finally {
srv.close();
}
});
});
+16
View File
@@ -1854,6 +1854,22 @@ try {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN chat_token_hash TEXT").run();
}
// v2.8.65–2.8.66 shipped a chat between users (chat_* tables, uploads under
// <data dir>/chat/); it was removed in v2.8.67, and its data goes with it.
try {
db.exec(`
DROP TABLE IF EXISTS chat_files;
DROP TABLE IF EXISTS chat_messages;
DROP TABLE IF EXISTS chat_members;
DROP TABLE IF EXISTS chat_rooms;
`);
if (!process.env.DASHBOARD_DB_PATH) {
fs.rmSync(path.join(getDataDir(), "chat"), { recursive: true, force: true });
}
} catch {
/* best effort: a leftover is unused */
}
// Server mode: whose browser a push subscription is (`users.id`); NULL in
// local mode. A user's notifications go only to their own subscriptions.
try {
+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 };
+146 -3
View File
@@ -574,7 +574,7 @@ async function signingState(account) {
* (EFOREIGNSIGN); off removes only our own settings. No value is a secret —
* the key's PATH crosses sudo's argv, never its content.
*/
async function setSigning(account, { id = null, replace = false } = {}) {
async function setSigning(account, { id = null, replace = false, clearForeign = false } = {}) {
const { git } = require("./worktree");
const home = homeOf(account);
const state = await signingState(account);
@@ -583,7 +583,9 @@ async function setSigning(account, { id = null, replace = false } = {}) {
/* already unset */
});
if (id == null) {
if (state.state === "foreign") return state;
// The user's own setup is left alone unless they asked to take it out
// (`clearForeign`): only the three settings go, never their key file.
if (state.state === "foreign" && !clearForeign) return state;
if ((await globalGet(account, "user.signingkey")) !== "") {
await unset("commit.gpgsign");
await unset("user.signingkey");
@@ -605,6 +607,138 @@ async function setSigning(account, { id = null, replace = false } = {}) {
return signingState(account);
}
// ── Commit signing per workspace (`includeIf "gitdir:…"`) ──────────────────
//
// A workspace is signed by a small config file of OURS per workspace
// (`lane-<id>.gitconfig`: gpg.format ssh, user.signingkey, commit.gpgsign),
// pulled into the account's `~/.gitconfig` by `includeIf "gitdir:<dir>"` for
// the workspace's own git dir and for the git dirs of the worktrees its
// sessions make (`ah-<id>-*`, pty-run.js). Every git that reads the account's
// config — the terminal, the Changes panel, an agent — then signs there and
// nowhere else; nothing is written into the user's repository.
const LANE_SIGN_RE = /^lane-(\d+)\.gitconfig$/;
/** Where the per-workspace files live: ours, beside the account's keys. */
function signingDir(account) {
return account.kind === "member"
? path.join(paths(account).base, "signing")
: path.join(getClaudeHome(), "agenthub-signing");
}
const laneSignFile = (account, laneId) =>
path.join(signingDir(account), `lane-${Number(laneId)}.gitconfig`);
/** `[{key, value}]` of the `includeIf.*.path` lines naming one of OUR files. */
async function ourIncludes(account) {
const { git } = require("./worktree");
const out = await git(homeOf(account), [
"config",
"--global",
"--null",
"--get-regexp",
"^includeif\\..*\\.path$",
]).then(
(r) => r.stdout,
() => ""
);
const dir = signingDir(account);
const rows = [];
// `--null`: "key\nvalue\0" — a git dir or a path may hold spaces.
for (const rec of out.split("\0")) {
const at = rec.indexOf("\n");
if (at <= 0) continue;
const key = rec.slice(0, at);
const value = rec.slice(at + 1);
if (path.dirname(value) === dir && LANE_SIGN_RE.test(path.basename(value))) {
rows.push({ key, value });
}
}
return rows;
}
/** Take every include of `file` out of `~/.gitconfig` (the user's own includes stay). */
async function dropIncludes(account, file) {
const { git } = require("./worktree");
for (const r of await ourIncludes(account)) {
if (r.value !== file) continue;
await git(homeOf(account), [
"config",
"--global",
"--fixed-value",
"--unset-all",
r.key,
r.value,
]).catch(() => {});
}
}
/** The key file a workspace file signs with (its `signingkey` line), or null. */
function laneSignKeyFile(account, file) {
const text = readText(account, file) || "";
const m = /^\s*signingkey\s*=\s*"?(.*?)"?\s*$/m.exec(text);
return m ? m[1].replace(/\\(["\\])/g, "$1") : null;
}
/**
* Which workspaces sign their commits, and with which key:
* `[{laneId, key}]` (`key` null when the key file it names is gone).
*/
async function laneSigningList(account) {
const keys = currentKeys(account);
const seen = new Map();
for (const r of await ourIncludes(account)) {
const laneId = Number(LANE_SIGN_RE.exec(path.basename(r.value))[1]);
if (seen.has(laneId)) continue;
const keyFile = laneSignKeyFile(account, r.value);
seen.set(laneId, keys.find((k) => k.file === keyFile)?.id ?? null);
}
return [...seen].map(([laneId, key]) => ({ laneId, key })).sort((a, b) => a.laneId - b.laneId);
}
const gitQuote = (v) => `"${String(v).replace(/(["\\])/g, "\\$1")}"`;
/**
* Sign the commits of workspace `laneId` (folder `cwd`) with key `id`, or stop
* (`id` null). The git dirs come from git itself, run in the workspace's
* folder as its account — never from the request. ENOREPO when the folder is
* not a git repository.
*/
async function setLaneSigning(account, { laneId, cwd, id = null }) {
const n = Number(laneId);
if (!Number.isSafeInteger(n) || n <= 0) throw err("ENOTFOUND", "no such workspace");
const file = laneSignFile(account, n);
await dropIncludes(account, file);
if (id == null) {
removeFile(account, file);
return;
}
const k = currentKeys(account).find((x) => x.id === id);
if (!k) throw err("ENOKEY", "no such SSH key");
const { git } = require("./worktree");
const rev = (args) => git(cwd, ["rev-parse", ...args]).then((r) => r.stdout.trim());
let gitDir;
let commonDir;
try {
gitDir = await rev(["--absolute-git-dir"]);
commonDir = await rev(["--path-format=absolute", "--git-common-dir"]);
} catch {
throw err("ENOREPO", "this workspace's folder is not a git repository");
}
writeText(
account,
file,
`# Written by AgentHub: commit signing for workspace ${n} (Settings → Git).\n` +
`[gpg]\n\tformat = ssh\n[user]\n\tsigningkey = ${gitQuote(k.file)}\n` +
`[commit]\n\tgpgsign = true\n[tag]\n\tgpgsign = false\n`,
{ secret: false }
);
const add = (pattern) =>
git(homeOf(account), ["config", "--global", "--add", `includeIf.gitdir:${pattern}.path`, file]);
await add(gitDir);
// The worktrees this workspace's sessions make (`claude --worktree=ah-<id>-…`).
await add(path.join(commonDir, "worktrees", `ah-${n}-*`));
}
/**
* Delete one key — `{id}`, or the DEFAULT key of `{type}` — or every key when
* neither is given; false when there was none. A login naming a deleted key
@@ -619,11 +753,18 @@ async function removeSshKey(account, which) {
id != null ? k.id === id : type != null ? k.id === KEY_FILES[type] : true
);
if (!gone.length) return false;
// Commits signed with a key about to go would all fail: stop signing first.
// Commits signed with a key about to go would all fail: stop signing first —
// account-wide, and in every workspace that signs with it.
const signingKey = await globalGet(account, "user.signingkey");
if (signingKey && gone.some((k) => k.file === signingKey)) {
await setSigning(account, { id: null }).catch(() => {});
}
const goneIds = new Set(gone.map((k) => k.id));
for (const l of await laneSigningList(account)) {
if (goneIds.has(l.key)) {
await setLaneSigning(account, { laneId: l.laneId, id: null }).catch(() => {});
}
}
for (const k of gone) {
removeFile(account, k.file);
removeFile(account, `${k.file}.pub`);
@@ -1425,6 +1566,8 @@ module.exports = {
importSshKey,
parsePrivateKey,
signingState,
laneSigningList,
setLaneSigning,
setSigning,
removeSshKey,
terminalSshCommand,
+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
+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"],
+27 -1
View File
@@ -594,7 +594,13 @@ const paths = {
summary:
"Sign every commit with one of the account's keys (`key`: its id), or stop (`key: null`)",
operationId: "setCommitSigning",
requestBody: json({ key: { type: "string", nullable: true }, replace: { type: "boolean" } }),
description:
"The account-wide setting (every repository of the account). `clearForeign: true` with `key: null` also takes out a signing setup the user made themselves — the three settings only, never their key file. Per workspace: `PUT /api/me/git/signing/lane`.",
requestBody: json({
key: { type: "string", nullable: true },
replace: { type: "boolean" },
clearForeign: { type: "boolean" },
}),
responses: {
200: { description: "The identity; `signing.state` is on, off or foreign." },
400: { description: "ENOKEY" },
@@ -602,6 +608,26 @@ const paths = {
},
},
},
"/api/me/git/signing/lane": {
put: {
tags: ["Git identity"],
summary:
"Sign ONE workspace's commits with one of the account's keys (`key`: its id), or stop (`key: null`)",
description:
"Writes a small file of AgentHub's (`lane-<id>.gitconfig`: gpg.format ssh, user.signingkey, commit.gpgsign) and includes it from the account's `~/.gitconfig` with `includeIf \"gitdir:<the workspace's git dir>\"` and `gitdir:<common dir>/worktrees/ah-<id>-*` (its sessions' worktrees) — nothing is written into the repository. The workspace must be one the caller may see whose folder runs as this same account. `GET /api/me/git` answers `laneSigning: [{laneId, key}]`; deleting a key stops every workspace that signs with it.",
operationId: "setLaneCommitSigning",
requestBody: json({ laneId: { type: "integer" }, key: { type: "string", nullable: true } }, [
"laneId",
]),
responses: {
200: { description: "The identity, with `laneSigning`." },
400: { description: "ENOKEY" },
403: { description: "EFOREIGN — the workspace runs as another account; EHOSTIDENTITY." },
404: { description: "ENOTFOUND — no such workspace (or not the caller's)." },
409: { description: "ENOREPO — the workspace's folder is not a git repository." },
},
},
},
"/api/me/git/ssh-terminal": {
put: {
tags: ["Git identity"],
@@ -154,6 +154,14 @@ const paths = {
"`1` = only sessions blocked on the user (`awaiting_input_since IS NOT NULL AND status = 'active'`), the UI's Waiting filter. Applied before `limit`/`offset`, so `total` stays exact.",
example: "1",
},
{
name: "mine",
in: "query",
schema: { type: "string", enum: ["1"] },
description:
"Server mode: `1` = only the caller's OWN sessions, even for an admin or analyst (the pet's count). No-op in local mode.",
example: "1",
},
{
name: "from",
in: "query",
+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.
+51 -2
View File
@@ -14,7 +14,10 @@
* public half ever comes back), and whether commits are signed with one of
* the keys (`PUT /signing`: `gpg.format ssh` + `user.signingkey` +
* `commit.gpgsign` in the account's `~/.gitconfig`; a signing key the user
* set themselves is replaced only on `replace: true`). The account is the signed-in user's
* set themselves is replaced only on `replace: true`, or taken out on
* `clearForeign: true` — their key file is never touched), or — per workspace —
* which of the caller's workspaces sign (`PUT /signing/lane`, an
* `includeIf "gitdir:…"` of one small file of ours each). The account is the signed-in user's
* own Linux account under S3, else the host's, which only an admin (or local
* mode) may change (`EHOSTIDENTITY`). Nothing here takes a path from the
* request, and every write passes the same-origin guard.
@@ -47,6 +50,9 @@ const STATUS = {
EBADKEY: 400,
EKEYPASSPHRASE: 400,
EFOREIGNSIGN: 409,
ENOREPO: 409,
ENOTFOUND: 404,
EFOREIGN: 403,
};
function fail(res, e) {
@@ -70,6 +76,8 @@ async function view(account, username) {
codecommitRegions: identity.CODECOMMIT_REGIONS,
https: identity.listHttps(account),
signing: await identity.signingState(account),
// Workspaces whose commits are signed: `[{laneId, key}]`.
laneSigning: await identity.laneSigningList(account),
author: await identity.getAuthor(account),
// What the author email form suggests while none is set.
defaultEmail: identity.defaultEmail(username),
@@ -148,7 +156,48 @@ router.put("/signing", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
const id = typeof req.body?.key === "string" && req.body.key ? req.body.key : null;
await identity.setSigning(account, { id, replace: req.body?.replace === true });
await identity.setSigning(account, {
id,
replace: req.body?.replace === true,
clearForeign: req.body?.clearForeign === true,
});
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
});
/**
* Sign the commits of ONE workspace with `{laneId, key}` (`key: null` stops).
* The workspace must be one the caller may see (scopeGuard, `laneId` in the
* body) whose folder runs as this same account — a workspace another account
* runs could never read this account's `~/.gitconfig` (EFOREIGN).
*/
router.put("/signing/lane", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
const laneId = Number(req.body?.laneId);
const id = typeof req.body?.key === "string" && req.body.key ? req.body.key : null;
// Stopping touches only this account's own file, so it needs no workspace:
// one removed from the dashboard while it signed can still be turned off.
if (id == null && Number.isSafeInteger(laneId) && laneId > 0) {
await identity.setLaneSigning(account, { laneId, id: null });
return res.json(await view(account, usernameOf(req)));
}
const lane = Number.isSafeInteger(laneId) ? require("../lib/lanes").getLane(laneId) : null;
if (!lane || !require("../lib/scope").canSeeLane(req, laneId)) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such workspace" } });
}
const folder = identity.accountForDir(lane.cwd);
if (
folder.kind !== account.kind ||
(folder.kind === "member" && folder.acct.login !== account.acct.login)
) {
throw Object.assign(new Error("this workspace runs as another account"), {
code: "EFOREIGN",
});
}
await identity.setLaneSigning(account, { laneId, cwd: lane.cwd, id });
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
+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
+7
View File
@@ -160,6 +160,13 @@ router.get("/", (req, res) => {
);
params.push(Number(lane), Number(lane));
}
// `mine=1` = the requesting user's OWN sessions whatever their role (the
// pet's count): an admin/analyst otherwise sees every user's. "1" locally.
if (req.query.mine === "1") {
const o = require("../lib/scope").ownSessionWhere(req, "s.id", "s.lane_id");
where.push(o.where);
params.push(...o.params);
}
// `awaiting=1` = only sessions blocked on the user (an active session with
// `awaiting_input_since`), the UI's "Waiting" filter, done before the limit.
if (req.query.awaiting === "1") {
+39 -2
View File
@@ -1,5 +1,5 @@
/**
* @file WebSocket functionalities for real-time communication with clients, including connection management, heartbeat for detecting dead connections, and broadcasting messages to all connected clients.
* @file WebSocket functionalities for real-time communication with clients, including connection management, heartbeat for detecting dead connections, and broadcasting messages to all connected clients (server mode: each user only what they may see, and an admin/analyst's session frames marked `mine` when the session is their own).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -9,6 +9,14 @@ const { isWebSocketLoggedIn, isServerMode, userFromRequest } = require("./lib/au
const { broadcastAudience, exactAudience, audienceIncludes, canSeeRun } = require("./lib/scope");
let wss = null;
// Session-scoped broadcasts an admin/analyst gets with `mine` (see broadcast).
const MINE_TYPES = new Set([
"session_created",
"session_updated",
"agent_created",
"agent_updated",
"new_event",
]);
// How often an open terminal's login is re-checked in server mode.
const PTY_RECHECK_MS = Number(process.env.AGENTHUB_PTY_RECHECK_MS) || 10000;
@@ -230,7 +238,8 @@ function initPtyWebSocket(server) {
function broadcast(type, data) {
if (!wss) return;
const message = JSON.stringify({ type, data, timestamp: new Date().toISOString() });
const timestamp = new Date().toISOString();
const message = JSON.stringify({ type, data, timestamp });
// Server mode: worked out once, and only when a member is connected.
let audience;
// A message for exactly these users (a member's own config change): no
@@ -255,6 +264,24 @@ function broadcast(type, data) {
return audienceIncludes(audience, client.user);
};
let stripped;
// Server mode: an admin/analyst gets every user's session news, so a
// session-scoped frame tells them whether it is their OWN (`mine`) — the
// pet counts only those. Worked out once per broadcast, on first need.
const owned = MINE_TYPES.has(type) && isServerMode();
let mineMsg;
let otherMsg;
const forStaff = (client) => {
if (audience === undefined) {
try {
audience = broadcastAudience(type, data);
} catch {
audience = { staffOnly: true };
}
}
const mine = Boolean(audience && audience.owners && audience.owners.has(client.user.id));
if (mine) return (mineMsg ??= JSON.stringify({ type, data, timestamp, mine: true }));
return (otherMsg ??= JSON.stringify({ type, data, timestamp, mine: false }));
};
wss.clients.forEach((client) => {
if (client.readyState === 1 && reaches(client)) {
try {
@@ -264,6 +291,10 @@ function broadcast(type, data) {
client.send(stripped);
return;
}
if (owned && !only && client.user && !member) {
client.send(forStaff(client));
return;
}
client.send(message);
} catch {
// Client closed between readyState check and send — safe to ignore
@@ -331,7 +362,13 @@ function closeWebSocket() {
}
}
/** Tests only: broadcast to these fake clients (`{readyState, user, send}`). */
function _setClientsForTest(clients) {
wss = clients ? { clients } : null;
}
module.exports = {
_setClientsForTest,
initWebSocket,
initPtyWebSocket,
broadcast,