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:
+99
-19
@@ -3,8 +3,9 @@
|
||||
* @description `GET /api/accounts` — the Claude account pool as the balancer
|
||||
* sees it (server/lib/claude-pool.js): per account its live panes, $ spent in
|
||||
* the last 5 h / 7 d, the last quota reading and whether it counts as full;
|
||||
* `codex` is the same for the Codex pool (server/lib/codex-pool.js). Never a
|
||||
* key or a token. `?force=1` re-reads every quota first.
|
||||
* `codex` is the same for the Codex pool (server/lib/codex-pool.js), and
|
||||
* `cursor` for the Cursor pool (server/lib/cursor-pool.js: API keys only, no
|
||||
* quota). Never a key or a token. `?force=1` re-reads every quota first.
|
||||
*
|
||||
* Accounts are added from the host's shell (`agenthub accounts …`) or, in
|
||||
* server mode, by an admin on the Users page: `POST /` (`{tool, name, …}`) and
|
||||
@@ -15,13 +16,17 @@
|
||||
* live login and the host's home becomes one more placement the server
|
||||
* refreshes); `credentials` — the text of a `.credentials.json` (or a macOS
|
||||
* Keychain item's JSON); or `token` — a `claude setup-token` token. A Codex
|
||||
* one is an OpenAI API key or the text of an `auth.json` (`token`).
|
||||
* one is an OpenAI API key or the text of an `auth.json` (`token`); a Cursor
|
||||
* one is a Cursor API key (`token`).
|
||||
* `PUT /pins` (`{tool, userId, account}`, `account: null` unpins) pins one
|
||||
* user's sessions of a tool to one pool account (account-pins.js) — a Claude
|
||||
* login goes into their home at once, so running panes move without a
|
||||
* restart (`switched`); `PUT /:name/users` (`{tool, userIds}`) is the
|
||||
* Users page's Edit of one account — exactly those users run on it; `GET /`
|
||||
* lists the pins under `pins`.
|
||||
* lists the pins under `pins`. Pool GROUPS (pool-groups.js) split the pool by
|
||||
* team: `POST /groups` (`{name}`), `PATCH /groups/:id` (`{name?, userIds?,
|
||||
* accounts?: {tool: [names]}}`), `DELETE /groups/:id`; `GET /` lists them
|
||||
* under `groups`.
|
||||
* A Codex account may also be a saved Codex `profile` (tool-profiles.js), moved
|
||||
* in the same way. `GET /profiles[?tool=codex]` lists the saved profiles that
|
||||
* can be moved in (identity only). Like every Users-page change
|
||||
@@ -34,11 +39,13 @@
|
||||
const { Router } = require("express");
|
||||
const pool = require("../lib/claude-pool");
|
||||
const codexPool = require("../lib/codex-pool");
|
||||
const cursorPool = require("../lib/cursor-pool");
|
||||
const auth = require("../lib/auth");
|
||||
const { recordAccountChange } = require("../lib/audit");
|
||||
const profiles = require("../lib/cc-profiles");
|
||||
const toolProfiles = require("../lib/tool-profiles");
|
||||
const pins = require("../lib/account-pins");
|
||||
const groups = require("../lib/pool-groups");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -49,9 +56,12 @@ router.get("/", async (req, res) => {
|
||||
res.json({
|
||||
accounts: pool.poolStatus(),
|
||||
codex: codexPool.poolStatus(),
|
||||
cursor: cursorPool.poolStatus(),
|
||||
pins: auth.isServerMode()
|
||||
? { claude: pins.listPins("claude"), codex: pins.listPins("codex") }
|
||||
: { claude: [], codex: [] },
|
||||
? Object.fromEntries(pins.TOOLS.map((t) => [t, pins.listPins(t)]))
|
||||
: Object.fromEntries(pins.TOOLS.map((t) => [t, []])),
|
||||
// Server mode's pool groups (pool-groups.js); none in local mode.
|
||||
groups: auth.isServerMode() ? groups.listGroups() : [],
|
||||
blockPercent: pool.BLOCK_PERCENT,
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -98,6 +108,7 @@ const STATUS = {
|
||||
EBADTOKEN: 400,
|
||||
ENOACCOUNT: 404,
|
||||
ENOUSER: 404,
|
||||
ENOGROUP: 404,
|
||||
EEXISTS: 409,
|
||||
ETOOMANY: 429,
|
||||
ESTALE: 409, // a pasted login whose refresh token was already spent
|
||||
@@ -105,7 +116,7 @@ const STATUS = {
|
||||
};
|
||||
|
||||
/** One pool change: server mode, the admin's password confirmed, audited. */
|
||||
function poolChange(handler) {
|
||||
function poolChange(handler, { anyTool = false } = {}) {
|
||||
return async (req, res) => {
|
||||
try {
|
||||
if (!auth.isServerMode() || !req.user) {
|
||||
@@ -114,8 +125,8 @@ function poolChange(handler) {
|
||||
});
|
||||
}
|
||||
const body = req.body && typeof req.body === "object" ? req.body : {};
|
||||
if (body.tool !== "claude" && body.tool !== "codex") {
|
||||
throw Object.assign(new Error("tool: claude or codex"), { code: "EBADTOOL" });
|
||||
if (!anyTool && !pins.TOOLS.includes(body.tool)) {
|
||||
throw Object.assign(new Error(`tool: ${pins.TOOLS.join(", ")}`), { code: "EBADTOOL" });
|
||||
}
|
||||
await auth.confirmPassword(req.user, body.currentPassword);
|
||||
const out = await handler(req, body);
|
||||
@@ -230,15 +241,17 @@ router.post(
|
||||
"/",
|
||||
poolChange(async (_req, body) => {
|
||||
const account =
|
||||
body.tool === "codex"
|
||||
? body.profile
|
||||
? fromCodexProfile(String(body.profile), body.name)
|
||||
: codexPool.addAccount(body.name, codexSecret(body.token))
|
||||
: body.profile
|
||||
? fromProfile(String(body.profile), body.name)
|
||||
: body.credentials
|
||||
? pool.addLoginAccount(body.name, await pastedLogin(body.credentials))
|
||||
: pool.addAccount(body.name, body.token);
|
||||
body.tool === "cursor"
|
||||
? cursorPool.addAccount(body.name, { apiKey: body.token })
|
||||
: body.tool === "codex"
|
||||
? body.profile
|
||||
? fromCodexProfile(String(body.profile), body.name)
|
||||
: codexPool.addAccount(body.name, codexSecret(body.token))
|
||||
: body.profile
|
||||
? fromProfile(String(body.profile), body.name)
|
||||
: body.credentials
|
||||
? pool.addLoginAccount(body.name, await pastedLogin(body.credentials))
|
||||
: pool.addAccount(body.name, body.token);
|
||||
return {
|
||||
status: 201,
|
||||
json: { account },
|
||||
@@ -247,6 +260,71 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
// ── Groups: each team's members run on the team's own accounts ─────────────
|
||||
|
||||
router.post(
|
||||
"/groups",
|
||||
poolChange(
|
||||
async (_req, body) => {
|
||||
const g = groups.createGroup(body.name);
|
||||
return { status: 201, json: { group: g }, action: `pool: create group ${g.name}` };
|
||||
},
|
||||
{ anyTool: true }
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* One group's edit, each part optional: `name`, `userIds` (exactly its members
|
||||
* afterwards — one group per user, so a user in another group moves here),
|
||||
* `accounts` (`{tool: [names]}`, exactly its accounts of each tool named).
|
||||
*/
|
||||
router.patch(
|
||||
"/groups/:id",
|
||||
poolChange(
|
||||
async (req, body) => {
|
||||
const id = Number(req.params.id);
|
||||
let g = groups.listGroups().find((x) => x.id === id);
|
||||
if (!g) throw Object.assign(new Error(`no group ${req.params.id}`), { code: "ENOGROUP" });
|
||||
const audits = [];
|
||||
if (body.name !== undefined && body.name !== g.name) {
|
||||
groups.renameGroup(id, body.name);
|
||||
audits.push({ action: `pool: rename group ${g.name} to ${String(body.name).trim()}` });
|
||||
}
|
||||
const name = body.name !== undefined ? String(body.name).trim() : g.name;
|
||||
if (body.userIds !== undefined) {
|
||||
groups.setGroupUsers(id, body.userIds);
|
||||
audits.push({ action: `pool: group ${name} members ${JSON.stringify(body.userIds)}` });
|
||||
}
|
||||
if (body.accounts !== undefined) {
|
||||
if (!body.accounts || typeof body.accounts !== "object" || Array.isArray(body.accounts)) {
|
||||
throw Object.assign(new Error("accounts: {tool: [names]}"), { code: "EBADNAME" });
|
||||
}
|
||||
for (const [tool, names] of Object.entries(body.accounts)) {
|
||||
groups.setGroupAccounts(id, tool, names);
|
||||
audits.push({ action: `pool: group ${name} ${tool} accounts ${JSON.stringify(names)}` });
|
||||
}
|
||||
}
|
||||
g = groups.listGroups().find((x) => x.id === id);
|
||||
return {
|
||||
json: { group: g },
|
||||
audits: audits.length ? audits : [{ action: `pool: group ${name}` }],
|
||||
};
|
||||
},
|
||||
{ anyTool: true }
|
||||
)
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/groups/:id",
|
||||
poolChange(
|
||||
async (req) => {
|
||||
const g = groups.deleteGroup(Number(req.params.id));
|
||||
return { json: { ok: true }, action: `pool: delete group ${g.name}` };
|
||||
},
|
||||
{ anyTool: true }
|
||||
)
|
||||
);
|
||||
|
||||
router.put(
|
||||
"/pins",
|
||||
poolChange(async (_req, body) => {
|
||||
@@ -302,7 +380,9 @@ router.put(
|
||||
router.delete(
|
||||
"/:name",
|
||||
poolChange((req, body) => {
|
||||
(body.tool === "claude" ? pool : codexPool).removeAccount(req.params.name);
|
||||
({ claude: pool, codex: codexPool, cursor: cursorPool })[body.tool].removeAccount(
|
||||
req.params.name
|
||||
);
|
||||
return {
|
||||
json: { ok: true },
|
||||
action: `pool: remove ${body.tool} account ${req.params.name}`,
|
||||
|
||||
Reference in New Issue
Block a user