2026-10-01 22:36:26 +00:00
2026-10-01 22:36:26 +00:00
2026-10-01 22:36:26 +00:00
2026-10-01 22:36:26 +00:00

@modelprofile.com/authswitch

Manage Codex, OpenCode and Claude Code accounts, switch interactively, and inspect live subscription, usage and reset information. Codex switching also preserves ChatGPT remote-control pairings.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

The problem

The Codex CLI holds exactly one active credential. Logging in as a second account overwrites the first, so working across two accounts means re-running the browser login every time.

Remote control makes that worse. A ChatGPT client pairs with the local Codex app-server, and the pairing is recorded in Codex' state database keyed by the account id. Swap the credential naively and the running app-server keeps serving the old identity until it is restarted, and the incoming account can come back without the enrollment that made it reachable from the ChatGPT app.

authswitch keeps a stash of credentials keyed by account email, captures each account's remote-control enrollments next to its credential, and puts both back together.

Install

The package is published to npmjs:

pnpm install -g @modelprofile.com/authswitch

Node.js 24 or newer is required.

On Linux, the account authority runs as a systemd user service. Install and start it once:

authswitch authority service install   # write ~/.local/share/systemd/user/authswitch-authority.service
authswitch authority service enable    # start it with your user manager from now on
authswitch authority service start
authswitch authority doctor            # the daemon's release and the harness tools its PATH finds

Run install from the shell you use Codex and Claude Code in. The unit runs the authswitch that shell's PATH finds -- pnpm's global launcher, which stays in place across upgrades -- by its absolute path, and refuses when that command is another release than the one installing (authswitch --version --json names the release, its version and package root). It carries that shell's PATH, through which the launcher finds Node and the daemon finds codex and claude, and, when set, CODEX_HOME, CLAUDE_CONFIG_DIR, ANTHROPIC_CONFIG_DIR, XDG_CONFIG_HOME, XDG_DATA_HOME, AUTHSWITCH_HOME and AUTHSWITCH_CODEX_BIN; nothing else from that environment, and never a token. A relative value of one of them is refused, since the daemon runs from /. The install result names the command the unit runs. doctor reports each harness tool the daemon cannot find and the reinstall that fixes it.

After upgrading with pnpm add -g @modelprofile.com/authswitch, stop the daemon, install the unit again and start the daemon on the new release. A running service's unit is not replaced: install over it refuses and names these three steps.

authswitch authority service stop
authswitch authority service install
authswitch authority service start

Authority SDK (new account registry)

The package also exports a typed client for a per-user authswitch authority daemon. Its SmartData registry stores account identity separately from sealed refresh grants, runtime bindings, durable device-operation receipts, Codex enrollment records, and native-handoff and migration journals. A daemon owns OpenAI device login, targeted reauthentication, proactive refresh, and, on request, revocation at removal for grants added through this API. Management snapshots expose separate account and login rows, including each login's purpose, owner, health, problem and available actions; neither snapshots nor events contain credentials. A login that a native tool refreshes also names that tool -- ownerTool is claude_code, codex or opencode, and null exactly when no native tool holds it, because the authority refreshes it or nothing does. The authority names it rather than leaving a consumer to derive it from the purpose, which would be wrong as soon as two purposes share a tool or one tool gains a second purpose. The snapshot also lists nativeAssignments: which account a vendor tool's own home on this host runs on, as { id, tool, accountId, loginId, state, revision } with the home named by its hash. Only homes the authority itself switches are recorded -- the host's Claude Code and Codex homes adopted by a verified import, and Claude container homes -- and state is switching while a handoff to another account is in flight and quarantined until one is resolved. The login of an adopted home names its owner: claude_native or codex_native. A native store the authority has not adopted -- an OpenCode store, or a Codex home imported before 11.3.0 and not submitted again since -- appears as its login instead, with owner: 'legacy_native' and its ownerTool, because the authority does not decide what that store holds. Every change of an assignment is an account event, so a subscriber sees it like any other change. A removed account is left out of the snapshot unless the caller asks with includeRemoved (on snapshot, snapshotAll and subscribe): it then appears with removed: true and its logins with health: 'removed'. Every account also names the provider tenancy its logins act in, organizationId: a Claude account's organization UUID -- Claude Code's oauthAccount.organizationUuid, the orgId of claude auth status --json -- and an OpenAI account's ChatGPT account id (the chatgpt_account_id claim, a personal or team ChatGPT workspace, not an OpenAI API organization). It identifies, never authorizes, and is published from the identity the authority recorded when the account was added, so no provider is asked for it. A daemon older than 10.3.0 leaves it out, which is why it is optional in the type; authswitch account list then prints organization not published. An account label follows one published rule, isAuthSwitchAccountLabel (at most authSwitchAccountLabelMaxLength characters, no space at either end, no control character); a rename that breaks it is refused with invalid_input.

import { randomUUID } from 'node:crypto';
import { AuthSwitchClient } from '@modelprofile.com/authswitch/authority-client';
import { resolveAuthSwitchAuthorityPaths } from '@modelprofile.com/authswitch';

const paths = resolveAuthSwitchAuthorityPaths();
const client = new AuthSwitchClient(paths.authoritySocketPath, paths.runtimeSocketPath);
const snapshot = await client.snapshotAll();
const operationId = randomUUID();
const operation = await client.beginAddOpenAi(operationId);
// Keep operation.id to discover its final result after navigation or reconnect.
const receipt = await client.getOperation(operation.id);
// Or follow it without polling: each answer comes when the operation changes.
let current = operation;
while (['starting', 'pending', 'committing'].includes(current.state)) {
  current = await client.watchOperation(current.id, current.revision);
}

Browser code can import the credential-free DTOs from @modelprofile.com/authswitch/authority-contract. The backend-only binding, access, release and managed Codex requests are named by @modelprofile.com/authswitch/authority-runtime-contract, and the one-time import's own DTOs by @modelprofile.com/authswitch/authority-import-contract; those capabilities must never reach a browser. Two of the runtime requests are served on the runtime socket -- resolving access and releasing a binding -- while authswitch.authority.bind, its inverse authswitch.authority.unbind and the managed Codex codex.start and codex.stop are served on the management socket by design: minting a capability is the act that authorizes a runtime, so only a trusted management caller may perform it, and a container that is given the runtime directory can use and release a binding it was handed but can never create one or release one it was not handed. A backend binds an account to a runtime incarnation by exact account ID, login ID and purpose, and keeps the returned capability private. Targeted reauthentication likewise requires the exact login; an account's presentation default never selects a grant for either action. Completed and interrupted operations remain discoverable through getOperation() and paged listOperations(). watchOperation(id, afterRevision, waitMs = 30000) is the same read as a long poll, the way events waits: it answers as soon as the operation's revision passes afterRevision -- a sign-in answers starting with no prompt, then pending with its device prompt, then its outcome -- at once for a finished operation, and with the unchanged operation once waitMs (at most 30000) runs out. Only a change of that operation answers it. A completed receipt and its account/grant change commit together, so the receipt resolves a lost response or an event that arrives first. Add and reauthentication callers supply one UUID for the logical start and reuse it after a lost response. Replaying that UUID returns the same matching receipt without starting another provider login; using it for a different target is rejected. Cancelling a durable starting receipt prevents a late provider handle from promoting or overwriting it. Aborting a client request only detaches that request and does not cancel the daemon-owned login. A client that disconnects before its answer -- a long poll it stopped reading, or one the daemon's shutdown settles after the client went away -- ends only its own connection: the daemon drops that request's pending work and keeps serving every other client. Each request is bounded by its own deadline -- 35 s unless its route states another, for an import submit authSwitchImportSubmitTimeoutMs (120 s for the import plus the longest Codex's app-server restart can take within Codex's own bounds, about 31 minutes in all), the wait plus 5 s for a long poll -- and nothing in the transport ends it sooner: the daemon gives a connection 35 s from acceptance to deliver its complete request, however it trickles in, and sets no limit once it is admitted. The runtime-only socket resolves that binding to a current access token; its directory can be mounted into a container without exposing the management socket. A provider that rejects a specific access-token generation can request a newer one through rejectedGrantGeneration; the authority coalesces concurrent rejection callbacks and never replays an uncertain refresh. A ChatGPT login backs a Claude Code runtime too (runtime: 'claude'); a Claude account never binds, because it reaches Claude Code through the native switch. After an external Flex, OpenCode or Claude runtime has fenced new work and drained its pending access resolutions and provider requests, it can release its exact binding capability through the runtime socket. Every bind receives a fresh random capability, so a stale release cannot fence or delete a successor even if the caller reused an incarnation label. Release waits for admitted server-side resolution handlers, but handler completion does not prove response delivery and cannot retract an access token already received or in use; the runtime owner therefore owns that drain. Generic release cannot revoke a managed Codex binding, whose daemon-owned stop still closes the runtime and clears its durable run before internal revocation. Binding release never transfers or changes ownership of the account grant.

A holder that lost its capability -- a binding an earlier run of it left behind -- releases it with unbind({ runtime, scopeId, incarnationId, expectedRevision }) (authswitch.authority.unbind), naming the binding by the identity it bound it under, as the snapshot and getBinding publish it. Unbinding needs no ready login, so the leftover bindings of an account whose sign-in has ended are released, and the account removed, without signing in again. It is authorized exactly as bind is: the management socket is the trust boundary, and a runtime and scope are the namespace a holder binds under and owns -- AGL, for example, binds under its own installation's scopes -- so a holder names only its own. The daemon releases only the binding at exactly that runtime and scope, and only while it is still the incarnation and revision the holder read: another scope, another runtime, or a successor bound in the meantime is left alone, and the answer is inactive, as it is for a binding already released. A binding's revision is the authority revision of the bind that wrote it, so it never repeats for a runtime and scope, even after a release: a repeated or stale unbind never names a successor bound under the same incarnation. Its capability is fenced and released exactly as the runtime release would, so admitted access resolutions finish first. Managed Codex bindings are never named here. While the login is ready, a holder can instead bind the same runtime and scope again, which replaces the capability, and release the returned one.

getBinding(bindingId) (authswitch.authority.binding) reads one binding by the id bind returned, credential-free, or null once the authority holds none by that id, so a backend checks its binding without reading the whole snapshot.

Removing an account that still backs a Flex, OpenCode or Claude binding is refused with account_busy, naming those runtimes: their holder stops them and releases the bindings first -- by capability, or by unbind when it no longer holds one -- so a removal never strands a runtime whose next access would fail. Managed Codex bindings are the daemon's own; a removal that goes ahead stops the account's managed Codex first and then takes them with the account. A removal its checks refuse stops nothing; only an account change that lands between those checks and the removal can refuse it after the stop.

Removing an account never reaches the network unless the caller asks. removeAccount(accountId, expectedRevision, { revoke: true }) (revoke: true on authswitch.authority.remove, --revoke on the command line) also revokes the ChatGPT sign-in the authority held for that account at OpenAI (/oauth/revoke, the request Codex sends when it logs out). The revocation runs after the removal committed and is best effort, like Codex's: the account is removed either way. The revocations get at most 10 seconds in all, so a revoking removal answers within the 35 seconds the client allows any request. The answer carries revoked: yes when OpenAI confirmed it, failed when it did not, or not within those 10 seconds (the sign-in may still be valid there, and the daemon's log says so without the token), not_applicable when the authority held no ChatGPT sign-in of its own for the account (a login a native tool refreshes is that tool's), and not_requested without revoke. A daemon older than this client refuses revoke as an invalid request before changing anything.

A revocation is irreversible, and authswitch cannot tell whether another tool still refreshes from the same sign-in. A token is opaque; the import ledger records of each source only its kind, a hash of its location, a digest of its bytes and the account and login it resolved to; and a login does not record whether its current sign-in came from an import or from a device sign-in the authority ran itself. The same ChatGPT identity turning up both as an imported copy and as a native Codex store proves the same account, not the same sign-in, so no check can tell a shared sign-in from two independent ones. Revoking an imported login can therefore sign out a native Codex, OpenCode or other home that still runs on that sign-in. Ask for revoke only for a sign-in made through account add openai or account reauth, or when every other copy of it should stop working too.

Every route on either socket answers in one of three ways: a result, a refusal, or a fault. A refusal is an answer -- the daemon completed its check, nothing was left half-done, and its message says what the owner does instead -- so it crosses the wire marked, with the message verbatim beside { reason, code }. A fault is anything the daemon did not decide: the transport replaces its text with Internal server error, and a caller must never present that as if it said what to do next. A malformed request is neither; it keeps its own text and carries no marker, because a caller's own bug is not an instruction for an owner.

asAuthSwitchRefusal(error) from @modelprofile.com/authswitch/authority-contract is the one reader of that marker: it answers { code, instruction } for a refusal and null for a fault. The code is the closed TAuthSwitchRefusalCode set, which is what a client branches on without reading the text: authority_closing, not_found, account_changed, account_busy, login_unavailable, binding_unauthorized, native_owner_holds_login, claude_home_unregistered, claude_handoff_pending, claude_receipt_missing, import_refusal, codex_unsupported, invalid_input, login_needs_reauth, access_not_fresh, codex_home_unregistered, codex_handoff_pending and codex_app_server_unavailable, plus the two the legacy commands answer with on this side of the socket, authority_holds_login and authority_unavailable (see "The legacy fence" below). The instruction is authored for the owner and contains no path, credential or digest; only an importer refusal may name the process the owner has to stop. The set is producer-owned: a consumer branches on the codes it knows and keeps a default branch, which is what lets the authority name a new one without breaking it.

The runtime's own answers are named the same way. authswitch.authority.bind refuses login_unavailable when the authority does not hold the login itself, and authswitch.authority.resolveAccess refuses binding_unauthorized for a capability that authorizes nothing -- missing, wrong, or superseded while the credential was being resolved. Those are one answer on purpose: an absent binding and a wrong capability are compared against the same zero hash, so nothing distinguishes them, and the repair is the same either way -- bind again. A binding whose account has since been reauthorized reads login_unavailable instead, because binding again cannot help until that account holds a login again. A device sign-in or preuse operation this authority does not hold reads not_found, from whichever route met it. What the access loop decides about the login itself is marked as well: login_needs_reauth when only a new device sign-in brings the login back (after which a bound runtime binds again), and access_not_fresh when the login is intact but the provider could not renew its access yet, so the same request can succeed later. A changed identity or a view that keeps moving during resolution stays an unmarked fault, because nobody decided it. Binding an account this authority does not hold at all stays one too: the storage layer throws Account is not available for this runtime., which a socket caller reads as Internal server error, because naming it is a decision about which layer authors owner instructions rather than a wording fix.

The import routes keep the marker they published in 8.1.0, authswitch_import_refusal, and isAuthSwitchImportRefusal from @modelprofile.com/authswitch/authority-import-contract is the same reader asking for the import_refusal code, so a consumer written against 8.1.0 keeps working and a client of this release still reads the payload a daemon of that release answers with. The distinction matters most for import.submit: a refusal there is a submit that decided -- it left nothing half-done, and the ledger row it wrote says where that source stands -- while an unmarked failure leaves the outcome unknown, and that source must then be read with authswitch.authority.import.status rather than submitted again.

A daemon that is shutting down admits no new request -- every route answers authority_closing -- and settles the requests it already admitted before it closes its store: a waiting events or watchOperation read answers normally from the open store instead of failing.

A client reading a daemon that runs an older release -- one not restarted after an upgrade -- fails with AuthSwitchDaemonOutdatedError, which names the restart (authswitch authority service stop, then start), and a subscription reports unavailable with the reason daemon_outdated once and retries every five seconds until the daemon is restarted.

AuthSwitchClient.subscribe(onSnapshot, onEvent, signal, { onStatus }) reports current after a fresh snapshot or verified heartbeat, unavailable on disconnect or resync, and closed on abort. Keep cached account actions disabled while status is not current. Every change-event batch supplies a fresh snapshot before onEvent, so a consumer does not need to reconstruct account state from events. lastVerifiedAt records the last successful daemon response, including an empty heartbeat.

AuthSwitchClient.doctorPage() reads credential-free diagnostic evidence without unsealing a grant, refreshing a token or contacting a provider. Each bounded page separately stamps the database observation, daemon state and managed-Codex process observation. daemon.harnessBinaries says whether the daemon's own PATH finds each harness tool (codex, or what AUTHSWITCH_CODEX_BIN names; claude; opencode): found or not_found, absent from a daemon older than 10.3.0. Stored account and login rows explicitly report liveCheck: not_checked; a ready daemon therefore does not claim that a stored grant or provider is healthy. Native homes and journals omit paths, digests and sealed values, while managed Codex reports durable, in-memory and observed-process state independently with fixed problem categories. Continue each independent cursor to inspect the complete inventory. Pages are later observations rather than one atomic global snapshot, and the authority metadata revision is not a change clock for every native-home record. If the management socket is unavailable, an offline caller must leave the database state unknown instead of starting the database to inspect it.

The authority CLI uses that daemon surface without constructing the legacy native harness adapters:

authswitch account list [--json]
authswitch account add openai [--json]
authswitch account add claude [--json]
authswitch account use claude <account-id> [--json]
authswitch account use codex <account-id> [--json]
authswitch account reauth <account-id> <login-id> [--json]
authswitch account remove <account-id> [--revoke] [--json]
authswitch account operation get <operation-id> [--json]
authswitch account operation cancel <operation-id> [--json]
authswitch account operation resume <operation-id> [--json]
authswitch account preuse <account-id> <login-id> [--prompt <text>] [--model <id>] [--json]
authswitch account preuse --all [--prompt <text>] [--model <id>] [--json]
authswitch account preuse operation get <operation-id> [--json]
authswitch account preuse operation cancel <operation-id> [--json]
authswitch account preuse operation resume <operation-id> [--json]
authswitch authority doctor [--json]
authswitch authority import inventory [--json]
authswitch authority import status [--json]
authswitch authority import submit <source-id> --digest <source-digest> [--json]

account list --json prints the complete, consistent credential-free authority snapshot. Its human form labels login health as stored state and does not imply a live provider check. account add openai starts one standalone OpenAI device login. account reauth starts one device login for the exact account and managed login IDs; it never chooses a presentation default. Both commands print the durable operation ID, verification URL, user code and changed receipts until completion. account operation resume polls only the supplied durable ID, while get performs one read and cancel performs one explicit cancellation. Ctrl-C or the local 15-minute deadline detaches the CLI and leaves the daemon-owned operation running. If a start response is lost, the fixed diagnostic reports the caller-generated operation ID and exact resume command; the CLI does not replay the start request.

account add claude signs a Claude account in (see "Adding a Claude account" below): it starts a Claude sign-in, runs claude auth login on this terminal with CLAUDE_CONFIG_DIR set to the home the authority made for it, and follows the sign-in until the authority has imported the login. With --json it writes each changed sign-in as one JSON Line and sends Claude Code's own output to stderr. A Claude Code that cannot be started, or that exits without signing in, ends the sign-in, and the command exits nonzero. account use claude switches the host's Claude Code to the account by the id account list shows: it finds the Claude login the authority holds for that account and runs authswitch.authority.claude.switch, following the handoff with the claude.handoff long poll until it is settled (a daemon older than 11.3.0 cannot be followed that way, and the command says so and exits nonzero). It prints whether Claude Code now runs on the account, whether the switch was undone -- with the proof that failed -- or whether Claude Code's home is quarantined; --json prints the handoff. It exits nonzero unless the switch committed, and answers at once, changing nothing, when Claude Code already runs on the account. account use codex switches the host's Codex the same way (see "The host's Codex home" below): it finds the OpenAI login the authority holds and renews for the account, runs authswitch.authority.codex.switch and prints whether Codex now runs on the account, whether the switch was undone -- with the problem and the proof that failed -- or whether Codex's home is quarantined, and how Codex's app-server came back; --json prints the daemon's answer. It exits nonzero unless the switch committed, and answers at once, changing nothing, when Codex already runs on the account. Every refusal the daemon answers these commands with, and every other authority command, is printed as the daemon wrote it.

account remove removes one authority account by the id account list shows, at the revision it reads just before, and prints the removal and what happened to its ChatGPT sign-in; --json prints the daemon's answer, { account, revoked }. --revoke asks for the revocation described under removal above, with the same caveat: authswitch cannot tell whether another tool still runs on that sign-in. The command exits nonzero when OpenAI did not confirm a requested revocation, although the account is removed.

account preuse sends one quota-consuming prompt through the exact managed OpenAI account and login IDs. The default prompt is Write 2000 words about strawberries.; --prompt replaces it and --model requests one exact model. --all freezes one consistent authority snapshot, walks its canonical daemon-owned OpenAI managed logins in snapshot order, and starts one fresh durable operation per login whose stored canPreuse capability is true. It does not infer readiness from plan, usage, token expiry or binding capability. A login whose snapshot capability is false is reported as skipped with the snapshot revision, login revision, stored health, problem and observation time; no request or operation ID is created for that skip.

Preuse operations run sequentially and are never retried automatically. Every terminal receipt advances an --all run, including not_sent, refused and outcome_unknown, while any attempted operation that does not complete makes the final exit status nonzero. Snapshot-ineligible skips alone do not make an otherwise successful batch fail. Running --all again is a new quota-consuming run with new operation IDs. Use account preuse operation resume to continue observing an existing receipt. Only the explicit cancel command asks the daemon to cancel one. A receipt stopped by a limit carries it as limit ({ kind, window, resetsAt }); a sent request that failed for another stated reason is outcome_unknown with problem request_uncertain and carries its cause as failure ({ kind, status, providerCode }, for example { "kind": "http", "status": 503, "providerCode": null }), never the provider's text. Human output prints both as sentences.

A host that shows receipts in a browser words them the same way through @modelprofile.com/authswitch/preuse-text, the browser-safe entry point for those sentences:

import { describePreuseFailure, describePreuseLimit, localDateTime } from '@modelprofile.com/authswitch/preuse-text';
import type { IAuthSwitchPreuseOperation } from '@modelprofile.com/authswitch/authority-contract';

const lines = (operation: IAuthSwitchPreuseOperation): string[] => [
  ...(operation.limit ? [describePreuseLimit(operation.limit, Date.now())] : []),
  ...(operation.failure ? [`Cause: ${describePreuseFailure(operation.failure)}`] : []),
];

describePreuseLimit(limit, now) names the window and the limit, and a stated reset as a countdown from now with the local time (Weekly usage limit reached, resets in 16h 46min (2026-09-27 05:12), or due to reset now once it has passed); describePreuseFailure(failure) names the cause as the tail of a sentence (the provider answered HTTP 503). localDateTime(timestamp) is the local time both use, 2026-09-27 05:12. The module loads nothing but that time text, which imports nothing either; the package root exports the same three functions from the same source.

For account add, account reauth and account operation resume, --json writes JSON Lines: each line is one complete operation receipt, and unchanged revisions are omitted. account operation get --json and account operation cancel --json each write one JSON object followed by a newline. Human output includes the resume command on every unfinished receipt. These device commands do not switch the active native login.

For one preuse start or resume, --json likewise writes each complete changed receipt as one raw JSON Line; preuse get and cancel write one raw JSON object. A preuse --all --json stream tags each line as operation or skipped. Operation lines contain the complete durable receipt. Skip lines contain the exact account and login IDs plus the stored snapshot evidence described above. A lost start response, transport or output failure, Ctrl-C, or the local 15-minute observation deadline stops the batch before another login starts and reports the current operation ID and resume command. It detaches without replaying or implicitly cancelling the daemon operation.

authority doctor --json incrementally writes one JSON document. Its pages array contains every independently stamped bounded page; observationSpan is written only after all cursors complete, and globallyAtomic is false. A failure during paging returns nonzero and leaves the document incomplete rather than presenting partial evidence as a successful observation span. The doctor command imposes no inventory cap. Account listing and doctor do not contact a provider, unseal a grant, mutate authority state, start the service, open the database offline or fall back to a legacy credential store. If the daemon is unavailable before the first response, it fails with a fixed diagnostic on stderr and leaves stdout empty. The older native-store commands documented below still coexist until the coordinated major-version migration and removal; these authority routes never use them.

The Claude native home

The daemon serves Claude Code's own credential store, which stays the native store that Claude Code reads. authswitch.authority.claude.switch, .handoff and .handoffs on the management socket move that one home between two authority logins and report the journal of those moves, and authswitch.authority.usage answers for an Anthropic account from it. Each switch carries a revision that grows with every change of it, and .handoff waits for one: with afterRevision and waitMs (at most 30000), which come together or not at all, it answers once the revision passes afterRevision, at once for a settled switch (committed, aborted or quarantined), or unchanged after waitMs. AuthSwitchClient.watchClaudeNativeHandoff() is that long poll; pass each answer's revision back until the switch is settled. A daemon older than 11.3.0 names no revision and refuses the wait. The home is CLAUDE_CONFIG_DIR or ~/.claude, and the authority identifies it by the hash of the credential file Claude Code owns -- the same value the import publishes as that source's path hash, which is what lets a verified receipt adopt it.

The daemon is wired for that home whether or not Claude Code is installed, so doctor reports claudeNativeAuthority: configured on any host; the claudeHomes page is what says whether a home has been adopted. Until a verified claude_native import has adopted one, every Claude route refuses, except the Claude sign-in, which only adds accounts. A CLAUDE_CONFIG_DIR that is not an absolute path is refused when the daemon starts, because the same variable also decides which file the import would read; it is never resolved against a working directory.

A switch is only attempted when the host can prove Claude Code uses that native login: Claude Code 2.1.273 or newer installed, a subscriber login in the store, no credential override in the environment (including the remote well-known token files), no managed or local settings selecting another auth source, and every Claude Code session running in that home on a verified release -- one whose lock table, live credential pickup and refresh compare-and-swap were read from its bundle, currently 2.1.273 and 2.1.282. An installed release above 2.1.273 that is not verified is proven by Claude Code's own answer instead: claude auth status --json must report the claude.ai login of the outgoing account -- its organization and email, the only account identifiers it names -- read from this home and with no API key, before the write, and of the incoming account after it. That path runs only while no Claude Code session runs in the home. If Claude Code does not report the incoming login after the write, or the proof fails for any other reason, the exact outgoing bytes are written back while the locks are provably still held and the handoff is aborted with problem: unsupported_effective_auth; when they cannot be, the home is quarantined. A write whose proof never ran is proven by the reconcile that finds it before it can commit. Anything else refuses and leaves both logins where they were. A move that reaches the home and then cannot complete that proof is quarantined with problem: unsupported_effective_auth, and the handoff's optional proofFailure names which condition failed -- unsupported_release, subscription, override, settings, profile, profile_unreadable or running_session -- so an owner is told what to change. It is absent whenever problem is anything else. The settings that proof reads are the ones the daemon may speak for: the user settings file of the resolved home and the managed settings directory. A project's .claude/settings.json is read from the working directory of each running Claude session, never from the daemon's own, which is its service unit's and selects nothing. While a login is in the native home, Claude Code is its only refresher; the authority takes it back by the same journaled handoff.

The host's Codex home

The daemon also switches the host's own Codex home -- CODEX_HOME, or ~/.codex, as its import reads it -- between OpenAI logins it holds, so the conversations that live in that home continue on another account. authswitch.authority.codex.switch (AuthSwitchClient.switchCodexNative()), .handoff and .handoffs serve it, and authswitch account use codex <account-id> runs it from the command line. The home is registered by the hash of its auth.json path, the same value the import publishes as the codex_native source's path hash: a verified import of that source adopts the home and makes its login codex_native, the login Codex renews itself. A Codex home imported before 11.3.0 still has a legacy_native login; import status offers resume for it, and submitting the same source again (authority import inventory, then import submit) proves its current bytes with the provider and adopts it. Until a home is adopted, and on a daemon built without codexNative, the routes refuse with codex_home_unregistered.

One login runs in the home at a time, and each login has exactly one renewer: Codex for the one in the home, the authority for every other. The account's login is one grant (openai_managed), so the login Codex runs on cannot also back a managed Codex app-server or a runtime binding: the switch stops the incoming account's managed Codex first, and refuses with account_busy while a runtime a caller holds is bound to it. A switch is refused, changing nothing, while another one is pending or left the home quarantined (codex_handoff_pending), for the account Codex already runs on (invalid_input), and whenever Codex's own app-server is not there to prove it (codex_app_server_unavailable): not running, not managed by codex app-server daemon, or unable to say.

Otherwise it runs as one journaled transfer:

  1. It renews the incoming login first when its access token would expire within twenty minutes, so Codex does not need to renew it while the switch is being proven, and reserves the switch; from here the authority no longer renews the incoming login.
  2. It stops Codex's app-server with codex app-server daemon stop -- running Codex turns end -- and reads the home's auth.json. A home that does not hold the login the authority placed there is quarantined, and nothing is written.
  3. It saves the outgoing account's remote-control pairings from Codex's state database into the account's sealed pairing record, seals the outgoing login for the authority and journals the digests of the bytes on both sides of the write.
  4. It puts the incoming account's saved pairings back into the state database, and writes the incoming login as Codex's own auth.json (auth_mode: chatgpt, owner-only, atomically, only if the file is still the one it read).
  5. It starts the app-server again in its own systemd scope with the environment it ran with, exactly as a credential change always does (see "What a switch actually does"), and asks it: account/read must report a ChatGPT login of the incoming account's email and account/rateLimits/read must name its ChatGPT account.
  6. Only that answer commits the switch: the incoming login becomes codex_native, the outgoing one the authority's to renew, and the home's assignment moves.

A switch Codex does not prove -- another login selected by the environment or the configuration (proofFailure: other_login), a ChatGPT account it could not name (account_unnamed), or an app-server that did not come back (problem: app_server_unavailable) -- is undone: the app-server is stopped again, the exact outgoing bytes go back and it is started again. Where the home no longer holds what was written, or the app-server cannot be stopped to put it back, the home is quarantined, and neither login is renewed by the authority until the owner repairs it. A daemon that stops in the middle settles the switch when it starts: a home that still holds the outgoing login undoes it, one that holds the written login commits only once the running app-server proves it, and anything else is quarantined. The answer, { handoff, codexAppServer, codexAppServerDetail }, can take as long as two Codex restarts; AuthSwitchClient waits authSwitchCodexNativeSwitchTimeoutMs, and a lost answer is read with codex.handoff, which waits for a change past afterRevision exactly as claude.handoff does (AuthSwitchClient.watchCodexNativeHandoff()).

doctor lists the adopted home (codexHostHomes, with its account, login, pending switch and status) and every switch of it (codexHandoffs, with its phase, problem and failed proof), and says whether this daemon serves the switch (daemon.codexNativeAuthority), so the home a refusal calls quarantined is visible there. A daemon older than 11.3.0 answers none of these; authswitch authority doctor then shows Codex native authority=unsupported and never sends it the codexHostHomeAfter and codexHandoffAfter cursors it would refuse.

What a switch cannot see: a Codex process that runs on the home without the app-server (codex exec, or a TUI started with --no-daemon) keeps the login it loaded and may write its own renewal of it back into auth.json; close such processes before switching. The account Codex runs on reads no usage through the authority, since the authority holds no copy of its login, and it cannot be removed until Codex is switched to another account.

Adding a Claude account

A Claude account reaches the authority by one of three ways, and each ends with an import whose migration ledger row is the receipt a switch of the host home, or a move into a container home, requires.

  1. Adopt the host's own Claude Code home -- once, before anything else. The login Claude Code runs on stays Claude Code's (a projection, verified without rotating anything), and its home becomes the one the authority switches. Close every Claude Code session first: the import refuses while claude runs.

    authswitch authority import inventory    # the source of kind claude_native, with its id and digest
    authswitch authority import submit <source-id> --digest <source-digest>
    
  2. Import the Claude logins authswitch claude stash saved -- each is a source of kind authswitch_stash with provider anthropic in the same inventory, taken over by one rotating refresh, after which its saved copy is dead. Submit each the same way, with Claude Code closed. The saved copy of the account Claude Code runs on is refused, because Claude Code already holds that login, and so is one of an account whose login the authority already holds and still works; adopt the host home first, or its own import is refused in turn once the authority holds that account.

  3. Sign in -- authswitch account add claude, or from a backend the Claude sign-in routes below.

Then authswitch account use claude <account-id> switches Claude Code on this host to any of them.

authswitch.authority.claude.signin.start (AuthSwitchClient.startClaudeSignIn(operationId)) makes a private home for the sign-in -- a directory named by the operation id in the sign-in directory, which the service places at $XDG_DATA_HOME/authswitch/claude-signin (default ~/.local/share/authswitch/claude-signin), beside the store -- and answers IAuthSwitchClaudeSignInOperation: pending, with prompt: { flow: 'terminal', command, directory }. command is claude auth login with CLAUDE_CONFIG_DIR set to directory, quoted for a POSIX shell; a caller that runs Claude Code itself runs claude with authSwitchClaudeSignInArguments in that environment, as the command line does. Once .credentials.json is in the home and no Claude Code process runs on it, the authority imports the login -- one rotating refresh, the import a saved Claude login gets, so the authority is its only renewer and the import's ledger row (source kind claude_signin) is its receipt -- and removes the home; the sign-in is then complete with result: { accountId, loginId }. An import that refuses leaves it failed with the import's instruction in error, and one whose outcome is unknown outcome_unknown, pointing at authswitch authority import status. claude.signin.operation reads it, or waits for its next change when given afterRevision and waitMs (watchClaudeSignIn), and claude.signin.cancel ends a pending one and removes its home. A sign-in that holds no login fifteen minutes after it started fails, and one the authority's stop interrupts is interrupted; either way its home goes. A finished sign-in answers for ten minutes after it finished; after that its id is unknown (not_found). Sign-ins live in the running authority only: after a restart their ids are unknown, and the start removes every home left in the sign-in directory. A daemon built without claudeSignIn refuses these routes with claude_home_unregistered.

A sign-in, like the import of a saved login, replaces a login the authority already holds for that account only when that login has ended (needs_reauth); a working login is kept, and so is one Claude Code holds, and the sign-in or the import refuses saying so, before anything is written. An account whose login Claude Code runs on is not removed either: the removal refuses with native_owner_holds_login and the instruction to switch Claude Code to another account first (authswitch account use claude <account-id>), or to move the login out of its container home.

Claude container homes

A management caller that runs Claude Code in containers -- AGL's environments -- keeps several Claude logins in the authority and moves one of them into a container's own Claude home while that container runs. A login lives in exactly one place: sealed by the daemon, in the host home, or in one container home. The grant's owner records it, and so does the home's occupancy, whose login index is unique across the host and every container. The routes are on the management socket, the trust boundary that already scopes bind and unbind:

  • authswitch.authority.claude.homes.register {runtime: 'claude', scopeId, accountId, directory} registers a home the caller created, under its own scope and for one account, for the home's whole life. The authority proves only facts about the path. The directory must be absolute and normalized, and reached through no link. Its parent must be a directory of the daemon's user that no one else can write, and every directory above that must be changeable only by its owner or root (a root-owned sticky directory such as /tmp counts). The home must be neither the host's Claude home, nor the authority's data directory, nor another registered home, and must not nest with any of them, by path and by inode. Registrations run one at a time, so two concurrent ones of nested directories never both succeed. The parent is pinned by device and inode. The home itself may not exist yet; when it does, it must be a real directory of the daemon's user. The same scope, directory and account answer the existing registration. Anything else under that scope or directory is claude_home_conflict.
  • .unregister {homeId, expectedRevision} forgets an empty home. It answers claude_home_occupied while the home holds a login or has a move pending.
  • .moveIn {homeId, loginId} moves one ready login of the home's account in. A login due within the hour is renewed first, and one whose renewal fails is refused login_needs_reauth. The login is proven with the provider and written as .credentials.json (claudeAiOauth, every other key kept) and .claude.json (oauthAccount; the account caches go, every other key of the container's own file stays). It is read back and committed: the grant becomes claude_native, and the snapshot's nativeAssignments gains a row with location: 'container' and the home's scopeId. Only a committed answer means the login is in the home.
    • A login held by another home is claude_login_in_use, and the instruction names that home's scope; the snapshot names it too.
    • A login of another account is claude_home_conflict.
  • .moveOut {homeId} takes the login back and names the result. The file is container-controlled, so the provider, never the file's label, says whose login it is: its profile read with the returned access token while that has five minutes left, otherwise after one refresh the daemon sends as the login's new refresher, behind a durable marker.
    • returned: the very login moved in.
    • returned_replaced: another login the provider proves is the same account and organization. It is sealed as the grant's login. authswitch cannot tell a renewal by Claude Code from a new sign-in inside the container, and it revokes no previous token family.
    • needs_reauth, with problem:
      • returned_missing: no login in the home;
      • returned_foreign: another account's tokens, discarded and never sealed;
      • provider_rejected: the returned refresh token is dead;
      • exchange_uncertain: a refresh may have been sent and its answer was lost.
    • quarantined, with problem:
      • foreign_or_torn: a file that is unparseable, oversized, not a regular file or of another user;
      • claude_home_tampered: the home, or an entry in it, is not what was registered.
    • not_occupied: the home holds no login of the authority's, for example after a move-in whose answer was lost was undone.
    • Every result but a quarantine leaves the home without .credentials.json and without oauthAccount.
    • An unavailable provider moves nothing and answers access_not_fresh.
  • .resolve {homeId, operationId, outcome} is the owner's answer to a quarantine. It waits, answering claude_home_running, while a Claude Code process runs on the home.
    • abandon gives the login up (it needs a new sign-in) and empties the home when it can.
    • restore_sealed returns the authority's own sealed copy. It is allowed only for a move-in that never committed, and only once this call has emptied the home.

A daemon built with claudeContainerHomes: { homesDirectory } registers homes only strictly inside that directory and refuses any other with invalid_input; without it a home may lie anywhere the path facts admit.

After a committed move-in, the caller can prove inside the container that Claude Code runs on the expected account by comparing claude auth status --json with the snapshot's account: its orgId with the account's organizationId, and its email with the account's email.

Both moves are valid only while no container process can touch the home, and the caller vouches for that: it moves a login in during its start-probe window and out once its container is proven stopped. As defence in depth, the authority refuses either move with claude_home_running when a Claude Code process of this user runs on the home (resolved through the process's own mount namespace) or when a live Claude Code process cannot be read. It never executes anything found in a home -- no claude, no helper, no settings -- and takes none of Claude Code's locks: whether Claude Code in the container uses the login is the caller's to prove, inside the container.

A container home is touched only by descriptor. The parent is opened by path and proven to be the pinned directory. The home is opened through it without following a link. Every file is addressed as /proc/self/fd/<home>/<name> without following a link, and is read without blocking on a FIFO, as a regular file of the daemon's user of at most 4 MiB, in strict UTF-8. A write goes to a fresh file created exclusively under a random name and is renamed within the home, so a planted link or hard link is never written through. After every batch the home's name must still be the directory that was opened; otherwise the move is quarantined claude_home_tampered. A permission refusal is claude_home_tampered as well: an entry of another user of the container, or a stripped directory.

Every move is journalled before it touches a home, and the daemon settles an interrupted one when it starts or when the caller moves out:

  • A move-in that wrote nothing is undone.
  • A move-in whose home holds exactly the prepared login is emptied and undone after the running check, and the sealed copy stays; the authority never commits a move the caller did not see succeed.
  • A move-out that sealed the returned login finishes the removal and commits, while the credential file is the one it read or is gone.
  • A move-out that sent a refresh loses the login, because that refresh may have spent it.
  • Anything else is quarantined.

A move the daemon cannot settle when it starts -- a Claude Code process runs on the home, or anything else fails -- stays pending and keeps only that home blocked; every other account is served. The home reports why in unsettled (claude_home_running or settlement_failed, the latter also logged by the home's id), and its next move-out settles it.

A login a container home holds is that home's alone. A host switch to it is refused claude_login_in_use, naming the home's scope, and a switch to a login a move-in is taking is claude_handoff_pending. Its usage is never read from the host's home, nor under Claude Code's locks there: authswitch.authority.usage answers it unavailable with the problem held_in_container, and reads it again once the login is moved out. An import never adopts a registered container home as the host's.

doctor lists the registered homes (claudeContainerHomes, with their account, login, pending move, status and unsettled, never their path); claudeHomes lists the host's own home only. It also reports daemon.version and whether this daemon serves container homes (daemon.claudeContainerHomes). A daemon older than 10.2.0 answers none of these three; authswitch authority doctor then says the daemon is outdated, names the restart, and never sends it the claudeContainerHomeAfter cursor it would refuse. Removing an account removes its empty registrations with it. A home that holds a login refuses the removal through that login's native owner.

Testing against a real authority

@modelprofile.com/authswitch/testsupport starts a real authority daemon for a consumer's tests -- the daemon, its embedded database, and the management and runtime sockets -- on synthetic fixture homes, so a test talks to it through AuthSwitchClient exactly as it talks to the installed one:

import * as fs from 'node:fs';
import { AuthSwitchTestAuthority } from '@modelprofile.com/authswitch/testsupport';
import { AuthSwitchClient } from '@modelprofile.com/authswitch/authority-client';

const support = await AuthSwitchTestAuthority.start();
try {
  const client = new AuthSwitchClient(support.authoritySocketPath, support.runtimeSocketPath);
  const alice = await support.seedClaudeAccount('alice');
  const home = support.claudeHomeDirectory('env-one');
  fs.mkdirSync(home, { mode: 0o700 });
  const registration = await client.registerClaudeHome({ scopeId: 'my-app/env-one', accountId: alice.accountId,
    directory: home });
  await client.moveClaudeHomeIn(registration.id, alice.loginId);
  const account = (await client.snapshot()).accounts.find(item => item.id === alice.accountId);
  // account.organizationId === alice.organizationId, the organization the home's .claude.json names.
  await client.moveClaudeHomeOut(registration.id);
} finally {
  await support.stop();
}
  • AuthSwitchTestAuthority.start({ parentDirectory? }) creates a private temporary directory in parentDirectory (the system temporary directory by default) and runs everything below it: the database, the three sockets, the fixture host Claude home, the fake host Codex and homesDirectory. It refuses a parent directory inside one of this user's real stores -- ~/.claude, ~/.claude.json, ~/.codex, ~/.authswitch, ~/.config/authswitch, ~/.config/opencode, ~/.local/share/opencode, ~/.local/share/authswitch, CLAUDE_CONFIG_DIR, CODEX_HOME, AUTHSWITCH_HOME, and the opencode and authswitch directories of XDG_DATA_HOME, XDG_CONFIG_HOME and XDG_RUNTIME_DIR -- and a path too long for a Unix socket.

  • Container homes are confined to homesDirectory; claudeHomeDirectory(name) names one there without creating it. Any other directory is refused invalid_input.

  • seedClaudeAccount(name, { generation?, accessExpiresAt? }) puts a ready Claude login of fixture account name into the authority's custody with the verified import receipt a move-in requires, and answers { name, accountId, loginId, accountUuid, organizationId, email, accessExpiresAt }. Seeding the same name with another generation adds a second login of the same account. The identity is authSwitchFixtureClaudeIdentity(name) (user-<name>, org-<name>, <name>@example.test) and every token starts with fixture-.

  • provider is the AuthSwitchFakeClaudeProvider the daemon talks to. It answers Anthropic's profile and token endpoints for the fixture tokens it issued, with the switches a test needs (profileDown, refreshTokens.set(token, 'rejected'), onProfile, the profileCalls and refreshCalls counters), and fails every other request, OpenAI's included, so nothing a test starts reaches a real provider. Secrets are sealed with a plain encoding, never in a keyring, and no Claude Code process is ever seen running on a home.

  • Claude sign-ins run in claudeSignInDirectory. claudeSignInLogin(name, { generation?, accessExpiresAt? }) answers the two files claude auth login leaves once fixture account name signed in -- files['.claude.json'] and files['.credentials.json'] -- and the { name, accountId, loginId, accountUuid, organizationId, email } the authority imports them as, and tells the provider the login's tokens so the authority's rotating refresh succeeds. A consumer's fake claude, run with authSwitchClaudeSignInArguments and the prompt's directory as CLAUDE_CONFIG_DIR, writes them there and exits; since no Claude Code process is ever seen on a home, the authority imports as soon as .credentials.json is there, so the fake writes .claude.json first and .credentials.json last, each by renaming a complete file into place. completeClaudeSignIn(directory, name) writes them itself, for a test that needs no claude at all. The sign-in completes within a fraction of a second; follow it with watchClaudeSignIn:

    const operation = await client.startClaudeSignIn(randomUUID());
    const lena = support.completeClaudeSignIn(operation.prompt!.directory, 'lena');
    let current = operation;
    while (current.state === 'pending' || current.state === 'committing') {
      current = await client.watchClaudeSignIn(current.id, current.revision);
    }
    // current.result is { accountId: lena.accountId, loginId: lena.loginId }
    
  • The host's own Codex is codexHost, an AuthSwitchFakeCodexHost in the authority's directory: a fake codex that answers app-server daemon version, stop and start as Codex does, and an app-server that loads the home's auth.json when it starts and answers the two requests a switch proves itself with -- account/read with the ChatGPT login it loaded, account/rateLimits/read with that login's ChatGPT account. The daemon serves codex.switch, .handoff and .handoffs on it. seedOpenAiAccount(name, { generation?, accessExpiresAt? }) puts a ready OpenAI login of fixture account name into the authority's custody, and adoptCodexHost(name, { accessExpiresAt? }) makes the host's Codex run on fixture account name as a verified import leaves it -- its sign-in in the home, its login codex_native, the home adopted -- and starts the app-server; both answer { name, accountId, loginId, chatgptAccountId, email, accessExpiresAt }. The identity is authSwitchFixtureOpenAiIdentity(name) (user-<name>, workspace-<name>, <name>@example.test); the tokens are unsigned, valid for three hours unless stated, and never renewed within that time, so no request reaches OpenAI. On the fake, chatgptAccountId() names the account the home's auth.json holds, requests() lists what the app-server was asked, reportApiKey(true) makes it report an API key -- a switch is then undone with proofFailure: 'other_login' -- and stopAppServer(), startAppServer() and isRunning() drive it. The fake runs without a systemd user manager, so a switch answers codexAppServer: 'restarted_unscoped':

    const kai = await support.adoptCodexHost('kai');
    const lena = await support.seedOpenAiAccount('lena');
    const { handoff } = await client.switchCodexNative(lena.accountId, lena.loginId);
    // handoff.phase === 'committed'; support.codexHost.chatgptAccountId() === lena.chatgptAccountId
    // Codex ran on kai before; kai's login is the authority's now, lena's is Codex's own. A switch still under way is
    // followed with watchCodexNativeHandoff(handoff.id, handoff.revision) until it is settled.
    const back = await client.switchCodexNative(kai.accountId, kai.loginId);
    
  • stop() closes the daemon, stops the fake Codex app-server and removes the directory with everything in it; the process then has no handle of the authority left, and no fake Codex process runs.

The fake provider, the fake Codex and the plain encoding live in this entry alone; the package root never loads them.

The one-time account import

The three authority import commands move the accounts that existed before the authority into it.

import inventory reads this host and changes nothing: no authority record, no legacy file, no credential. It lists every Codex stash directory, saved credential record and native Codex, Claude and OpenCode store with the authority account it resolves to, derived from the claims the stored token already carries -- no provider is contacted. A malformed or unrecognised entry is reported rather than skipped, so an empty result is never mistaken for an empty store. The report carries no token, no token digest and no source path; a source is named by a hash of its location and a digest of its bytes.

Each source is classified by who refreshes its login after the import.

  • adopt -- a copy authswitch made for itself, and AGL's own records. Nothing else refreshes such a copy, so the authority takes it over outright. It is verified by one real refresh, which rotates the token: from that moment the legacy copy is dead. That is the point of no return for that account, and the only fallback is one device sign-in.
  • project -- a native Codex, Claude or OpenCode store. The vendor tool keeps refreshing its own store, so the authority registers the account, leaves the grant with its native owner and holds no copy of the credential. It is verified by a provider read with the access token already in the store, which rotates nothing. Two refreshers on one rotating refresh token is the failure this avoids.

A verified Claude projection also adopts the native home it came from, so the daemon may serve Claude switches for it. The receipt is what adopts it, and a home belongs to one login at a time: a second home for a login another one already holds -- a .credentials.json copied to another CLAUDE_CONFIG_DIR -- and a home whose active login has since moved are both refused with the importer's own code, so the submit is known to have decided, the ledger row stays verified and no home changed.

A verified Codex projection adopts the host's Codex home the same way, in one commit with its login becoming codex_native, so the daemon may switch it (see "The host's Codex home"). A Codex home imported before 11.3.0 is adopted by submitting its source again: its row stays complete, and the bytes Codex has written since are proven with the same provider read before the home is adopted.

import submit takes the source id and the digest the owner read in the inventory. The daemon re-reads the source itself and refuses if the digest changed, so approving one source can never import a different one; no credential crosses the socket for a source this package can read. It refuses while an authswitch watch or a native harness process that owns the source is running. Codex's app-server is the one refresher the import restarts itself: for a native Codex store or a saved OpenAI login -- the sources that app-server refreshes -- the submit stops it with codex app-server daemon stop, reads the source again and checks its digest once nothing can refresh it, imports it, and starts the app-server again with codex app-server daemon start, also when the import fails or refuses after the stop. Codex restarts; running Codex turns end. An app-server that was not running stays stopped. The start runs in its own transient scope of the user's systemd manager (see "What a switch actually does"), so the app-server outlives the authority's service. The result's codexAppServer says which happened -- not_running, restarted, restarted_unscoped or start_failed, the last with the reason among its problems -- and codexAppServerDetail why it runs without a scope of its own (unsupported_platform or user_manager_unavailable) and which environment it got (preserved, or allowlist with the reason); both are absent for every other source. A refusal after the stop says in its text whether the app-server came back. The submit waits as long as Codex's own bounds allow the restart; a caller whose answer is lost reads the source's ledger row with authswitch authority import status and whether Codex's app-server runs with codex app-server daemon version. Everything else that would refuse the submit refuses before Codex is stopped. The import still refuses, and stops nothing, where a restart is not safe: when Codex cannot say whether its app-server runs (an app-server it cannot ask counts as running), and when the app-server on this Codex home's control socket is one codex app-server daemon does not manage -- daemon version names no backend for it, and Codex's own daemon stop refuses it with "app server is running but is not managed by codex app-server daemon". Codex answers codex app-server daemon version only while its app-server runs; a stopped one makes it fail on the control socket, and that failure counts as stopped only when no control socket exists in the Codex home and neither daemon pid file names a live process. An adopt whose refresh fails, or whose answer is lost, is never retried: the source is quarantined, its grant is left needing re-authentication, and the command says so. A projection fails differently, because nothing rotating was ever sent for it: a native store that could not be proven live stays a pending native owner and is imported by submitting the same source again once its own tool has refreshed it. That refresh rewrites the file, so the retry carries the digest the inventory prints afterwards: a row waiting for its native owner follows its own tool's bytes, and nothing else does. What it never follows is another login. A row names the account and the grant it was approved for, and a source that now holds a different account's login is refused with that stated -- before the account is registered, because the provider read that proves a projection asks about the identity the new bytes themselves carry and would prove the wrong login perfectly well. Neither that refusal nor "already imported" ever clears: no route forgets a row or re-points it at another account.

import status reads the migration ledger, the grants and the handoffs -- never the host -- and reports, per source, where it stands, which owner refreshes its login now, and what to do next: nothing, submit the same source again, or sign in to that account again. Each entry also carries the source's sourcePathHash, so a backend that submitted an external source finds its own record by sourceKind and that hash -- also after a submit whose outcome it never learned -- without deriving the ledger row id. Submitting a source again is how an interrupted run resumes, and the answer comes from the same durable record the daemon decides on: a source whose sign-in was in flight when the run died is reported as needing a device sign-in, not as resumable, because a rotating refresh token is never sent a second time. Sources that were never submitted have no ledger row and appear only in the inventory.

An import deletes nothing: retiring the legacy files is a separate, later step. The authority store is deliberately not backed up, so losing it costs one device sign-in per account -- and the remote-control enrollments and runtime bindings with it.

Where a daemon reads those stores is stated by whoever builds it, never defaulted. The legacy stores are this user's real credentials, and every location they are found at comes from the environment, so AuthSwitchAuthorityDaemon takes a required legacyImport: either the locations to read (an env and an absolute homeDirectory, plus the provider operations that import uses) or 'none', which builds a daemon with no account import at all and answers import inventory, import status and import submit with the import_refusal code -- a decided refusal of the closed set, never an empty inventory and never a fault, so a caller reads it exactly as it reads every other import refusal. A construction that names neither -- no locations and no 'none' -- is refused before the daemon exists, rather than reading whichever host happened to start it. The per-user daemon this package installs -- authswitch authority daemon, the service unit's own entry point -- is the one place that takes those locations from this host.

AuthSwitchClient.getUsage(accountId, loginId) reads OpenAI managed limits or a Claude host login's profile and usage through the management socket. The tagged, credential-free reading preserves Claude's labelled five-hour, weekly and model windows, plan provenance, extra usage facts and explicit partial availability. Missing values never become zero. The daemon caches one result in SmartData for a bounded current and stale period; a new reading emits one account event without changing the account row revision. Concurrent reads and rejected-token refresh share the existing provider and authority owners; usage adds no polling timer. Native Claude reads hold the qualified vendor-home lease through both status requests and publication, and revalidate the exact credential and config source even when serving a cached result. Native grants are never refreshed by the daemon. Pending, quarantined, setup-token-without-profile-scope and other unsupported contexts return explicit unavailable status without a provider request. Production native home adoption still requires the separate verified migration cutover.

AuthSwitchClient.startPreuse() accepts one client-generated operation UUID and one exact OpenAI account/login/purpose. The daemon resolves an access-only grant, optionally reads the model catalog, then commits request_may_have_started immediately before FlexHarness sends one inference with retries disabled. Reusing the UUID with the same request returns its durable receipt; changing the target, prompt or requested model is rejected. A crash, cancellation or shutdown after the marker remains outcome_unknown and is never replayed. Before-marker refusals are not_sent. A reached usage limit is named in the receipt: problem is usage_limit or rate_limit and limit carries { kind, window, resetsAt } (null otherwise). The account's usage reading stops the prompt before the marker (not_sent); a provider that refuses the prompt with a limit before it begins to answer settles it refused, a terminal state in which nothing was consumed. Only the request hash, selected model, timing and safe token counts are stored; the prompt, generated prose and credentials are absent from persistence and management responses. Account removal and targeted reauthentication refuse while that account has an active preuse request.

A trusted in-process daemon host can call AuthSwitchAuthorityDaemon.startManagedCodex() for a bound account and workspace. It owns a private Codex app-server, passes only the authority's current access token through Codex's external-token login, answers its unauthorized callback through the authority, and revokes the binding on stop. The returned runtime exposes an interactive codex --remote unix://… launch descriptor and native thread/start, thread/resume and turn/start methods for noninteractive consumers. A backend in another process asks the running authority for the same runtime over the management socket: AuthSwitchClient.startManagedCodex({ accountId, loginId, purpose: 'openai_managed', scopeId, codexHomeDirectory }) (authswitch.authority.codex.start) answers { scopeId, accountId, loginId, codexHomeDirectory, socketPath } and the backend attaches to socketPath as an app-server client (for example with @modelprofile.com/mcp-crossharness's CodexAppServerClient), stating each thread's working directory itself. The authority keeps the login, answers Codex's token refresh and owns the process; it leaves every other server request to the attached clients, and a client that does not own the login leaves the refresh request, which Codex sends to every client, unanswered. Asking again with the same scope, account, login and home answers the same runtime, which is how a backend that restarted finds it again; another account or home on a running scope is account_busy. stopManagedCodex(scopeId) (authswitch.authority.codex.stop) stops it and releases its binding. The runtime also ends when the authority stops, and the backend asks again once it is back. The socket path is a local control endpoint, not a credential, and stays with a backend of the same user. The daemon runs managedCodexExecutable (the service states AUTHSWITCH_CODEX_BIN or codex). Callers supply a private, durable codexHomeDirectory for Codex's own configuration and conversation state. SmartData binds that home immutably to one account and scope, and the daemon records its active process/socket identity before launching it. Admission refuses an existing nonempty native home or a surviving app-server owner; a fresh empty home is created on first use. A removal or re-sign-in of an account that goes ahead stops that account's managed Codex first, as a Codex switch restarts Codex: running turns end, attached clients are disconnected, and until the device operation settles or the removal finishes, starting Codex on the account is account_busy, so a client asks again then, as after any stop. One its checks refuse -- a stale revision, a login that is not the account's, a binding its holder still has to release -- stops nothing. A durably recorded managed Codex process this daemon does not run -- one left by an earlier run of it -- refuses both with account_busy until its exact process is proven gone; an uncertain launch remains blocked. An account keeps its login id when it is removed and signed in again, so a home bound to it serves it again. Only the private socket directory is removed on close. Codex's credential store remains ephemeral and does not read or write auth.json in that home. Codex binds the app-server socket in its own per-user directory, <realpath /tmp>/codex-daemon-<euid>, and publishes the private socket path as a link to it. The daemon checks that publication with @modelprofile.com/mcp-crossharness's observeCodexUnixSocket and connects only when the link is exactly Codex's own, in a directory other users cannot rewrite, and the socket behind it is the user's own in a 0700 directory of the user's; anything else fails the start with that package's error. Codex keeps a zero-byte startup lock in that directory for each socket path it has served; the file is Codex's own and authswitch does not remove it. Managed Codex runs Codex 0.156.0 or newer -- the floor @modelprofile.com/mcp-crossharness's requireCodexVersion checks, the first release that publishes the socket alias -- and checks the contract at every start instead of pinning one release. Before anything is recorded or started, the codex it will run must report a readable version at or above the floor, and its own protocol description (codex app-server generate-json-schema) must still offer every surface managed Codex uses: the requests account/login/start, thread/start, thread/resume and turn/start; the external ChatGPT token login with the fields it sends; the account/chatgptAuthTokens/refresh callback with the unauthorized reason and the reply it gives; and the thread and turn parameters it sends. After connecting, the app-server must report exactly the version that was checked, because Codex can replace itself between the check and the start. Each failure is the codex_unsupported refusal naming the version or the missing surface, so a Codex update that drops something managed Codex relies on stops new managed sessions by name instead of failing one later. A prerelease above the floor runs; a prerelease of 0.156.0 itself is below it. Managed-Codex process ownership observation is qualified on Linux and reads exact argument boundaries from /proc; on other hosts doctor reports the process observation as unknown, and authority actions that require survivor proof refuse instead of guessing. Codex (verified through 0.157) does not persist an unused thread created by thread/start; resume applies after Codex has committed a rollout. The existing CLI has not yet been moved to this path.

This SDK is additive at this stage. The commands documented below still use the existing credential stores, except that the legacy fence below refuses them a login the authority holds. The authority daemon does not import those stores automatically, replace their active native sessions, or take over their refresh grants. An active Codex/OpenCode native grant remains pending until its owner is idle and an explicit handoff verifies the latest native source. Claude Code's own native credential files remain vendor-owned; a container setup-token grant is a separate grant under the same account. The one-time migration and deletion of authswitch's old stores are separate cutover steps.

The backend-only Claude native handoff journals an exact two-file write and transfers ownership between two account grants in one SmartData transaction. An inactive Claude grant is refreshed by the daemon only after a durable pre-send marker; pending, native, and quarantined grants cannot enter that exchange. A native home and incoming grant require a verified import receipt: a migration ledger row that binds the exact source digest to one account and grant. A source counts as migrated only after such a verified import. authswitch authority import submit writes one for the host's Claude home and for every saved Claude login it imports, and a Claude sign-in writes one for the login it imports (see "Adding a Claude account"). The installed daemon serves this host's Claude home, and switches it only once such an import has adopted it. Handoff receipts are queryable after a lost response. runningEffectiveAuth: unsupported_effective_auth means the native store changed, while an already-running Claude process's effective login cannot be proven externally; qualified live native-store switching still works.

The authority database admits exactly the collections of one registry and prepares each at startup. Startup rejects unknown collections, unknown metadata schemas and an existing store without its metadata document. Published 7.0.0 metadata is upgraded in place to the shape 6.4/6.5 already wrote, while account IDs, sealed grants, enrollments and uncertain states remain intact. A 7.0.0 store that a restore had marked closed is refused, not opened. Startup also removes the backup digest that 6.4.0 to 7.0.0 stored on native handoff and migration ledger rows, and refuses a store holding a record only the retired backup and restore system could have written. The store is deliberately not backed up: its refresh grants are sealed to this host's TPM, so a lost store is recovered by one device re-login per account.

The legacy fence

Once the authority has taken a saved copy over, the legacy save and switch must not write it again: an adopted copy's refresh token was rotated away by the import, and activating it would hand a tool a dead login. runAuthSwitchMutation -- the one path the command line, the guide, watch and the hosted AuthSwitchService all take -- asks first, and decides in three branches.

  • The authority answers: it is asked for this record's ledger row through import status on the management socket, keyed by the same source id the importer uses (a hash of the saved copy's path). A verified or complete row refuses with authority_holds_login; any other row, or none, lets the command run. An authority that answers decides, whether or not its service unit is installed.
  • Nothing answers within four seconds -- no socket, a silent one, or no XDG_RUNTIME_DIR -- and the authority's service unit is installed: the command refuses with authority_unavailable and the instruction to run authswitch authority service start. Unknown is never treated as idle.
  • Nothing answers and no unit is installed: the legacy commands behave exactly as before. This is every host before the cutover.

What tells the second branch from the third is durable, never the socket: the runtime directory is tmpfs, so after a reboot the socket is absent until the user service starts -- exactly when a fence keyed on it would open. The evidence is the unit authswitch authority service install writes, $XDG_DATA_HOME/systemd/user/authswitch-authority.service (default ~/.local/share/systemd/user/authswitch-authority.service), read through smartdaemon's SystemdUnitFile.inspect(). The installer and the fence derive that directory from one rule, $XDG_DATA_HOME or else the home directory; the installer takes both from its own process and its account's home, the fence from the locations its caller states. The two agree unless $HOME is overridden while XDG_DATA_HOME is unset, and then every store the fence guards lies under that overridden home too. The fence writes nothing and keeps no record of its own.

The check works without a login session. Reading the unit needs only its directory, so a cron job or a plain ssh command with no XDG_RUNTIME_DIR is decided by the unit exactly like a desktop session: no unit, the legacy commands run; the unit installed, they refuse with authority_unavailable. The stated XDG_RUNTIME_DIR names only the socket to ask; an absent one is never replaced by this process's own. The answer depends on the unit directory derived from the stated XDG_DATA_HOME or home, and on nothing else: a malformed XDG_RUNTIME_DIR or XDG_CONFIG_HOME never refuses the command, and neither does a malformed process XDG_DATA_HOME when the caller states other locations. A unit that cannot be inspected -- a file the installer would not have written, a directory another user can write -- refuses rather than being read as absent, and so does a stated XDG_DATA_HOME that names no usable unit directory, because the reader can then tell an installed authority from none no better. The refusal says why after its first sentence: a path smartdaemon will not vouch for is named with the reason and, where a generic fix is safe, the command -- for a group-writable ~/.local, "/home/you/.local is writable by its group or by others, so nothing beneath it can be trusted; remove that with chmod go-w /home/you/.local and run this again." A unit file with another mode than 0644 gets chmod 0644; a symbolic link, a foreign owner or a file the installer could not have written is named without a command; any other failure is named by its error class and code. authswitch authority service install explains its failures in the same words. It also refuses to replace a unit that is in use or not in a state systemd can replace safely, names smartdaemon's reason and what to do about it: for a running service, stop it with authswitch authority service stop, install again, then start it with authswitch authority service start; for a queued job, wait for it; for a pending reload, run systemctl --user daemon-reload; drop-in files and a unit systemd loads from another file are named. On a platform without systemd no unit can be installed.

The unit says an authority exists, not which stores it imports from, so while an installed authority is stopped the legacy save and switch refuse for every saved copy of every store, until it runs again. A record the importer could never have read -- an account id that names no saved-copy file -- is never asked about.

drop / rm and the remove mutation are deliberately not fenced: they delete the user's own saved copy and write no credential. A projected source is never fenced either -- its native store stays with its own tool. The command line takes this host's locations only when it composes its own harnesses, and with injected harnesses is fenced only by locations its caller states; AuthSwitchOperations, runAuthSwitchMutation and an AuthSwitchService given its harnesses take the locations as a required argument (authSwitchHostLegacyFence() names this host's, 'none' states that a caller owns no authority), so no library path reads a host it was not given. AuthSwitchOperations and the hosted service ask the fence before a coordinator they were given as well, so a refused mutation never has its host stop the runtimes it would restart. The hosted service reports a fence refusal as the operation's problem, in the instruction's own words.

Usage

Run authswitch, authswitch -i, or authswitch --interactive for an interactive guide. Use the arrow keys and Enter to switch accounts, save the current login, list accounts and live status, check remote control, preuse one or all accounts, or remove a saved account. The guide shows the current account and returns to the menu after each successful action; choose Back in a submenu or Exit to finish.

Before showing the actions, the guide checks whether the current credential has a readable saved copy of its own account. A login without one gets an offer to save it while keeping it active; a login its harness merely refreshed is already saved. The switch action checks again before offering targets; declining that save cancels the switch. Scripts still preserve the outgoing login automatically through the harness adapter.

When saving, choose whether to keep the account active or clear the active login so you can run codex login for another account. Removing a saved account requires confirmation and leaves the active login in place.

The guide requires an interactive terminal. With no arguments, piped output and CI runs show help instead; explicitly requesting -i, --interactive, or --tui without an interactive terminal fails. Explicit commands remain available for scripts:

authswitch                        # interactive guide
authswitch -i                     # explicitly open the guide
authswitch codex --interactive    # guide for one harness
authswitch --tui                  # full-screen account management
authswitch codex --tui            # start the dashboard on Codex
authswitch list                   # all known accounts across registered harnesses, with live status
authswitch list --json            # complete account/status document for scripts
authswitch limits                 # every account and limit type: used % and reset countdown
authswitch active                 # which account each provider is using right now
authswitch codex stash            # save the active credential under its account email
authswitch codex list             # Codex accounts, including an unsaved active login, with live status
authswitch codex use [email]      # activate one; prompts when no email is given
authswitch opencode use [email] --stop        # stop running OpenCode instances first
authswitch opencode use [email] --keep-running  # switch without stopping anything
authswitch codex preuse <email>   # send the default prompt without activating the account
authswitch codex preuse --all     # the same for every Codex account, one account at a time
authswitch watch                  # check usage every 2 minutes and switch when an account runs out
authswitch claude watch --threshold 90 --dry-run  # report what it would switch, for one harness
authswitch codex current          # print the account currently in use
authswitch codex drop <email>     # forget a stash
authswitch codex doctor           # report credential storage and remote-control state
authswitch opencode list          # all OpenCode provider logins and saved accounts
authswitch opencode stash openai --keep
authswitch opencode use alice@example.com
authswitch opencode login openai  # device login; save the new account without switching
authswitch codex login            # save a new Codex account without disturbing the current login
authswitch claude stash --keep    # save a Claude Code subscriber login, while it keeps running
authswitch claude list --json
authswitch claude use alice@example.com

OpenCode and Claude Code

Both adapters support current, stash, use, drop, doctor, the guide, the management TUI and human/JSON lists. The command line carries out every credential change itself; it asks no other program, AGL included, to carry it out. Every operation, including use and stash, works while OpenCode or Claude Code is running: the credential file is written atomically, the outgoing login is re-saved from disk first, and a write that races a native rewrite is detected and refused rather than half-applied. A running Claude Code picks a switch up on its next request, so it never needs a restart. Status lookups never activate, switch or clear a login; reading a saved Claude login whose access token has expired does renew that saved copy's own tokens, and nothing else. preuse remains a Codex capability.

Running instances during a switch

Claude Code picks a switch up live. A running session re-reads its credential file whenever the file changes, before its next request, and reloads .claude.json when that changes (verified against Claude Code 2.1.273 and 2.1.282). So use, stash and a backend activation never ask for Claude Code to be stopped and never suggest a restart; a switch reports Running Claude Code sessions use it from their next request. What authswitch must not do is write in the middle of that session's own writes, so every Claude Code operation that reads or writes the native login runs while holding Claude Code's own locks, taken with its own library and options: the OAuth refresh lock (<config dir>/.oauth_refresh.lock and its legacy twin <config dir>.lock), the credential write lock (<config dir>/.storage-write.lock) and the global config lock (.claude.json.lock). A lock Claude Code holds is waited for up to 15 seconds, without holding the others meanwhile, so a live session is never blocked by a waiting switch; after that the operation fails with nothing changed. A lock left behind by a Claude Code that died goes stale after its own threshold and is taken over, as Claude Code itself would.

A running OpenCode can still overwrite a switch. Not by holding the previous login: its auth service caches nothing and re-reads auth.json on every lookup. The window is a request already in flight, which read the previous login before the switch and can write that login's own rotation back afterwards -- and OpenCode replaces the whole provider map in place rather than atomically. So before use and before stash without --keep, authswitch lists OpenCode's own running instances -- pid, start time and command line -- and offers to stop them:

2 OpenCode process(es) are running:
  pid 4821  started 2026-09-16 14:02 UTC  opencode
  pid 5533  started 2026-09-16 14:02 UTC  opencode serve
? Stop these 2 OpenCode process(es) first? (y/N)

Answering yes sends SIGTERM to exactly those pids and waits up to ten seconds; an instance that survives is reported by pid and the switch continues. Three flags decide the same thing without a prompt:

Flag Effect
--stop stop the listed instances gracefully, then switch
--force-stop the same, then SIGKILL the ones that ignored SIGTERM
--keep-running switch without stopping anything

--stop and --force-stop also stop Claude Code's instances when you ask for it; without them nothing about Claude Code's processes is listed or offered, and --keep-running is accepted and changes nothing.

Without a terminal and without a flag, nothing is ever signalled: the instances are listed, the switch proceeds, and the command says which flag would have changed that. SIGKILL is only ever sent for --force-stop. Only pids authswitch enumerated for the current user in that operation are signalled -- never a name or pattern match, never another user's process -- and a pid is re-verified against a fresh listing before a forced kill, so a recycled pid cannot inherit it. The session that is running the command is listed but never stopped: stopping it would kill the switch. On Windows the instances are listed but not stopped, because its process list does not identify their owner. stash --keep needs none of this: it writes no native file and is guarded by proving the native sources unchanged around the read.

After an OpenCode switch that left instances running, the command says so explicitly, because one of them refreshing its token can put the previous account back. A backend activation through AuthSwitchService gets that same caveat in its outcome, but no listing and no offer: a host has no terminal to consent with, and a supervisor that completed the mutation itself owns its own runtime's lifecycle.

authswitch codex login and authswitch opencode login openai use the shared OpenAI device login flow. They display a verification link and code, then save the new account without changing the active native login. Ctrl-C cancels login. Select the saved account with use when ready to switch. Claude Code and other OpenCode providers retain their native login commands.

The ChatGPT sign-in

authswitch runs the ChatGPT sign-in itself, as the one party that signs in and refreshes: the device code and its poll, the authorization-code exchange, the refresh-token grant, the revocation, the claims of the tokens, and the account's wham/usage limits. AuthSwitchOpenAiProvider is that provider -- beginLogin, refreshCredential (one shared request per refresh token, so a rotating grant is sent once), revokeCredential, inspectCredential and getAccountRateLimits -- and interfaces.openai holds its types. It holds no credential of its own. The protocol is the one Codex's own login uses, adapted from OpenAI Codex (Apache-2.0; see third-party-notices.md), and every failure is a fixed message that carries no response body, token or header. Inference with a login's access is not auth: FlexHarness's /openai module serves it from { accessToken, accountId, isFedrampAccount } alone, which is exactly what the authority's resolveAccess answers.

The protocol itself lives in @modelprofile.com/authswitch-signin, published from this repository's ts_signin folder and shipped inside this package as well. It also serves applications that sign accounts in on a server: a device sign-in whose state any process can finish after a restart, and OpenAiGrantManager, which keeps a grant usable for many processes over a store the application supplies. See its readme.

Hosted account management

AuthSwitchService exposes credential-free account lists, login capabilities, login prompts, saved-account identifiers and mutation outcomes for authenticated hosts such as AGL. Every request has protocolVersion: 1. list, login and mutate start bounded background operations; get polls the returned operation ID and cancel cancels a pending login. Operations outlive a disconnected client, and completed results remain available for ten minutes. Call close() during host shutdown. The host must authenticate and authorize every request.

Login requests identify the harness, provider and flow explicitly; the one flow is device, which needs no browser on the machine that signs in. AuthSwitchLogin is the backend login owner. Its default provider is AuthSwitchOpenAiProvider, authswitch's own ChatGPT sign-in (see "The ChatGPT sign-in"), and it accepts any other provider that implements IAuthLoginProvider. Its credential-bearing completion is for backend storage only. When an enclosing registry owns an injected adapter, construct it with { disposeProviders: false } as the second argument and dispose that registry after closing the login service.

OpenCodeHarness.importCredential(providerId, credential) saves an inactive login. activateCredential(providerId, credential) preserves and verifies the outgoing native login before activation; the caller retains ownership of the incoming credential's durable storage. Both are backend APIs, never wire payloads.

AuthSwitchOperations is shared by the command, guide and TUI, and is built with a coordinator (or undefined) and the legacy fence locations described in "The legacy fence". A host that injects its own harnesses into AuthSwitchService states that fence as the third argument. The coordinator receives only the harness, operation, account ID, consent to wait, and a credential-location fingerprint. A failed or ambiguous transport never falls through to a second local mutation. Nothing passes a coordinator by default: the authswitch command line builds its operations with none and carries out each mutation itself. AglAuthSwitchCoordinator, the bridge that ran agl authswitch --request <json>, is deprecated because AGL no longer serves that command; it and the coordinator parameters of AuthSwitchOperations, AuthSwitchCli and AuthSwitchService are slated for removal in the next major.

authSwitchMutationReplacesLogin(mutation) says whether a mutation replaces the harness's active login -- switch, or save without keepActive -- and therefore concerns the instances running on it. The command line, the dashboard and a host such as AGL all read this one rule; remove and a save that keeps the login active leave the loaded login in place.

That rule, assertAuthSwitchMutation and the TAuthSwitchMutation command are also published on their own at @modelprofile.com/authswitch/mutation, which a browser can bundle, as it can the preuse sentences at ./preuse-text:

import { authSwitchMutationReplacesLogin, type TAuthSwitchMutation } from '@modelprofile.com/authswitch/mutation';

That module imports nothing at all -- no Node built-in, no package, no other module of this one -- so a web client decides what a mutation means to the instances it shows without pulling a credential store into the bundle. The package root stays Node-only: it reaches the stash, and with it node:fs, node:child_process and node:crypto. Both entry points export the same three names from the same source, so a Node caller can keep importing them from the package root.

OpenCode credentials live in ${XDG_DATA_HOME:-~/.local/share}/opencode/auth.json. Several providers can be active at once. Saving or switching openai preserves every other provider, and stash openai without --keep clears only OpenAI. When several logins are active, the guide asks which to save; scripts must name the provider or account. In the TUI, select an active row before pressing a or c. ChatGPT OAuth accounts display their email and use the same direct status APIs as Codex. An anthropic OAuth login holds the same Claude subscriber grant Claude Code stores, so it reports Claude's own plan, organization and usage windows through the account endpoints described under Claude Code; OpenCode keeps no scopes and no account identity with that entry, so both are left to the service. An access token inside Claude Code's five-minute margin is renewed first, with the request OpenCode's own Anthropic provider sends (opencode-anthropic-auth: console.anthropic.com, Claude Code's public client, no scope), and the rotated pair is written back into auth.json atomically, preserving every other provider. Nothing is stranded by that write: OpenCode's auth service re-reads auth.json on every lookup and its Anthropic provider asks for the credential again inside every request, so a running instance picks up the rotation exactly as it picks up its own. A refusal is reported and nothing is written, and a saved copy is never renewed, because OpenCode's own entry holds the same refresh token. API keys and opaque provider tokens have no reliable email, so their labels contain the provider and a SHA-256 fingerprint. Their quota/billing information is explicitly unavailable. OPENCODE_AUTH_CONTENT overrides the file and blocks file management. Provider configuration and environment credentials remain managed by OpenCode.

Claude Code supports subscriber file logins on Linux and Windows. The adapter pairs ~/.claude/.credentials.json with ~/.claude.json's oauthAccount; with CLAUDE_CONFIG_DIR, both files are inside that directory. Switching preserves unrelated settings and credential entries and clears native account-specific caches. A metadata write failure rolls back our writes and keeps the outgoing account saved. macOS Keychain switching and environment/API-key/cloud-provider logins are explicitly unsupported. A file login shadowed by an environment override cannot be switched. User and current-directory project settings are also checked for credential helpers and provider environment overrides; helpers are never executed. Authswitch manages the local file login, so launch-time flags, managed policies and credentials supplied by embedding applications remain under Claude Code's control.

Claude's direct OAuth profile and usage lookups report the plan, weekly and five-hour usage, per-model and per-surface windows, and available extra-usage budget information. The usage response's limits[] rows are the authoritative window list: which meters apply, their scope, the service's severity (normal, warning, critical) and its headline pick are Claude's, so a new per-model weekly limit such as Fable weekly appears without an authswitch release. list --json keeps the rows in the service's order; the tables and limits --json order them as described under Condensed views. Rows are classified on their kind and reset group exactly as sent. Labels combine the service's model or surface name with the window period, and such a scoped row is treated as a feature limit. Rows in a reset group authswitch cannot represent (anything but the five-hour session and the weekly window) are not shown; one Unsupported limits availability fact names them. A malformed row, or a limits value that is not a list, makes the whole usage lookup unavailable rather than partially guessed. Responses without limits[] rows fall back to the older keyed weekly, five-hour and model windows. These endpoints require the user:profile scope and may reject expired or restricted logins. A null reset timestamp is shown as Not scheduled. Billing renewal and cancellation dates and earned reset credits are unavailable from these endpoints; authswitch does not infer them from account creation or token expiry dates. The Claude Code adapter declares that, so limits explains it once for the provider instead of per account.

Nothing but Claude Code refreshes a Claude login, so a saved one stops being readable a few hours after it was saved. Reading a saved, inactive account's status therefore refreshes it first, exactly as Claude Code would: when its access token expires within five minutes, authswitch exchanges the stored refresh token at Claude's token endpoint, keeps every other field of the login, and writes the result only while the saved record still holds the refresh token it sent. The request is the one Claude Code sends, down to the scopes: a login of Claude Code's own OAuth client is renewed with Claude Code's current scope set plus the project scopes the login carries -- not with the set the login happens to be stored under, which can be an older or wider one the service no longer accepts -- and a set the service refuses as invalid_scope is sent once more with the stored scopes. A login that names its own OAuth client keeps its stored scopes, because only that client's grant knows them. A login that is neither one of Claude Code's subscriber logins nor stored with a plan is not sent at all. The refresh holds the account store's lock, so a switch started at the same time waits for it and then activates the refreshed login. The active login is never refreshed -- Claude Code owns it, and two parties refreshing one login can invalidate the grant. A refusal is reported, never worked around -- a rejected grant says so and says what fixes it (below), and a service failure, an unreachable service or an unsupported response leave the saved login untouched. Requests carry User-Agent: authswitch/<version>, and HTTP 429 is reported as rate limiting with the service's retry time -- never as an exhausted or expired account.

Claude Code also rotates the refresh token of the login it is running on, so reading the accounts (list, limits, active, current, watch, the dashboard) mirrors that login into its own saved record whenever the two differ. Without it a login replaced outside authswitch -- a /login in Claude Code, a new device login -- left the stash holding the token from before the rotation, which the sign-in service can refuse: it did for two saved logins on 2026-09-18. The record keeps its account, its identity and its save time, Claude Code's own files are only ever read, and a login with no saved record of its own is never saved implicitly; stash is what saves a login. An installation whose native login authswitch cannot read mirrors nothing, and a read waits at most a second for a lock a switch or Claude Code holds before leaving the saved copy to the next read.

Codex is mirrored the same way, for the same reason: its app-server refreshes the login it runs on its own schedule, so the saved copy of that login held older tokens between switches -- and, because a saved login used to mean the exact credential in ~/.codex/auth.json, an account read as unsaved from its first refresh on and the guide offered to save what was already saved. A saved Codex login is now one whose stored credential belongs to the account its record names, and reading the accounts writes the refreshed credential into that record. Only the saved copy is written: ~/.codex/auth.json is read, no app-server is stopped or signalled for it, and the record keeps its account, its remote-control enrollments and its save time. A login with no saved record of its own, a record whose credential is missing, and a record that belongs to another Codex account with the same email are all left alone; stash is what saves or repairs those. A copy that could not be written says so beside the account and is tried again on the next read.

An account the service has put on hold is not a dead login: the hold refuses the grant while it lasts and the same login works again once it is lifted, so such an account keeps its stored plan, says that it is on hold and where to appeal, and is asked again on the next read.

A grant the sign-in service refused is remembered in the saved record itself, as a hash of the request that was refused -- the refresh token, the client and the scopes, never the token itself -- so a new authswitch run, a restarted watch or a second dashboard does not replay a dead login. Because the hash covers the request and not only the token, a release that changes how authswitch asks gives every such login one honest attempt under the new derivation, while a refusal of the request authswitch still sends stays remembered. Saving that login again clears it, as does any refresh that replaces the saved credential. Such a login reports Login expired or was rejected. Log in again with Claude Code and save it. Claude Code rotates a login's refresh token as it uses it, and the service can refuse the older one a saved copy still holds once that login has been replaced outside authswitch; authswitch claude stash --keep saves the new one.

Each n/a in a Claude row has a reason in the footnote under the table, and each reason names what to do:

  • Login refresh: Login expired or was rejected. -- the sign-in service refused the saved refresh token itself. Log in again in Claude Code for that account and run authswitch claude stash --keep; nothing else fixes it.
  • Login refresh: This Claude account is on hold and cannot sign in. -- the account, not the login. Appeal at the address in the message; the saved login is kept and is asked again on the next read.
  • Login refresh: This saved login has no refresh token, and no subscriber scope or plan to refresh with. -- the saved record is missing what a refresh is made of. Save the login again.
  • Login refresh: this saved login's access token has expired and cannot be renewed for this installation. -- authswitch cannot read Claude Code's own files here (the macOS Keychain, or an incomplete native login), so it cannot prove this login is not the active one, and never refreshes a login that might be.
  • Login refresh: The Claude sign-in service returned HTTP ..., ... could not be reached., ... timed out., ... is rate limiting requests (HTTP 429) -- the service, not the login. The saved login is untouched; read again later.
  • Profile: ... or Usage: ... -- the account endpoints, asked with a token the refresh had just accepted. Profile: Login expired or was rejected. means the access token itself was refused; the other wordings are the service, as above.
  • Claude returned a different account or organization -- the answer was about another account and was discarded whole, rather than shown under this account's name.
  • Claude profile response is unsupported or Claude usage response is unsupported -- the service answered in a shape authswitch does not read. Nothing is guessed from a response it does not understand.

An OpenCode anthropic row reads the same endpoints and so shows the same reasons. Its renewal adds Login refresh: Login expired or was rejected. Log in again with opencode auth login., which is Anthropic refusing the refresh token OpenCode's entry holds -- no retry changes that, and the refusal is never remembered, because OpenCode owns that entry. This saved Anthropic login's access token has expired. is a stashed copy, which is left alone so that the login OpenCode uses keeps a refresh token the service still accepts.

OpenCode and Claude saved records are separate owner-only files under ~/.authswitch/opencode and ~/.authswitch/claude. Emails are accepted as account references; ambiguous matches report provider-qualified references and full IDs.

Restarting Codex's app-server

A host that restarts Codex on request -- AGL's "Restart Codex" -- calls CodexDaemon.restart(), which runs the same codex app-server daemon commands a credential change runs around it, with nothing changed in between:

import { CodexDaemon, CodexHome, CodexAppServerRefusal } from '@modelprofile.com/authswitch';

const daemon = new CodexDaemon(new CodexHome(codexHomeDirectory));
try {
  const outcome = await daemon.restart(); // ICodexAppServerRestart
  // outcome.restart: 'restarted' | 'restarted_unscoped' | 'start_failed'
  // outcome.wasRunning: whether a running app-server was stopped first
} catch (error) {
  if (!(error instanceof CodexAppServerRefusal)) throw error;
  // error.reason: 'unmanaged' | 'unknown' | 'stop_failed'; error.restart: how a start after a failed stop went
}

It stops a running app-server with codex app-server daemon stop -- running Codex turns end -- and then always starts it with codex app-server daemon start, also when none was running. The start runs from HOME in its own systemd user scope, so the app-server outlives the host that restarted it; where no user manager answers it starts directly and answers restarted_unscoped with the reason. The app-server gets back the environment the running one had, read from /proc before the stop; when none ran, or it could not be read, it gets the minimal environment codexAppServerEnvironment states, and the outcome says so (environment: 'allowlist' with environmentProblem). wasRunning says whether a running app-server was stopped first, and describeCodexRestart(outcome) words the outcome, as a start where none was running. A start that fails is the answer (start_failed with problem), never a throw.

It refuses, as a credential change does and before anything is stopped, an app-server codex app-server daemon does not manage (unmanaged) and a Codex that cannot say whether its app-server runs (unknown). A stop that fails refuses with stop_failed, after starting the app-server again unless Codex says it still runs. The call takes at most codexAppServerRestartBoundMs.

Preusing an account

preuse sends one text prompt through the selected account. Use it after a reset to make the first token-consuming request for the next usage window:

authswitch codex preuse alice@example.com
authswitch codex preuse alice@example.com --prompt "Write 2000 words about strawberries."
authswitch codex preuse alice@example.com --model gpt-5.5 --prompt "Describe a strawberry in one sentence."
authswitch codex preuse --all
authswitch codex preuse --all --model gpt-5.5

The default prompt is Write 2000 words about strawberries. Without an account, an interactive terminal shows a picker of the harness's accounts, plus All accounts when it has more than one and Back. Choosing one account runs it exactly as naming it would; choosing All accounts confirms once and then runs as --all does, table and exit status included. Scripts must specify an account or --all and, when multiple harnesses are registered, the harness. Active and saved accounts are supported, including an active login that has not been stashed.

--all preuses every account the harness lists, active and saved, each exactly once -- an active login and its own saved record are one account -- and it cannot be combined with an account reference. The accounts run strictly one after another, never in parallel, each with one request and no retry, and each one is reported as it goes. The run ends with a table and a counts line:

[1/3] Preusing alice@example.com (Codex)…
Prompt completed with gpt-5.5. Tokens: 19 input, 2711 output, 2730 total.
[2/3] Preusing bob@example.com (Codex)…
Prompt completed with gpt-5.5. Tokens: 19 input, 2711 output, 2730 total.
[3/3] Preusing apikey:9f2 (Codex)…
Codex preuse requires a ChatGPT subscription login; API-key logins have no subscription reset window.

Preuse results
┌────────────────────┬───────────┬─────────┬─────────────────────┬──────────────┐
│ Account            │ Outcome   │ Model   │ Tokens in/out/total │ Next reset   │
├────────────────────┼───────────┼─────────┼─────────────────────┼──────────────┤
│ alice@example.com  │ Completed │ gpt-5.5 │ 19/2711/2730        │ 5d 18h 53min │
│ bob@example.com    │ Completed │ gpt-5.5 │ 19/2711/2730        │ 5d 18h 53min │
│ apikey:9f2         │ Skipped   │ -       │ -                   │ -            │
└────────────────────┴───────────┴─────────┴─────────────────────┴──────────────┘
2 completed, 1 skipped.

Next reset is the lead window of the reset schedule read after that account's prompt, the same window the single-account reset table lists first. An account that sent no prompt has no model, token count or deadline of its own, and says so rather than showing a zero. Skipped is a refusal the adapter stated before any request -- an API-key login, a missing or expired credential, a model catalog that could not be read, a usage limit the account has reached -- so nothing was consumed for it. Failed and Interrupted may both have consumed tokens and are never retried; Not run is an account the interruption never reached. Every account that did not complete prints its reason, and a Failed one always names its cause without the provider's text: a limit, a rejected token, the 180-second deadline, a cancellation, an answer that ended unfinished, an HTTP error status (the provider answered HTTP 503), an answer that could not be read, an error the answer stream reported with the provider's error code, a connection that failed before any answer, or an error that names no cause.

Before it sends anything, Codex preuse reads the account's usage (wham/usage). When the account's limit is reached, the account is Skipped with the window and its reset in local time, for example Weekly usage limit reached, resets in 16h 46min (2026-09-27 05:12). No preuse prompt was sent. A reading that fails decides nothing: the prompt is sent and the provider's own answer counts, except a rejected token, which is refused before sending as the catalog read refuses it. When the provider still refuses the prompt with a usage or rate limit before it begins to answer -- a 429 naming usage_limit_reached, or a stream that fails with it before its first output -- the reason names that limit and says that no tokens were consumed. A limit reported after the answer began is named the same way, with its window and reset, and says that tokens may already have been consumed: the provider had begun to answer.

The guide (authswitch, authswitch -i) offers Preuse an account or all accounts (consumes quota) for a harness that supports it. It asks which account or All accounts, confirms once and names what the prompt will be sent to, then reports exactly as the command line does: one account's reset schedule, or the results table for all of them. Back or a declined confirmation sends nothing, and an account the adapter refuses returns to the menu rather than ending the guide.

Codex inference uses the published FlexHarness models/providers packages and the selected ChatGPT login's access token and account ID. It selects the account's advertised default text model unless --model is supplied, preferring a supported low reasoning setting when the catalog provides one. API-key logins are rejected because they do not have ChatGPT subscription reset windows. Expired credentials must be renewed through Codex and saved again.

Each invocation consumes quota and sends one inference request per account. It does not watch for resets or schedule future requests. No active login is switched, credential refreshed or written, app-server restarted, or earned reset consumed. The prompt has no tools, files or conversation history. The generated prose is drained without being printed or stored; the command reports provider token counts and then reads the account's reset schedule. It does not infer that a timer started merely from a zero-percent usage reading or a successful response.

Ctrl-C cancels the request, and in a --all run it stops the run before the next account starts. Codex preuse has a three-minute deadline, with a ten-second model-catalog limit. Failed or interrupted requests can have consumed tokens and are never retried automatically. A completed prompt exits with 0 even if the subsequent reset lookup fails, reporting that uncertainty separately; inference failure exits with 1, invalid arguments or unsupported harnesses with 2, and an interrupted prompt with 130. A named single account also exits 1 when it was refused, because it is the account the command was asked to preuse; --all exits 0 when every account either completed or was skipped for a stated reason. Check authswitch list before repeating an uncertain request. --json remains a list-only option.

Watching usage and switching automatically

authswitch watch reads every account's usage on an interval and switches a harness to a better saved account when the one in use runs out:

authswitch watch                              # every harness that supports it, every 2m, at 95%
authswitch claude watch                       # one harness (also: authswitch watch claude)
authswitch watch --interval 5m --threshold 90 # check less often, switch earlier
authswitch watch --dry-run                    # report decisions, change nothing
authswitch watch --once                       # one check, then exit
authswitch watch --json                       # one JSON event per line
14:02:07 Watching Claude Code, Codex every 2m; switching at 95%.
14:02:07 Running Codex sessions keep the login they loaded; new sessions use the switched one.
14:02:09 Claude Code alice@example.com (type:Max) (5h:17%) (w:4%) ok
14:02:10 Codex switched bob@example.com → carol@example.com (type:Pro) (5h:3%) (w:12%) — Codex weekly 96% ≥ 95%
14:04:09 Claude Code alice@example.com (type:Max) (5h:21%) (w:4%) ok
14:04:10 Codex carol@example.com (type:Pro) (5h:4%) (w:12%) ok

Watch covers the harnesses whose adapter declares autoSwitch: Claude Code and Codex. OpenCode is not watched, because most of its providers report no usage and its lifecycle belongs to its supervisor. Naming an unsupported harness exits with 2, as does an interval outside 1 minute to 1 day, a threshold outside 50 to 100, or any other argument the command cannot act on. --interval takes seconds or a unit (120, 90s, 2m, 1h, 1d).

How it decides. Each check reads the harness's accounts and every account's usage, then applies one rule set per harness:

  • The account in use is used up when any of its general account windows has reached the threshold. The watch then switches to the saved account whose account windows are all at least ten points below the threshold, preferring the one whose longest window resets soonest (capacity that expires first is used first), then the least used, then by name.
  • When every other saved account is used up too, it switches to the one that is usable again first, and only when that is more than one interval sooner than the account in use.
  • An account limited only on a feature window (one model or surface) is traded for an account that is below the margin on every window; without one it stays, because it still serves everything else.
  • A reading that is unknown, too old, rejected or rate limited never counts as used up and never makes an account a target. The line then says so instead of saying ok.

What it does and does not do. A switch goes through the same path as use and never asks for a session of yours to be stopped: Claude Code picks the new login up on its next request, and running Codex sessions keep the login they loaded while new ones use the switched account (the start line says so once). A Codex switch does stop and start Codex' own managed app-server, because auth.json cannot be rewritten underneath it, so its remote-control connection drops for a moment. A switch that is refused or fails is reported and decided again on the next check. Checks never overlap; a check that takes longer than the interval is followed by the next one at once. An account whose lookup a provider refused (HTTP 429) or whose lookup failed is not asked about again until the time the provider named, or after a delay that doubles from one interval up to half an hour, and the account's last reading stands in meanwhile while it is less than ten minutes old. Claude's profile is read at most once an hour per account while watching; usage is always live.

One watch runs per AUTHSWITCH_HOME: a second one exits with 1 and names the running pid, and a lock left behind by a watch that died is taken over. Ctrl-C or SIGTERM cancels the lookups in flight, releases the lock and exits with 0. --once exits with 0, or 1 when a switch it attempted did not complete. --json writes one event per line (start, tick, switch, error) with accounts, readings and reasons, and no credential.

Management dashboard

authswitch --tui opens a resizable account table with subscription, usage and reset availability, usage bars for the lead window and the next general window, scrollable account details and an activity log. With multiple registered adapters, a harness selector switches the view; account selection and mutations always belong to the displayed harness. Status loads incrementally through each account's status API, without activating it.

Key Action
Arrows, Home/End, PageUp/PageDown Select accounts or scroll the focused view
Tab / Shift-Tab Move focus between harnesses, accounts, tabs and details
Enter Switch to the selected saved account; activate a harness in its selector
/, s, S Filter the table, cycle its sort column, reverse sorting
a / c Save the current login and keep it active / save and clear it
d Remove the selected saved copy, preserving the active login
p / P Preuse the selected account / every account of this harness (consumes quota)
r / g Refresh status / run harness diagnostics
q / Ctrl-C Close and restore the terminal

The preuse keys appear in the key line only for a harness that supports preuse, and each run states how many accounts it will consume quota on before it starts. The footer follows the run account by account, the activity log keeps each account's result, and the table is read again afterwards because the prompts moved the usage it shows. Closing the dashboard stops the run before its next account.

Save, switch, preuse and removal actions require confirmation. Confirmations start on Cancel; use Left/Right or Tab to select the action, then Enter. Switching first checks the current credential and offers to save an unsaved login. A declined, failed or unverified save prevents switching. Plain character shortcuts remain available as text while editing a table filter; Enter keeps the filter and Escape clears it. Closing waits for an in-flight operation to finish its cleanup.

Interactive mode flags accept an optional harness and cannot be combined with commands or another mode flag.

Getting your accounts into the stash

stash reads whatever Codex currently has active, files it under the account's email, and empties the active slot so the next login starts clean:

authswitch codex stash            # -> stashed alice@example.com
codex login                       # log in as the second account
authswitch codex stash            # -> stashed bob@example.com

Use --keep to take a checkpoint without logging yourself out.

Switching

authswitch codex use alice@example.com

Or with no argument, to pick from a list:

authswitch codex use

The account argument also accepts the directory slug, the local part, or a unique prefix, so authswitch codex use alice resolves to alice@example.com when that is unambiguous. It is deliberately not a substring match — this argument decides which credential becomes active, and an incidental substring hit should never pick an account you did not name.

So you know what you switch to, every harness's switch names the account with a badge of its type and its current usage:

? Which Claude Code account should be active?
❯ phil@example.com (active) (type:Max) (5h:12%) (w:75%)
  alice@example.com (type:Pro, unverified) (w:40%)
...
Now active: alice@example.com (type:Pro, unverified) (w:40%)

The badge appears in the account picker of use, in the dashboard's switch confirmation and on the Now active: line that reports a completed switch (in the dashboard, its activity log). type is the account type as limits shows it; 5h and w are the account's five-hour and weekly usage windows, rounded to whole percent -- except that a window the provider reports below 100% never reads as the exhausted 100%. Feature limits never appear in it, and when several account windows share a period without the provider picking one of them, that part is left out rather than guessed.

Usage is read live and once per command. The picker looks up the offered accounts, at most four at a time, before it asks; a switch named on the command line switches first and then looks up only its target for the Now active: line. Whatever has not answered after 2.5 seconds is left out and its requests are cancelled, so the command still exits at once. Every part the provider did not report is omitted, and with none known the account is named alone. Reading usage never delays the switch beyond that deadline and never fails it. The dashboard reuses the status it already shows and makes no extra lookup.

What a switch actually does

use runs as one sequence, and steps 1 to 4 stop at the first failure:

  1. Stop the app-server. It holds the state database open, owns the remote-control websocket, and refreshes tokens on its own schedule. Reading or writing auth.json while it runs races that refresh — the copy taken could be a token the daemon has already rotated away, and a credential the daemon wrote afterwards would be destroyed unstashed. authswitch stops it through Codex' own codex app-server daemon stop rather than signalling the process behind Codex' back, and aborts if it will not stop. Codex restarts; running Codex turns end. An app-server codex app-server daemon does not manage -- daemon version names no backend for it -- is refused by name before anything is stopped, because Codex's own stop refuses it too. When Codex cannot answer at all, the app-server counts as running only if a daemon pid file names a live process. Codex bounds its own lifecycle commands (up to 375 s for its operation lock, 300 s of shutdown grace, 10 s to force a stop), and authswitch waits past those bounds rather than cutting a stop short.
  2. Check in the outgoing credential. Only now is the active credential read, stashed under its account email, and read back from the stash to prove it landed. A credential is never overwritten until a copy has been verified on disk. Re-stashing an account that already has a stash refreshes it.
  3. Write the incoming credential to auth.json, owner-only, through an exclusively-created temp file and a rename.
  4. Restore the incoming account's remote-control enrollments into the state database. If the state database or its enrollment table is missing, this fails loudly rather than reporting that it restored nothing.
  5. Start the app-server again — but only if it was running to begin with, so the machine's posture is unchanged. This runs even when an earlier step failed: a failed switch must not leave remote control down, and its error then also says whether the app-server came back. Codex detaches the app-server it starts with setsid alone, so on Linux it would stay in the control group of whatever started it and end with that service -- the authority's, or AGL's. codex app-server daemon start therefore runs in a transient scope of the user's systemd manager (systemd-run --user --scope, through @push.rocks/smartdaemon), from the home directory, with the environment the app-server ran with before the stop: every Codex turn runs its tools with that environment, so the owner's agent socket (SSH_AUTH_SOCK), GPG_AGENT_INFO and whatever else their shell set stay. On Linux, step 1 reads it from /proc/<pid>/environ of the process a daemon pid file names, when that process has app-server among its arguments and this Codex home as its CODEX_HOME (or $HOME/.codex). Only what systemd sets itself or refuses is left out -- XDG_RUNTIME_DIR and INVOCATION_ID, which the new scope sets anew, and SYSTEMD_* and DBUS_*; the lost session bus is no loss, because a Codex home that keeps its login in an OS keyring is refused before anything is stopped -- and a variable no scope can carry (a name such as BASH_FUNC_x%%, a value over 32768 characters) is left out and named. When it is not that process, or any read fails, the start falls back to the minimal one codexAppServerEnvironment names -- CODEX_HOME, HOME, PATH, the user's identity, locale, time zone and base directories, and the certificate and configuration paths Codex carries into its own daemon, nothing else of the process that restarts it and no proxy variable -- and says so with the reason; codex app-server daemon stop and start from the owner's shell then give it the shell's environment back. Where no systemd user manager answers, or the host has none, it starts directly and says so. A start never throws: whatever fails is start_failed with the reason. A stop that fails and leaves Codex unable to say whether its app-server still runs starts it again too, and the refusal says the app-server may have stopped and how the start went. A switch or save through the hosted service or AuthSwitchOperations reports what happened in the outcome's codexAppServer -- not_running, restarted, restarted_unscoped (started without a scope of its own) or start_failed -- with codexAppServerDetail (unscopedReason, environment, environmentProblem), and watch --json carries both on a Codex switch event, whose text says them too.

stash follows the same quiesce-and-resume discipline, for the same reason.

If the switch completes but something did not finish — most likely the app-server refusing to come back — the command prints what is outstanding and exits non-zero, so it cannot look like a success to a script.

Running use for the account that is already active is not a no-op: it restores that account's enrollments, which is the repair path when remote control has stopped working for the current login.

Re-selecting the active account keeps its current tokens, including any refresh since it was last saved, and updates the verified stash before restoring pairings.

doctor prints where the credential lives, which state database is in use, every enrollment row, whether the app-server is up, and any stash directory left incomplete by an interrupted write.

Account status

authswitch list lists every registered harness; authswitch codex list limits the output to Codex. Harnesses appear in the order limits uses, by provider name. Each section includes saved accounts and an identifiable active login even if it has never been saved, and draws up to four tables under the harness's name:

Codex
┌───────────────────┬──────────────────┬────────────────────┬─────────────┬───────────────┐
│ Account           │ Status           │ Usage              │ Renewal     │ Earned resets │
├───────────────────┼──────────────────┼────────────────────┼─────────────┼───────────────┤
│ alice@example.com │ active, saved    │ weekly 12% · 3d 4h │ 2026-10-08  │ 0             │
│ Pro               │ 2026-09-17 13:32 │ 5h 37% · 2h 13m    │ auto-renews │               │
├───────────────────┼──────────────────┼────────────────────┼─────────────┼───────────────┤
│ bob@example.com   │ saved            │ usage n/a          │ n/a         │ n/a           │
│ Plus, unverified  │ 2026-09-14 11:32 │                    │             │               │
└───────────────────┴──────────────────┴────────────────────┴─────────────┴───────────────┘

┌───────────────────┬────────────────────┬────────┬───────────┐
│ Account           │ Limit type         │ Used % │ Resets in │
├───────────────────┼────────────────────┼────────┼───────────┤
│ alice@example.com │ Codex weekly       │    12% │     3d 4h │
│ Pro               │ Codex five-hour    │    37% │    2h 13m │
│                   │ Code review weekly │   100% │    3h 59m │
└───────────────────┴────────────────────┴────────┴───────────┘

┌────────────────┬───────────────────┐
│ Metric         │ alice@example.com │
├────────────────┴───────────────────┤
│ Saved login                        │
│ remote-control │ 1                 │
└────────────────┴───────────────────┘

┌─────────────────┬─────────────────────────────────────────────────────────┐
│ Account         │ Problem                                                 │
├─────────────────┼─────────────────────────────────────────────────────────┤
│ bob@example.com │ Subscription and limits: Login expired or was rejected. │
└─────────────────┴─────────────────────────────────────────────────────────┘
  1. Accounts -- one row per account, divided from the next. Account is the label (bold when active) with the account type beneath it, as in limits, and, for a harness with several credential slots, the slot (openai · Pro). Status says whether the login is active and saved, with the local date and time of its last save beneath; an active login without a save reads not saved. Usage holds one compact line per headline window, <window> <used>% · <reset countdown>, lead window first as described below. Renewal is the same cell as in limits, and a harness whose provider never reports billing dates, such as Claude Code, gets one note beneath the table instead of an unexplained n/a per account. Earned resets appears only when an account of the harness reports earned reset credits: the available count, then one line per reported credit.
  2. Limits -- the limits table of this harness, without its Provider and Renewal columns, for the accounts that report windows.
  3. Details -- one comparison of every remaining provider fact: Metric and one column per account, split into pages when the terminal is too narrow, with a full-width row per section. Facts already shown above are left out: those that restate a displayed structured field, and one that only repeats the account's own label. Dated rows, such as Codex's daily token totals, are merged across accounts and keep the newest seven days. A sentence that every account of its section states identically, such as Claude's note that it reports no billing dates, is printed once beneath the table. Saved-login notes (Codex remote-control pairing, account IDs) form the Saved login section; a note that only names the account's credential slot is left out, because the Accounts table names it.
  4. Problems -- a login changed outside authswitch first (Active login changed outside authswitch: expected alice@example.com, now bob@example.com (not saved) — authswitch claude stash --keep saves it), then every account's problems, then the harness's own. Problems of one account that share a message are merged with their topics (Profile, usage: above the message), and accounts with the same problems share one block, whose Account cell names them all on one line.

Human output never prints a raw ISO timestamp: dates and times are local (2026-10-08, 2026-09-17 13:32), and a timestamp inside provider text is rewritten the same way. list --json keeps every original value. A note beneath a table wraps to the terminal, with its continuation lines under the note's prefix.

The lead window is the provider's headline window, otherwise weekly. Countdowns use two units, such as 3d 4h; due means the reported deadline has passed, without claiming the service has refreshed the quota, and not scheduled means the provider reported no reset. All countdowns share the same snapshot time. When the provider picks a headline window (Claude does, for example its Fable weekly limit), that window leads. Otherwise the general account windows lead, weekly first. A general window is named by its period (weekly, 5h) unless another shown general window has the same period; a feature limit, or a general window sharing its period, is named by its label (Fable weekly 100% · 2d). Feature limits such as Spark and code review appear in the Limits table and lead the usage cell only when the provider picks one as its headline. The TUI uses the same selection and names. Window durations come from the provider, not from the plan name or the slot a window arrives in; a Pro account with only a general weekly window gets no invented 5-hour limit.

Every window label names the window by how long it is -- Codex weekly, Codex five-hour, Claude weekly, Code review weekly -- so the same length reads the same in every harness. The named lengths are five-hour, daily, weekly, monthly and annual, the same set Codex names its own windows with, and its primary/secondary slot is shown (Codex weekly (secondary)) only where two windows of one length would otherwise share a label. A length with no name of its own keeps its compact duration (Codex 90m) instead of reading as a period it is not.

When accounts of one harness share a label, every table numbers the later ones (alice@example.com (2)), so each account keeps its own column in the Details comparison and reads the same everywhere. Missing data stays n/a; known zeroes remain zero. Plans read as in limits and the switch badge (Max), and stored plans are marked unverified (Plus, unverified). Renewal dates are shown only when an adapter can provide live billing data. Codex uses the desktop app's account-check endpoint for automatic renewal, explicit renewal/cancellation dates and subscription expiry.

The layout follows the terminal width. From 120 columns every Accounts column stands alone; from 100 the earned resets join the usage cell; from 60 the status joins the account cell; below 60 one Status cell holds status, usage, renewal and earned resets; below 40 every table becomes labelled lines, separated by a rule line per block. Each list section covers one harness, so no colour line is drawn.

Condensed views

authswitch limits answers "how much is left, when does it come back, and when does the subscription renew" in one table. It is one row per account and limit type, sorted by provider and then by account email with each harness and each account kept together, with a two-unit countdown (2h 13m, 3d 4h, 45m, <1m, due) and the account's next billing event:

Account limits
┌──────────────────┬───────────────────┬───────────────────┬────────┬───────────────┬─────────────┐
│ Provider         │ Account           │ Limit type        │ Used % │     Resets in │ Renewal     │
├──────────────────┼───────────────────┼───────────────────┼────────┼───────────────┼─────────────┤
┃ Claude Code      │ phil@example.com  │ Claude weekly     │    75% │         4d 3h │ n/a         │
┃                  │ Max               │ Claude five-hour  │    12% │         1h 2m │             │
┃                  │                   │ Fable weekly      │    88% │         2d 5h │             │
├──────────────────┼───────────────────┼───────────────────┼────────┼───────────────┼─────────────┤
┃ Codex            │ alice@example.com │ Codex weekly      │    12% │         3d 4h │ 2026-10-08  │
┃                  │ Pro               │ Codex five-hour   │    37% │        2h 13m │ auto-renews │
┃                  │                   │ Code review weekl │   100% │        3h 59m │             │
┃                  │                   │ y                 │        │               │             │
┃                  ├───────────────────┼───────────────────┼────────┼───────────────┼─────────────┤
┃                  │ bob@example.com   │ n/a               │    n/a │           n/a │ n/a         │
┃                  │ Plus, unverified  │                   │        │               │             │
├──────────────────┼───────────────────┼───────────────────┼────────┼───────────────┼─────────────┤
┃ OpenCode / anthr │ anthropic API key │ n/a               │    n/a │           n/a │ n/a         │
┃ opic             │  20e751c3b707     │                   │        │               │             │
┃                  │ n/a               │                   │        │               │             │
├──────────────────┼───────────────────┼───────────────────┼────────┼───────────────┼─────────────┤
┃ OpenCode / opena │ alice@example.com │ Codex five-hour   │     0% │ not scheduled │ 2026-10-02  │
┃ i                │ Pro               │                   │        │               │ cancels     │
└──────────────────┴───────────────────┴───────────────────┴────────┴───────────────┴─────────────┘

n/a — Codex · bob@example.com: Subscription and limits: Login expired or was rejected.
n/a — OpenCode / anthropic · anthropic API key 20e751c3b707: This provider login does not expose a
      supported subscription or quota API.
n/a — Claude Code: renewal dates are not exposed by Claude's account API.

The table fits 100 columns; at that width long provider names, account labels and limit types wrap inside their cells.

Renewal spans the rows of its account with two lines: the local calendar date, and beneath it auto-renews when the provider says the subscription renews then, or cancels when it ends then -- a scheduled cancellation, or an expiry with automatic renewal off. Anything else reads n/a: the date comes only from live billing data, never from a plan, a quota reset, account creation or token expiry. A harness whose provider never reports billing dates, such as Claude Code, gets one footnote instead of an unexplained n/a per account.

A number is never invented. An account whose provider exposes no quota API, whose lookup failed, or whose login cannot be read shows n/a in Used % and Resets in, and the footnote under the table says why — one line per distinct reason. A provider is never dropped from the table for lacking data, and one failed account never blanks another's numbers. not scheduled means the provider reported usage without a reset deadline; it is not the same as n/a.

Limit types are the provider's own window names, ordered general-before-feature and weekly-first within an account. Claude's per-model and per-surface limits carry the service's name (Fable weekly); an unscoped Claude meter other than the five-hour session and the weekly total keeps its kind in parentheses (Claude weekly (weekly_oauth)). The provider column names the harness, qualified by the credential slot when a harness owns several (OpenCode / openai).

Each provider and each account is named once. The Provider cell spans all rows of its provider, and the Account cell spans the rows of its account with two lines: the account label, and beneath it, dimmed, the account type — the provider's own plan name with its first letter capitalised (Max, Pro, Prolite). A type read from the stored login because the live lookup failed is marked unverified (Plus, unverified), and an account whose provider reports no plan, such as an API-key login, shows n/a. A provider row without an account has no type line.

Used % is coloured by the provider's own reading when it reports one: orange for warning, red for critical, uncoloured for normal. Without a reading, n/a is orange and an exhausted window (100% or more) is red. Colour is omitted when NO_COLOR is set, output is not a terminal, or the terminal is narrower than 40 columns.

Rows are grouped in two independent ways. A divider separates one account from the next, so an account's limit types read as one block; a provider row without an account is a block of its own. Inside a provider's span the divider starts at the Account column, and a change of provider draws a full rule. A line along the left edge (┃) marks the harness: all its rows share one colour, including every credential slot of an OpenCode installation, and the next harness takes the next colour (cyan, orange, green, pink, blue, then again from the start). Without colour the ┃ edge still shows where the data rows are. Groups follow harness and account ids, never labels: two accounts that share an email are separate blocks, ordered by account id, and two harnesses that share a label are ordered by harness id. A harness is placed by its first provider name, so no other provider sorts between its slots. Below 40 columns the table becomes labelled lines; every row repeats its provider and its account with the type as a second line, a rule line separates the accounts, and no colour line is drawn.

authswitch active answers "what am I logged in as" for every harness at once:

Active accounts
┌──────────────────────┬────────────────────────────────┬────────────┬─────────────────┐
│ Provider             │ Account                        │      Saved │ Source          │
├──────────────────────┼────────────────────────────────┼────────────┼─────────────────┤
┃ Claude Code          │ phil@example.com               │    45m ago │ credential file │
┃ Codex                │ alice@example.com              │ 2h 13m ago │ credential file │
┃ Flex                 │ none                           │        n/a │ none            │
┃ OpenCode / anthropic │ anthropic API key 20e751c3b707 │    30d ago │ credential file │
┃ OpenCode / openai    │ alice@example.com              │  not saved │ credential file │
└──────────────────────┴────────────────────────────────┴────────────┴─────────────────┘

note — OpenCode / openai · alice@example.com: This active login is not saved yet; authswitch cannot
       restore it after a switch.

Source is where the shown login comes from: credential file is the harness's own live credential, stash only means nothing is active and only saved copies exist, none means neither. Saved is how long ago authswitch last saved that login, or not saved — which is also the warning that a switch could not bring it back. A harness with no active login keeps its row and explains itself in a note. Each harness has its own colour line, ordered and coloured as in limits; the table has no dividers, since every row is a different credential slot.

Source also answers whether the file still holds what the last switch wrote. When authswitch switches a login it records that account and the hash of the credential it wrote, in its own store, never in the harness file. If a later read finds a different account in that slot, the row's source reads credential file (changed) and a note explains it:

note — Claude Code · bob@example.test: The active login changed outside authswitch since the last
       switch (for example a new login). It now holds bob@example.test, which is saved; authswitch
       claude use alice@example.test switches back.

For a harness that does not pick up swaps live, such as OpenCode, the sentence also names the other cause: a running instance that wrote its previous login back. An unsaved current login adds how to keep it (authswitch <harness> stash --keep saves it).

authswitch <harness> current prints the same sentence. The same account with a rotated token is that account refreshing itself and is not reported; for Claude Code and Codex the rotation is copied into that account's saved record. Re-running the switch re-arms the Claude Code and OpenCode check against the file's current contents; Codex has no such check.

Both commands accept a harness qualifier (authswitch claude limits) and --json. Neither changes which account is in use: they never activate, switch or clear a login and they need no harness stopped. Like list, they may renew a saved Claude login's own tokens when its access token has expired, because otherwise its usage cannot be read at all. Every provider lookup is bounded by the same 10-second timeout and isolated per account as authswitch list.

JSON output

authswitch list --json, authswitch codex list --json, and the ls alias write one JSON document to stdout, with no color, tables or progress messages. --json may appear before or after the command and requires list, ls, limits or active. It cannot be combined with a mutation or interactive mode. Unknown arguments to these commands exit with 2.

limits --json emits IAccountLimits and active --json emits IActiveAccounts: the same rows as the tables, plus the machine-readable fields the tables condense — accountId, providerAccountId, slotId, accountType ({ plan, source } with the adapter's plan name, such as max or pro, sanitised but not display-formatted, and source live or stored; null when no type is known or the row names no account; both documents carry it), the limits rows' billing (the account's reported hasActiveSubscription, autoRenew and ISO renewsAt, cancelsAt and expiresAt, each omitted when not reported; null when the provider reported none or the row names no account), scope, windowSeconds, the ISO resetAt beside the human resetsIn, the provider's severity (normal, warning, critical, or null when it gave none) and headline (true for the window the provider picks for a single-value summary), the ISO savedAt beside the human savedAgo, the per-row unavailableReason that the footnotes summarise, and drift, which carries the expected and current account ids, their labels, whether the current one is saved, the ISO switchedAt of the switch that was undone, and the same sentence as reason. accountId remains the opaque authswitch identifier used for harness operations; providerAccountId is the exact provider-native identifier, or null when the credential reports none. Both documents carry schemaVersion: 2, the shared generatedAt snapshot every countdown is relative to, and complete, which is false when any account or harness could not be read.

The exported IAccountList contract contains:

  • schemaVersion: 3, generatedAt (ISO UTC), and complete.
  • harnesses[]: id, label, loginHint, saveUnavailableReason, accounts, credentialDrift (slots whose file no longer holds the account the last switch wrote), and harness-level problems (distinguishing failed discovery from an empty list).
  • Each account's opaque id, exact provider-native providerAccountId (or null), label, isActive, verified isStashed, savedAt, details, and status containing all labelled facts, problems, and optional typed summary fields. Missing fields remain omitted, not replaced by zero; summary.usageWindows[] carries severity and headline only when the provider reported them.

JSON retains full fact strings and absolute ISO reset/expiry timestamps. It exports the credential-free adapter contract, never credentials, raw HTTP responses or harness internals. A partial result still produces valid JSON and exits with 1; successful and empty results exit with 0. complete describes lookup success, not whether the provider exposes every possible metric.

Version 3 adds providerAccountId without changing the opaque id used by mutation commands. Version 2 allowed usageWindows[].resetAt to be null and added optional account slotId fields. Consumers must accept schema version 3, preserve the two identifiers' distinct meanings and handle null provider IDs and reset timestamps. Scripts should qualify mutation commands with codex, opencode or claude, since all three now register by default. Saved Codex credentials retain their existing format.

For each ChatGPT login, Codex status includes the live plan, every usage window the service reports with remaining percentages and reset times, additional limits such as code review, credit availability and balance, spend limits when returned, earned reset availability and expiry details, lifetime and peak daily tokens, activity streaks, and the latest seven reported daily token totals.

The Codex adapter implements the read-only GET requests used by Codex 0.155.0 at chatgpt.com/backend-api/wham: usage, rate-limit-reset-credits, and profiles/me. Billing uses chatgpt.com/backend-api/accounts/check/v4-2023-04-27, verified against the official desktop bundle 26.908.40834. It selects the exact credential account; another workspace's default entry cannot supply its billing information. Each request uses that account's access token and account ID. For the active account it reads the current credential, which may be newer than the stash. Listing never switches accounts, starts or stops an app-server, refreshes tokens, writes ~/.codex/auth.json, stores status results, or consumes an earned reset. The one write it makes is the saved copy of the active login, brought onto the credential Codex is running on as described above.

These backend contracts come from Codex's source and can change. Missing fields, expired logins, denied requests, timeouts, and unsupported responses are shown explicitly; they are never reported as zero usage. Other accounts and successful sections still appear, and a failed status lookup makes the command exit nonzero. An expired saved login must be renewed through Codex and saved again.

The returned usage plan is not a billing status. Billing dates come from the separate account-check response, never from a quota reset, token expiry or stored plan. Automatic renewal is on only when the provider says it will renew and no cancellation date is scheduled. A non-renewing account without a cancellation date or expiry does not acquire an invented date. When renewal is off, a returned renewal timestamp is never shown as a renewal; the Renewal cell shows the expiry as the day the subscription ends. JSON keeps every returned field.

The billing request uses a verified Chromium-compatible User-Agent because the service challenged the generic authswitch User-Agent in controlled comparisons. This is a fixed HTTP request profile and requires no local browser installation. Other Codex status endpoints retain the authswitch User-Agent. If the billing endpoint still returns a Cloudflare verification challenge, that is reported explicitly as unavailable billing and a partial result, without treating it as a rejected login. Authswitch does not import browser cookies, replay desktop integrity state, refresh credentials or switch accounts to bypass the challenge. A plan from the stored login remains unverified when the live usage request fails. API-key logins and keyring credentials cannot provide these ChatGPT subscription metrics.

Harness adapters

The CLI operates on IAuthHarness, with opaque account IDs scoped to each harness. Codex, OpenCode and Claude Code are built-in adapters. Adding an adapter does not require changing command routing, save prompts, account pickers or status rendering:

import { AuthSwitchCli, CodexHarness } from '@modelprofile.com/authswitch';
import { AnotherHarness } from './anotherharness.js'; // your IAuthHarness implementation

const cli = new AuthSwitchCli([new CodexHarness(), new AnotherHarness()]);
process.exitCode = await cli.run(process.argv.slice(2));

Each adapter owns discovery, identity matching, save verification, credential storage, switching and process lifecycle, live status APIs, and diagnostics. readState() and readAccountStatus() must never activate, switch or clear a login. readState() may bring the saved copy of the active login onto the credential that login's harness last wrote -- the Claude Code and Codex adapters do, because both harnesses rotate the login they run -- and must write nothing else. readAccountStatus() may renew the tokens of the saved copy it is reading, when the provider gives it no other way to answer (the Claude Code adapter does, under the store's lock, for saved and inactive logins only); it must never touch the active login's credential. readAccountStatus(accountId, options) receives an optional IHarnessStatusOptions whose signal cancels the lookup's requests; authswitch aborts it when a status is no longer wanted, such as a switch badge past its deadline or a closed dashboard, so an adapter should pass it to every request it makes. Its optional session belongs to a caller that reads the same accounts repeatedly, such as authswitch watch: session.reuse(key, maxAgeMs, lookup) returns the value that key resolved to less than maxAgeMs ago, so an adapter can stop repeating a lookup whose answer rarely changes (the Claude adapter reuses its profile for an hour). Keys are the adapter's own and carry no secret; a rejected lookup is not remembered, and outside a session every lookup is live. switchAccount() must preserve and verify the outgoing credential without relying on the interactive save prompt. removeAccount() removes only the saved copy. Status facts carry provider-specific labels and units; unsupported capabilities are explicit. Adapter labels, details, facts and outcomes contain plain text; smartconsole owns terminal formatting for both the guide and dashboard. Credentials, email assumptions and Codex enrollment metadata stay out of the shared interface. The existing CodexSwitcher API and single-switcher CLI constructor remain available.

An account's optional slotId identifies the credential it replaces. Accounts in different slots may be active simultaneously. saveCurrent({ keepActive, accountId }) must target the selected active account; omitting accountId is allowed only when the adapter has one unambiguous active login. usageWindows[].resetAt may be null when the provider explicitly reports no scheduled reset; missing windows stay omitted.

Adapters may implement preuseAccount(accountId, options) as an optional quota-consuming capability. IHarnessPreuseOptions carries the prompt, optional model identifier and cancellation signal; IHarnessPreuseResult returns the selected model and optional input/output/total token counts. The adapter owns credential selection and inference and must leave the active login unchanged. Unsupported adapters reject the command before account selection. Prompt results never contain credentials or raw provider responses. A refusal that sent no request must be a PreuseError with requestStarted false, which is what a run over several accounts reports as skipped rather than failed; every other error, PreuseError or not, is treated as a request that may have consumed tokens. preuseAccounts(harness, accounts, options, report) is that run: it sends one prompt per account in turn, reports a started and a finished event for each, reads the account's reset schedule after a completed prompt, stops before the next account once the signal aborts, and returns the per-account results with their counts.

Adapters can also return an optional IHarnessAccountStatus.summary with a subscription plan and its live/stored provenance, labelled usage windows with duration, used percentage and UTC reset time, and available reset credits with optional expiry details. This harness-neutral data supports shared presentation without parsing provider-specific fact strings. Missing data stays unknown; explicit zeroes remain zero. A successful reset-detail lookup supplies the summary count; if it fails, the limits response can still supply availability.

Each usage window may set scope: 'account' | 'feature' (default account). Adapters mark model-specific or feature-specific quotas as feature; those quotas appear in the Limits table, JSON and details and lead the account summary only when the provider marks one as its headline (see below). Weekly windows are identified by durationSeconds: 604800; neither window labels nor plan names are parsed to infer limits.

A window may also carry the provider's own reading: severity?: TUsageSeverity ('normal' | 'warning' | 'critical') and headline?: true for the window the provider picks for a single-value summary. Adapters set them only when the provider reports them, never from local thresholds. A headline window leads the account summary whatever its scope; the first one in the adapter's order wins if several are marked.

Optional summary.billing contains provider-reported hasActiveSubscription, autoRenew, renewsAt, cancelsAt, and expiresAt, independent of the plan name. Dates are ISO UTC and omitted when unknown. They must come from a live billing source; entitlement expiry alone does not establish renewal or cancellation. Codex omits this field if its billing lookup fails and supplies a corresponding problem.

Facts may supply a section label for grouped comparison and a summaryKey (subscription, billing, usageWindows, resets, or resetDetails) when the fact duplicates a structured field. The list hides such a fact only if that field is displayed; JSON retains every fact. Adapters without this metadata continue to display their facts in Additional information. A fact label ending in an ISO date (Tokens 2026-09-15) marks a daily series, which the list merges across accounts.

Three optional adapter declarations shape the shared views. liveSwap: true says that running instances pick up a credential swap on their next request: the CLI and TUI then offer no stop before a switch (an explicit --stop or --force-stop is still honoured through processes), outcomes carry no restart advice, and the credential drift sentence names only a new login as the cause. renewalUnavailableReason is a lower-case clause for a provider that never reports renewal dates; list and limits print it once as <label>: <reason>. autoSwitch: true says that every saved account's usage can be read and that a switch needs no session of the user's stopped, which is what authswitch watch requires before it switches a harness by itself; a helper process the adapter manages itself may still be restarted by its own switch, as Codex' app-server is. The built-in Claude Code adapter declares all three; Codex declares autoSwitch: true with liveSwap: false, and OpenCode declares neither.

With multiple adapters, the guide first asks which harness to manage and provides an action to choose another. Unqualified mutation commands prompt for a harness; scripts must qualify them. Account references never resolve across harnesses.

Where things are stored

What Where
Stashed credentials ~/.authswitch/codex/<email>/auth.json (mode 0600, in a 0700 directory)
Saved OpenCode/Claude logins ~/.authswitch/<harness>/<id>.json (mode 0600, in a 0700 directory)
Last switch per credential slot ~/.authswitch/<harness>/switches.json (account ids, credential hashes and timestamps; no credentials)
Stash metadata and enrollments ~/.authswitch/codex/<email>/stash.json
Codex' active credential $CODEX_HOME/auth.json, default ~/.codex/auth.json
Codex' remote-control enrollments the remote_control_enrollments table in $CODEX_HOME/state_<n>.sqlite
A Claude sign-in's home, while it runs $XDG_DATA_HOME/authswitch/claude-signin/<operation-id>/, default under ~/.local/share (0700; removed when the sign-in ends)

CODEX_HOME and AUTHSWITCH_HOME both override their defaults, which is also how the test suite runs against a throwaway directory. AUTHSWITCH_CODEX_BIN points at a specific codex executable when it is not on PATH, and NO_COLOR turns off colouring (as does piping the output anywhere that is not a terminal).

Account identity comes from the id_token inside the credential — the email claim names the stash, the account id ties it to its enrollments. The token is decoded, never verified, and never printed: authswitch only ever reports emails, plan names and server names.

Limits

Keyring-backed credentials cannot be switched. Codex can keep credentials in an OS keyring instead of auth.json, in which case there is no file to move. authswitch detects this and refuses rather than half-working; doctor reports it as keyring (not switchable). Switch Codex to file storage via auth_credentials_store_mode in config.toml and log in again to use this tool.

Other Codex clients keep running. Stopping the managed app-server does not stop an editor extension that spawned its own. Close it, or expect it to keep using the credential it already loaded.

A running OpenCode can undo a switch. Its auth service keeps no login in memory -- every lookup re-reads auth.json -- but a request already in flight, which read the previous login before the switch, can write that login's own rotation back afterwards, over a provider map OpenCode replaces in place. Authswitch lists those instances, offers to stop them, warns when any keep running, and detects the overwrite afterwards — but it cannot prevent it while they run. Restart them after a switch. A running Claude Code picks the switch up instead; its guarantee rests on the behaviour and lock protocol verified against Claude Code 2.1.273, and authswitch claude active still reports a login that changed outside authswitch.

The Codex stash is keyed by email. Two Codex workspaces with the same email cannot both be saved under that key. Their account IDs are distinguished during listing and identity checks, and a collision refuses to overwrite either login. Account IDs in the shared harness interface do not have this email restriction.

A stash is a credential. ~/.authswitch holds live refresh tokens. It is written owner-only, but it is not encrypted, and it belongs in the same threat model as ~/.codex/auth.json itself.

Development

pnpm install
pnpm build
pnpm test

The test suite builds a fixture CODEX_HOME with a real SQLite state database and exercises the full stash/login/restore cycle, including the case where a fresh login has removed an account's enrollment row. It never touches the developer's own Codex installation.

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the repository license file.

The ChatGPT sign-in and usage-limit code is adapted from OpenAI Codex, which is licensed under the Apache License, Version 2.0. Its notices are in third-party-notices.md and its license in openai-codex-license.txt; both ship with the package.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.

Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.

Company Information

Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany

For any legal inquiries or further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

S
Description
No description provided
Readme
14 MiB
Languages
TypeScript 100%