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
+20
View File
@@ -872,6 +872,26 @@ function startBackgroundServices() {
}, POOL_REFRESH_MS);
if (poolTimer.unref) poolTimer.unref();
}
// Server mode's spend quotas (user-quota.js): who is over theirs now, the
// flag files their hooks read, the panes no hook can stop. Off with
// DASHBOARD_QUOTA_SWEEP_MS=0; a no-op in local mode.
const QUOTA_SWEEP_MS = Number(process.env.DASHBOARD_QUOTA_SWEEP_MS ?? 60_000);
if (
Number.isFinite(QUOTA_SWEEP_MS) &&
QUOTA_SWEEP_MS > 0 &&
require("./lib/auth").isServerMode()
) {
const quotaSweep = () => {
try {
require("./lib/user-quota").sweep();
} catch (err) {
console.warn("user quota sweep failed:", err.message);
}
};
const quotaTimer = setInterval(quotaSweep, QUOTA_SWEEP_MS);
if (quotaTimer.unref) quotaTimer.unref();
setImmediate(quotaSweep);
}
// Another dashboard on the same database (hooks reach only one of them)
// moving a lane: re-broadcast it here. One pragma per beat when nothing
// changed — and only while such a dashboard runs at all: alone on its data