Compare commits

..

3 Commits

Author SHA1 Message Date
nntrivi2001 33d30f5e1f chore: v2.8.31 — remote-only branches in the branch switcher, spend quota per user 2026-10-28 15:01:16 +07:00
nntrivi2001 14e979837e fix(git): the branch switcher lists and checks out branches only the remote has
GET /api/lanes/:id/git/branches read only refs/heads, so a branch someone
else pushed never showed after a fetch or pull, and `switch` refused it
(ENOBRANCH). It now also returns `remotes`: fetched remote-tracking branches
with no local branch of that name (origin/HEAD left out), listed under
"On the remote"; switching to one runs `git switch -c <b> --track
refs/remotes/<remote>/<b>` with the remote ref looked up, never passed
through (bare name only when exactly one remote has it, else EAMBIGUOUS).
The bar also re-reads the branch list after a fetch or pull instead of
keeping the list it loaded first.
2026-10-28 14:57:32 +07:00
nntrivi2001 fa27b299e7 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.
2026-10-28 14:31:11 +07:00
41 changed files with 2244 additions and 50 deletions
+1
View File
@@ -327,6 +327,7 @@ graph TD
| `routes/settings.js` | System info (DB size, hook status, server uptime, transcript cache stats), data export as one versioned JSON bundle and matching import/restore (`POST /api/settings/import` via `server/lib/data-transfer.js` — idempotent, session-atomic, non-destructive; consolidates machines), session cleanup (abandon stale, purge old), clear all data (including the fired-alert feed and webhook delivery log; alert *rules* and webhook *targets* are preserved as user configuration), reset pricing, reinstall hooks |
| `routes/alerts.js` | HTTP surface for the rules-based alerting engine: alert-rule CRUD (`GET/POST /api/alerts/rules`, `PATCH/DELETE /api/alerts/rules/:id` — rule_type is immutable after creation, config re-validated against the stored type on PATCH), the fired-alert feed (`GET /api/alerts` with `?unacked=true` + pagination, response carries `total` and `unacked` counts), and acknowledgement (`POST /api/alerts/:id/ack`, `POST /api/alerts/ack-all`, broadcasting `alert_updated`), and the read-only dry run `POST /api/alerts/rules/preview` (`{rule_type, config, days?, cooldown_seconds?}` → what `previewRule` says it would have fired over the last `days`, 1–90, default 7). Every rule mutation calls `invalidateRuleCache()` so the evaluation engine picks up changes immediately |
| `routes/budgets.js` + `lib/budgets.js` | Spend budgets: a budget IS an alert rule of type `budget` (CRUD through `/api/alerts/rules`). `lib/budgets.js` computes a period's bounds in the user's local time (`tz_offset` saved with the rule; day / week from Monday / month), its spend priced like `GET /api/pricing/cost` by the day tokens were spent (via `dailyTokenRows` + `costWhere`, scoped to `tool`/`lane`), and the pace projection to the period's end; `GET /api/budgets/status` lists every enabled budget with `spend`, `pct`, `projected` and the thresholds `crossed` |
| `lib/user-quota.js` | Server mode's spend quota per user (`user_quotas`, row 0 = the default; `PUT /api/users/quota-default`, `PATCH /api/users/:id {quota}`, `GET /api/me/quota`): `limit_usd` per local `day` or sliding `5h`, spend = the users page's `hourlyTokenRows` + `calculateCost` over owned sessions. Over it: `spawnRun` throws `EQUOTA` (429); a 60 s `sweep` writes each pane's `AGENTHUB_QUOTA_FILE` (the hook handler then exits 2 on `UserPromptSubmit`, from the file alone, fail-open), leaves the owner's omp keys out of the gateway's clients file and ends panes no hook can stop |
| `lib/alerts.js` | Rule evaluation engine for the alerting feature. Six rule types — `cost_threshold` (a session's cost ≥ `usd`, on token-bearing events like `token_threshold`) and `budget` (on the sweep: each of `thresholds` % of `limit_usd` for the current `period` fires ONCE per period, deduped on `details.period_start` + `details.threshold` and bypassing the cooldown) plus the four original ones: `event_pattern` (match `event_type` / `tool_name` / `summary_contains`, optionally requiring ≥ `count` matching events within `window_minutes` — counted via a dynamically built, statement-cached SQL query), `token_threshold` (session total tokens ≥ `total_tokens`, only evaluated on token-bearing events: PostToolUse / Stop / SubagentStop / SessionEnd), `inactivity` (active session whose `updated_at` — bumped on every ingested event — is older than `minutes`), and `status_duration` (agent stuck in `working`/`waiting` with no activity for `minutes`, joined against active sessions). Event-driven types run via `evaluateEvent()` called from `routes/hooks.js` **after** the ingest transaction commits and the HTTP response is sent — alerting can never slow down or fail hook ingestion, and `evaluateEvent` is itself fully try/catch-guarded per rule. Time-based types run via `sweepTimeRules()` on a 60 s unref'd interval (same pattern as the hooks watchdog). `fireAlert()` applies per-(rule, session, agent) cooldown dedup (`cooldown_seconds`, default 300) by checking the most recent `alert_events` row for the scope, then persists and broadcasts `alert_triggered`. Enabled rules are cached in memory (hook ingest is hot) and invalidated on every CRUD mutation. `validateRuleConfig()` normalizes + validates type-specific config and is shared with the routes. After persisting and broadcasting a fired alert, `fireAlert()` hands it to `lib/webhooks.js` `dispatchAlert()` fire-and-forget (lazy-required to keep the module graph acyclic) — webhook delivery never blocks or fails alert firing |
| `routes/webhooks.js` | HTTP surface for universal webhook targets: target CRUD (`GET/POST /api/webhooks`, `PATCH/DELETE /api/webhooks/:id` — `type` is immutable after creation), a redacted provider catalog (`GET /api/webhooks/providers`, drives the UI form), a synchronous test probe (`POST /api/webhooks/:id/test` — always 200, the `ok` flag carries the downstream delivery result), and a per-target delivery log (`GET /api/webhooks/:id/deliveries`). Validation is registry-driven: required URL (per provider), per-provider config fields, https enforcement, generic-family secret/headers. **Security**: target URLs are masked (host + last 4 chars) and secret config fields + custom-header values are redacted in every response — full URLs, signing secrets, and credentials (routing keys, api keys, bot tokens) are stored server-side and never leave the server. PATCH uses "set-flag" semantics (omit `url`/`secret`/`headers`/`config` to leave unchanged); `config` is merged over the existing config so one field can change without re-sending secrets. Every mutation calls `invalidateWebhookCache()` |
| `lib/webhook-providers.js`| Declarative registry of the 14 first-class providers (+ generic). Each entry declares a `family` (`chat` / `api` / `generic`), a payload `format`ter, URL resolution (`urlFrom(config)` for Telegram/Opsgenie that derive the endpoint, `defaultUrl` for PagerDuty, or a user-supplied URL), optional `authFrom(config)` headers (Opsgenie GenieKey), and the credential `fields` the UI renders + the route validates. Formatters emit each platform's native body: Slack Block Kit, Discord embed, Teams Adaptive Card wrapped in the Power Automate Workflows `{ type: "message", attachments: [...] }` envelope (the legacy O365-connector MessageCard transport was retired May 2026), Google Chat text, Mattermost/Rocket.Chat Slack-style attachments, Telegram sendMessage (HTML), PagerDuty Events API v2 (with `dedup_key`), Opsgenie Alert API, Splunk On-Call/VictorOps, and the generic `{ event, alert }` envelope. A provider may also declare `verifyResponse(body)` to veto a 2xx that actually signals failure (Splunk On-Call returns 200 with `result:"failure"`). `publicProviders()` returns the redacted catalog. Adding a provider = one registry entry + a formatter — no route or delivery changes |
+3 -2
View File
File diff suppressed because one or more lines are too long
+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>
);
@@ -129,6 +129,20 @@ vi.mock("../../lib/api", () => ({
daily_costs: [],
})),
},
me: {
// Server mode: this user's own spend quota, used up.
quota: vi.fn().mockResolvedValue({
quota: {
limit_usd: 10,
period: "day",
spend: 12,
pct: 120,
exceeded: true,
resetsAt: "2026-10-29T17:00:00.000Z",
since: "2026-10-28T17:00:00.000Z",
},
}),
},
budgets: {
status: vi.fn().mockResolvedValue({
budgets: [
@@ -183,6 +197,16 @@ describe("StatusBar", () => {
expect(chip.getAttribute("title")).toBe("Monthly: $85.00 of $100.00 this period");
});
it("shows the user's own spend quota, red once used up", async () => {
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
const chip = await screen.findByTestId("status-bar-user-quota");
expect(chip.textContent).toBe("Quota 120%");
expect(chip.className).toContain("text-status-danger");
expect(chip.getAttribute("title")).toMatch(
/^Your spend quota is used up \(\$12\.00 of \$10\.00 today\)/
);
});
it("says Disconnected when the socket is down", () => {
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();
+24 -3
View File
@@ -1,7 +1,9 @@
/**
* @file The lane's git action row: ahead/behind against the upstream, and the
* writes `POST /api/lanes/:id/git/:action` offers that are not per-file —
* fetch, pull, push, switch branch, create/delete a branch, publish a repo
* fetch, pull, push, switch branch (a local one, or one only a remote has —
* listed under "On the remote" and checked out as a new tracking branch; the
* list is re-read after every fetch and pull), create/delete a branch, publish a repo
* that has no remote yet (a URL, then `origin` + `push -u`), and start a merge
* (the picked branch, or the upstream once diverged; resolving it is the
* Changes panel's job). Committing is
@@ -114,6 +116,8 @@ export function LaneGitBar({
const [error, setError] = useState<string | null>(null);
const [switching, setSwitching] = useState(false);
const [branches, setBranches] = useState<string[] | null>(null);
// Fetched remote branches with no local one yet (`origin/feature`).
const [remoteBranches, setRemoteBranches] = useState<string[]>([]);
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
const [picked, setPicked] = useState("");
const [newBranch, setNewBranch] = useState("");
@@ -138,6 +142,7 @@ export function LaneGitBar({
setError(null);
setSwitching(false);
setBranches(null);
setRemoteBranches([]);
setWorktrees([]);
setPicked("");
setNewBranch("");
@@ -152,6 +157,9 @@ export function LaneGitBar({
if (!git.available) return null;
const dirty = git.dirty + git.untracked;
// A remote branch: switching creates its local tracking branch; it cannot
// be deleted here or given a sibling worktree before that.
const pickedRemote = remoteBranches.includes(picked);
const merging = !!git.merging;
const run = async (action: Action, body?: RunBody) => {
@@ -184,6 +192,9 @@ export function LaneGitBar({
setPicked("");
setBranches(null);
await refreshBranches();
} else if ((action === "fetch" || action === "pull") && branches !== null) {
// A fetch may have brought branches the list does not show yet.
await refreshBranches();
}
onChanged();
} catch (err) {
@@ -217,6 +228,7 @@ export function LaneGitBar({
const r = await api.lanes.gitBranches(gitTarget(laneId, session, source));
if (laneNow.current !== laneId) return;
setBranches(r.branches);
setRemoteBranches(r.remotes || []);
setWorktrees(r.worktrees || []);
} catch {
if (laneNow.current === laneId) setBranches([]);
@@ -446,6 +458,15 @@ export function LaneGitBar({
</option>
);
})}
{remoteBranches.length > 0 && (
<optgroup label={t("gitBar.remoteBranches")}>
{remoteBranches.map((b) => (
<option key={`remote:${b}`} value={b}>
{b}
</option>
))}
</optgroup>
)}
</select>
<Btn
label={t("gitBar.switchApply")}
@@ -476,7 +497,7 @@ export function LaneGitBar({
})
: t("gitBar.newLaneHint")
}
disabled={!picked || heldBy.has(picked) || picked === git.branch}
disabled={!picked || heldBy.has(picked) || picked === git.branch || pickedRemote}
// The sibling lane is created through the normal Add-lane flow,
// prefilled — one provisioning path, not a second one here.
onClick={() => onNewLane(picked)}
@@ -486,7 +507,7 @@ export function LaneGitBar({
label={t("gitBar.branchDelete")}
icon={<Trash2 className="h-3 w-3" />}
busy={busy === "branch-delete"}
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
disabled={!picked || picked === git.branch || !!heldBy.get(picked) || pickedRemote}
title={t("gitBar.branchDeleteHint")}
onClick={() =>
void confirmRun(
@@ -4,7 +4,8 @@
* buttons a repo with no remote may press (and that it may publish to a typed URL),
* what an unborn repo and a branch with no upstream show, that it offers no commit (the
* Changes panel's staged-only box owns that), that a dirty tree blocks the branch switch client-side (the server
* refuses it too), and that a failed action shows git's own words rather than
* refuses it too), that branches only the remote has are offered (and the list
* is re-read after a fetch), and that a failed action shows git's own words rather than
* a generic sentence.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -236,6 +237,43 @@ describe("LaneGitBar", () => {
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
});
it("lists branches only the remote has, switches to one, and re-reads the list after a fetch", async () => {
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
available: true,
branches: ["development", "feat/thing"],
remotes: ["origin/feat/new"],
current: "feat/thing",
worktrees: [],
});
const user = userEvent.setup();
render(
<LaneGitBar
laneId={3}
git={facts({ dirty: 0, untracked: 0 })}
onChanged={vi.fn()}
onNewLane={vi.fn()}
/>
);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
expect(
screen.getByRole("group", { name: "On the remote — switching creates the local branch" })
).toBeInTheDocument();
await user.selectOptions(select, "origin/feat/new");
// Not a local branch yet: nothing to delete, no worktree to start on it.
expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
expect(screen.getByRole("button", { name: /New workspace here/ })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", { branch: "origin/feat/new" })
);
vi.mocked(api.lanes.gitBranches).mockClear();
await user.click(screen.getByRole("button", { name: /Fetch/ }));
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "fetch", {}));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(1));
});
it("creates a branch without switching, then offers a sibling lane on it", async () => {
const user = userEvent.setup();
const onNewLane = vi.fn();
+87 -3
View File
@@ -5,7 +5,9 @@
* account), a new password (typed or generated, copyable — someone else's
* may be anything and makes them choose their own at next sign-in; your own
* must pass every rule of `lib/passwordRules.ts`), the workspaces the
* account owns (remove, add from every workspace), and "sign out everywhere".
* account owns (remove, add from every workspace), its spend quota (the
* default, no limit, or its own dollars per day / 5 h — `UserQuota.tsx`), and
* "sign out everywhere".
* Every change is staged and sent as ONE `PATCH /api/users/:id` carrying only
* what changed, confirmed with the admin's OWN password (`currentPassword`) —
* the server validates the whole change before it applies any of it. A
@@ -17,7 +19,14 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNod
import { useTranslation } from "react-i18next";
import { Copy, Eye, EyeOff, Plus, Sparkles, X } from "lucide-react";
import { confirmDialog } from "../../lib/confirm";
import { api, type AuthUser, type ServerUserUsage, type UserPatch } from "../../lib/api";
import {
api,
type AuthUser,
type ServerUserUsage,
type UserPatch,
type UserQuota,
type UserQuotaSetting,
} from "../../lib/api";
import type { Lane } from "../../lib/types";
import { fmtCost, formatDateTime } from "../../lib/format";
import { copyText } from "../../lib/clipboard";
@@ -27,6 +36,7 @@ import { PasswordRules } from "./PasswordRules";
import { useDialog } from "../../hooks/useDialog";
import { Switch } from "../Switch";
import { Checkbox } from "../Checkbox";
import { QuotaFields, parseLimit, useQuotaLabel, type QuotaPeriod } from "./UserQuota";
type Role = AuthUser["role"];
const ROLES: Role[] = ["admin", "analyst", "developer"];
@@ -55,10 +65,13 @@ export function UserEditDialog({
user,
self,
users,
quotaDefault = null,
onClose,
onDone,
}: {
user: ServerUserUsage;
/** The default spend quota, named beside the "Default" choice. */
quotaDefault?: UserQuota | null;
/** The account being edited is the signed-in admin's own. */
self: boolean;
users: ServerUserUsage[];
@@ -81,6 +94,18 @@ export function UserEditDialog({
const [added, setAdded] = useState<number[]>([]);
const [pick, setPick] = useState("");
const [signOut, setSignOut] = useState(false);
// Spend quota: absent from an older server, which then gets no quota field.
const quotaSetting = user.quotaSetting;
const [quotaMode, setQuotaMode] = useState<UserQuotaSetting["mode"]>(
quotaSetting?.mode ?? "default"
);
const [quotaLimit, setQuotaLimit] = useState(
quotaSetting?.mode === "custom" ? String(quotaSetting.limit_usd) : ""
);
const [quotaPeriod, setQuotaPeriod] = useState<QuotaPeriod>(
quotaSetting?.mode === "custom" ? quotaSetting.period : (quotaDefault?.period ?? "day")
);
const quotaLabel = useQuotaLabel();
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -117,12 +142,33 @@ export function UserEditDialog({
if (added.length) patch.addWorkspaces = added;
if (removed.size) patch.removeWorkspaces = [...removed];
if (signOut) patch.signOut = true;
const quotaValue = parseLimit(quotaLimit);
const quotaBad = quotaMode === "custom" && quotaValue === null;
if (quotaSetting && !quotaBad) {
const same =
quotaMode === quotaSetting.mode &&
(quotaMode !== "custom" ||
(quotaSetting.mode === "custom" &&
quotaSetting.limit_usd === quotaValue &&
quotaSetting.period === quotaPeriod));
if (!same) {
patch.quota =
quotaMode === "custom"
? {
mode: "custom",
limit_usd: quotaValue as number,
period: quotaPeriod,
tz_offset: new Date().getTimezoneOffset(),
}
: { mode: quotaMode };
}
}
const changed = Object.keys(patch).length > 0;
// A blank field keeps the password. Someone else's may be anything (they
// choose their own at next sign-in); your own must pass every rule.
const weakOwn =
self && password.length > 0 && passwordProblems(password, user.username).length > 0;
const ready = changed && current.length > 0 && !weakOwn && !busy;
const ready = changed && current.length > 0 && !weakOwn && !quotaBad && !busy;
const submit = async () => {
if (!ready) return;
@@ -479,6 +525,44 @@ export function UserEditDialog({
)}
</Section>
{quotaSetting && (
<Section title={t("quota.title")}>
<div className="space-y-2" data-testid="user-edit-quota">
<select
className="input w-full text-sm"
value={quotaMode}
onChange={(e) => setQuotaMode(e.target.value as UserQuotaSetting["mode"])}
aria-label={t("quota.title")}
>
<option value="default">
{quotaDefault
? t("quota.modes.defaultOf", { quota: quotaLabel(quotaDefault) })
: t("quota.modes.defaultNone")}
</option>
<option value="unlimited">{t("quota.modes.unlimited")}</option>
<option value="custom">{t("quota.modes.custom")}</option>
</select>
{quotaMode === "custom" && (
<QuotaFields
limit={quotaLimit}
period={quotaPeriod}
onLimit={setQuotaLimit}
onPeriod={setQuotaPeriod}
onKeyDown={onKeyDown}
/>
)}
<span className="block text-[11px] text-fg-muted">
{user.quota
? t("quota.now", {
spend: fmtCost(user.quota.spend),
limit: quotaLabel(user.quota),
})
: t("quota.enforceHint")}
</span>
</div>
</Section>
)}
<Section title={t("edit.sessions")}>
<Checkbox
checked={signOut}
+269
View File
@@ -0,0 +1,269 @@
/**
* @file UserQuota.tsx
* @description Spend quotas per user on the Users page (server mode;
* server/lib/user-quota.js): `QuotaSummary`, a row's "spent / limit per day"
* line with its bar — red once used up, with when it opens again;
* `QuotaFields`, a limit in dollars and its period (a day from local midnight,
* or a sliding 5 hours); and `QuotaDefaultCard`, the default every user
* without one of their own gets — an admin edits it with their own password
* again (`PUT /api/users/quota-default`), an analyst only reads. A user's own
* setting is part of the Edit user form (`UserEditDialog`). A user over their
* quota can start no session and their prompts are refused until it resets.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState, type KeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import { Gauge, Pencil } from "lucide-react";
import { api, type UserQuota, type UserQuotaStatus } from "../../lib/api";
import { fmtCost, formatDateTime } from "../../lib/format";
import { isSubmitEnter } from "../../lib/keys";
import { Checkbox } from "../Checkbox";
import { ConfirmModal } from "../ConfirmModal";
import { Meter } from "../health/HostHealth";
export type QuotaPeriod = UserQuota["period"];
export const QUOTA_PERIODS: QuotaPeriod[] = ["day", "5h"];
/** "$10 / day", "$2.50 / 5 h". */
export function useQuotaLabel() {
const { t } = useTranslation("users");
return (q: Pick<UserQuota, "limit_usd" | "period">) =>
t(`quota.per.${q.period}`, { limit: fmtCost(q.limit_usd) });
}
/** A row's quota: spent of the limit, a bar, and when a used-up one opens again. */
export function QuotaSummary({ quota }: { quota: UserQuotaStatus | null | undefined }) {
const { t } = useTranslation("users");
const label = useQuotaLabel();
if (quota === undefined) return null;
if (quota === null) {
return <div className="text-[11px] text-fg-muted">{t("quota.none")}</div>;
}
return (
<div
className="mt-1 space-y-0.5"
data-testid="user-quota"
title={
quota.exceeded && quota.resetsAt
? t("quota.blockedUntil", { at: formatDateTime(quota.resetsAt) })
: undefined
}
>
<div
className={`whitespace-nowrap text-[11px] ${quota.exceeded ? "text-status-danger" : "text-fg-muted"}`}
>
{t("quota.used", { spend: fmtCost(quota.spend), limit: label(quota) })}
{quota.source === "default" && <span className="ml-1">{t("quota.defaultTag")}</span>}
</div>
<Meter value={quota.pct} label={t("quota.title")} />
</div>
);
}
/** A dollar limit and its period. `limit` is the raw input text. */
export function QuotaFields({
limit,
period,
onLimit,
onPeriod,
onKeyDown,
disabled,
}: {
limit: string;
period: QuotaPeriod;
onLimit: (v: string) => void;
onPeriod: (v: QuotaPeriod) => void;
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
disabled?: boolean;
}) {
const { t } = useTranslation("users");
return (
<div className="flex gap-2">
<div className="relative w-36">
<span className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-sm text-fg-muted">
$
</span>
<input
type="number"
min={0}
step="0.01"
inputMode="decimal"
className="input w-full pl-6 text-sm tabular-nums"
value={limit}
disabled={disabled}
onChange={(e) => onLimit(e.target.value)}
onKeyDown={onKeyDown}
aria-label={t("quota.limit")}
/>
</div>
<select
className="input flex-1 text-sm"
value={period}
disabled={disabled}
onChange={(e) => onPeriod(e.target.value as QuotaPeriod)}
aria-label={t("quota.period")}
>
{QUOTA_PERIODS.map((p) => (
<option key={p} value={p}>
{t(`quota.periods.${p}`)}
</option>
))}
</select>
</div>
);
}
/** A positive dollar amount from the input text, or null. */
export function parseLimit(text: string): number | null {
const n = Number(text);
return text.trim() !== "" && Number.isFinite(n) && n > 0 ? Math.round(n * 100) / 100 : null;
}
/** The default quota: shown to staff, edited by an admin with their own password. */
export function QuotaDefaultCard({
quotaDefault,
isAdmin,
onSaved,
}: {
/** `undefined` while loading, null for none. */
quotaDefault: UserQuota | null | undefined;
isAdmin: boolean;
onSaved: (message: string) => void;
}) {
const { t } = useTranslation("users");
const label = useQuotaLabel();
const [editing, setEditing] = useState(false);
return (
<section className="card" data-testid="quota-default-card">
<div className="flex flex-wrap items-center gap-3 px-5 py-4">
<Gauge className="h-4 w-4 text-fg-muted" />
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-fg-primary">{t("quota.cardTitle")}</h2>
<p className="text-xs text-fg-muted">{t("quota.cardDescription")}</p>
</div>
<div className="text-sm tabular-nums text-fg-primary" data-testid="quota-default-value">
{quotaDefault === undefined
? "…"
: quotaDefault
? label(quotaDefault)
: t("quota.noDefault")}
</div>
{isAdmin && quotaDefault !== undefined && (
<button type="button" className="btn-ghost text-xs" onClick={() => setEditing(true)}>
<Pencil className="h-3.5 w-3.5" /> {t("quota.editDefault")}
</button>
)}
</div>
{editing && (
<QuotaDefaultDialog
current={quotaDefault ?? null}
onClose={() => setEditing(false)}
onDone={(message) => {
setEditing(false);
onSaved(message);
}}
/>
)}
</section>
);
}
function QuotaDefaultDialog({
current,
onClose,
onDone,
}: {
current: UserQuota | null;
onClose: () => void;
onDone: (message: string) => void;
}) {
const { t } = useTranslation("users");
const [on, setOn] = useState(current !== null);
const [limit, setLimit] = useState(current ? String(current.limit_usd) : "");
const [period, setPeriod] = useState<QuotaPeriod>(current?.period ?? "day");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const value = parseLimit(limit);
const ready = password.length > 0 && (!on || value !== null) && !busy;
const submit = async () => {
if (!ready) return;
setBusy(true);
setError(null);
try {
await api.users.setQuotaDefault(
on && value !== null
? { limit_usd: value, period, tz_offset: new Date().getTimezoneOffset() }
: null,
password
);
onDone(t("quota.defaultSaved"));
} catch (e) {
const err = e as Error & { code?: string };
setError(
err.code && t(`errors.${err.code}`, { defaultValue: "" })
? t(`errors.${err.code}`)
: err.message
);
} finally {
setBusy(false);
}
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
};
return (
<ConfirmModal
open
title={t("quota.defaultDialog")}
confirmLabel={t("save")}
cancelLabel={t("common:cancel")}
destructive={false}
busy={busy}
disabled={!ready}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div className="mt-3 space-y-3">
<Checkbox
checked={on}
onChange={setOn}
label={t("quota.defaultOn")}
labelClassName="text-sm text-fg-secondary"
/>
<QuotaFields
limit={limit}
period={period}
onLimit={setLimit}
onPeriod={setPeriod}
onKeyDown={onKeyDown}
disabled={!on}
/>
<p className="text-[11px] text-fg-muted">{t("quota.enforceHint")}</p>
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
<input
type="password"
className="input mt-1 w-full text-sm"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={onKeyDown}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</label>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
@@ -0,0 +1,157 @@
/**
* @file UserQuota.test.tsx
* @description Spend quotas on the Users page: a row's "spent of limit" line
* (red once used up, "(default)" when it is the default), the default quota
* card (an admin edits it with their own password, an analyst only reads),
* and the Edit user form sending a user's own quota as part of its ONE PATCH.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ServerUserUsage } from "../../../lib/api";
const users = vi.hoisted(() => ({
setQuotaDefault: vi.fn(),
update: vi.fn(),
get: vi.fn(),
}));
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { users, lanes } };
});
import { QuotaDefaultCard, QuotaSummary } from "../UserQuota";
import { UserEditDialog } from "../UserEditDialog";
beforeEach(() => {
vi.clearAllMocks();
users.setQuotaDefault.mockResolvedValue({ user: {}, quotaDefault: null });
users.update.mockResolvedValue({ user: {} });
users.get.mockResolvedValue({ user: {}, workspaces: [] });
lanes.list.mockResolvedValue({ lanes: [] });
});
describe("QuotaSummary", () => {
it("shows spent of the limit, red and marked default once used up", () => {
render(
<QuotaSummary
quota={{
limit_usd: 10,
period: "day",
source: "default",
spend: 12.5,
pct: 125,
exceeded: true,
resetsAt: "2026-10-29T17:00:00.000Z",
since: "2026-10-28T17:00:00.000Z",
}}
/>
);
const line = screen.getByText(/of \$10\.00 \/ day/);
expect(line.textContent).toContain("$12.50");
expect(line.textContent).toContain("(default)");
expect(line.className).toContain("text-status-danger");
expect(screen.getByRole("meter")).toHaveAttribute("aria-valuenow", "100");
});
it("says No quota without one, and nothing for an older server", () => {
const { container, rerender } = render(<QuotaSummary quota={null} />);
expect(screen.getByText("No quota")).toBeInTheDocument();
rerender(<QuotaSummary quota={undefined} />);
expect(container.textContent).toBe("");
});
});
describe("QuotaDefaultCard", () => {
it("an admin sets the default with their own password", async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
render(<QuotaDefaultCard quotaDefault={null} isAdmin onSaved={onSaved} />);
expect(screen.getByTestId("quota-default-value").textContent).toBe("No default — no limit");
await user.click(screen.getByRole("button", { name: /Edit/ }));
await user.click(screen.getByRole("checkbox", { name: "Limit what each user may spend" }));
await user.type(screen.getByLabelText("Limit in dollars"), "25");
await user.selectOptions(screen.getByLabelText("Period"), "5h");
const save = screen.getByRole("button", { name: "Save" });
expect(save).toBeDisabled(); // no password yet
await user.type(screen.getByLabelText("Your password"), "pw");
await user.click(save);
await waitFor(() => expect(onSaved).toHaveBeenCalled());
expect(users.setQuotaDefault).toHaveBeenCalledWith(
expect.objectContaining({ limit_usd: 25, period: "5h" }),
"pw"
);
});
it("an analyst only reads it", () => {
render(
<QuotaDefaultCard
quotaDefault={{ limit_usd: 5, period: "day" }}
isAdmin={false}
onSaved={() => {}}
/>
);
expect(screen.getByTestId("quota-default-value").textContent).toBe("$5.00 / day");
expect(screen.queryByRole("button", { name: /Edit/ })).toBeNull();
});
});
describe("UserEditDialog quota", () => {
const mia: ServerUserUsage = {
id: 7,
username: "mia",
role: "developer",
disabled: false,
workspaces: 0,
sessions: 0,
last7d: { cost: 0, tokens: 0 },
last30d: { cost: 0, tokens: 0 },
quotaSetting: { mode: "default" },
quota: null,
} as ServerUserUsage;
it("sends the user's own limit in the one PATCH", async () => {
const user = userEvent.setup();
const onDone = vi.fn();
render(
<UserEditDialog
user={mia}
self={false}
users={[mia]}
quotaDefault={{ limit_usd: 10, period: "day" }}
onClose={() => {}}
onDone={onDone}
/>
);
const select = screen.getByLabelText("Spend quota");
expect(screen.getByRole("option", { name: "Default ($10.00 / day)" })).toBeInTheDocument();
await user.selectOptions(select, "custom");
await user.type(screen.getByLabelText("Limit in dollars"), "40");
await user.type(screen.getByLabelText("Your password"), "pw");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(onDone).toHaveBeenCalled());
expect(users.update).toHaveBeenCalledWith(
7,
{ quota: expect.objectContaining({ mode: "custom", limit_usd: 40, period: "day" }) },
"pw"
);
});
it("No limit is a change; an empty custom limit keeps Save off", async () => {
const user = userEvent.setup();
render(
<UserEditDialog user={mia} self={false} users={[mia]} onClose={() => {}} onDone={() => {}} />
);
await user.type(screen.getByLabelText("Your password"), "pw");
await user.selectOptions(screen.getByLabelText("Spend quota"), "custom");
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
await user.selectOptions(screen.getByLabelText("Spend quota"), "unlimited");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(users.update).toHaveBeenCalledWith(7, { quota: { mode: "unlimited" } }, "pw")
);
});
});
+1
View File
@@ -195,6 +195,7 @@
"gitBar.switch": "Branch",
"gitBar.switchApply": "Switch branch",
"gitBar.switchCurrent": "current",
"gitBar.remoteBranches": "On the remote — switching creates the local branch",
"gitBar.switchDirty": "Commit or discard the uncommitted changes first",
"gitBar.switchHint": "Switch this worktree to another local branch",
"gitBar.switchLabel": "Switch branch…",
+7
View File
@@ -78,6 +78,13 @@
"quotaWindow": {
"wk": "Week",
"mo": "Month"
},
"userQuota": "Quota {{pct}}%",
"userQuotaTitle": "Your spend quota: {{spend}} of {{limit}} {{period}}",
"userQuotaBlocked": "Your spend quota is used up ({{spend}} of {{limit}} {{period}}): no new session and no prompt until {{at}}",
"userQuotaPeriod": {
"day": "today",
"5h": "in the last 5 hours"
}
},
"version": "Version",
+35 -2
View File
@@ -87,7 +87,8 @@
"ELOCALMODE": "Accounts are managed in server mode only.",
"ELASTADMIN": "The last admin cannot be deleted, demoted or disabled.",
"EWEAKPASSWORD": "The new password does not meet every rule (or is the current one).",
"EPWCHANGE": "Choose your own password first."
"EPWCHANGE": "Choose your own password first.",
"EBADQUOTA": "Enter a positive dollar limit and a period of a day or 5 hours."
},
"readOnly": "Analysts can view accounts but not change them.",
"auditTitle": "Audit log",
@@ -412,5 +413,37 @@
"spendSub": "7 d {{week}} · 30 d {{month}}",
"sessionsN": "{{n}} sessions",
"workspacesN": "{{n}} workspaces",
"tmuxCol": "tmux"
"tmuxCol": "tmux",
"quota": {
"title": "Spend quota",
"limit": "Limit in dollars",
"period": "Period",
"periods": {
"day": "per day (from local midnight)",
"5h": "per 5 hours (sliding)"
},
"per": {
"day": "{{limit}} / day",
"5h": "{{limit}} / 5 h"
},
"used": "{{spend}} of {{limit}}",
"defaultTag": "(default)",
"none": "No quota",
"blockedUntil": "Used up — no new session and no prompt until {{at}}",
"cardTitle": "Default spend quota",
"cardDescription": "Every user without a quota of their own gets this one. Once it is used up they can start no session and their prompts are refused until it resets; a pane no hook can stop (Antigravity, Cursor, Codex without trusted hooks, omp on its own login) is ended.",
"noDefault": "No default — no limit",
"editDefault": "Edit",
"defaultDialog": "Default spend quota",
"defaultOn": "Limit what each user may spend",
"defaultSaved": "Default spend quota saved",
"enforceHint": "Over the quota: no new session, Claude Code and Codex prompts refused, other AI tools' panes ended — until it resets.",
"modes": {
"defaultOf": "Default ({{quota}})",
"defaultNone": "Default (no limit)",
"unlimited": "No limit",
"custom": "Their own limit"
},
"now": "Now: {{spend}} of {{limit}}"
}
}
+1
View File
@@ -195,6 +195,7 @@
"gitBar.switch": "Nhánh",
"gitBar.switchApply": "Chuyển nhánh",
"gitBar.switchCurrent": "hiện tại",
"gitBar.remoteBranches": "Trên remote — switch sẽ tạo nhánh local",
"gitBar.switchDirty": "Hãy commit hoặc bỏ các thay đổi chưa commit trước",
"gitBar.switchHint": "Đổi worktree này sang nhánh local khác",
"gitBar.switchLabel": "Đổi nhánh…",
+7
View File
@@ -78,6 +78,13 @@
"quotaWindow": {
"wk": "Tuần",
"mo": "Tháng"
},
"userQuota": "Quota {{pct}}%",
"userQuotaTitle": "Quota chi phí của bạn: {{spend}} trên {{limit}} {{period}}",
"userQuotaBlocked": "Bạn đã dùng hết quota chi phí ({{spend}} trên {{limit}} {{period}}): không mở phiên mới, không gửi prompt đến {{at}}",
"userQuotaPeriod": {
"day": "hôm nay",
"5h": "trong 5 giờ qua"
}
},
"version": "Phiên bản",
+35 -2
View File
@@ -87,7 +87,8 @@
"ELOCALMODE": "Chỉ quản lý tài khoản được ở chế độ server.",
"ELASTADMIN": "Không thể xoá, hạ quyền hay khoá admin cuối cùng.",
"EWEAKPASSWORD": "Mật khẩu mới chưa đạt đủ các yêu cầu (hoặc trùng mật khẩu hiện tại).",
"EPWCHANGE": "Hãy đặt mật khẩu riêng của bạn trước."
"EPWCHANGE": "Hãy đặt mật khẩu riêng của bạn trước.",
"EBADQUOTA": "Nhập hạn mức USD lớn hơn 0 và chu kỳ ngày hoặc 5 giờ."
},
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
"auditTitle": "Nhật ký audit",
@@ -412,5 +413,37 @@
"spendSub": "7 ngày {{week}} · 30 ngày {{month}}",
"sessionsN": "{{n}} session",
"workspacesN": "{{n}} workspace",
"tmuxCol": "tmux"
"tmuxCol": "tmux",
"quota": {
"title": "Quota chi phí",
"limit": "Hạn mức (USD)",
"period": "Chu kỳ",
"periods": {
"day": "mỗi ngày (từ nửa đêm giờ địa phương)",
"5h": "mỗi 5 giờ (trượt)"
},
"per": {
"day": "{{limit}} / ngày",
"5h": "{{limit}} / 5 giờ"
},
"used": "{{spend}} trên {{limit}}",
"defaultTag": "(mặc định)",
"none": "Không giới hạn",
"blockedUntil": "Đã hết — không mở phiên mới, không gửi prompt đến {{at}}",
"cardTitle": "Quota chi phí mặc định",
"cardDescription": "Áp cho mọi user không có quota riêng. Khi dùng hết, user không mở được phiên mới và prompt bị từ chối đến khi quota mở lại; pane không hook nào chặn được (Antigravity, Cursor, Codex chưa tin hook, omp dùng login riêng) sẽ bị dừng.",
"noDefault": "Chưa có mặc định — không giới hạn",
"editDefault": "Sửa",
"defaultDialog": "Quota chi phí mặc định",
"defaultOn": "Giới hạn chi phí của mỗi user",
"defaultSaved": "Đã lưu quota chi phí mặc định",
"enforceHint": "Vượt quota: không mở phiên mới, prompt Claude Code và Codex bị từ chối, pane của các AI tool khác bị dừng — đến khi quota mở lại.",
"modes": {
"defaultOf": "Mặc định ({{quota}})",
"defaultNone": "Mặc định (không giới hạn)",
"unlimited": "Không giới hạn",
"custom": "Hạn mức riêng"
},
"now": "Hiện tại: {{spend}} trên {{limit}}"
}
}
+40 -1
View File
@@ -545,6 +545,31 @@ export type ServerUserUsage = AuthUser & {
/** Active login sessions (cookies not expired). */
logins?: number;
lastLoginAt?: string | null;
/** This user's own spend quota setting (absent from an older server). */
quotaSetting?: UserQuotaSetting;
/** The quota that binds them now with its spend; null = no limit. */
quota?: UserQuotaStatus | null;
};
/** A spend quota: `limit_usd` per day (from local midnight of `tz_offset`) or per sliding 5 h. */
export type UserQuota = {
limit_usd: number;
period: "day" | "5h";
/** getTimezoneOffset() minutes of the zone a `day` starts in. */
tz_offset?: number;
/** Where it comes from: the default, or the user's own. */
source?: "default" | "user";
};
/** A user's own setting: the default applies, no limit, or a limit of their own. */
export type UserQuotaSetting =
{ mode: "default" } | { mode: "unlimited" } | ({ mode: "custom" } & UserQuota);
/** A quota now (`GET /api/users`, `GET /api/me/quota`). */
export type UserQuotaStatus = UserQuota & {
spend: number;
pct: number;
/** Used up: no new session starts and prompts are refused until `resetsAt`. */
exceeded: boolean;
resetsAt: string | null;
since: string;
};
/** A priced slice of a user's spend (`GET /api/users/:id/details`). */
export type UsageSlice = { key: string; cost: number; tokens: number; sessions: number };
@@ -773,6 +798,8 @@ export type UserPatch = {
addWorkspaces?: number[];
removeWorkspaces?: number[];
signOut?: true;
/** Their own spend quota (`default` = the default applies). */
quota?: UserQuotaSetting;
};
/** GET /api/me/account — the signed-in account's own information. */
export type MyAccount =
@@ -1210,6 +1237,8 @@ export const api = {
/** PUT /api/me/prefs — merge; `null` deletes a key. */
savePrefs: (prefs: Record<string, string | null>) =>
request<UserPrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
/** GET /api/me/quota — this account's own spend quota now (null: no limit, or local mode). */
quota: () => request<{ quota: UserQuotaStatus | null }>("/me/quota"),
/** GET /api/me/instructions — this account's own agent instructions (issue #67). */
instructions: () => request<AgentInstructionsView>("/me/instructions"),
/** PUT /api/me/instructions — blank clears; put before every workspace's own. */
@@ -1271,7 +1300,15 @@ export const api = {
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
/** GET /api/users — every account with its recent usage (admin/analyst). */
list: () =>
request<{ users: ServerUserUsage[] }>(`/users?tz_offset=${new Date().getTimezoneOffset()}`),
request<{ users: ServerUserUsage[]; quotaDefault?: UserQuota | null }>(
`/users?tz_offset=${new Date().getTimezoneOffset()}`
),
/** PUT /api/users/quota-default — the default spend quota (null = none). */
setQuotaDefault: (quota: UserQuota | null, currentPassword: string) =>
request<{ user: AuthUser; quotaDefault: UserQuota | null }>("/users/quota-default", {
method: "PUT",
body: JSON.stringify({ quota, currentPassword }),
}),
/** GET /api/users/:id/details — one account in full (admin/analyst). */
details: (id: number) =>
request<UserDetails>(`/users/${id}/details?tz_offset=${new Date().getTimezoneOffset()}`),
@@ -4283,6 +4320,8 @@ export const api = {
request<{
available: boolean;
branches: string[];
/** Fetched remote branches with no local one yet (`origin/feature`); absent from an older server. */
remotes?: string[];
current: string | null;
worktrees: LaneWorktree[];
}>(gitUrl(id, "/branches")),
+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)}
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.30 (rebuild client to match)
UI build v2.8.31 (rebuild client to match)
</span>
</p>
</div>
+8 -4
View File
@@ -89,7 +89,7 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
@@ -105,6 +105,7 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
| `PUT /api/me/prefs` `{prefs: {<key>: "<value>" \| null}}` | merged (`null` deletes a key), the same shape back; `400 EBADPREFS` for a key outside the list below or a value that is not a string of at most 8 KB |
| `GET /api/me/instructions` | `{instructions, max, tools}` — `tools` lists the AI tool ids whose new panes receive them (`["claude","codex"]`; Cursor CLI and Antigravity have no per-session way to take them). The account's own agent instructions (Settings → AI tools → My agent instructions, `""` when none); `401 EAUTH` with no login |
| `PUT /api/me/instructions` `{instructions: "<markdown>" \| null}` | replaced, the same shape back; blank or `null` clears; `400 EBADFIELD` not a string, `413 ETOOBIG` over 16 000 characters. Put BEFORE the workspace's own instructions in every new Claude/Codex pane of a workspace the account owns (local mode: every workspace) — see Workspace context below |
| `GET /api/me/quota` | server mode: `{quota: {limit_usd, period: "day"\|"5h", tz_offset, source: "default"\|"user", spend, pct, exceeded, resetsAt, since} \| null}` — the account's own [spend quota](SERVER-MODE.md#spend-quotas) now; `null` without one, and always in local mode |
| `GET /api/me/pool-accounts` | server mode: `{tools: [{tool, pin, choice, accounts: [{name, kind, email, expired, quotaPercent, full}]}]}` — per tool (`claude`, `codex`, `cursor`) with at least one account the account's [pool group](SERVER-MODE.md#a-users-own-pool-account) allows; never a token. Local mode: `{tools: []}` |
| `PUT /api/me/pool-accounts` `{tool, account: "<name>" \| null}` | the account's new sessions of `tool` run on `account` (`null` = automatic): `{tool, choice, switched}` — `switched` as for an admin's pin (a Claude login goes into the home at once). Below an admin's pin (`409 ELOCKED`), only an account the group allows (`404 ENOACCOUNT`), `400 EBADTOOL`; login cookie only, audited |
| `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) |
@@ -941,8 +942,11 @@ read the disk instead for a folder that is not a git repo (below).
GET /api/lanes/:id/git/branches
```
`{"available": true, "branches": [...], "current": "feat/x", "worktrees": [...]}`
— the local branches of the workspace's own repo, for the branch switcher. The repo
`{"available": true, "branches": [...], "remotes": ["origin/feat/y"], "current": "feat/x", "worktrees": [...]}`
— the local branches of the workspace's own repo, for the branch switcher, and
`remotes`: the fetched remote-tracking branches that have no local branch of
that name yet (the remotes' `HEAD` left out). The switcher lists them under
"On the remote" and re-reads the list after every fetch and pull. The repo
comes from the workspace row, never from the query (that is what `GET
/api/lanes/branches` is for, which takes a candidate path for a workspace that does
not exist yet; in server mode with `AGENTHUB_USERS_ROOT` set, a developer may name
@@ -1058,7 +1062,7 @@ cannot collide on `index.lock`.
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` — or, with `block`, of only that hunk's `block`-th run of +/- lines (the other runs folded into context, as an edited `git add -p` hunk) | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "block": 0, "context": 3}` |
| `discard-hunk` | `git apply -R` of that one hunk (or that one `block` of it), off the working tree | `{"path": "...", "hunk": "@@ ... @@", "block": 0, "context": 3, "confirm": true}` |
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"`, or — with `paths` — `git commit --only -- <paths>`: exactly those files' working-tree content (an untracked one is added first, and taken back out if the commit fails), every other change left as it was | `{"message": "...", "only": "staged"}` / `{"message": "...", "paths": ["..."]}` |
| `switch` | `git switch [-c] <branch>` | `{"branch": "...", "create": false}` |
| `switch` | `git switch [-c] <branch>`; a branch only a remote has (`origin/feat/y`, or bare `feat/y` when exactly one remote has it — else `400 EAMBIGUOUS`) becomes `git switch -c feat/y --track refs/remotes/origin/feat/y`, the remote ref looked up among the fetched ones | `{"branch": "...", "create": false}` |
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` |
| `publish` | `git remote add origin -- <url>` then `git push -u origin HEAD` — only when the repo has NO remote | `{"url": "https://host/owner/repo.git"}` |
+49
View File
@@ -710,6 +710,55 @@ omp login # e.g. anthropic
> runs; it cannot carry a login away. A member who edits the `agenthub`
> provider out of their `models.yml` gets it back at their next omp pane.
## Spend quotas
An admin limits what each user may spend, in dollars, on the Users page
(`server/lib/user-quota.js`):
- **Default spend quota** card: one limit every user without their own gets
(`PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null}`).
- **Edit user → Spend quota**: *Default*, *No limit*, or *Their own limit*
(`PATCH /api/users/:id` `{quota: {mode: "default" | "unlimited" | "custom", …}}`).
- A period is `day` (from local midnight of the zone the admin's browser was
in when they saved it) or `5h` (sliding: the current UTC hour and the four
before it — spend booked in hour H counts until H + 5 h).
- Both writes need the admin's own password again and are audited, like
every account change; each one re-applies at once (`sweep`).
Spend is the users page's own figure: the tokens of the sessions the user
OWNS through a workspace, priced by `calculateCost`. The Users table shows
each user's spend against their quota; the user sees their own as **Quota N%**
in the status bar (`GET /api/me/quota`).
Once a user's spend reaches the limit, until the window opens again:
- **No new session.** Every start, resume or prompt from the workspace home
goes through `spawnRun`, which answers `429 EQUOTA` with the reason.
- **Claude Code and Codex prompts are refused** by the hooks. Every pane
carries `AGENTHUB_QUOTA_FILE`. The dashboard writes that file every minute:
the host's under `<data dir>/user-quota/u<id>.json`, a member's (S3) in
their handoff dir through `write-as`. While it says `blocked`,
`scripts/hook-handler.js` exits 2 on `UserPromptSubmit` with the reason on
stderr, and the CLI drops the prompt and shows why. The hook reads only that
local file and never asks the dashboard. It fails open: a missing or
unreadable file, or a passed `until`, lets the prompt through. Codex takes
this only with AgentHub's Codex hooks installed and trusted.
- **A member's omp on the shared login** (above) loses its gateway key: the
run's key is left out of `clients.json` until the quota opens. Its pane stays.
- **Every other pane of theirs is ended.** This covers Antigravity, Cursor, a
Codex without trusted hooks (a member's Codex always), an omp on its own
login, and any other catalog tool. Plain shells are left alone.
Limits of the check:
- Tokens land after a turn, so a user can finish one turn past the limit
(omp/Codex journals are polled, so a little more).
- A user's own terminal outside AgentHub carries no flag file.
- Without S3 every pane runs as the host account, which can edit the flag.
Turn the minute sweep off with `DASHBOARD_QUOTA_SWEEP_MS=0`. The `EQUOTA`
refusal at start does not depend on it.
## One Linux user per account
Every pane of a user, an agent's shell included, runs as that user's own Linux
+180 -5
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:
@@ -12203,8 +12276,8 @@ paths:
get:
tags:
- Lanes
summary: The lane repo's own local branches
description: Feeds the lane's branch switcher. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard.
summary: The lane repo's own local branches, and the remote ones not checked out yet
description: Feeds the lane's branch switcher. `branches` are the local branches; `remotes` the fetched remote-tracking branches with no local branch of that name yet (`origin/feature`, the remotes' HEAD left out) — `switch` to one creates its local tracking branch. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard.
operationId: listLaneOwnBranches
parameters:
- name: id
@@ -12214,7 +12287,7 @@ paths:
type: integer
responses:
'200':
description: '{available, branches, current, worktrees}.'
description: '{available, branches, remotes, current, worktrees}.'
'404':
description: Lane not found.
/api/lanes/{id}/git/commit-message:
@@ -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:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.30",
"version": "2.8.31",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.30",
"version": "2.8.31",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.30",
"version": "2.8.31",
"bin": {
"agenthub": "bin/agenthub.js"
},
+34
View File
@@ -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);
+47 -5
View File
@@ -138,7 +138,10 @@ describe("collectChanges — work range", () => {
const out = await collectChanges(LANE, { range: "work" });
// `--untracked-files=normal` folded these into one "docs/" row that
// opened nothing; the panel needs the files, like JetBrains and VS Code.
assert.deepEqual(out.files.map((f) => f.path).sort(), ["docs/deep/notes.txt", "docs/report.md"]);
assert.deepEqual(out.files.map((f) => f.path).sort(), [
"docs/deep/notes.txt",
"docs/report.md",
]);
assert.ok(out.files.every((f) => f.untracked && f.added === 1));
assert.equal((await gitFacts(LANE)).untracked, 2);
});
@@ -244,10 +247,7 @@ describe("collectChanges — branch range", () => {
it("falls back to the default branch without a base, and refuses a base that is gone", async () => {
// No recorded base (an adopted folder): what origin/HEAD names.
assert.equal(
(await collectChanges(LANE, { range: "branch", base: null })).base,
"development"
);
assert.equal((await collectChanges(LANE, { range: "branch", base: null })).base, "development");
await assert.rejects(() => collectChanges(LANE, { range: "branch", base: "nope" }), {
code: "ENOBASE",
});
@@ -374,6 +374,48 @@ describe("runGitAction", () => {
});
});
it("lists a branch only the remote has after a fetch, and switching tracks it", async () => {
const { listRemoteBranches } = require("../lib/worktree");
// Someone else pushes a new branch; this clone has never seen it.
gc(OTHER, "checkout", "-qb", "feat/remote-only");
write(OTHER, "r.txt", "remote\n");
gc(OTHER, "add", "-A");
gc(OTHER, "commit", "-qm", "remote work");
gc(OTHER, "push", "-q", "origin", "feat/remote-only");
assert.equal(
(await listRemoteBranches(LANE)).some((r) => r.name === "origin/feat/remote-only"),
false
);
await runGitAction(LANE, "fetch");
const remote = await listRemoteBranches(LANE);
assert.ok(remote.some((r) => r.name === "origin/feat/remote-only"));
// origin/HEAD and branches that already have a local one are left out.
assert.ok(!remote.some((r) => r.branch === "HEAD" || r.branch === "development"));
const result = await runGitAction(LANE, "switch", { branch: "origin/feat/remote-only" });
assert.equal(result.branch, "feat/remote-only");
assert.equal(g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim(), "feat/remote-only");
assert.equal(
g(LANE, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}").trim(),
"origin/feat/remote-only"
);
assert.ok(!(await listRemoteBranches(LANE)).some((r) => r.branch === "feat/remote-only"));
gc(LANE, "checkout", "-q", "development");
});
it("switches to a bare remote branch name when exactly one remote has it", async () => {
gc(OTHER, "checkout", "-qb", "feat/bare");
gc(OTHER, "push", "-q", "origin", "feat/bare");
await runGitAction(LANE, "fetch");
const result = await runGitAction(LANE, "switch", { branch: "feat/bare" });
assert.equal(result.branch, "feat/bare");
assert.equal(
g(LANE, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}").trim(),
"origin/feat/bare"
);
gc(LANE, "checkout", "-q", "development");
});
it("refuses a branch another worktree holds", async () => {
const wt = path.join(ROOT, "sibling");
g(LANE, "worktree", "add", "-q", wt, "development");
+1
View File
@@ -1122,6 +1122,7 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
const r = await request("GET", `/api/lanes/${laneId}/git/branches`);
assert.equal(r.status, 200);
assert.deepEqual(r.body.branches, ["feat/api-changes"]);
assert.deepEqual(r.body.remotes, []); // no remote at all
assert.equal(r.body.current, "feat/api-changes");
});
+343
View File
@@ -0,0 +1,343 @@
/**
* @file user-quota.test.js
* @description Spend quotas per user (server/lib/user-quota.js): the default
* and a user's own setting, spend over a day and a sliding 5 h window, the
* admin routes (password again), `/api/me/quota`, the EQUOTA refusal of a new
* pane, the sweep's flag files and the panes it ends, the omp gateway keys it
* leaves out, and the hook handler refusing a prompt from the flag alone.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const { spawnSync } = require("node:child_process");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "user-quota-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
process.env.CODEX_HOME = path.join(TMP, ".codex");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
process.env.DASHBOARD_QUOTA_SWEEP_MS = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
const quota = require("../lib/user-quota");
const runs = require("../lib/pty-run");
let server;
let BASE;
const BOSS_PASS = "admin-pass-1";
const ids = {};
let laneId;
async function call(method, p, { cookie, body } = {}) {
const res = await fetch(BASE + p, {
method,
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => null) };
}
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
return res.headers.get("set-cookie").split(";")[0];
}
const HOUR = 3_600_000;
const hourOf = (ms) => new Date(ms).toISOString().slice(0, 13);
/** `tokens` output tokens of Sonnet 4.5 ($15 / M) booked to `sid` in the hour of `at`. */
function spend(sid, tokens, at = Date.now()) {
db.prepare(
`INSERT INTO token_usage (session_id, model, output_tokens)
VALUES (?, 'claude-sonnet-4-5-20250929', ?)
ON CONFLICT DO UPDATE SET output_tokens = output_tokens + excluded.output_tokens`
).run(sid, tokens);
db.prepare(
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
VALUES (?, ?, 'claude-sonnet-4-5-20250929', ?)`
).run(sid, hourOf(at), tokens);
}
before(async () => {
process.env.AGENTHUB_MODE = "server";
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
ids.lou = (await auth.createUser("lou", "member-pass-2", "developer")).id;
laneId = Number(
db
.prepare(
"INSERT INTO lanes (cwd, title, tool, owner_id) VALUES ('/w/mia', 'Mia', 'claude', ?)"
)
.run(ids.mia).lastInsertRowid
);
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, updated_at) VALUES ('sm', 'mia work', 'active', '/w/mia', ?, ?)"
).run(laneId, new Date().toISOString());
spend("sm", 1_000_000); // $15 now
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("settings", () => {
it("a user without a row of their own gets the default; unlimited and custom override it", () => {
assert.equal(quota.effective(ids.mia), null);
quota.setDefault({ limit_usd: 10, period: "day", tz_offset: -420 });
assert.deepEqual(quota.effective(ids.mia), {
source: "default",
limit_usd: 10,
period: "day",
tz_offset: -420,
});
quota.setUserQuota(ids.mia, { mode: "unlimited" });
assert.equal(quota.effective(ids.mia), null);
assert.deepEqual(quota.getOverride(ids.mia), { mode: "unlimited" });
quota.setUserQuota(ids.mia, { mode: "custom", limit_usd: 50, period: "5h" });
assert.equal(quota.effective(ids.mia).source, "user");
assert.equal(quota.effective(ids.mia).limit_usd, 50);
quota.setUserQuota(ids.mia, { mode: "default" });
assert.equal(quota.effective(ids.mia).source, "default");
quota.setDefault(null);
assert.equal(quota.effective(ids.mia), null);
});
it("refuses a bad limit, period or mode", () => {
for (const bad of [
{ mode: "custom", limit_usd: 0, period: "day" },
{ mode: "custom", limit_usd: 5, period: "week" },
{ mode: "custom", limit_usd: 5, period: "day", tz_offset: 9999 },
{ mode: "nope" },
]) {
assert.throws(() => quota.setUserQuota(ids.mia, bad), { code: "EBADQUOTA" });
}
});
});
describe("spend windows", () => {
it("a day starts at the zone's local midnight and resets at the next one", () => {
const now = Date.parse("2026-10-28T03:30:00Z"); // 10:30 at UTC+7
const w = quota.windowOf({ period: "day", tz_offset: -420 }, now);
assert.equal(w.since, "2026-10-27T17");
assert.equal(new Date(w.nextReset).toISOString(), "2026-10-28T17:00:00.000Z");
});
it("5 h is the current hour and the four before it; it opens as the oldest hours leave", () => {
const now = Date.parse("2026-10-28T10:20:00Z");
assert.equal(quota.windowOf({ period: "5h" }, now).since, "2026-10-28T06");
const byHour = new Map([
["2026-10-28T06", 4],
["2026-10-28T08", 3],
["2026-10-28T10", 2],
]);
// 9 spent, limit 6: 06's $4 leaving brings it to 5 < 6 → at 06 + 5 h.
assert.equal(
new Date(quota.slidingReset(byHour, 9, 6)).toISOString(),
"2026-10-28T11:00:00.000Z"
);
// limit 3: everything up to 08 must go → 08 + 5 h.
assert.equal(
new Date(quota.slidingReset(byHour, 9, 3)).toISOString(),
"2026-10-28T13:00:00.000Z"
);
});
it("counts the spend of the sessions a user owns, inside the window only", () => {
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, updated_at) VALUES ('old', 'old', 'completed', '/w/mia', ?, ?)"
).run(laneId, new Date().toISOString());
spend("old", 1_000_000, Date.now() - 8 * HOUR); // outside 5 h
quota.setUserQuota(ids.mia, { mode: "custom", limit_usd: 100, period: "5h" });
const s = quota.statusFor(ids.mia);
assert.equal(s.spend, 15);
assert.equal(s.exceeded, false);
assert.equal(s.resetsAt, null);
assert.equal(quota.statusFor(ids.lou), null);
quota.setUserQuota(ids.mia, { mode: "default" });
});
});
describe("routes", () => {
it("an admin sets the default with their password; the list shows each user's quota", async () => {
const boss = await login("boss", BOSS_PASS);
const noPass = await call("PUT", "/api/users/quota-default", {
cookie: boss,
body: { quota: { limit_usd: 10, period: "day" } },
});
assert.equal(noPass.status, 403);
const bad = await call("PUT", "/api/users/quota-default", {
cookie: boss,
body: { quota: { limit_usd: -1, period: "day" }, currentPassword: BOSS_PASS },
});
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADQUOTA");
const ok = await call("PUT", "/api/users/quota-default", {
cookie: boss,
body: { quota: { limit_usd: 10, period: "day" }, currentPassword: BOSS_PASS },
});
assert.equal(ok.status, 200, JSON.stringify(ok.body));
assert.equal(ok.body.quotaDefault.limit_usd, 10);
const list = await call("GET", "/api/users", { cookie: boss });
assert.equal(list.body.quotaDefault.limit_usd, 10);
const mia = list.body.users.find((u) => u.id === ids.mia);
assert.deepEqual(mia.quotaSetting, { mode: "default" });
assert.equal(mia.quota.spend, 15);
assert.equal(mia.quota.exceeded, true);
assert.ok(mia.quota.resetsAt);
});
it("a developer reads their own quota and may not change any", async () => {
const me = await login("mia", "member-pass-1");
const r = await call("GET", "/api/me/quota", { cookie: me });
assert.equal(r.status, 200);
assert.equal(r.body.quota.exceeded, true);
const put = await call("PUT", "/api/users/quota-default", {
cookie: me,
body: { quota: null, currentPassword: "member-pass-1" },
});
assert.equal(put.status, 403);
});
it("PATCH /api/users/:id {quota} overrides one user's, audited", async () => {
const boss = await login("boss", BOSS_PASS);
const r = await call("PATCH", `/api/users/${ids.mia}`, {
cookie: boss,
body: { quota: { mode: "custom", limit_usd: 40, period: "day" }, currentPassword: BOSS_PASS },
});
assert.equal(r.status, 200, JSON.stringify(r.body));
assert.equal(quota.statusFor(ids.mia).exceeded, false);
const audit = db
.prepare("SELECT path FROM audit_log WHERE owner_id = ? ORDER BY id DESC LIMIT 1")
.get(ids.mia);
assert.match(audit.path, /spend quota \$40\/day/);
const bad = await call("PATCH", `/api/users/${ids.mia}`, {
cookie: boss,
body: { quota: { mode: "lots" }, currentPassword: BOSS_PASS },
});
assert.equal(bad.status, 400);
quota.setUserQuota(ids.mia, { mode: "default" }); // back to the $10 default: over
});
});
describe("enforcement", () => {
it("refuses a new pane of an owner over quota with EQUOTA and a reason", () => {
assert.throws(
() => quota.assertSpawnAllowed(ids.mia),
(err) => {
assert.equal(err.code, "EQUOTA");
assert.match(err.message, /\$15\.00 of \$10\.00 per day/);
return true;
}
);
quota.assertSpawnAllowed(ids.lou); // under the default: no throw
quota.assertSpawnAllowed(null); // a workspace with no owner
});
it("gives a pane its owner's flag file in server mode only", () => {
const env = quota.flagEnv(ids.mia, null);
assert.match(env.AGENTHUB_QUOTA_FILE, /user-quota[/\\]u\d+\.json$/);
delete process.env.AGENTHUB_MODE;
try {
assert.deepEqual(quota.flagEnv(ids.mia, null), {});
quota.assertSpawnAllowed(ids.mia); // local mode never refuses
assert.equal(quota.statusAll().size, 0);
} finally {
process.env.AGENTHUB_MODE = "server";
}
});
it("sweep: writes the flag, ends the panes no hook can stop, keeps Claude's, clears on raise", () => {
const realList = runs.listRuns;
const realKill = runs.killRun;
const killed = [];
runs.listRuns = () => [
{ id: `agenthub-lane-${laneId}`, laneId, status: "running", tool: "claude" },
{ id: `agenthub-lane-${laneId}-1`, laneId, status: "running", tool: "cursor" },
];
runs.killRun = (id) => killed.push(id) > 0;
try {
quota.__reset();
const r = quota.sweep();
assert.deepEqual(r.blocked, [ids.mia]);
assert.deepEqual(killed, [`agenthub-lane-${laneId}-1`]);
const file = quota.flagEnv(ids.mia, null).AGENTHUB_QUOTA_FILE;
const flag = JSON.parse(fs.readFileSync(file, "utf8"));
assert.equal(flag.blocked, true);
assert.match(flag.message, /quota is used up/);
assert.ok(Date.parse(flag.until) > Date.now());
// Lou never went over: no file is written for them.
assert.equal(fs.existsSync(quota.flagEnv(ids.lou, null).AGENTHUB_QUOTA_FILE), false);
assert.equal(quota.isRunBlocked(`agenthub-lane-${laneId}-3`), true);
quota.setUserQuota(ids.mia, { mode: "unlimited" });
killed.length = 0;
quota.sweep();
assert.deepEqual(killed, []);
assert.equal(JSON.parse(fs.readFileSync(file, "utf8")).blocked, false);
assert.equal(quota.isRunBlocked(`agenthub-lane-${laneId}`), false);
} finally {
runs.listRuns = realList;
runs.killRun = realKill;
quota.setUserQuota(ids.mia, { mode: "default" });
quota.__reset();
}
});
it("the omp gateway loses a blocked owner's key, never the queue's", () => {
const { clientsFor } = require("../lib/omp-share");
const t = { standby: ["s1"], used: { a: { hash: "ha" }, b: { hash: "hb" } } };
const out = clientsFor(t, (id) => id === "a");
assert.equal(out.a, undefined);
assert.deepEqual(out.b, { tokenSha256: "hb" });
assert.equal(Object.keys(out).filter((k) => k.startsWith("standby-")).length, 1);
});
});
describe("hook handler", () => {
const HANDLER = path.resolve(__dirname, "..", "..", "scripts", "hook-handler.js");
const flag = path.join(TMP, "flag.json");
const run = (args, file) =>
spawnSync(process.execPath, [HANDLER, ...args], {
input: "{}",
env: { ...process.env, AGENTHUB_QUOTA_FILE: file, CLAUDE_DASHBOARD_PORT: "1" },
encoding: "utf8",
});
it("refuses a Claude Code or Codex prompt while the flag says blocked", () => {
fs.writeFileSync(
flag,
JSON.stringify({ blocked: true, until: "2999-01-01T00:00:00Z", message: "quota used up" })
);
for (const args of [["UserPromptSubmit"], ["UserPromptSubmit", "codex"]]) {
const r = run(args, flag);
assert.equal(r.status, 2);
assert.match(r.stderr, /quota used up/);
}
});
it("lets everything else through: other events, Antigravity, an expired or missing flag", () => {
assert.equal(run(["PreToolUse"], flag).status, 0);
assert.equal(run(["UserPromptSubmit", "antigravity"], flag).status, 0);
assert.equal(run(["UserPromptSubmit"], path.join(TMP, "none.json")).status, 0);
fs.writeFileSync(flag, JSON.stringify({ blocked: true, until: "2000-01-01T00:00:00Z" }));
assert.equal(run(["UserPromptSubmit"], flag).status, 0);
fs.writeFileSync(flag, "{not json");
assert.equal(run(["UserPromptSubmit"], flag).status, 0);
});
});
+17
View File
@@ -2004,6 +2004,23 @@ db.exec(`
PRIMARY KEY (group_id, tool, account)
);
`);
// Spend quotas (server/lib/user-quota.js): user_id 0 is the default every
// user without a row of their own gets; a row whose limit_usd is NULL means
// "no limit". No foreign key (0 is no user) — a deleted user's row goes with
// the trigger. Additive.
db.exec(`
CREATE TABLE IF NOT EXISTS user_quotas (
user_id INTEGER PRIMARY KEY,
limit_usd REAL,
period TEXT NOT NULL DEFAULT 'day' CHECK(period IN ('day','5h')),
tz_offset INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TRIGGER IF NOT EXISTS trg_user_quotas_user_gone AFTER DELETE ON users
BEGIN
DELETE FROM user_quotas WHERE user_id = OLD.id;
END;
`);
// A database from before the Cursor pool: its `tool` CHECK allowed only
// claude/codex. Rebuilt with every pin kept.
{
+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
+26 -1
View File
@@ -55,6 +55,7 @@ const {
git: rawGit,
isValidBranchName,
listBranches,
listRemoteBranches,
branchCheckedOutAt,
statusCounts,
operationInProgress,
@@ -532,7 +533,31 @@ async function runGitAction(dir, action, body = {}) {
if (exists) throw bad("EBRANCHEXISTS", `branch ${branch} already exists`);
return done(action, await git(dir, ["switch", "-c", branch]), branch);
}
if (!exists) throw bad("ENOBRANCH", `branch ${branch} does not exist locally`);
if (!exists) {
// A branch only a remote has (fetched, never checked out here): its local
// tracking branch is created and switched to. Named either as the remote
// ref (`origin/feature`) or bare (`feature`, when exactly one remote has
// it). The remote ref is looked up among the fetched ones, never passed
// through from the request.
const remote = (await listRemoteBranches(dir)).filter(
(r) => r.name === branch || r.branch === branch
);
const pick = remote.find((r) => r.name === branch) || (remote.length === 1 ? remote[0] : null);
if (!pick) {
if (remote.length > 1) {
throw bad(
"EAMBIGUOUS",
`branch ${branch} is on several remotes (${remote.map((r) => r.name).join(", ")}): pick one`
);
}
throw bad("ENOBRANCH", `branch ${branch} does not exist locally or on a remote`);
}
return done(
action,
await git(dir, ["switch", "-c", pick.branch, "--track", `refs/remotes/${pick.name}`]),
pick.branch
);
}
const busyAt = await branchCheckedOutAt(dir, branch);
if (busyAt) throw bad("EBRANCHBUSY", `branch ${branch} is checked out at ${busyAt}`);
return done(action, await git(dir, ["switch", branch]), branch);
+17 -3
View File
@@ -108,16 +108,29 @@ function refill(t) {
while (t.standby.length < STANDBY) t.standby.push(newToken());
}
/** The gateway's view: every queued key and every key a run holds — hashes only. */
function clientsFor(t) {
/**
* The gateway's view: every queued key and every key a run holds — hashes
* only. A run whose workspace owner is over their spend quota
* (user-quota.js) is left out until it opens again: its omp can no longer
* reach a model, while its pane and key stay.
*/
function clientsFor(t, isBlocked = quotaBlocked) {
const out = {};
for (const s of t.standby) out[`standby-${sha(s).slice(0, 16)}`] = { tokenSha256: sha(s) };
for (const [id, u] of Object.entries(t.used)) {
if (u && typeof u.hash === "string") out[id] = { tokenSha256: u.hash };
if (u && typeof u.hash === "string" && !isBlocked(id)) out[id] = { tokenSha256: u.hash };
}
return out;
}
function quotaBlocked(runId) {
try {
return require("./user-quota").isRunBlocked(runId);
} catch {
return false;
}
}
let lastClients = null;
function save(t) {
@@ -519,6 +532,7 @@ module.exports = {
takeToken,
pruneTokens,
clientsFor,
clientsFor,
readTokens,
PROVIDER,
TOKEN_ENV,
+5
View File
@@ -471,6 +471,8 @@ function spawnRun(args) {
// their .mcp.json) or a member in someone else's.
if (lane) require("./run-as").assertOwnerFits(lane.cwd, laneOwner); // EOWNERFOLDER
assertCwdAccount(laneId, cwd); // ECWDACCOUNT
// Server mode: an owner over their spend quota starts nothing new (user-quota.js).
require("./user-quota").assertSpawnAllowed(laneOwner); // EQUOTA
const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED
// A Claude session on the vendor's own API runs on the least-loaded pool
// account, if the admin added any (claude-pool.js) — or the one the
@@ -684,6 +686,9 @@ function spawnRun(args) {
for (const k of Object.keys(env)) delete env[k];
Object.assign(env, own);
}
// Server mode: where this pane's hooks look for its owner's quota flag
// (user-quota.js) — a refused prompt is decided by the hook, locally.
Object.assign(env, require("./user-quota").flagEnv(laneOwner, member));
// `after-start` tools take no prompt on their command line: paste it once
// the CLI has had `delayMs` to draw its input box. ponytail: fixed delay,
+467
View File
@@ -0,0 +1,467 @@
/**
* @file user-quota.js
* @description Spend quotas per user (server mode only; local mode has one
* user and never reads any of this). An admin sets a DEFAULT quota (row
* `user_id = 0` of `user_quotas`) and may override it per user — a limit of
* their own, or none at all (`limit_usd` NULL). A quota is `limit_usd` per
* `period`: `day` (from the local midnight of `tz_offset`, the admin's
* browser's getTimezoneOffset() when they saved it) or `5h` (a sliding
* window: the current UTC hour and the four before it — spend booked in hour
* H counts until H + 5 h). Spend is the users page's own booking
* (`hourlyTokenRows` over the sessions a user OWNS through a workspace,
* priced by `calculateCost`), never a second formula.
*
* A user over their quota cannot chat on until it resets:
* - `assertSpawnAllowed` refuses every new pane (`EQUOTA`, 429) — start,
* resume and a prompt from the workspace home all pass `spawnRun`;
* - every pane carries `AGENTHUB_QUOTA_FILE` (`flagEnv`), and `sweep` writes
* that file `{blocked, until, message}` — the host's under the data dir,
* a member's (S3) in their handoff dir through `write-as` — so the hook
* handler refuses a `UserPromptSubmit` of Claude Code or Codex locally,
* without asking the dashboard (scripts/hook-handler.js; fail-open);
* - a member's omp on the shared login loses its gateway key while blocked
* (`isRunBlocked`, omp-share.js `clientsFor`);
* - a pane no hook can stop (Antigravity, Cursor, a Codex without trusted
* hooks, an omp on its own login, any other catalog tool) is ended.
* Spend lands after a turn, so a user may end one turn past the limit.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const PERIODS = ["day", "5h"];
const DEFAULT_ID = 0;
const HOUR_MS = 3_600_000;
const WINDOW_HOURS = 5;
const db = () => require("../db").db;
const fail = (code, message) => Object.assign(new Error(message), { code });
const isServerMode = () => require("./auth").isServerMode();
const hourOf = (ms) => new Date(ms).toISOString().slice(0, 13);
const hourMs = (h) => Date.parse(`${h}:00:00.000Z`);
const round = (n) => Math.round(n * 10000) / 10000;
// ── settings ──────────────────────────────────────────────────────────────
function row(userId) {
return db()
.prepare("SELECT limit_usd, period, tz_offset FROM user_quotas WHERE user_id = ?")
.get(userId);
}
const asConfig = (r, source) =>
r && r.limit_usd != null
? { source, limit_usd: r.limit_usd, period: r.period, tz_offset: r.tz_offset }
: null;
/** The default quota, or null when there is none. */
function getDefault() {
return asConfig(row(DEFAULT_ID), "default");
}
/**
* A user's own setting: `default` (no row — the default applies),
* `unlimited`, or `custom` with its limit.
*/
function getOverride(userId) {
const r = row(userId);
if (!r) return { mode: "default" };
if (r.limit_usd == null) return { mode: "unlimited" };
return { mode: "custom", limit_usd: r.limit_usd, period: r.period, tz_offset: r.tz_offset };
}
/** The quota that binds `userId` now, or null (no limit). */
function effective(userId) {
const r = row(userId);
if (r) return asConfig(r, "user");
return getDefault();
}
/** `{limit_usd, period, tz_offset}` checked; throws EBADQUOTA. */
function validate(cfg) {
const limit = Number(cfg?.limit_usd);
if (!Number.isFinite(limit) || limit <= 0 || limit > 1_000_000) {
throw fail("EBADQUOTA", "limit_usd must be a positive number of dollars");
}
if (!PERIODS.includes(cfg.period)) {
throw fail("EBADQUOTA", `period must be one of: ${PERIODS.join(", ")}`);
}
const tz = cfg.tz_offset == null ? 0 : cfg.tz_offset;
if (!Number.isInteger(tz) || Math.abs(tz) > 14 * 60) {
throw fail("EBADQUOTA", "tz_offset must be minutes within ±840");
}
return { limit_usd: limit, period: cfg.period, tz_offset: tz };
}
function upsert(userId, limit, period, tz) {
db()
.prepare(
`INSERT INTO user_quotas (user_id, limit_usd, period, tz_offset, updated_at)
VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT(user_id) DO UPDATE SET limit_usd = excluded.limit_usd,
period = excluded.period, tz_offset = excluded.tz_offset,
updated_at = excluded.updated_at`
)
.run(userId, limit, period, tz);
}
/** Set the default quota (`null` removes it: no limit for anyone without their own). */
function setDefault(cfg) {
if (cfg == null) {
db().prepare("DELETE FROM user_quotas WHERE user_id = ?").run(DEFAULT_ID);
return null;
}
const v = validate(cfg);
upsert(DEFAULT_ID, v.limit_usd, v.period, v.tz_offset);
return getDefault();
}
/** A user's setting checked: `{mode}` or `{mode: "custom", limit_usd, period, tz_offset}`. */
function validateUserQuota(q) {
const mode = q?.mode;
if (mode === "default" || mode === "unlimited") return { mode };
if (mode === "custom") return { mode, ...validate(q) };
throw fail("EBADQUOTA", "quota.mode must be one of: default, unlimited, custom");
}
/** A user's setting: `{mode: "default" | "unlimited" | "custom", …}`. */
function setUserQuota(userId, q) {
if (!Number.isInteger(userId) || userId <= 0) throw fail("EBADQUOTA", "no such user");
const v = validateUserQuota(q);
if (v.mode === "default") {
db().prepare("DELETE FROM user_quotas WHERE user_id = ?").run(userId);
} else if (v.mode === "unlimited") {
upsert(userId, null, "day", 0);
} else {
upsert(userId, v.limit_usd, v.period, v.tz_offset);
}
return getOverride(userId);
}
/** `$5/day`, `$2.50/5h`, `no limit` — for the audit log. */
function describe(q) {
if (!q || q.mode === "unlimited") return "no limit";
if (q.mode === "default") return "the default";
return `$${q.limit_usd}/${q.period}`;
}
// ── spend ─────────────────────────────────────────────────────────────────
/**
* The window a quota counts over at `now`: `since` (a UTC hour,
* `YYYY-MM-DDTHH`, as hourly token rows are keyed) and, for a day, when the
* next one starts. Buckets are whole UTC hours, so a half-hour zone's day
* starts up to 30 min early.
*/
function windowOf(cfg, now) {
if (cfg.period === "5h") {
return { since: hourOf(now - (WINDOW_HOURS - 1) * HOUR_MS), nextReset: null };
}
const off = cfg.tz_offset || 0;
const local = new Date(now - off * 60_000);
local.setUTCHours(0, 0, 0, 0);
const start = local.getTime() + off * 60_000;
return { since: hourOf(start), nextReset: start + 24 * HOUR_MS };
}
/**
* When a 5 h quota opens again: the oldest hours leave the window first, so
* it is the end of the first hour whose leaving takes the spend under the limit.
*/
function slidingReset(byHour, spend, limit) {
let left = spend;
for (const [h, cost] of [...byHour].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
left -= cost;
if (left < limit) return hourMs(h) + WINDOW_HOURS * HOUR_MS;
}
return null;
}
/**
* Every user's quota and spend now: Map(user id → status). Only users with a
* quota are listed. One token read for all of them.
* @returns {Map<number, {limit_usd, period, tz_offset, source, spend, pct,
* exceeded: boolean, resetsAt: string|null, since: string}>}
*/
function statusAll({ now = Date.now(), userIds = null } = {}) {
const out = new Map();
if (!isServerMode()) return out;
const ids =
userIds ||
db()
.prepare("SELECT id FROM users WHERE disabled = 0")
.all()
.map((r) => r.id);
const quotas = [];
for (const id of ids) {
const cfg = effective(id);
if (cfg) quotas.push({ id, cfg, win: windowOf(cfg, now) });
}
if (!quotas.length) return out;
const { calculateCost, hourlyTokenRows } = require("../routes/pricing");
const { OWNED_SESSIONS } = require("../routes/users");
const rules = require("../db").stmts.listPricing.all();
const oldest = quotas.reduce((a, q) => (q.win.since < a ? q.win.since : a), quotas[0].win.since);
const owner = new Map();
for (const r of db().prepare(OWNED_SESSIONS).all()) {
if (!owner.has(r.session_id)) owner.set(r.session_id, r.uid);
}
// A session that spent in the window was touched in it.
const rows = hourlyTokenRows(
`WHERE COALESCE(NULLIF(s.updated_at, ''), s.started_at) >= ? AND s.id IN (SELECT session_id FROM (${OWNED_SESSIONS}))`,
[oldest]
);
const byUser = new Map();
for (const r of rows) {
const uid = owner.get(r.session_id);
if (uid == null) continue;
if (!byUser.has(uid)) byUser.set(uid, []);
byUser.get(uid).push(r);
}
for (const { id, cfg, win } of quotas) {
const mine = (byUser.get(id) || []).filter((r) => r.date >= win.since);
const spend = mine.length ? calculateCost(mine, rules).total_cost : 0;
const exceeded = spend >= cfg.limit_usd;
let resetsAt = win.nextReset;
if (cfg.period === "5h") {
const hours = new Map();
for (const r of mine) {
if (!hours.has(r.date)) hours.set(r.date, []);
hours.get(r.date).push(r);
}
const byHour = new Map(
[...hours].map(([h, list]) => [h, calculateCost(list, rules).total_cost])
);
resetsAt = exceeded ? slidingReset(byHour, spend, cfg.limit_usd) : null;
}
out.set(id, {
...cfg,
spend: round(spend),
pct: round((spend / cfg.limit_usd) * 100),
exceeded,
resetsAt: resetsAt ? new Date(resetsAt).toISOString() : null,
since: `${win.since}:00:00.000Z`,
});
}
return out;
}
/** One user's status, or null without a quota. */
function statusFor(userId, opts = {}) {
if (userId == null) return null;
return statusAll({ ...opts, userIds: [userId] }).get(userId) || null;
}
/** "HH:MM" (and the date when it is not today) in the quota's own zone. */
function localTime(iso, tzOffset, now = Date.now()) {
if (!iso) return null;
const off = (tzOffset || 0) * 60_000;
const at = new Date(Date.parse(iso) - off).toISOString();
const today = new Date(now - off).toISOString().slice(0, 10);
const utc = -(tzOffset || 0) / 60;
const zone = `UTC${utc >= 0 ? "+" : ""}${utc}`;
return `${at.slice(0, 10) === today ? "" : `${at.slice(0, 10)} `}${at.slice(11, 16)} ${zone}`;
}
/** The text a refused prompt or start shows. */
function message(s, now = Date.now()) {
const per = s.period === "5h" ? "5 hours" : "day";
const when = localTime(s.resetsAt, s.tz_offset, now);
return (
`AgentHub: your spend quota is used up — $${s.spend.toFixed(2)} of $${s.limit_usd.toFixed(2)} per ${per}.` +
(when ? ` It opens again at ${when}.` : "") +
" Ask an admin to raise it if you need more."
);
}
/** Throws EQUOTA when the workspace owner is over their quota. No-op in local mode. */
function assertSpawnAllowed(ownerId) {
if (!isServerMode() || ownerId == null) return;
const s = statusFor(ownerId);
if (s?.exceeded) throw Object.assign(fail("EQUOTA", message(s)), { quota: s });
}
// ── the flag file the hook handler reads ─────────────────────────────────
const FLAG = "quota.json";
const hostDir = () => path.join(require("./claude-home").getDataDir(), "user-quota");
const hostFlag = (userId) => path.join(hostDir(), `u${userId}.json`);
/**
* The env a pane of `ownerId` gets: where its hooks look for the flag —
* the member's handoff dir (S3: the pane runs as them) or the host's data dir.
*/
function flagEnv(ownerId, member) {
if (!isServerMode() || ownerId == null) return {};
const file = member ? path.join(require("./run-as").handoffDir(member), FLAG) : hostFlag(ownerId);
return { AGENTHUB_QUOTA_FILE: file };
}
function writeHostFlag(userId, text) {
fs.mkdirSync(hostDir(), { recursive: true, mode: 0o700 });
const file = hostFlag(userId);
const tmp = `${file}.${process.pid}.tmp`;
fs.writeFileSync(tmp, text, { mode: 0o600 });
fs.renameSync(tmp, file);
}
/** Write a user's flag everywhere their panes may read it. Best effort per place. */
function writeFlags(user, s, now) {
const body = s?.exceeded
? { blocked: true, until: s.resetsAt, message: message(s, now) }
: { blocked: false };
const text = `${JSON.stringify(body)}\n`;
try {
writeHostFlag(user.id, text);
} catch (err) {
console.warn(`user quota: flag for ${user.username} not written:`, err.message);
}
let acct = null;
try {
acct = require("./run-as").accountForUser(user.username);
} catch {
acct = null;
}
if (!acct) return;
try {
require("./run-as").handoffWrite(acct, FLAG, text);
} catch (err) {
console.warn(`user quota: ${user.username}'s flag not written:`, err.message);
}
}
// ── the sweep ────────────────────────────────────────────────────────────
/** Users over their quota now (by the last sweep). */
let blocked = new Set();
/** user id → the flag text last written, so an unchanged state writes nothing. */
const written = new Map();
function laneOwner(laneId) {
return db().prepare("SELECT owner_id FROM lanes WHERE id = ?").get(laneId)?.owner_id ?? null;
}
/** Is run `runId`'s workspace owner over quota (by the last sweep)? omp-share asks. */
function isRunBlocked(runId) {
if (!blocked.size) return false;
const laneId = require("./pty-run").laneIdFromRunId(runId);
return laneId != null && blocked.has(laneOwner(laneId));
}
/**
* Can a hook refuse this pane's next prompt? Claude Code always (the plugin's
* hooks, a member's managed-settings hooks); Codex only on the host's own
* CODEX_HOME with our UserPromptSubmit hook installed and trusted (members
* get no Codex hooks); a member's omp on the shared login through its
* gateway key. Everything else must be ended.
*/
function hookCanStop(run, member) {
if (run.tool === "claude" || run.tool === "shell") return true;
if (run.tool === "codex" && !member) {
try {
const ev = require("./codex-hooks").status().events?.UserPromptSubmit;
return !!(ev?.installed && ev?.trusted);
} catch {
return false;
}
}
if (run.tool === "omp" && member) {
try {
const share = require("./omp-share");
return share.available() && share.enabled();
} catch {
return false;
}
}
return false;
}
/** End the live panes of `userIds` that no hook can stop. Returns the run ids ended. */
function endUnstoppable(userIds) {
if (!userIds.size) return [];
const runs = require("./pty-run");
const runAs = require("./run-as");
const ended = [];
for (const run of runs.listRuns()) {
if (run.status !== "running" || !userIds.has(laneOwner(run.laneId))) continue;
if (hookCanStop(run, runAs.accountForLane(run.laneId))) continue;
try {
if (runs.killRun(run.id)) ended.push(run.id);
} catch (err) {
console.warn(`user quota: ${run.id} not ended:`, err.message);
}
}
return ended;
}
/**
* Recompute every user's state, rewrite the flags that changed, end the
* panes no hook can stop and re-list the omp gateway's keys. Called every
* minute by the server and right after an admin changes a quota.
*/
function sweep({ now = Date.now() } = {}) {
if (!isServerMode()) return { blocked: [] };
const users = db().prepare("SELECT id, username FROM users").all();
const status = statusAll({ now });
const next = new Set();
for (const u of users) {
const s = status.get(u.id) || null;
if (s?.exceeded) next.add(u.id);
const text = s?.exceeded ? `${s.resetsAt}|${s.limit_usd}` : "open";
// A user never blocked has no flag to clear: nothing is written for them.
if (written.get(u.id) === text || (!written.has(u.id) && !s?.exceeded && !hadFlag(u.id))) {
continue;
}
writeFlags(u, s, now);
written.set(u.id, text);
}
const changed = next.size !== blocked.size || [...next].some((id) => !blocked.has(id));
blocked = next;
const ended = endUnstoppable(next);
if (changed) {
try {
require("./omp-share").syncClients();
} catch {
/* sharing off or not available */
}
}
return { blocked: [...next], ended };
}
/** A flag left by an earlier server (it may still say blocked). */
function hadFlag(userId) {
try {
return fs.existsSync(hostFlag(userId));
} catch {
return false;
}
}
/** Test seam: forget what the sweep remembered. */
function __reset() {
blocked = new Set();
written.clear();
}
module.exports = {
PERIODS,
getDefault,
getOverride,
effective,
setDefault,
setUserQuota,
validateUserQuota,
describe,
windowOf,
slidingReset,
statusAll,
statusFor,
message,
assertSpawnAllowed,
flagEnv,
isRunBlocked,
hookCanStop,
sweep,
__reset,
};
+34
View File
@@ -228,6 +228,39 @@ async function listBranches(sourceRepo) {
return { branches, current };
}
/**
* A repo's remote-tracking branches that have NO local branch of the same
* name yet — what a fetch brought in that `listBranches` cannot show.
* `[{name: "origin/feature", remote: "origin", branch: "feature"}]`, the
* remotes' symbolic `HEAD`s left out. The remote's name is matched against
* `git remote` (longest first), so a remote whose name holds a `/` splits right.
*/
async function listRemoteBranches(dir) {
const remotes = (await git(dir, ["remote"])).stdout
.split("\n")
.map((r) => r.trim())
.filter(Boolean)
.sort((a, b) => b.length - a.length);
if (!remotes.length) return [];
const refs = (
await git(dir, ["for-each-ref", "--format=%(refname:short)%00%(symref)", "refs/remotes"])
).stdout
.split("\n")
.filter(Boolean)
.map((l) => l.split("\0"));
const local = new Set((await listBranches(dir)).branches);
const out = [];
for (const [name, symref] of refs) {
if (symref) continue; // origin/HEAD → origin/main
const remote = remotes.find((r) => name.startsWith(`${r}/`));
if (!remote) continue;
const branch = name.slice(remote.length + 1);
if (!branch || branch === "HEAD" || local.has(branch)) continue;
out.push({ name, remote, branch });
}
return out;
}
/**
* Resolve the base branch: try origin/<wanted>, then <wanted>, then HEAD.
*/
@@ -978,6 +1011,7 @@ module.exports = {
isGitRepo,
isValidBranchName,
listBranches,
listRemoteBranches,
resolveBase,
slugify,
listWorktrees,
+119 -2
View File
@@ -19,7 +19,8 @@
* (Auth) — Settings → Account: the account's own profile, password and logins
* - GET/PUT/DELETE /api/me/avatar (Auth) — the account's own picture
* - POST /api/me/avatar/url (Auth) — the picture as a link, redirected to
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form (its `quota` too)
* - PUT /api/users/quota-default, GET /api/me/quota (Auth) — spend quotas per user
* - GET /api/users/{id}/avatar (Auth) — one account's picture (admin/analyst)
* - GET /api/users/tmux, POST /api/users/{id}/tmux-restart, POST /api/users/tmux-restart-idle
* (Auth) — each member's own tmux server and an admin's restart of it (S3)
@@ -37,6 +38,48 @@ const tags = [
];
const schemas = {
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: ["userId", "username", "account"],
@@ -214,6 +257,32 @@ const paths = {
},
},
},
"/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: err("EAUTH"),
},
},
},
"/api/me/instructions": {
get: {
tags: ["Auth"],
@@ -504,11 +573,20 @@ const paths = {
type: "object",
properties: { cost: { 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,
},
},
},
},
@@ -712,10 +790,11 @@ const paths = {
addWorkspaces: { type: "array", items: { type: "integer" } },
removeWorkspaces: { type: "array", items: { type: "integer" } },
signOut: { type: "boolean", enum: [true] },
quota: { $ref: "#/components/schemas/UserQuotaSetting" },
}),
responses: {
...changeResponses,
400: err("EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL"),
400: err("EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL / EBADQUOTA"),
409: err("ESELF / ENOTOWNED / EOWNERFOLDER"),
},
},
@@ -734,6 +813,44 @@ const paths = {
},
},
},
"/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: changeBody(
{
quota: {
allOf: [{ $ref: "#/components/schemas/UserQuota" }],
nullable: true,
},
},
["quota"]
),
responses: {
...changeResponses,
200: {
description: "Saved",
content: {
"application/json": {
schema: {
type: "object",
properties: {
user: { $ref: "#/components/schemas/AuthUser" },
quotaDefault: {
allOf: [{ $ref: "#/components/schemas/UserQuota" }],
nullable: true,
},
},
},
},
},
},
400: err("EBADQUOTA"),
},
},
},
"/api/users/{id}/password": {
post: {
tags: ["Auth"],
+3 -3
View File
@@ -854,13 +854,13 @@ const paths = {
"/api/lanes/{id}/git/branches": {
get: {
tags: ["Lanes"],
summary: "The lane repo's own local branches",
summary: "The lane repo's own local branches, and the remote ones not checked out yet",
description:
"Feeds the lane's branch switcher. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard.",
"Feeds the lane's branch switcher. `branches` are the local branches; `remotes` the fetched remote-tracking branches with no local branch of that name yet (`origin/feature`, the remotes' HEAD left out) — `switch` to one creates its local tracking branch. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard.",
operationId: "listLaneOwnBranches",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
200: { description: "{available, branches, current, worktrees}." },
200: { description: "{available, branches, remotes, current, worktrees}." },
404: { description: "Lane not found." },
},
},
+3
View File
@@ -411,6 +411,9 @@ const paths = {
409: errorJson(
"Conflict with live state: ERUNLIVE (the pane is busy), ENOTSHARED, EMEMBERGONE, EOWNERFOLDER or ECWDACCOUNT."
),
429: errorJson(
"EQUOTA (server mode): the workspace owner's spend quota is used up until it resets."
),
503: errorJson("tmux is not installed (ETMUXMISSING)."),
500: errorJson("Unexpected failure (EINTERNAL)."),
},
+12 -4
View File
@@ -48,6 +48,7 @@ const {
isGitRepo,
isValidBranchName,
listBranches,
listRemoteBranches,
listWorktrees,
resetWorktree,
resolveBase,
@@ -1913,7 +1914,9 @@ router.get("/:id/git/tree", async (req, res) => {
});
/**
* The lane's own local branches, for the branch switcher. Separate from
* The lane's own local branches, for the branch switcher, plus `remotes`: the
* fetched remote-tracking branches with no local branch yet (`origin/feature`
* — switching to one creates its tracking branch). Separate from
* `GET /branches` (which takes a candidate repo PATH from the query, for a
* lane that does not exist yet); this one resolves the repo from the lane row,
* so no path ever comes from the request.
@@ -1925,9 +1928,10 @@ router.get("/:id/git/branches", async (req, res) => {
try {
const { branches, current } = await listBranches(cwd);
const worktrees = await listWorktrees(cwd).catch(() => []);
res.json({ available: true, branches, current, worktrees });
const remotes = (await listRemoteBranches(cwd).catch(() => [])).map((r) => r.name);
res.json({ available: true, branches, remotes, current, worktrees });
} catch {
res.json({ available: false, branches: [], current: null, worktrees: [] });
res.json({ available: false, branches: [], remotes: [], current: null, worktrees: [] });
}
});
@@ -1987,6 +1991,7 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
"EBADMESSAGE",
"EBADBRANCH",
"ENOBRANCH",
"EAMBIGUOUS",
"EBRANCHEXISTS",
"EBRANCHBUSY",
"EDIRTY",
@@ -3041,11 +3046,14 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
// A refused field (EBADMODEL, EBADEFFORT, …) or a thing the lane's tool
// cannot do (EUNSUPPORTED: a fork on a non-Claude lane) is the request's fault.
// ENOTSHARED (S3): the tool is not under /opt/agenthub/tools yet — a setup state.
// EQUOTA (server mode): the workspace owner is over their spend quota.
const status = /^(EBAD|EUNSUPPORTED$)/.test(err.code || "")
? 400
: ["ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code)
? 409
: 500;
: err.code === "EQUOTA"
? 429
: 500;
return res.status(status).json({ error: { code: err.code, message: err.message } });
}
+12
View File
@@ -36,6 +36,8 @@
* own choice; PUT `{tool, account}` chooses one (`null` = automatic) — refused
* while an admin pins it (`ELOCKED`) or for an account it may not use
* (`ENOACCOUNT`). An account write like the profile's: cookie only, audited.
* `/quota` is the account's own spend quota now (server/lib/user-quota.js —
* `null` without one, and in local mode), for the status bar.
*
* S3 (one Linux user per account): an account's dark/light choice also becomes
* the theme in their own Claude Code settings, written as them.
@@ -170,6 +172,16 @@ router.put("/instructions", sameOriginGuard, (req, res) => {
res.json(instructionsView(key));
});
// The account's own spend quota now (server/lib/user-quota.js): `{quota}` —
// limit, period, spend, whether it is used up and when it opens again — or
// `{quota: null}` with no limit, and always in local mode.
router.get("/quota", (req, res) => {
const key = userKey(req);
if (!key) return unauth(res);
const id = auth.isServerMode() ? (req.user || auth.userFromRequest(req))?.id : null;
res.json({ quota: id == null ? null : require("../lib/user-quota").statusFor(id) });
});
router.post("/onboarded", sameOriginGuard, (req, res) => {
const key = userKey(req);
if (!key) return unauth(res);
+4
View File
@@ -672,6 +672,10 @@ router.post("/", (req, res) => {
) {
return res.status(409).json({ error: { code: err.code, message: err.message } });
}
// Server mode: the workspace owner is over their spend quota (user-quota.js).
if (err.code === "EQUOTA") {
return res.status(429).json({ error: { code: err.code, message: err.message } });
}
// Nor is a host missing tmux: the request was fine, the dependency is not
// installed. 400 would tell the user to fix their input.
if (err.code === "ETMUXMISSING") {
+57 -2
View File
@@ -37,6 +37,12 @@
* non-empty and makes that account pick its own at its next login
* (`must_change_password`); an admin's own new password must pass every
* rule, like Settings → Account's (`validateOwnPassword`, EWEAKPASSWORD).
*
* Spend quotas (server/lib/user-quota.js): `GET /` also gives each account
* its own setting (`quotaSetting`) and the quota binding it now with its spend
* (`quota`), plus the `quotaDefault`; `PUT /quota-default` sets or clears the
* default and `PATCH /:id` `{quota}` a user's own (`default`, `unlimited` or
* `custom`) — account changes like the rest, and each re-sweeps at once.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -130,6 +136,8 @@ router.get("/", (req, res) => {
rules
);
const none = { cost: 0, tokens: 0 };
const quota = require("../lib/user-quota");
const quotas = quota.statusAll();
const users = listUsers().map((u) => ({
...u,
...(logins.get(u.id) || { logins: 0, lastLoginAt: null }),
@@ -138,8 +146,12 @@ router.get("/", (req, res) => {
today: today.get(u.id) || none,
last7d: last7d.get(u.id) || none,
last30d: last30d.get(u.id) || none,
// Spend quota (user-quota.js): this user's own setting, and the quota that
// binds them now with its spend (null: no limit).
quotaSetting: quota.getOverride(u.id),
quota: quotas.get(u.id) || null,
}));
res.json({ users });
res.json({ users, quotaDefault: quota.getDefault() });
});
router.get("/storage", (req, res) => {
@@ -164,6 +176,7 @@ const STATUS = {
EWEAKPASSWORD: 400,
EBADROLE: 400,
EBADFIELD: 400,
EBADQUOTA: 400,
EREAUTH: 403,
ELOCALMODE: 403,
ENOUSER: 404,
@@ -196,7 +209,9 @@ function accountChange(handler) {
await auth.confirmPassword(req.user, body.currentPassword);
const out = await handler(req, body);
recordAccountChange(req.user.id, out.user.id, req.method, out.action);
res.status(out.status || 200).json({ user: out.user });
res
.status(out.status || 200)
.json({ user: out.user, ...("quotaDefault" in out && { quotaDefault: out.quotaDefault }) });
} catch (err) {
const status = STATUS[err.code];
if (!status) return res.status(500).json({ error: { message: err.message } });
@@ -507,6 +522,37 @@ router.post(
})
);
/**
* The default spend quota every user without one of their own gets
* (server/lib/user-quota.js): `{quota: {limit_usd, period, tz_offset}}`, or
* `{quota: null}` for none. The admin's password again, audited.
*/
router.put(
"/quota-default",
accountChange(async (req, body) => {
const quota = require("../lib/user-quota");
if (body.quota === undefined) {
throw Object.assign(new Error("quota: an object or null"), { code: "EBADQUOTA" });
}
const saved = quota.setDefault(body.quota);
quotaSweep();
return {
user: auth.publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(req.user.id)),
action: `default spend quota: ${saved ? `$${saved.limit_usd}/${saved.period}` : "none"}`,
quotaDefault: saved,
};
})
);
/** Apply a quota change now rather than at the next minute's sweep. */
function quotaSweep() {
try {
require("../lib/user-quota").sweep();
} catch (err) {
console.warn("user quota sweep failed:", err.message);
}
}
router.patch(
"/:id",
accountChange(async (req, body) => {
@@ -542,6 +588,8 @@ router.patch(
if (body.signOut !== undefined && body.signOut !== true) {
throw Object.assign(new Error("signOut: true"), { code: "EBADFIELD" });
}
const quotaLib = require("../lib/user-quota");
const quota = body.quota === undefined ? null : quotaLib.validateUserQuota(body.quota);
const add = laneIds(body.addWorkspaces, "addWorkspaces");
const remove = laneIds(body.removeWorkspaces, "removeWorkspaces");
const runAs = require("../lib/run-as");
@@ -584,6 +632,11 @@ router.patch(
auth.endSessions(u.id, keepToken);
done.push("signed out everywhere");
}
if (quota) {
quotaLib.setUserQuota(u.id, quota);
done.push(`spend quota ${quotaLib.describe(quota)}`);
quotaSweep();
}
if (disabledChange) {
auth.setDisabled(u.username, body.disabled);
done.push(body.disabled ? "disable" : "enable");
@@ -710,3 +763,5 @@ router.post(
);
module.exports = router;
// Which account each session belongs to, for the spend quotas (server/lib/user-quota.js).
module.exports.OWNED_SESSIONS = OWNED_SESSIONS;