feat(server-mode): spend quota per user — no new session, prompts refused once used up

An admin sets a default spend quota on the Users page and overrides it per
user (Edit user → Spend quota: default / no limit / their own), in dollars
per local day or per sliding 5 hours. Spend is the users page's own
hourlyTokenRows + calculateCost over the sessions a user owns.

Over the quota, until it opens again:
- spawnRun refuses every start/resume/home prompt with EQUOTA (429);
- a minute sweep writes each pane's AGENTHUB_QUOTA_FILE (host: data dir,
  member: handoff dir via write-as) and hook-handler.js exits 2 on
  UserPromptSubmit from that file alone (Claude Code, Codex with trusted
  hooks) — fail-open, never asks the dashboard, never for Antigravity;
- a member's omp on the shared login loses its gateway key (clientsFor);
- panes no hook can stop (Antigravity, Cursor, Codex without trusted
  hooks, omp on its own login) are ended.

GET /api/me/quota feeds a "Quota N%" chip in the status bar.
New: server/lib/user-quota.js, user_quotas table, PUT /api/users/quota-default,
PATCH /api/users/:id {quota}, docs in SERVER-MODE.md § Spend quotas.
This commit is contained in:
2026-10-28 14:31:11 +07:00
parent 9e3d6f4ca1
commit fa27b299e7
29 changed files with 2044 additions and 23 deletions
+21 -1
View File
@@ -17,7 +17,9 @@
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
* role, active/disabled, password, owned workspaces, sign out everywhere, sent
* as one change), and the Claude/Codex account pool every user's sessions run
* on (`AccountPoolCard`).
* on (`AccountPoolCard`). Each row's spend shows its spend quota (spent of the
* limit, red once used up) and the default quota sits in its own card
* (`UserQuota.tsx`); a user's own quota is part of Edit.
* Every change asks for the admin's OWN password again (the
* server refuses without it); an analyst only reads. A row shows the display
* name and email under the username when set, and "New password due" while
@@ -61,6 +63,7 @@ import {
type AuthUser,
type MemberTmuxRow,
type ServerUserUsage,
type UserQuota,
type UserStorage,
} from "../lib/api";
import { fmt, fmtCost, formatBytes, formatDateTime } from "../lib/format";
@@ -74,6 +77,7 @@ import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog";
import { AccountPoolCard } from "../components/users/AccountPoolCard";
import { UserDetail } from "../components/users/UserDetail";
import { UserAvatar } from "../components/users/UserAvatar";
import { QuotaDefaultCard, QuotaSummary } from "../components/users/UserQuota";
import {
MemberTmuxBar,
TmuxCell,
@@ -104,6 +108,8 @@ export function Users({
const isAdmin = me?.role === "admin";
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
// The default spend quota (`undefined` until loaded or from an older server).
const [quotaDefault, setQuotaDefault] = useState<UserQuota | null | undefined>(undefined);
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
@@ -151,6 +157,7 @@ export function Users({
.list()
.then((r) => {
setUsers(r.users);
setQuotaDefault(r.quotaDefault);
setError(null);
})
.catch((e: Error) => setError(e.message)),
@@ -406,6 +413,7 @@ export function Users({
month: fmtCost(u.last30d.cost),
})}
</div>
<QuotaSummary quota={u.quota} />
</td>
<StorageCell storage={home} measuring={measuring} disk={homesDisk} />
{showTmux && (
@@ -487,6 +495,17 @@ export function Users({
);
})()}
{quotaDefault !== undefined && (
<QuotaDefaultCard
quotaDefault={quotaDefault}
isAdmin={isAdmin}
onSaved={(message) => {
toast.success(message);
void load();
}}
/>
)}
<AccountPoolCard isAdmin={isAdmin} />
{children}
@@ -559,6 +578,7 @@ export function Users({
{editing && (
<UserEditDialog
user={editing}
quotaDefault={quotaDefault ?? null}
self={editing.id === me.id}
users={users ?? []}
onClose={() => setEditing(null)}