/** * @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 --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 `/agenthub-cursor-accounts/.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ĩ */ 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, };