feat: account pool per tool with groups and a Cursor pool; update runs in the background

- The update dialog can be closed while an update applies: it keeps running,
  the page still reloads itself when the new server is up, and a failure
  re-opens the dialog with its error.
- Account pool card: one tab per AI tool (Claude Code, Codex, Cursor CLI,
  Oh My Pi, and every other installed tool, which says it has no pool),
  accounts as cards with 5-hour and weekly quota shown as what is LEFT plus
  their reset times, and the saved profiles of Settings → AI tools →
  Profiles as cards with "Share to pool".
- Cursor account pool (server/lib/cursor-pool.js): Cursor API keys handed to
  a cursor-agent pane only through tmux -e CURSOR_API_KEY;
  `agenthub accounts add <name> --tool cursor`.
- Pool groups (server/lib/pool-groups.js, /api/accounts/groups): a group's
  members run only on its accounts of each tool, a user in no group on the
  accounts no group lists, a pin still wins. account_pins loses its tool
  CHECK (rebuilt, pins kept); three new tables.
- Oh My Pi login sharing can be switched from its pool tab too.
This commit is contained in:
2026-10-22 17:02:08 +07:00
parent 44426faf16
commit 685eb748e8
30 changed files with 2891 additions and 299 deletions
+55 -2
View File
@@ -660,6 +660,13 @@ export type PoolAccount = {
spend7d: number;
tokens5h: number;
tokens7d: number;
/** The last usage reading of the account's own token, or null before one. */
quota?: {
fiveHour: CcUsageWindow | null;
sevenDay: CcUsageWindow | null;
checkedAt: string;
error: string | null;
} | null;
quotaPercent: number | null;
full: boolean;
};
@@ -673,6 +680,11 @@ export type CodexPoolAccount = {
spend7d: number;
tokens5h: number;
tokens7d: number;
/** The newest `rate_limits` its sessions' rollouts carry (`5h`, `wk`, …), or null. */
quota?: {
windows: { label: string; usedPercent: number; resetsAt: string | null }[];
asOf: string;
} | null;
quotaPercent: number | null;
full: boolean;
};
@@ -683,14 +695,37 @@ export type AccountPin = {
account: string;
pinnedAt?: string;
};
/** One Cursor pool account — a Cursor API key, never the key itself; no quota. */
export type CursorPoolAccount = Omit<CodexPoolAccount, "kind"> & { kind: "apikey" };
export type AccountPool = {
accounts: PoolAccount[];
codex: CodexPoolAccount[];
/** Absent from a server older than the Cursor pool. */
cursor?: CursorPoolAccount[];
/** Per tool; absent from an older server, empty in local mode. */
pins?: { claude: AccountPin[]; codex: AccountPin[] };
pins?: Partial<Record<PoolTool, AccountPin[]>>;
/** Server mode's pool groups; absent from an older server, empty in local mode. */
groups?: PoolGroup[];
blockPercent: number;
};
export type PoolTool = "claude" | "codex";
/**
* A pool group: its members (one group per user) run only on its accounts;
* a user in no group runs on the accounts no group lists (the shared pool).
*/
export type PoolGroup = {
id: number;
name: string;
createdAt?: string;
users: { userId: number; username: string }[];
accounts: Partial<Record<PoolTool, string[]>>;
};
/** What `PATCH /api/accounts/groups/:id` takes — every part optional. */
export type PoolGroupPatch = {
name?: string;
userIds?: number[];
accounts?: Partial<Record<PoolTool, string[]>>;
};
export type PoolTool = "claude" | "codex" | "cursor";
/** A saved switch-user profile that can be moved into the pool (no token). */
export type PoolProfile = {
name: string;
@@ -1182,6 +1217,24 @@ export const api = {
method: "DELETE",
body: JSON.stringify({ tool, currentPassword }),
}),
/** POST /api/accounts/groups — admin: a new, empty pool group. */
createGroup: (name: string, currentPassword: string) =>
request<{ group: { id: number; name: string } }>("/accounts/groups", {
method: "POST",
body: JSON.stringify({ name, currentPassword }),
}),
/** PATCH /api/accounts/groups/:id — admin: each part given is set exactly. */
updateGroup: (id: number, body: PoolGroupPatch, currentPassword: string) =>
request<{ group: PoolGroup }>(`/accounts/groups/${id}`, {
method: "PATCH",
body: JSON.stringify({ ...body, currentPassword }),
}),
/** DELETE /api/accounts/groups/:id — its members go back to the shared pool. */
deleteGroup: (id: number, currentPassword: string) =>
request<{ ok: true }>(`/accounts/groups/${id}`, {
method: "DELETE",
body: JSON.stringify({ currentPassword }),
}),
},
// ───────────────────────── Updates / self-update API ─────────────────────────
/** Self-update status: whether this install is a git clone and, if so,