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
+31 -2
View File
@@ -22,6 +22,9 @@
* its fullest window, live account only — and, on hover or focus, a panel
* above grouping every account per tool (saved Claude profiles too): name,
* sign-in expiry, and a row per window with its bar, percent and reset time.
* Server mode: the user's OWN spend quota (`GET /api/me/quota`, read with the
* costs) as "Quota N%" — amber from 80 %, red once used up, when the user can
* start nothing and their prompts are refused until it opens again.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -30,9 +33,9 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router-dom";
import { RefreshCw, Wifi, WifiOff } from "lucide-react";
import { api, type RunHandle } from "../lib/api";
import { api, type RunHandle, type UserQuotaStatus } from "../lib/api";
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
import { fmt, fmtCostFull, formatDateTime, formatModelName, timeAgo } from "../lib/format";
import { AiToolIcon } from "./AiToolIcon";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
@@ -369,6 +372,8 @@ export function StatusBar({
const [runs, setRuns] = useState<RunHandle[]>([]);
// The budget furthest past one of its thresholds, if any.
const [budget, setBudget] = useState<BudgetStatus | null>(null);
// Server mode: this user's own spend quota (null: none, or local mode).
const [myQuota, setMyQuota] = useState<UserQuotaStatus | null>(null);
const location = useLocation();
const shown = selectedFrom(location.pathname, location.search);
const lane = shown.lane;
@@ -414,6 +419,10 @@ export function StatusBar({
if (live()) setBudget(hit[0] ?? null);
})
.catch(() => undefined);
api.me
.quota()
.then((r) => live() && setMyQuota(r?.quota ?? null))
.catch(() => undefined);
if (lane !== null) {
api.pricing
.totalCost({ lane })
@@ -524,6 +533,26 @@ export function StatusBar({
{t("statusBar.budget", { pct: Math.round(budget.pct) })}
</Link>
)}
{myQuota && (
<span
data-testid="status-bar-user-quota"
title={t(myQuota.exceeded ? "statusBar.userQuotaBlocked" : "statusBar.userQuotaTitle", {
spend: usd(myQuota.spend),
limit: usd(myQuota.limit_usd),
period: t(`statusBar.userQuotaPeriod.${myQuota.period}`),
at: myQuota.resetsAt ? formatDateTime(myQuota.resetsAt) : "",
})}
className={`tabular-nums ${
myQuota.exceeded
? "text-status-danger"
: myQuota.pct >= 80
? "text-status-warning"
: "text-fg-muted"
}`}
>
{t("statusBar.userQuota", { pct: Math.min(999, Math.round(myQuota.pct)) })}
</span>
)}
<QuotaChips />
</footer>
);