Account Management¶
This page is the sidekick-shared API guide. For the end-user walkthrough (VS Code Accounts view, status bar,
sidekick accounts, credential health, parallel sessions, platform notes) see
Account Switcher.
Sidekick account management lets Node hosts acquire, list, and switch Claude Max and Codex CLI accounts through
sidekick-shared. The API is designed for desktop apps, VS Code extension hosts, and CLIs that need isolated
login flows without reimplementing Claude/Codex credential detection.
Requires sidekick-shared@^0.25.0. Every login and switch entry point comes in a sync and an async form
(switchAccount/switchAccountAsync, getAccountLoginStatus/getAccountLoginStatusAsync,
finalizeAccountLogin/finalizeAccountLoginAsync, prepareCodexAccount/prepareCodexAccountAsync,
finalizeCodexAccount/finalizeCodexAccountAsync, switchToCodexAccount/switchToCodexAccountAsync).
The sync forms probe the codex CLI with blocking child processes (up to a few seconds) and are meant for
one-shot CLI callers; hosts with a UI event loop — extension hosts, desktop apps — should use the async forms,
which run the same probes off the loop. On sidekick-shared 0.21.0–0.24.4 only the sync forms exist.
Account State¶
Use the provider-neutral helpers when building account switchers:
import { getActiveAccountStatus, listAllAccounts, switchAccountAsync } from 'sidekick-shared';
const status = getActiveAccountStatus();
const all = listAllAccounts();
const result = await switchAccountAsync('claude-code', 'account-uuid');
if (!result.success) throw new Error(result.error);
if (result.warning) showWarning(result.warning);
listAllAccounts() returns Claude entries, Codex profiles, and active account ids keyed by provider:
type AccountProviderId = 'claude-code' | 'codex';
interface ListAllAccountsResult {
claude: AccountEntry[];
codex: SavedAccountProfile[];
activeByProvider: Record<AccountProviderId, string | null>;
}
Live vs. saved active account¶
The activeByProvider ids above come from the saved registry pointer, which only Sidekick's own switch flow
updates. For display surfaces that must reflect the account a user is actually logged into — even after a
native claude /login or codex login outside Sidekick — use the live-first resolvers instead:
import { resolveActiveClaudeAccount, resolveActiveCodexAccount } from 'sidekick-shared';
import type { ResolvedActiveAccount } from 'sidekick-shared';
const claude: ResolvedActiveAccount = resolveActiveClaudeAccount();
// claude.source === 'live' → from live provider auth (label set when it matches a saved profile)
// claude.source === 'registry' → no usable live identity; fell back to the saved active pointer
// claude.source === 'none' → neither a live identity nor a saved active account
const codex = resolveActiveCodexAccount();
Each resolver prefers the live provider auth (~/.claude/.claude.json oauthAccount; the ~/.codex/auth.json
id_token JWT) over the saved pointer, falls back to the registry, and — on an unambiguous match to a saved
profile — self-heals the activeByProvider pointer so registry-keyed data (quota history, auto-switch) tracks
the real account. Self-heal is best-effort and never creates or deletes profiles; an unknown live account is
shown as-is with no label.
React to account changes¶
Instead of polling getActiveAccountStatus(), subscribe to onAccountsChanged() (0.25.0). It combines
process-local mutation signals, filesystem watches on the account stores, and a low-frequency catch-up poll,
and emits only when the status actually changed:
import { onAccountsChanged } from 'sidekick-shared';
const subscription = onAccountsChanged(
({ reason, status }) => {
// reason: 'local' | 'filesystem' | 'poll'
refreshAccountSwitcher(status.claude, status.codex);
},
{ emitCurrent: true },
);
// later: subscription.dispose();
The library's own quota services (QuotaPoller, MultiProviderQuotaService, CodexQuotaWatcher) subscribe
to the same signal: they stay dormant while no matching account exists and wake when one appears.
Sync and Health (0.26.6)¶
The live homes (CLAUDE_CONFIG_DIR or ~/.claude; CODEX_HOME or ~/.codex) are the source of truth for
which account is logged in; saved profiles are backups that syncLiveAccountState() keeps fresh. One sync
registers logins the registry has never seen (label = email, metadata.origin: 'live-sync'), folds the live
credential into a private backup when it is newer (Claude: expiresAt; Codex: last_refresh, then access-token expiry), merges duplicate
Codex profiles for one workspace (oldest keeps id and label; archived homes have their account credentials removed after verified backup),
and re-points the active pointer silently. ensureDefaultAccounts() runs it at startup after removing
abandoned isolated logins; onAccountsChanged() runs it on every filesystem or poll event before reporting.
import { syncLiveAccountState, listAccountsWithHealth, getAccountHealth } from 'sidekick-shared';
const report = await syncLiveAccountState({ reason: 'manual' });
report.claude.registered; // { id, email } when a live login was learned
for (const view of listAccountsWithHealth()) {
view.health.state; // 'fresh' | 'expiring' | 'expired' | 'unknown' | 'missing'
view.health.refreshExpiresAt; // ms since epoch; `refreshExpiryEstimated` when derived from the snapshot age
view.source; // 'learned' (auto-registered) | 'registered'
}
Health comes from a secret-free health.json sidecar next to each profile, written by every credential
store. getAccountHealth(provider, id, { probe: 'cache' }) never spawns; probe: 'store' re-reads the stored
credential (a bounded Keychain call on macOS) and rewrites the sidecar. listAccountsWithHealth defaults to
probe: 'auto', which probes only profiles that have no sidecar yet. Use persist: false for inspection without sidecar writes. Claude refresh tokens expire about three
weeks after issue; snapshots that predate the CLI recording that expiry are estimated and marked as such.
One Copy per Claude Login (0.27.6)¶
Claude Code rotates the refresh token on every refresh, so every refresh-token chain may have exactly one copy a
claude process can use. The library enforces that:
| Account | Profile-home store (suffixed Keychain item / <home>/.credentials.json) |
Private backup accounts/credentials/<uuid>.credentials.json |
|---|---|---|
| live | empty (identity and health.json only) |
mirrors the live credential; Claude Code never reads it |
| inactive | the single usable copy, claudeAiOauth only (MCP tokens follow live) |
last known credential |
storeClaudeProfileCredentials()is the chokepoint: for the live account it writes the backup and sidecar only.syncLiveAccountState()removes a copy of the live login left in its profile home (report.claude.demoted), and when that copy is strictly newer than live it installs it first (report.claude.repaired).- Every write into the live store passes
decideLiveClaudeWrite(): a switch needs the outgoing login captured first; a re-login (applyClaudeProfileToLiveHome) only replaces live with a strictly fresher credential of the same account; a rollback never clobbers a store that changed.writeActiveCredentials()now requires a config directory — the live store is only written through a guarded switch. - A switch holds the swap lock and Claude Code's refresh locks (
<home>/.oauth_refresh.lock), writes a journal (accounts/claude/switch-journal.json), and the next sync finishes or rolls back an interrupted one from the live refresh-token fingerprint. Sidecars recordrefreshFp(sha256 prefix, never the token). - Keychain writes use
-X <hex>throughsecurity -iup to 4032 bytes and argv beyond (Claude Code's own behaviour), then read back;ClaudeCredentialWriteErrorreports a write that did not land intact.readClaudeCredentialStore()tellsok,absent,corrupt, anderrorapart.
Ledger and doctor¶
Every directory Sidekick writes a Claude credential into, or points a login at, is recorded in
accounts/claude/credential-homes.json (readClaudeCredentialLedger()), and its Keychain item carries a
sidekick-agent-hub:<directory> comment (SIDEKICK_KEYCHAIN_COMMENT_PREFIX).
inspectClaudeCredentialStores() reports orphan, live-duplicate, corrupt, and stale-entry findings for
stores whose ownership is proven that way, never by name pattern; repairClaudeCredentialStores({ dryRun: false })
fixes them (dry run by default). sidekick accounts doctor [--fix] is the CLI front end.
One Copy per Codex Login (0.27.6)¶
The live account has no account credential in its profile home. Private backups are byte-preserved, verified,
mode 0600, and stored under accounts/codex/backups/<id>.auth.json (recognized legacy account credentials
use <id>.credentials.json). No CLI runs in this backup directory. readStoredCodexAccount(id) reads the
freshest saved credential without materializing it. getAccountLaunchEnv uses the live home for the live
account, and can restore an inactive account's backup for an explicit launch.
Every live write uses the shared guard policy and one Codex installer. Switches capture and verify the outgoing
login, verify the target backup, journal the transfer, recheck the live snapshot, remove the target's usable
copy, install and verify live, and commit the registry. Only then does the outgoing account get its inactive
copy. report.codex.demoted, repaired, and journal describe migration and recovery, just as for Claude.
Codex freshness uses last_refresh, then access-token expiry; filesystem mtime never authorizes a live write.
Unknown or tied evidence cannot authorize replacing the same login. Rollback requires an exact snapshot match.
Codex's refresh semaphore is process-local. Live writes are blocked while Codex CLI/app/IDE consumers are
detected or process discovery fails. force bypasses only credential health. No process probe can exclude a
new external writer starting after the probe; callers must close Codex consumers before changing live auth.
getCodexCredentialStoreMode() rejects keyring, auto (even with an old auth file), ephemeral, and unreadable
configuration for account operations. An absent setting follows Codex's file default. Profile homes force file
mode. Modern .credentials.json may hold MCP credentials; Sidekick preserves those and handles only recognized
legacy account payloads. A legacy install that would overwrite unrelated data is refused.
Explicit beginAccountLogin('codex', ...) never imports live credentials. Temporary login homes live under
accounts/codex/logins/<loginId>/; the returned configDir/codexHome is the path the host should use. Stop the
login process before calling finalize or discard. The shared spawn helper and VS Code runner do this themselves.
Finalization transfers the login into private/canonical storage and removes its temporary home. If activation
is blocked (for example while Codex apps run), the result has success: false, profileId set, and an error
starting "The login was saved, but could not be activated"; the login is saved and the live account is
unchanged. Hosts that must not fail there can finalize with activate: false and switch later.
prepareCodexAccount(label) retains current-login import behavior; pass { importCurrent: false } for a fresh
isolated sign-in. Authenticated abandoned homes with unknown identities are reported for manual review.
Doctor¶
inspectCodexCredentialStores() reports live duplicates, old stash credentials, abandoned homes, unreadable
stores, and interrupted switches. repairCodexCredentialStores() is a dry run by default; pass { dryRun: false }
to repair proven Sidekick-owned stores after stopping Codex. Unknown or unreadable credentials are preserved.
sidekick accounts doctor [--provider codex] is read-only; --fix uses the existing confirmation/--yes flow.
Switch Result and Undo (0.26.6)¶
switchAccount[Async] returns a SwitchAccountResult (a superset of AccountManagerResult, so existing
callers compile):
const result = await switchAccountAsync('claude-code', id, { force: false, verifyWithCli: false });
result.verified; // the live store read back the written credential
result.verification; // 'store' | 'cli' | 'none' | 'failed'; the result explains recovery
result.needsLogin; // the target's stored credential is expired or missing; nothing was written
result.runningConsumers; // [{ kind: 'claude-cli' | 'claude-desktop' | 'codex-cli' | 'codex-app' | 'vscode-extension-host', pids, switched, reachability }]
result.warnings; // per-consumer reachability sentences + health warnings (also joined into `warning`)
result.hints; // "New claude sessions use …"
result.undoToken; // pass to undoLastSwitch(provider, token) to revert; a newer switch invalidates it
result.alreadyActive; // the target was already live; nothing was written
The switch is two-phase: the outgoing live credential is captured first (registering the account if unknown;
the switch refuses when it cannot be saved, even with force), then the target is installed, verified by
re-reading the store, and only then does the active pointer move. force bypasses Claude health and keep-alive checks. For Codex it bypasses only health; consumer and lease checks remain mandatory. detectRunningAccountConsumers() returns the same consumer list without switching;
describeAccountConsumer(kind) builds an entry for kinds the host detects itself (its own extension host).
Isolated Launch (0.26.6)¶
import { getAccountLaunchEnv } from 'sidekick-shared';
const launch = getAccountLaunchEnv('codex', id); // { env: { CODEX_HOME }, envUnset, command, home, health, warnings, error? }
The env points a child claude/codex at the profile home without touching the live login. envUnset lists
variables the host must remove from the child (CLAUDE_SECURESTORAGE_CONFIG_DIR overrides Claude Code's
keychain naming). The helper refuses expired/missing profiles. For the live account it returns the live home instead, because
a session in its profile home would be a second copy of the live login: Codex gets CODEX_HOME set to the live
home, and Claude drops CLAUDE_CONFIG_DIR (listed in envUnset) when the live home is the default one. An
inactive Claude profile whose home has no credential is restored from its backup first, with a warning; an
inactive Codex profile is restored silently, and the launch is refused when its profile copy is older than its
backup or no safe credential exists. Pass restoreBackup: false to skip restoring (keep-alive does). Codex
launches require file-mode credential storage.
Keep-Alive (0.26.6)¶
refreshInactiveAccounts() refreshes eligible inactive accounts through the official CLI. As of 0.27.6 the
Codex refresh goes through codex app-server and both providers take a lease, as described below. Claude uses
claude auth status. Codex uses a bounded stdio codex app-server session: initialize, initialized,
then account/read with refreshToken: true. It starts no inference turn. codex login status only reads
ChatGPT login state and cannot serve as keep-alive.
Both providers skip the live account, a matching live refresh fingerprint, live-sync provenance, missing
profile credentials, and copies older than their private backup. Eligibility and a lease are acquired under
the provider swap lock. Codex switches cannot bypass that lease with force. The refresh process is stopped
before the lease is released. Codex success requires a persisted credential advance for the expected account;
an RPC response or exit code alone is insufficient. Unsupported CLI versions receive an update hint.
Hosts continue to use the existing runner injection signature. keepAliveCommand('codex') now describes
an app-server process; the default runner implements its stdio protocol. Custom runners must implement that
protocol or provide an equivalent test double, rather than merely spawn the command and wait.
Windows Notes¶
Credentials are plain files under %USERPROFILE%\.claude and %USERPROFILE%\.codex; atomic writes retry
transient EPERM/EBUSY sharing violations. Running consumers are detected with tasklist. The Claude
Desktop and Codex desktop apps are matched by image name (Claude.exe, Codex.exe).
TTY-Less Login¶
beginAccountLogin creates an isolated profile and returns the command a host should run. It does not spawn a
process and does not change the active account.
import {
beginAccountLogin,
getAccountLoginStatusAsync,
finalizeAccountLoginAsync,
} from 'sidekick-shared';
const begin = beginAccountLogin('claude-code', 'Work'); // runs `claude auth login` (or `codex login`)
if (!begin.success) throw new Error(begin.error);
// begin.env: variables to set; begin.envUnset: variables the child must NOT inherit.
// Pass { existingAccountId } to re-authenticate a saved profile in place.
if (begin.alreadyComplete) {
const res = await finalizeAccountLoginAsync('claude-code', begin.loginId, { activate: true });
if (!res.success) throw new Error(res.error);
} else {
// Spawn begin.command with begin.args in your terminal or PTY.
// Merge begin.env into the child environment.
while ((await getAccountLoginStatusAsync('claude-code', begin.loginId)).state === 'pending') {
await sleep(2000);
}
const res = await finalizeAccountLoginAsync('claude-code', begin.loginId, { activate: true });
if (!res.success) throw new Error(res.error);
if (res.warning) showWarning(res.warning);
}
A host that gives up on a login (cancelled, timed out, closing) calls discardAccountLogin(provider, loginId),
which removes the temporary home and its Keychain item and refuses ids of saved accounts. finalizeAccountLogin
removes the temporary home on every exit once the sign-in is seen.
For hosts that can let Sidekick spawn the child process, use the convenience wrapper (it discards the pending login on every exit that does not end in a saved account):
import { spawnAccountLogin } from 'sidekick-shared';
const res = await spawnAccountLogin('codex', 'Work', {
stdio: 'inherit',
onStatus: (status) => updateLoginUi(status),
timeoutMs: 180_000,
});
Runtime Schemas¶
sidekick-shared exports Zod schemas from sidekick-shared/schemas; the package root re-exports only the pre-0.26.6 ones (accountProviderIdSchema through listAllAccountsResultSchema):
import {
beginAccountLoginResultSchema,
accountLoginStatusSchema,
accountManagerResultSchema,
listAllAccountsResultSchema,
} from 'sidekick-shared/schemas';
Use these at IPC or sidecar boundaries so runtime validation and TypeScript types stay aligned:
As of 0.25.0, account and quota entry points guarantee results that validate against the schemas exported in
the same release — re-parsing a value returned directly by the library is unnecessary; reserve .parse() for
data that crossed a process or IPC boundary.
Available account-management schemas:
| Schema | Validates |
|---|---|
accountProviderIdSchema |
'claude-code' or 'codex' |
beginAccountLoginResultSchema |
login begin success/failure payloads |
accountLoginStatusSchema |
pending, authenticated, or failed status |
accountManagerResultSchema |
switch/finalize result payloads |
accountEntrySchema |
Claude account registry entries |
savedAccountProfileSchema |
provider-neutral saved account profiles |
listAllAccountsResultSchema |
provider-neutral account list payloads |
accountHealthSchema, accountViewSchema |
per-account health and list views (0.26.6) |
switchAccountResultSchema |
verified switch results with consumers and undo token |
runningAccountConsumerSchema |
running apps that hold a login |
syncReportSchema |
syncLiveAccountState reports |
accountLaunchEnvSchema |
isolated-launch environments |
lastSwitchRecordSchema |
undo records |
Operational Notes¶
- Browser OAuth is interactive; the host must present the spawned Claude or Codex login terminal.
- macOS may show a keychain prompt the first time
securityreads Claude Code's item. - Codex OS-keyring logins are refused at add and switch time (see above); file credentials are required to finalize.
- Running consumers keep the previous account until they restart;
SwitchAccountResult.runningConsumersnames them. The Claude Desktop app is never switched. claude auth loginis the default login command (0.26.6); override withopts.loginCommandorSIDEKICK_CLAUDE_LOGIN_ARGSfor older CLIs.spawnAccountLogintreatstimeoutMsas an inactivity budget andmaxTimeoutMs(default 900 s) as the hard ceiling.- The shell-hook helpers (
installShellHook,uninstallShellHook,isShellHookInstalled,setTerminalActiveProfile) were removed in 0.26.6; usegetAccountLaunchEnvandwriteAccountLauncher. - Deprecated in 0.27.6:
writeLauncher(pins one profile home;writeAccountLauncherresolves the home at run time),applyActiveClaudeToLiveHome(now a guarded re-login that refuses to follow the pointer to another account), andresolveActiveClaudeHome(returns the live home for the live account). - A host that runs Sidekick against a throwaway
SIDEKICK_CONFIG_DIRshould also give it a throwawayHOME; since 0.27.6 no Keychain item is created for the live login either way.