feat(pool): pin a user to one Claude/Codex pool account

An admin pins a server-mode user to one pool account per tool
(account_pins; PUT /api/accounts/pins with the password step-up and audit,
the Users page's Pin a user, agenthub accounts pin|unpin). The workspace
owner's pin wins over the balancer whenever the pane may use the account;
a pinned login still waits while another pool login holds the home.
This commit is contained in:
2026-10-19 10:05:19 +07:00
parent aa8ac6cad9
commit 9c98638ec0
20 changed files with 776 additions and 39 deletions
+76 -6
View File
@@ -2855,8 +2855,8 @@ const COMMAND_GROUPS = [
],
[
"accounts",
"list|add|remove [name] [--tool codex]",
"Claude/Codex account pool: new sessions spread over these accounts",
"list|add|remove [name]|pin <account> <user>|unpin <user> [--tool codex]",
"Claude/Codex account pool: new sessions spread over these accounts; pin = one user always on one",
],
["repl", "", "Open the interactive shell (also: shell, i)"],
],
@@ -3814,7 +3814,7 @@ const SUBCOMMANDS = {
service: ["install", "uninstall", "status"],
server: ["setup", "off", "status"],
users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"],
accounts: ["list", "add", "remove"],
accounts: ["list", "add", "remove", "pin", "unpin"],
pricing: ["set", "delete", "reset"],
webhooks: ["test"],
import: ["rescan", "path"],
@@ -4286,7 +4286,18 @@ async function cmdCodexAccounts(sub, name, flags) {
}
pool.resetQuota();
table(
["codex account", "kind", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota", "state"],
[
"codex account",
"kind",
"live",
"$ 5h",
"$ 7d",
"tok 5h",
"tok 7d",
"quota",
"state",
"pinned",
],
pool
.poolStatus()
.map((a) => [
@@ -4301,10 +4312,53 @@ async function cmdCodexAccounts(sub, name, flags) {
? a.quota.windows.map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(" ")
: "–",
a.full ? "full" : "ok",
pinnedUsers("codex", a.name),
])
);
}
/**
* `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex] —
* that user's new sessions run on that account (server/lib/account-pins.js).
*/
function cmdAccountPin(sub, positional, tool) {
const pins = require(path.join(REPO_ROOT, "server", "lib", "account-pins.js"));
const [first, second] = positional;
const username = sub === "pin" ? second : first;
if (!username || (sub === "pin" && !first)) {
throw new Error("usage: agenthub accounts pin <account> <user> | unpin <user> [--tool codex]");
}
const user = require(path.join(REPO_ROOT, "server", "db.js"))
.db.prepare("SELECT id FROM users WHERE username = ?")
.get(username);
if (!user) throw new Error(`no user ${username}`);
const pin = pins.setPin(user.id, tool, sub === "pin" ? first : null);
console.log(
pin.account
? `${c.green("✔")} ${pin.username}'s new ${tool} sessions run on ${pin.account}`
: `${c.green("✔")} ${pin.username} unpinned — new ${tool} sessions use the balancer`
);
if (!require(path.join(REPO_ROOT, "server", "lib", "auth.js")).isServerMode()) {
console.log(c.dim(" Pins apply in server mode only."));
}
console.log(c.dim(" Running sessions keep their account until they end."));
}
/** The `pinned` column: who an admin pinned to `account`. */
function pinnedUsers(tool, account) {
try {
return (
require(path.join(REPO_ROOT, "server", "lib", "account-pins.js"))
.listPins(tool)
.filter((p) => p.account === account)
.map((p) => p.username)
.join(",") || "–"
);
} catch {
return "–";
}
}
/** `agenthub accounts …` — the Claude account pool (server/lib/claude-pool.js). */
async function cmdAccounts(sub, positional, flags = {}) {
serverAuth(); // the same settings + database the server reads
@@ -4316,6 +4370,8 @@ async function cmdAccounts(sub, positional, flags = {}) {
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") {
throw new Error("--tool is claude (the default) or codex");
}
if (sub === "pin" || sub === "unpin")
return cmdAccountPin(sub, positional, flags.tool || "claude");
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
if (sub === "add") {
console.log(
@@ -4337,7 +4393,18 @@ async function cmdAccounts(sub, positional, flags = {}) {
await pool.refreshQuotas({ force: true });
const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–");
table(
["account", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota 5h", "quota wk", "state"],
[
"account",
"live",
"$ 5h",
"$ 7d",
"tok 5h",
"tok 7d",
"quota 5h",
"quota wk",
"state",
"pinned",
],
pool
.poolStatus()
.map((a) => [
@@ -4356,6 +4423,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
: a.quota?.error
? `no quota (${a.quota.error})`
: "ok",
pinnedUsers("claude", a.name),
])
);
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
@@ -4363,7 +4431,9 @@ async function cmdAccounts(sub, positional, flags = {}) {
return;
}
console.error(
c.red("✖ Usage: agenthub accounts list | add <name> | remove <name> [--tool codex]")
c.red(
"✖ Usage: agenthub accounts list | add <name> | remove <name> | pin <account> <user> | unpin <user> [--tool codex]"
)
);
process.exitCode = 1;
}