From fdf8b01e0452f5047eb048e44dac1722f09da193 Mon Sep 17 00:00:00 2001 From: yetone Date: Tue, 6 Oct 2026 12:54:17 +0800 Subject: [PATCH] =?UTF-8?q?claude:=20a=20Team=20seat=20and=20a=20personal?= =?UTF-8?q?=20plan=20of=20one=20email=20keep=20their=20own=20usage=20(netf?= =?UTF-8?q?ishx=20on=20X:=20with=20several=20Claude=20Code=20accounts=20th?= =?UTF-8?q?e=20Usage=20page's=20allowances=20and=20plans=20got=20mixed=20u?= =?UTF-8?q?p)=20=E2=80=94=20ClaudeCodeMovedOff=20matched=20"me@x.com=20?= =?UTF-8?q?=C2=B7=20Acme"=20and=20"me@x.com"=20as=20one=20account,=20so=20?= =?UTF-8?q?once=20Claude=20Code=20was=20switched=20from=20one=20to=20the?= =?UTF-8?q?=20other,=20a=20/usage=20run=20asked=20for=20the=20first=20(by?= =?UTF-8?q?=20a=20caller=20that=20looked=20before=20the=20switch)=20and=20?= =?UTF-8?q?a=20rate=5Flimit=5Fevent=20of=20a=20run=20started=20on=20it=20w?= =?UTF-8?q?ere=20kept=20as=20the=20first's:=20the=20seat's=20card=20showed?= =?UTF-8?q?=20the=20personal=20account's=20figures,=20and=20the=20other=20?= =?UTF-8?q?way=20round.=20Claude=20Code's=20account=20named=20from=20its?= =?UTF-8?q?=20profile=20(~/.claude.json)=20is=20now=20told=20by=20its=20wh?= =?UTF-8?q?ole=20name;=20the=20email-alone=20match=20stays=20only=20for=20?= =?UTF-8?q?a=20name=20from=20`claude=20auth=20status`,=20which=20gives=20n?= =?UTF-8?q?o=20org.=20The=20single-account=20Usage=20card=20reads=20its=20?= =?UTF-8?q?allowance=20under=20the=20name=20magpie=20gives=20the=20account?= =?UTF-8?q?=20(claudeAccount),=20not=20status's=20email,=20so=20a=20seat's?= =?UTF-8?q?=20own=20reading=20and=20what=20it=20said=20answering=20are=20f?= =?UTF-8?q?ound=20under=20one=20key.=20Not=20tried=20with=20two=20real=20C?= =?UTF-8?q?laude=20accounts=20of=20one=20email=20(only=20one=20here).=20cl?= =?UTF-8?q?aude=5Fseats=5Ftest=20(Claude=20Code=20moved=20seat=20=E2=86=92?= =?UTF-8?q?=20personal=20and=20back;=20the=20seat=20keeps=20its=205%=20aft?= =?UTF-8?q?er=20/usage=20reads=20100%=20on=20the=20personal=20account)=20f?= =?UTF-8?q?ails=20without=20the=20fix=20and=20passes=20with=20-race=20-cou?= =?UTF-8?q?nt=3D3;=20go=20test=20./internal/provider/=20./internal/gateway?= =?UTF-8?q?/,=20go=20vet,=20linux(nogui)/windows/nogui=20builds=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/claude_dirs.go | 23 +++++-- internal/provider/claude_seats_test.go | 79 +++++++++++++++++++++++++ internal/provider/subscription_usage.go | 10 +++- 3 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 internal/provider/claude_seats_test.go diff --git a/internal/provider/claude_dirs.go b/internal/provider/claude_dirs.go index 2dd81a20..34123f30 100644 --- a/internal/provider/claude_dirs.go +++ b/internal/provider/claude_dirs.go @@ -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 diff --git a/internal/provider/claude_seats_test.go b/internal/provider/claude_seats_test.go new file mode 100644 index 00000000..93466683 --- /dev/null +++ b/internal/provider/claude_seats_test.go @@ -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) + } +} diff --git a/internal/provider/subscription_usage.go b/internal/provider/subscription_usage.go index 8c31890b..7ba7d510 100644 --- a/internal/provider/subscription_usage.go +++ b/internal/provider/subscription_usage.go @@ -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)