claude: a Team seat and a personal plan of one email keep their own usage (netfishx on X: with several Claude Code accounts the Usage page's allowances and plans got mixed up) — ClaudeCodeMovedOff matched "me@x.com · Acme" and "me@x.com" as one account, so once Claude Code was switched from one to the other, a /usage run asked for the first (by a caller that looked before the switch) and a rate_limit_event of a run started on it were kept as the first's: the seat's card showed the personal account's figures, and the other way round. Claude Code's account named from its profile (~/.claude.json) is now told by its whole name; the email-alone match stays only for a name from claude auth status, which gives no org. The single-account Usage card reads its allowance under the name magpie gives the account (claudeAccount), not status's email, so a seat's own reading and what it said answering are found under one key. Not tried with two real Claude accounts of one email (only one here). claude_seats_test (Claude Code moved seat → personal and back; the seat keeps its 5% after /usage reads 100% on the personal account) fails without the fix and passes with -race -count=3; go test ./internal/provider/ ./internal/gateway/, go vet, linux(nogui)/windows/nogui builds pass.

This commit is contained in:
yetone committed 2026-10-06 12:54:17 +08:00
1 parent 391c45e367
commit fdf8b01e04
3 files changed
+103 -9

No files matched your search

+17 -6
View File
@@ -302,19 +302,30 @@ func (a *Account) AgentsOwn() bool { return a != nil && a.token == nil }
// ClaudeCodeMovedOff says Claude Code itself is signed in to another
// account than user now: what a Claude Code run in its own home says of
// the account it is on is no longer user's. false when it can't be told.
// Named from its profile, the account is told by its whole name: a Team
// seat and a personal plan of one email ("me@x.com · Acme", "me@x.com")
// are two accounts, and one's usage isn't the other's (netfishx on X).
func ClaudeCodeMovedOff(user string) bool {
on := claudeCodeOn()
return on != "" && !sameClaudeUser(on, user)
on, named := claudeCodeOn()
if on == "" {
return false
}
if named {
return !strings.EqualFold(strings.TrimSpace(on), strings.TrimSpace(user))
}
return !sameClaudeUser(on, user)
}
// claudeCodeOn is the account Claude Code itself is signed in to now, as
// magpie names it; "" when signed out or unknown.
func claudeCodeOn() string {
// magpie names it; "" when signed out or unknown. named: the name is from
// its profile (~/.claude.json), whole, not the email alone `claude auth
// status` gives.
func claudeCodeOn() (user string, named bool) {
l, ok := liveLogin("claude")
if !ok {
return ""
return "", false
}
return l.User
return l.User, len(l.Profile) > 0
}
// sameClaudeUser: a and b name one account, the one as magpie names a Team
+79
View File
@@ -0,0 +1,79 @@
package provider
import (
"context"
"path/filepath"
"sync/atomic"
"testing"
"time"
)
// claudeOnSeat signs Claude Code in to me@x.com's subscription of org,
// as /login leaves it: the sign-in and the profile naming its org.
func claudeOnSeat(t *testing.T, home, cred, org, orgID, plan string) {
t.Helper()
writeFile(t, filepath.Join(home, ".claude.json"), map[string]any{"oauthAccount": map[string]any{"emailAddress": "me@x.com",
"organizationName": org, "organizationUuid": orgID}})
writeFile(t, cred, map[string]any{"claudeAiOauth": map[string]any{"accessToken": "tok-" + orgID, "refreshToken": "r-" + orgID,
"expiresAt": time.Now().Add(time.Hour).UnixMilli(), "subscriptionType": plan}})
forgetAccountCaches()
}
// A Team seat and a personal Max of one email are two accounts: Claude
// Code moved from one to the other is moved off it, either way (netfishx
// on X: several Claude accounts' usage and plans mixed up).
func TestClaudeSeatAndPersonalOfOneEmailAreTwo(t *testing.T) {
home := claudeHome(t)
cred := claudeSignIn(t, home, time.Now().Add(time.Hour))
claudeOnSeat(t, home, cred, "Acme", "org-acme", "team")
if u, _ := claudeCodeOn(); u != "me@x.com · Acme" {
t.Fatalf("on %q", u)
}
if ClaudeCodeMovedOff("me@x.com · Acme") || ClaudeCodeMovedOff("ME@x.com · Acme ") {
t.Error("Claude Code on the Acme seat taken as moved off it")
}
if !ClaudeCodeMovedOff("me@x.com") {
t.Error("Claude Code on the Acme seat taken as on the personal me@x.com")
}
claudeOnSeat(t, home, cred, "me@x.com's Organization", "org-me", "max")
if u, _ := claudeCodeOn(); u != "me@x.com" {
t.Fatalf("on %q", u)
}
if !ClaudeCodeMovedOff("me@x.com · Acme") {
t.Error("Claude Code on the personal me@x.com taken as still on the Acme seat")
}
if ClaudeCodeMovedOff("me@x.com") {
t.Error("Claude Code on the personal me@x.com taken as moved off it")
}
}
// The seat's reading isn't overwritten by /usage run once Claude Code is
// on the personal account of the same email.
func TestClaudeSeatKeepsItsOwnUsage(t *testing.T) {
home := claudeHome(t)
cred := claudeSignIn(t, home, time.Now().Add(time.Hour))
claudeOnSeat(t, home, cred, "Acme", "org-acme", "team")
var out atomic.Value
out.Store("Current session: 5% used · resets " + soon(1) + " at 3:30pm (UTC)\n")
fakeClaudeUsage(t, &out, nil)
AskClaudeUsage()
if ws, err := claudeWindows(context.Background(), "me@x.com · Acme", true); err != nil || len(ws) == 0 || ws[0].Used != 5 {
t.Fatalf("seat: %v %+v", err, ws)
}
claudeOnSeat(t, home, cred, "me@x.com's Organization", "org-me", "max")
out.Store("Current session: 100% used · resets " + soon(1) + " at 3:30pm (UTC)\n")
claudeUsage.Lock()
for k, e := range claudeUsage.m {
e.tried = e.tried.Add(-claudeAskFloor)
claudeUsage.m[k] = e
}
claudeUsage.Unlock()
AskClaudeUsage()
// asked as the active account by a caller that looked before the switch
if ws, _ := claudeWindows(context.Background(), "me@x.com · Acme", true); len(ws) == 0 || ws[0].Used != 5 {
t.Fatalf("the seat shows the personal account's reading: %+v", ws)
}
if ws, err := claudeWindows(context.Background(), "me@x.com", true); err != nil || len(ws) == 0 || ws[0].Used != 100 {
t.Fatalf("personal: %v %+v", err, ws)
}
}
+7 -3
View File
@@ -334,7 +334,7 @@ func fetchSubscriptionUsage(ctx context.Context) []SubscriptionQuota {
if ls := accountsOf("claude"); len(ls) > 1 || p.Account.standIn {
fetches = append(fetches, perLogin(via("claude"), ls, "Claude Code", "claude-color")...)
} else {
fetches = append(fetches, withUser(ctx, p.Account.User, func() SubscriptionQuota { return claudeSubscriptionUsage(viaLogin("claude", p.Account.User)) }))
fetches = append(fetches, withUser(ctx, p.Account.User, func() SubscriptionQuota { return claudeSubscriptionUsage(viaLogin("claude", p.Account.User), p.Account.User) }))
}
}
if user, plan, ok := cursorIdentity(); !moved("cursor") && ok && !hidden["cursor"] {
@@ -491,13 +491,17 @@ type accountStatusError struct {
func (e *accountStatusError) Error() string { return http.StatusText(e.status) }
func claudeSubscriptionUsage(ctx context.Context) SubscriptionQuota {
// claudeSubscriptionUsage is the allowance of user, the account Claude Code
// is signed in to, by the name magpie gives it (claudeAccount): its
// readings and what it said answering are kept under that name, which for
// a Team seat isn't the email alone `claude auth status` gives.
func claudeSubscriptionUsage(ctx context.Context, user string) SubscriptionQuota {
q := SubscriptionQuota{Provider: "claude", Name: "Claude Code", Icon: "claude-color", Windows: []QuotaWindow{}}
if _, _, ok := claudeCredential(); !ok {
q.Error = "Claude Code is signed out; run claude auth login"
return q
}
user, plan, _ := claudeIdentity()
_, plan, _ := claudeIdentity()
q.Plan = plan
var err error
q.Windows, err = claudeWindows(ctx, user, true)