feat(pool): an admin's switch moves a user's Claude login at once

PUT /api/accounts/pins (and agenthub accounts pin) now writes a pinned
Claude login into the user's home right away (claude-pool.switchHome):
every home's newer pair is copied back first, the member's own login is
never written over, and no pane is restarted — running sessions take the
new pair at their next credentials read. A live home stays on the login it
holds, so unpinning does not flip it back. The Users page calls it
"Switch a user's account" and says what happened.
This commit is contained in:
2026-10-19 10:23:44 +07:00
parent 9c98638ec0
commit 776ad6466f
16 changed files with 208 additions and 89 deletions
+43 -3
View File
@@ -18,8 +18,10 @@
* 3. least $ spent in the last 5 hours, then in the last 7 days — the
* sessions its runs (`dashboard_runs.account`) held, over the hours they
* held them (pool-usage.js).
* An admin may pin a user to one account (account-pins.js): that user's
* panes skip the ranking whenever they may use it.
* An admin may switch a user to one account (account-pins.js): that user's
* panes skip the ranking whenever they may use it, and a login is written
* into their home at once (`switchHome`) — running panes take it at their
* next credentials read, without a restart.
* The quota is read with the account's own token in the background, at most
* every `QUOTA_TTL_MS`; a token the usage endpoint refuses simply has no
* reading, and ranking falls back to spend.
@@ -335,6 +337,40 @@ async function refreshLogins() {
}
}
/**
* An admin's switch (account-pins.js): put pool login `name` into `member`'s
* home NOW, over the pool login it holds — never over a login the member made
* themselves. Every home's newer pair is copied back first (refreshLogins),
* so the login leaving the home keeps its only live refresh token. Running
* panes are not restarted: Claude Code takes the new pair when it next
* re-reads its credentials (before a refresh), new panes at once. Answers
* `switched`, `kept` (already there), `own-login` (left alone), `expired`, or
* `next-session` (a setup-token, or no member home: it goes in at spawn).
*/
async function switchHome(member, name) {
const first = readAccount(validName(name));
if (!first) throw err("ENOACCOUNT", `no account ${name} in the pool`);
if (first.kind !== "login" || !member || member.host) return "next-session";
await refreshLogins();
const a = readAccount(first.name);
if (!a || expired(a)) return "expired";
const disk = homeCreds(member);
if (disk && ownsHome(a, member.name, disk)) return "kept";
const holder = disk && allAccounts().find((x) => ownsHome(x, member.name, disk));
if (disk && !holder) return "own-login";
if (holder) {
const placed = { ...holder.placed };
delete placed[member.name];
save({ ...holder, placed });
}
save(writeLogin(a, member));
runAs().markClaudeOnboarded(member, {
...(a.account ? { oauthAccount: a.account } : {}),
...(a.userID ? { userID: a.userID } : {}),
});
return "switched";
}
// ── Quota, read in the background ──────────────────────────────────────────
/** name → { at, fiveHour?, sevenDay?, error? } */
@@ -461,7 +497,10 @@ function pick(member, ownerId = null) {
} else {
// One .credentials.json per home: while a pane of theirs runs on a
// pool login, the next pane stays on that one.
live = usable.find((a) => a.kind === "login" && liveInHome(a.name, member)) || null;
// The login in the home is the one they run on — after an admin's
// switch (switchHome) it is no longer the one those panes started on.
const liveLogin = usable.find((a) => a.kind === "login" && liveInHome(a.name, member));
live = liveLogin ? placed || liveLogin : null;
}
}
// A pinned token goes in by env and wins outright; a pinned login waits
@@ -550,6 +589,7 @@ module.exports = {
poolStatus,
pickAccount,
forSpawn,
switchHome,
refreshQuotas,
refreshLogins,
__read: readAccount,