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
+177 -2
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 2.8.29
version: 2.8.30
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
@@ -4657,6 +4657,73 @@ components:
description: Events attributed to this run's inner agents, ordered by created_at then id. Capped at 5000 rows.
items:
$ref: '#/components/schemas/DashboardEvent'
UserQuota:
type: object
description: 'A spend quota: `limit_usd` per `period` — `day` from the local midnight of `tz_offset` (getTimezoneOffset() minutes), or `5h`, the current UTC hour and the four before it.'
required:
- limit_usd
- period
properties:
limit_usd:
type: number
minimum: 0
exclusiveMinimum: true
period:
type: string
enum:
- day
- 5h
tz_offset:
type: integer
minimum: -840
maximum: 840
source:
type: string
enum:
- default
- user
readOnly: true
UserQuotaSetting:
type: object
description: 'A user''s own quota setting: `default` (the default applies), `unlimited`, or `custom` with its own limit.'
required:
- mode
properties:
mode:
type: string
enum:
- default
- unlimited
- custom
limit_usd:
type: number
period:
type: string
enum:
- day
- 5h
tz_offset:
type: integer
UserQuotaStatus:
allOf:
- $ref: '#/components/schemas/UserQuota'
- type: object
properties:
spend:
type: number
description: USD spent in the current window
pct:
type: number
exceeded:
type: boolean
description: 'Used up: new panes are refused (EQUOTA) and prompts refused by the hooks'
resetsAt:
type: string
nullable: true
description: When it opens again
since:
type: string
description: Start of the current window
AccountPin:
type: object
required:
@@ -10975,6 +11042,12 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'429':
description: 'EQUOTA (server mode): the workspace owner''s spend quota is used up until it resets.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'500':
description: Unexpected failure (EINTERNAL).
content:
@@ -14453,6 +14526,30 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/me/quota:
get:
tags:
- Auth
operationId: getMyQuota
summary: The account's own spend quota now (null without one, and in local mode)
responses:
'200':
description: Quota
content:
application/json:
schema:
type: object
properties:
quota:
allOf:
- $ref: '#/components/schemas/UserQuotaStatus'
nullable: true
'401':
description: EAUTH
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/me/instructions:
get:
tags:
@@ -14966,6 +15063,16 @@ paths:
type: number
tokens:
type: integer
quotaSetting:
$ref: '#/components/schemas/UserQuotaSetting'
quota:
allOf:
- $ref: '#/components/schemas/UserQuotaStatus'
nullable: true
quotaDefault:
allOf:
- $ref: '#/components/schemas/UserQuota'
nullable: true
'403':
description: EADMIN — a developer
content:
@@ -15303,6 +15410,8 @@ paths:
type: boolean
enum:
- true
quota:
$ref: '#/components/schemas/UserQuotaSetting'
currentPassword:
type: string
responses:
@@ -15316,7 +15425,7 @@ paths:
user:
$ref: '#/components/schemas/AuthUser'
'400':
description: EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL
description: EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL / EBADQUOTA
content:
application/json:
schema:
@@ -15413,6 +15522,72 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/quota-default:
put:
tags:
- Auth
operationId: setDefaultUserQuota
summary: 'Admin: the default spend quota for every user without one of their own (`quota: null` removes it). A user over their quota can start no pane (429 EQUOTA) and their Claude Code/Codex prompts are refused by the hooks'
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- quota
- currentPassword
properties:
quota:
allOf:
- $ref: '#/components/schemas/UserQuota'
nullable: true
currentPassword:
type: string
responses:
'200':
description: Saved
content:
application/json:
schema:
type: object
properties:
user:
$ref: '#/components/schemas/AuthUser'
quotaDefault:
allOf:
- $ref: '#/components/schemas/UserQuota'
nullable: true
'400':
description: EBADQUOTA
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: EREAUTH (own password wrong) / EADMIN / ELOCALMODE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOUSER / ENOLANE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: ESELF — your own role or disable
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'429':
description: ETOOMANY — 10 wrong passwords in 15 minutes
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/{id}/password:
post:
tags: