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:
@@ -16,9 +16,18 @@
|
||||
* for the response is what made Claude Code sit at "running hooks" for seconds
|
||||
* whenever a dashboard was busy, slow, or wedged.
|
||||
*
|
||||
* The one thing it may refuse: in server mode a pane carries
|
||||
* `AGENTHUB_QUOTA_FILE` (server/lib/user-quota.js), and while that file says
|
||||
* its owner is over their spend quota a Claude Code or Codex
|
||||
* `UserPromptSubmit` exits 2 with the reason on stderr — both CLIs then drop
|
||||
* the prompt and show why. Decided from that local file alone, never by
|
||||
* asking the dashboard, and fail-open: no file, an unreadable one or an
|
||||
* expired `until` lets the prompt through. Antigravity never gets a refusal.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const http = require("http");
|
||||
|
||||
const hookType = process.argv[2] || "unknown";
|
||||
@@ -44,11 +53,36 @@ function resolvePorts() {
|
||||
|
||||
const ports = resolvePorts();
|
||||
|
||||
/** The reason to refuse this prompt (spend quota used up), or null to let it through. */
|
||||
function quotaRefusal() {
|
||||
const file = process.env.AGENTHUB_QUOTA_FILE;
|
||||
if (!file || hookType !== "UserPromptSubmit" || tool === "antigravity") return null;
|
||||
try {
|
||||
if (fs.statSync(file).size > 64 * 1024) return null;
|
||||
const flag = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (flag?.blocked !== true) return null;
|
||||
const until = Date.parse(flag.until);
|
||||
if (Number.isFinite(until) && until <= Date.now()) return null;
|
||||
return typeof flag.message === "string" && flag.message
|
||||
? flag.message
|
||||
: "AgentHub: your spend quota is used up.";
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const refusal = quotaRefusal();
|
||||
|
||||
let input = "";
|
||||
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => (input += chunk));
|
||||
process.stdin.on("end", () => {
|
||||
if (refusal) {
|
||||
// The prompt never runs, so it is not reported either.
|
||||
process.stderr.write(`${refusal}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
let parsedData;
|
||||
try {
|
||||
parsedData = JSON.parse(input);
|
||||
|
||||
Reference in New Issue
Block a user