Files
AgentHub/server/lib/cursor-pool.js
T
nntrivi2001 685eb748e8 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.
2026-10-22 17:02:08 +07:00

157 lines
5.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file cursor-pool.js
* @description The Cursor CLI account POOL, modelled on codex-pool.js's API-key
* half: Cursor API keys an admin added (`agenthub accounts add <name> --tool
* cursor`, or the Users page), and the balancer that gives every new
* `cursor-agent` pane one of them. The key reaches the pane only through tmux
* `-e CURSOR_API_KEY` (a member's launch file under S3) — `cursor-agent` reads
* it from there and it outranks the account's own `cursor-agent login` for
* that process only; nothing is written into anyone's home. An empty pool
* changes nothing: panes run on their home's own login as before.
*
* Records live in `<CLAUDE_HOME>/agenthub-cursor-accounts/<name>.json` (dir
* 0700, file 0600); no route returns a key.
*
* `pick` ranks by fewest live Cursor panes, then name — Cursor sessions carry
* no token counts here (cursor-usage.js) and the CLI exposes no quota, so
* there is nothing else to rank by. A user an admin pinned to one account
* (account-pins.js) skips the ranking.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { getClaudeHome } = require("./claude-home");
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
// A Cursor API key is one opaque token; never whitespace, never a newline
// (it travels as an env value through tmux and a member's launch file).
const KEY_RE = /^[A-Za-z0-9_.-]{20,500}$/;
const KEY_ENV = "CURSOR_API_KEY";
const err = (code, message) => Object.assign(new Error(message), { code });
const poolDir = () => path.join(getClaudeHome(), "agenthub-cursor-accounts");
const fileOf = (name) => path.join(poolDir(), `${name}.json`);
function validName(name) {
const n = String(name || "").trim();
if (!NAME_RE.test(n))
throw err("EBADNAME", "account name: 1–64 letters, digits, '.', '_' or '-'");
return n;
}
function readAccount(name) {
try {
const a = JSON.parse(fs.readFileSync(fileOf(name), "utf8"));
if (a.kind === "apikey" && typeof a.key === "string") return a;
} catch {
/* absent or not ours */
}
return null;
}
function allAccounts() {
let names = [];
try {
names = fs.readdirSync(poolDir()).filter((f) => f.endsWith(".json"));
} catch {
return [];
}
return names
.map((f) => f.slice(0, -5))
.filter((n) => NAME_RE.test(n))
.map(readAccount)
.filter(Boolean)
.sort((a, b) => a.name.localeCompare(b.name));
}
/** Add a Cursor API key (Cursor dashboard → Integrations → API keys). */
function addAccount(name, { apiKey } = {}) {
const n = validName(name);
const key = String(apiKey || "").trim();
if (!KEY_RE.test(key)) {
throw err("EBADTOKEN", "not a Cursor API key — create one in the Cursor dashboard");
}
if (readAccount(n)) throw err("EEXISTS", `account ${n} is already in the pool`);
const record = { name: n, kind: "apikey", key, addedAt: new Date().toISOString() };
fs.mkdirSync(poolDir(), { recursive: true, mode: 0o700 });
fs.chmodSync(poolDir(), 0o700);
fs.writeFileSync(fileOf(n), JSON.stringify(record, null, 2), { mode: 0o600, flag: "wx" });
return { name: n, kind: record.kind, addedAt: record.addedAt };
}
function removeAccount(name) {
const n = validName(name);
if (!readAccount(n)) throw err("ENOACCOUNT", `no account ${n} in the pool`);
fs.rmSync(fileOf(n));
require("./account-pins").dropAccount("cursor", n);
}
function livePanes(account) {
const { db } = require("../db");
return db
.prepare(
`SELECT COUNT(*) AS n FROM dashboard_runs
WHERE tool = 'cursor' AND account = ? AND status IN ('running', 'spawning')`
)
.get(account).n;
}
/** Every account with what the balancer sees — never a key. */
function poolStatus() {
return allAccounts().map((a) => ({
name: a.name,
kind: a.kind,
addedAt: a.addedAt,
livePanes: livePanes(a.name),
...require("./pool-usage").accountUsage("cursor", a.name, { wholeSession: true }),
quota: null,
quotaPercent: null,
full: false,
}));
}
/** The record a new Cursor pane should run on, or null (empty pool). */
function pick(ownerId = null) {
// The owner's group's accounts only (pool-groups.js).
const allowed = require("./pool-groups").allowed(ownerId, "cursor");
const usable = allAccounts().filter((a) => allowed(a.name));
if (usable.length === 0) return null;
const pin = require("./account-pins").pinFor(ownerId, "cursor");
const pinned = pin ? usable.find((a) => a.name === pin) : null;
if (pinned) return pinned;
return usable
.map((a) => ({ a, n: livePanes(a.name) }))
.sort((x, y) => x.n - y.n || x.a.name.localeCompare(y.a.name))[0].a;
}
/** `{name, kind}` of the account a new Cursor pane would get, or null. */
function pickAccount(ownerId = null) {
const a = pick(ownerId);
return a ? { name: a.name, kind: a.kind } : null;
}
/**
* What a new Cursor pane needs from the pool, or null: `{name, env}`. `env`
* goes through tmux `-e` (a member's launch file), never argv.
*/
function forSpawn(ownerId = null) {
const a = pick(ownerId);
return a ? { name: a.name, env: { [KEY_ENV]: a.key } } : null;
}
/** Is `name` an account in the pool? */
const hasAccount = (name) => NAME_RE.test(String(name)) && Boolean(readAccount(String(name)));
module.exports = {
hasAccount,
addAccount,
removeAccount,
poolStatus,
pickAccount,
forSpawn,
poolDir,
__read: readAccount,
};