Skip to content

feat(claude-accounts): per-project Claude account (ask on launch, remember per project) - #23228

Open
Ethan-Rivas wants to merge 119 commits into
stablyai:mainfrom
Ethan-Rivas:feat/per-project-agent-account
Open

Ethan-Rivas wants to merge 119 commits into
stablyai:mainfrom
Ethan-Rivas:feat/per-project-agent-account

Conversation

@Ethan-Rivas

@Ethan-Rivas Ethan-Rivas commented Sep 26, 2026 •

Copy link
Copy Markdown

Builds on #22691's --account launch. The first 16 commits are #22691's. After main's per-account Claude folders landed (#24434, which removed credential replay), this branch rebuilt the pinned launch on those folders, so it no longer copies or refreshes any login. This PR's own changes start at 4f038e1; the rebuild is in the merge 8780cbc830.

ELI5

If you use Claude with more than one account (say work and personal), Orca today has one "active" account for everything, so you have to remember to switch before working in each project, and switching affects every project at once. This PR lets each project remember its Claude account. Optionally, Orca asks the first time you start Claude in a project and remembers your choice, so work repos always start on the work account and personal repos on the personal one, even side by side. If the saved account can't be used, Claude doesn't quietly start on a different account; the pane explains why and lets you start on the active account for that one launch.

What Changed

Before: one global Claude account per machine. Every Claude tab, workspace and resume used whichever account was selected when it started. #22691 adds --account for the CLI and RPC only; the desktop app's launches (new tab, ⌘⌥T, New Workspace, resume) can't use it.

After, for the user:

  • Settings → Repository → Claude Account (next to GitHub Account): Default · Ask every time · each signed-in Claude account. Disabled for SSH and WSL projects, with a note.
  • Settings → Accounts → "Ask which Claude account to use for each project", off by default. When it's on, projects without a saved account show a small prompt (account dropdown + "Remember for this project") when you start Claude from New Workspace, a new agent tab / ⌘⌥T, quick launch or the tab-bar menu.
  • A pinned tab's tooltip shows its account email.
  • If the saved account can't be used (it was removed, the project runs in WSL, or Orca couldn't confirm the account it prepared), the pane explains why, with Start on active account where that helps (restarts just that pane, this launch only).
  • Resume and restore bring a session back on the account it started on, not the project's current setting.
  • Switching the selected account later doesn't move a pinned tab: it stays on its account.

Mechanism:

  • Repo.agentAccounts.claude = { mode: 'ask' } | { mode: 'account', accountId }, round-tripped like ghAccount (feat(github): bind projects to a specific gh account #13664): persistence, IPC, the repo.update schema (null clears).
  • SleepingAgentLaunchConfig.claudeAccountId carries a launch's account (or an "active this time" sentinel), so resume and restore replay it.
  • One main-process resolver decides the pinned account in both spawn lanes: explicit choice → the launch's recorded account → project setting → selected account. SSH and reattached panes are never pinned.
  • A pinned launch runs from the account's own folder. For an account other than the selected one, prepareForClaudeLaunch(target, { accountId }) sets up that account's folder (claude-profiles/<id>/home, via the router's new prepareAccountLaunch) and points CLAUDE_CONFIG_DIR at it. The pane gets no which-account pointer, so main's claude shell function isn't defined there and a later switch can't redirect it. The selected account takes main's normal path. Because nothing is copied, there are no reservations, Keychain seeding, read-back or switch guards.
  • Refusals carry a trailing [claude_pinned:<code>] token (account-missing, provenance, unsupported-host) so the renderer can localize them; the CLI and mobile strip it from human output (--json keeps it).
  • A small registry records which PTY runs which pinned account (ids only, persisted so labels survive a restart). Main stamps an optional claudeAccountId onto agent-status rows from it, and the tab label reads that.
  • Paired hosts: terminal.createAgentSession / ensureAgentSession carry the account only to hosts advertising the new agent-session.claude-account.v1 capability (their params are strict, so older hosts would otherwise reject the launch).
  • Host-side structured (native chat) Claude launches for a project with a saved account run as a terminal (pinned_claude_account reason), and a structured → terminal handoff keeps the session's own account. A pinned launch also skips the new-tab wait for a host's agent list, since it is a terminal whatever the host answers.

Why

Linked Issue

Fixes #23227

Visual Proof

Before After
before-a1-settings-repository
Settings → Repository has no Claude account choice
a1-settings-repository-claude-account-options
New "Claude Account" section: Default / Ask every time / each account
before-b-new-claude-tab
"+ → Claude" starts on the global account with no choice
b-choose-claude-account-prompt
With "ask" on, a prompt picks the account (Remember for this project)
before-d-tab-tooltip
Tab tooltip shows no account
d-tab-tooltip-pinned-account
A pinned tab shows its account email
n/a (new) e1-refusal-toast-account-missing
If the saved account can't be used: explanation + Start on active account
n/a (new) e2-started-on-active-account
The refused pane restarted in place on the active account

Testing

  • I manually tested these changes locally

  • Automated tests added/updated

  • Unit and integration tests:

    • persistence and RPC round-trip;
    • the resolver's rule order;
    • the account service: a non-selected account runs from its own folder with no pointer, the selected account takes the normal path, a missing or WSL account is refused before any folder is touched;
    • both spawn lanes: pinning, the pinned pane never getting the selected account's pointer while an unpinned one does, non-Claude, SSH and reattach unchanged;
    • the pinned PTY registry: labels, persistence, and dropping restored PTYs the daemon no longer runs;
    • refusal codes and copy;
    • settings section, toggle and prompt, including over the New Workspace composer;
    • cold resume, sleep/wake and restart after daemon death, replaying the original account and keeping "active this time" unpinned;
    • paired-host capability negotiation;
    • main's credential-persistence ratchet, with the registry's id-only file added as a reviewed surface.
  • e2e: tests/e2e/per-project-claude-account.spec.ts runs the real app hidden, with fake managed accounts and a stand-in claude. It checks the settings options, the prompt, Remember, that the pinned CLAUDE_CONFIG_DIR is the account's own folder (set up on first use), no re-prompt plus the tab label, and a refusal restarting in place on the active account. It runs on Linux CI and skips on macOS and Windows. Screenshots are written only when ORCA_PER_PROJECT_CLAUDE_ACCOUNT_SCREENSHOT_DIR is set.

  • Manual: to re-run on the account-folder rebuild (the earlier manual runs were on the credential-copying design): two projects on two accounts at once with /status confirming each, a one-time pick versus Remember, the prompt over New Workspace and Cancel creating nothing, a refusal and in-place restart, switching the selected account while a pinned tab runs, and an app restart bringing pinned tabs back on their accounts with labels.

  • Platforms: macOS (manual, to re-run). Linux runs in CI (e2e). Windows: unit tests only; the e2e skips there (the stand-in claude is POSIX). WSL projects are deliberately not pinned. SSH projects are never pinned (covered by tests).

  • pnpm tc passes. pnpm test over the account, terminal, launch, CLI and worker suites: every failure also fails on a clean main in the same environment.

AI Disclosure

Built with Claude Code (Claude Opus 5.5, with Sonnet 5 / Haiku 4.5 sub-agents for smaller tasks): design, implementation, tests and reviews, including the rebuild on main's account folders. Every change was reviewed and tested by me.

Review

AI review summary:

  • Cross-platform: path utilities only; logins live in main's account folders on every platform, and this PR adds no credential handling of its own; no platform-specific UI.
  • SSH / remote / local: SSH is never pinned, and the Settings section is disabled with a note. Remote runtimes and paired hosts get the account only through capability-gated optional fields. Older clients and hosts behave exactly as before.
  • Agents / integrations: Claude only. Storage is keyed per agent (agentAccounts.claude) for later agents. Non-Claude terminals take the same code path as before.
  • Performance: the resolver is one repo lookup per fresh local Claude spawn; an account folder is set up once, on first use; the prompt only fetches accounts when it will actually be shown.
  • Security: untrusted IPC, RPC and persisted ids resolve only to known managed accounts, never to a path. The "active this time" sentinel never pins. No login is copied, read back or written by this PR.
  • UI: shadcn Dialog/Select/Checkbox/Switch; strings through translate in all 5 locales; design-system lint clean on changed lines.

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

Known limitations

  • Split tabs: after splitting a tab whose pane was refused, the refusal message shows again once that pane is clicked (existing per-pane error behavior).
  • Older paired hosts (without the capability) can't honor a one-time pick. On a capable host, an agent-only launch that falls back to the legacy create path silently runs on the project's saved account instead of a one-time pick.
  • WSL: WSL projects aren't pinned yet. Main now has WSL account folders, so this is a follow-up rather than a blocker.
  • Removing an account a pinned tab is using deletes its folder, as it does for the selected account on main.

Related: #22893 (opt-in isolation from external agent config) is complementary: pinned sessions run in their account's own folder and never write to ~/.claude.

Follow-ups: WSL pinning on main's WSL account folders, per-worktree overrides, project-group inheritance (#6921 discussion), per-account usage bars, and Codex once it has a per-launch account primitive.

Checklist

  • This PR is small and focused. (Focused on one feature, but not small: it includes tests and 5-locale i18n, and the account-folder rebuild of feat(claude-accounts): launch a Claude agent on a specific managed account with --account #22691's pinned launch.)
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred) (the full pnpm lint and pnpm build weren't run locally: dependencies main added since can't be installed in this environment; CI will cover them)

Selection and removal checked for pinned terminals before the pinned launch's
reservation existed, and a usage fetch could stage or refresh an account while a
pinned launch seeded the same Keychain item. Host mutations, usage fetches and
pinned reservations now claim an account with one synchronous check-and-set and
hold it until they finish; a pinned launch waits out a usage fetch and refuses a
switch or removal in progress.
…queue

A pinned launch that met an in-flight usage fetch waited up to 40s inside the
process-wide auth mutation queue, stalling every Claude launch and sync. The
reservation is now taken (and waited for) before entering the queue; the queued
part re-validates the account and the reservation is released on every failure.
… writes

Re-auth replaced an account's managed credentials without the host-mutation
claim, so it could overwrite the store a pinned --account Claude refreshes. It
now refuses up front while pinned terminals hold the account and claims the
account from the credential write through rollback.
…als still run on

A host Claude keeps the account it started on and refreshes it through
~/.claude after the host switches away, so pinning that account with --account
gave one single-use refresh chain two owners. Unpinned host Claude PTYs and
structured children now record the account they started on (from the prepared
auth provenance, else the host selection), restored across restarts, and a
pinned reservation refuses while any of them is still live.
Store an optional Claude account choice on each repo, next to the
GitHub account: the default (active) account, a saved managed account,
or "ask every time". It is normalized on load and update, and travels
over repo.update as a new optional field.
Add an optional claudeAccountId to the sleeping-agent launch config so a
launch can record which Claude account it ran on. Recovery equivalence
and sleeping records keep the field; nothing reads it yet.
Move the pinned-account preparation, reservation release and spawn
registration out of the runtime spawn lane into claude-pinned-spawn so
the IPC lane can reuse them. No behavior change.
Pinned launch refusals now end with a [claude_pinned:<code>] token.
Errors cross IPC as plain strings, so the token lets the renderer map a
refusal to localized copy without parsing the English message.
…anes

Resolve the account a fresh Claude launch must run on (explicit
--account, then the launch config, then the project's saved account)
and pin it on both the renderer IPC lane and the runtime lane. SSH and
pre-adopted panes never pin, and host-side agent.launch falls back to a
terminal instead of a structured session when the project pins one.
New-tab and New Workspace launches resolve the project's saved account
in the renderer and record it in the launch config. A launch that pins
an account is routed to a terminal, since a structured session has no
way to run on a pinned account.
Add a Claude account picker to repository settings (also for folder
projects) and an opt-in "ask per project" toggle under Accounts, both
reachable from settings search. The repository project sections move
into their own component to keep RepositoryPane under the size limit.
When the ask setting is on and a project has no saved account, Claude
launches from the tab bar, quick launch and New Workspace first show a
prompt to pick an account, with an option to remember the choice for
the project.
…tive account

Map pinned-launch refusal codes to localized copy in the terminal error
toast, naming the account and terminal count when an account is still
in use. Offer "Start on active account" to restart only the refused
pane, or Retry for refusals that clear on their own. CLI and mobile
error text strips the refusal tag; --json output keeps it.
A pinned account cannot run inside WSL yet. Tag that refusal with a new
unsupported-host code so the terminal toast explains it and offers the
active account, and disable the Settings picker and the account prompt
for WSL projects.
Main reads each pinned PTY's account from the pinned registry and adds
it to the agent-status push, so the tab can show which account a
Claude terminal runs on. The live-entry history helpers move to their
own file to keep the builder under the size limit.
…t-account

# Conflicts:
#	src/main/persistence/loading-store/repo-lifecycle-operations.ts
#	src/main/persistence/tracking-repos/repo-update-operations.ts
…t-account

# Conflicts:
#	src/main/ipc/pty/ipc/spawn-commit.ts
…t-account

# Conflicts:
#	src/main/runtime/runtime-folder-worktree-create.ts
#	src/main/runtime/runtime-local-worktree-terminal-startup.ts
#	src/main/runtime/workspaces/startup/workspace-terminal-provisioning.ts
…t-account

# Conflicts:
#	src/renderer/src/components/tab-bar/QuickLaunchButton.tsx
#	src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts
#	src/renderer/src/lib/launch-agent-in-new-tab.ts
#	src/renderer/src/lib/launch-agent-web-host-tab.ts
…t-account

# Conflicts:
#	src/cli/specs/core.ts
#	src/main/agent-launch/agent-launch-mode.ts
…e executor test harness into their own modules to keep files under the line limit
…t-account

# Conflicts:
#	src/shared/protocol-version.ts
#	src/shared/runtime-capability-constants.ts
…t-account

# Conflicts:
#	src/renderer/src/components/tab-bar/TabHoverCard.tsx
…t-account

# Conflicts:
#	src/main/ipc/pty/runtime/spawn-commit.ts
#	src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx
…t-account

# Conflicts:
#	src/main/startup/main-window-agent-status.ts
…t-account

# Conflicts:
#	src/renderer/src/i18n/locales/en.json
#	src/renderer/src/i18n/locales/es.json
#	src/renderer/src/i18n/locales/ja.json
#	src/renderer/src/i18n/locales/ko.json
#	src/renderer/src/i18n/locales/zh.json
…t-account

# Conflicts:
#	src/shared/workspace-layout/workspace-layout-disk-fields.ts
#	src/shared/workspace-layout/workspace-layout-fact-locations.test.ts
…t-account

# Conflicts:
#	src/renderer/src/i18n/locales/en.json
#	src/renderer/src/i18n/locales/es.json
#	src/renderer/src/i18n/locales/ja.json
#	src/renderer/src/i18n/locales/ko.json
#	src/renderer/src/i18n/locales/zh.json
#	src/shared/account-runtime-capabilities.ts
…t-account

# Conflicts:
#	src/renderer/src/hooks/composer-state/quick-creation-execution.ts
#	src/renderer/src/lib/agent-session-launch-plan.ts
…t-account

# Conflicts:
#	src/renderer/src/components/settings/RepositoryPane.tsx
…t-account

# Conflicts:
#	src/main/ipc/pty/runtime/spawn-commit.ts
…t-account

# Conflicts:
#	src/main/ipc/pty/ipc/spawn-commit.ts
#	src/main/ipc/pty/runtime/spawn-commit.ts
…t-account

# Conflicts:
#	src/renderer/src/components/settings/RepositoryPane.tsx
…t-account

# Conflicts:
#	src/renderer/src/components/settings/RepositoryPane.tsx
…t-account

# Conflicts:
#	src/renderer/src/i18n/locales/es.json
…t-account

# Conflicts:
#	src/renderer/src/components/settings/RepositoryPane.tsx
…t-account

# Conflicts:
#	src/main/agent-launch/agent-launch-executor.test.ts
#	src/main/agent-launch/agent-launch-mode.ts
#	src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts
#	src/main/claude-accounts/claude-profile-router.ts
…t-account

# Conflicts:
#	src/main/persistence/tracking-repos/repo-hydration.ts
#	src/renderer/src/store/repos/repo-update.ts
#	src/shared/repo-types.ts
#	src/shared/rpc-contract/repo-update-params.ts
…t-account

# Conflicts:
#	src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx
… its own component to keep the error toast under the line limit
…t-account

# Conflicts:
#	src/main/ipc/agent-hooks.test.ts
…t-account

# Conflicts:
#	src/renderer/src/lib/launch-agent-web-host-tab.ts

This branch has not been deployed

No deployments
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.

[Feature]: Per-project Claude account (ask on launch, remember per project)

2 participants