Skip to content

Name a Claude ring after its account, and give a desktop session to the account that hosts it - #321

Merged
vinzdg merged 1 commit into
vinzdg:mainfrom
Splinter0000:paulo/account-identity
Sep 23, 2026
Merged

vinzdg merged 1 commit into
vinzdg:mainfrom
Splinter0000:paulo/account-identity

Conversation

@Splinter0000

Copy link
Copy Markdown
Contributor

Name a Claude ring after its account, and give a desktop session to the account that hosts it

Two things go wrong on a Mac with two Claude accounts. Both come from the same
place: the directory is treated as the account, and since the Claude desktop app
arrived it is not one.

1. A desktop session is drawn on the wrong ring

The desktop app leaves CLAUDE_CONFIG_DIR unset, so Claude Code registers every
session it hosts in ~/.claude/sessions — the default profile's directory —
whichever account the app is signed in to. Switching account inside the app
moves the credential and not the file.

The effect on a machine with ~/.claude (personal) and ~/.claude-work: work
done in the desktop app on the work account spins the personal ring, and the
work ring sits idle with a correct weekly limit beside it. Nothing about the app
says otherwise, and pinning CLAUDE_CONFIG_DIR does not fix it — that variable
is global and static, and which account the app is on is neither.

The registry entry carries nothing about the account. But the desktop app keeps
its own record of every session it hosts, one directory per account:

~/Library/Application Support/Claude/claude-code-sessions/
    <accountUuid>/<organizationUuid>/<hostSessionId>.json

and the registry entry carries that hostSessionId. .claude.json — already
read here for signedInAddress() and organizationID() — carries the matching
accountUuid on our side. So the two join, locally, with no network, no
keychain and nothing sent anywhere.

ClaudeSessionOwnership does that join:

  • a desktop-hosted session goes to the profile whose account uuid hosts it;
  • a terminal, VS Code or agent session stays with the directory it was filed in,
    because those do inherit the variable that chose the directory;
  • anything that cannot be established stays with its directory too — an
    unreadable index, a profile with no .claude.json, an account no profile on
    the machine is signed in to. A session on the wrong ring is a bug; a session
    on no ring at all is a worse one.

It is wired only when there is more than one Claude profile. With one, every
call path is byte-for-byte what it was.

2. The ring most people use every day has no name on it

displayName came from the directory: the default profile is always ~/.claude,
so it was just "Claude", and a second login was named for its folder —
Claude (work), whoever is actually signed in to it. Neither answers the one
question two Claude rings raise.

The signed-in address does, it is already read here for the Settings row, and it
costs no keychain prompt. So someone@gmail.com gives Claude Gmail and
someone@acme.co.uk gives Claude Acme. The domain rather than the local
part on purpose: a person's local part is usually the same word on every account
they own; the domain is what separates the personal login from the work one at a
glance.

The old spelling stays as the fallback rather than being dropped — a profile
signed out, or one Claude Code has not written .claude.json for yet, still has
to be called something, and the directory is all there is then.

Two accounts on one provider would both derive Claude Gmail, which is worse
than the directory names this replaces. Only a caller holding every profile can
see that clash, so ClaudeProfile.displayNames(for:) settles it and falls back
to the whole address — for the profiles that collide, and only those.

Provider ids are untouched (claude, claude-<slug>), so archived readings,
connection choices and hover-band keys survive.

What changed

File
Providers/ClaudeProfile.swift displayName from the account; accountLabel; displayNames(for:); accountID(); accountUuid on AccountFile.Account
Providers/ClaudeOAuthProvider.swift optional injected displayName, so a set of profiles can be named together
Sessions/ClaudeSessionRecord.swift keeps entrypoint and hostSessionID; isDesktopHosted
Sessions/ClaudeDesktopSessionIndex.swift newhostSessionId → account uuid
Sessions/ClaudeSessionOwnership.swift new — which profile draws which record
Sessions/ClaudeSessionMonitor.swift reads every profile's registry and keeps what is its own; records(in:) split out
App/AppDelegate.swift wires ownership when there is more than one profile

Notes for review

  • Only this profile's own directory is watched. A session adopted from
    another profile's directory arrives on the next liveness tick instead, two
    seconds later. A second descriptor per profile per profile seemed a lot of
    machinery to save a delay nobody can see, and the timer is already what covers
    what a file event cannot report. Happy to change it.
  • A miss in the index is deliberately not cached. The app writes its record
    a moment after Claude Code registers the session, so the first look can
    legitimately find nothing; remembering "unknown" would leave the session on
    the wrong ring for its whole life. A hit is kept for good — which account
    hosted a session is decided when it is created.
  • hostSessionId is checked, not trusted: it is a path component from a file
    another program writes.
  • Tests: ClaudeAccountNameTests (naming, including the collision and every way
    there is no label to give) and ClaudeSessionOwnershipTests (the index, who
    claims what, and the end-to-end read — including that with no ownership the
    read is exactly what it always was).

🤖 Generated with Claude Code

…he account that hosts it

The Claude desktop app leaves CLAUDE_CONFIG_DIR unset, so Claude Code
registers every session it hosts in ~/.claude/sessions -- the default
profile's directory -- whichever account the app is signed in to.
Switching account inside the app moves the credential and not the file,
so on a machine with two profiles the second account's work spun the
first account's ring, beside a weekly limit that was correct.

The registry entry names no account. The desktop app does, one directory
per account, and the entry carries the hostSessionId that names it:

    ~/Library/Application Support/Claude/claude-code-sessions/
        <accountUuid>/<organizationUuid>/<hostSessionId>.json

.claude.json already read here for the address carries the matching
accountUuid, so the two join locally -- no network, no keychain, nothing
sent anywhere. ClaudeSessionOwnership does the join: a desktop session
goes to the account that hosts it, a terminal or VS Code session stays
with the directory it was filed in because those inherit the variable
that chose it, and anything unprovable stays where it was. A session on
the wrong ring is a bug; a session on no ring at all is a worse one.
Wired only when there is more than one profile.

displayName came from the directory, which cannot answer the question
two rings raise: the default profile is always ~/.claude, so the account
most people use every day was the one ring with no name on it. The
signed-in address answers it and costs no keychain prompt, so
someone@gmail.com gives "Claude Gmail". The directory stays as the
fallback for a profile signed out or not yet written. Two accounts on
one provider would derive the same name; only a caller holding every
profile can see that, so displayNames(for:) settles it with the whole
address, for the profiles that collide and no others.

Provider ids are untouched, so archived readings and connection choices
survive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants