diff --git a/CHANGELOG.md b/CHANGELOG.md
index 25d344e..a79b674 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,109 @@
# Changelog
+## Unreleased
+
+- **Multiple Claude accounts, CLI-owned end to end**: one OpenCode server can
+ now drive several Claude subscriptions. Each account is a
+ `CLAUDE_CONFIG_DIR` — a self-contained Claude CLI home holding its own
+ credentials, transcripts and settings. The plugin never reads or writes a
+ token: connecting an account means running
+ `CLAUDE_CONFIG_DIR=
claude auth login` (the exact command is printed by
+ the panel and tools), so the CLI stays the sole owner of every credential
+ chain. Accounts come from `OPENCODE_CLAUDE_ACCOUNTS` (JSON array or
+ `id:label:configDir` entries) or the panel/tool-managed
+ `~/.local/share/opencode-claude/accounts.json`; with neither, behaviour is
+ the single-account setup, byte for byte. In multi-account mode every model
+ appears once per account (`sonnet@work`, named "Claude Sonnet 4.5 (Work)"),
+ requests may pin an account via the `x-opencode-claude-account` header, and
+ each conversation binds to its account so follow-up turns stay put. Unknown
+ account ids are rejected with 404 — never silently routed to the default
+ account (and its quota).
+- **Account switches never leak transcripts across logins**: a Claude-side
+ resume id belongs to one account's config dir. When a conversation moves to
+ a different account (model pick, header, panel/tool rebind), the stored
+ resume target is cleared and the turn starts a fresh Claude session with the
+ OpenCode history transferred — resuming another login's session id is never
+ attempted. Removing an account reconciles all of its session bindings back
+ to the default account the same way.
+- **Remaining quota, without spending any**: the proxy tracks every quota
+ window per account — the SDK's `rate_limit_event` reports one window at a
+ time, so it is merged with the control channel's plan-usage snapshot (the
+ structured data behind the CLI's `/usage` command), which reports the
+ five-hour, seven-day and Opus windows at once. Refreshing quota
+ (`POST /accounts/:id/quota/refresh`, panel button, or the
+ `refresh-quota` tool action) boots one idle CLI probe and reads the control
+ channel — no Messages API call, zero tokens spent. Probes are single-flight
+ per account with a cooldown and failure backoff. Remaining percent per
+ window is surfaced in the model name (` · 5h 96% 2h 20m · 7d 4% 5d`,
+ disable with `OPENCODE_CLAUDE_MODEL_QUOTA=0`), `/health`, `/quota`, 429
+ bodies and the control panel; a window whose reset time has passed shows
+ `?` instead of a stale number.
+- **Account identity from the CLI**: each account's login (email,
+ organization, plan) is read over the SDK control channel and cached with a
+ staleness window. The panel and `/accounts` flag two accounts that resolve
+ to the same email — the duplicate-login detection from the OAuth era,
+ rebuilt without the plugin ever seeing a credential.
+- **Per-account usage counters**: turns and token totals (input/output/cache)
+ are recorded per account per day at
+ `~/.local/share/opencode-claude/usage.json` and exposed via `/usage`, the
+ panel and the tools.
+- **Control panel**: a self-contained HTML page (no external assets, CSP
+ `default-src 'none'`) at the proxy root (`/` or `/panel`) shows accounts,
+ logins, quota windows, rate-limit state, usage counters and the
+ session→account map, and can add/rename/remove accounts, set the default,
+ move a session and refresh quota. Mutating routes require a same-origin
+ request; the panel stays loopback-only unless
+ `OPENCODE_CLAUDE_PANEL_HOST` widens the bind, honours `X-Forwarded-Prefix`
+ behind a reverse proxy, and `OPENCODE_CLAUDE_PANEL=0` disables the page
+ (JSON API stays).
+- **In-session management tools**: `claude_accounts` (list accounts with
+ login, quota, usage and binding counts) and `claude_account_manage`
+ (add/remove/rename/set-default/bind-session/refresh-quota) manage the
+ roster from inside a session without touching the panel port. Disable with
+ `OPENCODE_CLAUDE_TOOLS=0`. When accounts are configured via
+ `OPENCODE_CLAUDE_ACCOUNTS`, mutations are refused with a pointer to the env
+ var instead of silently writing a shadowed accounts.json.
+- **Per-account rate limits**: the rate-limit store, 429 gate, `Retry-After`
+ and countdown notes are all keyed by account — one exhausted subscription
+ no longer blocks turns on a healthy one, and `/health?account=` reports
+ the account you ask about.
+- **`$0 group usage limit` fails fast as a rate limit**: org spend-cap
+ errors ("usage limit reached for your group", `$0 balance`) are classified
+ as rate limits — 429 + gate — instead of generic 500s that hosts retry in
+ a loop against a wall.
+- **529 `overloaded` answered honestly**: Anthropic overload errors return
+ HTTP 529 with a short `Retry-After` instead of a generic 500, and do NOT
+ trip the local rate-limit gate — overload is Anthropic-side and transient,
+ not a subscription window.
+- **Local title/summary fallback when limited**: when the account is
+ rate-limited (or the meta turn itself dies on a limit), title and summary
+ requests answer 200 with a locally derived title/summary heuristic instead
+ of 429 — hosts stop burning retries on meta requests that cannot succeed,
+ and sessions still get a usable name. Meta requests also never bind a
+ conversation to an account.
+- **Smoke tests isolated from live state**: the test run redirects
+ `XDG_DATA_HOME` to a temp dir and clears `OPENCODE_CLAUDE_*` overrides, so
+ `bun test/smoke.ts` can never read or clobber a live rate-limit store,
+ account roster or session bindings, and never binds a production port.
+- **Host history transforms respected on resume**: on resumed turns the proxy
+ previously ignored the host's prior messages entirely — history came from
+ the Claude-side session transcript, so plugins rewriting conversation
+ history via `experimental.chat.messages.transform` (e.g.
+ `@tarquinen/opencode-dcp`) had no effect after turn 2. The proxy now
+ fingerprints the non-system messages of every turn and, when the incoming
+ array is no longer an extension of what the host sent last turn (messages
+ dropped, replaced, or edited), logs a warning and rebuilds the Claude
+ session from the transformed host array instead of resuming.
+ `OPENCODE_CLAUDE_DIVERGENCE_REBUILD=0` downgrades this to warn-only, and
+ `OPENCODE_CLAUDE_HOST_TRANSCRIPT=1` opts into full host-owned transcripts
+ (never resume; rebuild from the host array every turn).
+- **Meta requests no longer 400 on effort**: session title and summary
+ generation force-disable thinking but still forwarded the selected effort
+ (e.g. `max`), which the API rejects with
+ `400 output_config.effort 'max' is not supported when thinking is disabled`.
+ Effort is no longer sent for meta requests, and `startClaudeQuery` also
+ drops effort defensively whenever thinking is disabled.
+
## 0.11.0
- **Claude CLI-owned authentication**: removed the plugin's browser OAuth
diff --git a/README.md b/README.md
index 09f71b5..183bd07 100644
--- a/README.md
+++ b/README.md
@@ -115,7 +115,12 @@ opencode run "Summarise this repository in five bullets." --model claude-code/so
| **Auto-compact** | Long sessions compact like Claude Code; boundary events are surfaced in the stream. |
| **Session resume** | Sticky foreign Claude session IDs so follow-ups continue the same Agent SDK turn. |
| **History transfer** | When no Claude session can be resumed (first claude-code turn of a chat, model switch mid-conversation, pruned transcript), the full prior conversation is serialized into the prompt — Claude never starts blind. |
-| **Rate-limit counter** | Subscription limit state is tracked with its reset time; `GET /v1/rate-limit` answers "when are limits back", and doomed turns fail fast with 429 + `Retry-After`. |
+| **Host history transforms** | Plugins that rewrite conversation history via `experimental.chat.messages.transform` (e.g. DCP) work on resumed turns too: when the incoming message array stops being an extension of the last one, the proxy rebuilds the Claude session from the transformed host array instead of resuming. |
+| **Rate-limit counter** | Subscription limit state is tracked per account with its reset time; `GET /v1/rate-limit` answers "when are limits back", and doomed turns fail fast with 429 + `Retry-After`. Org spend-cap (`$0 group`) errors are classified as limits; 529 overload is answered as 529, not 500. |
+| **Multiple accounts** | Several Claude subscriptions side by side, each a self-contained `CLAUDE_CONFIG_DIR` the CLI owns. Per-session account binding, per-account quota/limits/usage, unknown ids rejected — never silently billed to the default account. |
+| **Remaining quota** | Every window (5h / 7d / Opus) tracked from SDK telemetry + the control channel's plan usage; shown as percent left in the model name, `/health`, `/quota` and 429 bodies. Explicit refresh reads the control channel of an idle CLI probe — zero tokens spent. |
+| **Control panel** | Self-contained HTML at the proxy root: accounts, logins, quota, usage, session→account map, add/rename/remove/connect. Same-origin mutations, loopback-only by default. |
+| **Management tools** | `claude_accounts` / `claude_account_manage` manage the roster from inside a session — no panel needed. |
| **Stall & cancel safety** | A silent turn is killed after a watchdog timeout instead of wedging the session forever, and a client disconnect tears the turn down instead of leaking a live CLI process. |
## Architecture
@@ -136,18 +141,138 @@ exact `effort` (+ adaptive thinking) into the Agent SDK.
The proxy records Agent SDK `rate_limit_event` telemetry and hard session-limit
errors (including the parsed reset time) to
-`~/.local/share/opencode-claude/rate-limit.json`.
+`~/.local/share/opencode-claude/rate-limit.json`, keyed per account.
- `GET /v1/rate-limit` → `{ limited, status, rateLimitType, utilization, resetsAt, resetsAtISO, resetInSeconds, message, updatedAt }` — poll this for a "limits reset in …" countdown. `utilization` is only present when the latest SDK event reported it — it is never carried over from an earlier limit window, so a freshly reset window never shows a stale percentage.
-- `GET /health` includes a compact `rateLimit` summary.
+- `GET /health` includes a compact `rateLimit` summary (add `?account=`
+ for a specific account).
- While a confirmed hard limit is active, new chat turns return HTTP **429**
with `Retry-After` + `x-claude-rate-limit-reset` headers and an
`error.type = "rate_limit_error"` body. The block lifts automatically at
reset time; the next turn then resumes the same Claude session (sticky
- session store is untouched).
+ session store is untouched). Limits are per account — an exhausted
+ subscription never blocks a healthy one.
+- Title/summary requests are never answered 429: when the account is limited,
+ they return a locally derived title/summary so the host does not burn
+ retries on meta calls that cannot succeed.
+- Org spend-cap errors (`$0 group usage limit`) are classified as rate limits
+ (429 + gate). Anthropic 529 `overloaded` is answered as HTTP 529 with a
+ short `Retry-After` and does not trip the local gate.
- `OPENCODE_CLAUDE_RATE_LIMIT_FAST_FAIL=0` disables the 429 gate (turns are
attempted and error normally).
+### Multiple accounts
+
+One OpenCode server can drive several Claude subscriptions. Each account is a
+`CLAUDE_CONFIG_DIR` — a self-contained Claude CLI home with its own
+credentials, transcripts and settings. The plugin never reads or writes a
+credential: connecting an account means running
+
+```bash
+CLAUDE_CONFIG_DIR=~/.claude-work claude auth login
+```
+
+(the exact command is printed by the panel and the management tools), so the
+CLI stays the sole owner of every credential chain.
+
+Configure accounts one of two ways (first non-empty wins):
+
+```bash
+# 1. Environment (read-only roster; panel/tool mutations are refused)
+OPENCODE_CLAUDE_ACCOUNTS='work:Work:~/.claude-work,personal:Personal:~/.claude-personal'
+# or a JSON array: [{"id":"work","label":"Work","configDir":"~/.claude-work","default":true}, …]
+
+# 2. Panel / tools — persisted to ~/.local/share/opencode-claude/accounts.json
+```
+
+With neither, the plugin behaves exactly like a single-account install.
+
+In multi-account mode:
+
+- Every model appears once per account: id `sonnet@work`, name
+ `Claude Sonnet 4.5 (Work) · 5h 96% 2h 20m · 7d 4% 5d` (quota suffix,
+ disable with `OPENCODE_CLAUDE_MODEL_QUOTA=0`).
+- Requests may pin an account with the `x-opencode-claude-account` header;
+ responses echo it.
+- Each conversation binds to its account; follow-up turns stay put. Moving a
+ conversation (model pick, header, panel, tool) clears the Claude-side
+ resume target — a session id from one login is never replayed against
+ another — and rebuilds the session from the transferred OpenCode history.
+- Unknown account ids are rejected with 404, never silently routed to the
+ default account.
+- Removing an account reconciles its session bindings back to the default
+ account.
+
+### Quota, identity & usage
+
+Post-#12 the plugin never talks to Anthropic directly, so quota is read from
+two CLI-owned signals: `rate_limit_event`s harvested from running turns (one
+window at a time, merged), and the SDK control channel's plan usage — the
+structured data behind the CLI's `/usage` command, which reports the
+five-hour, seven-day and Opus windows at once without any Messages API call.
+
+- `GET /quota` — last known windows per account (free, read-only).
+- `POST /accounts/:id/quota/refresh` — explicit refresh: boots one idle CLI
+ probe, reads plan usage + account identity over its control channel, tears
+ it down. Zero tokens spent; single-flight per account with cooldown and
+ failure backoff.
+- `GET /accounts` — accounts with login (email / organization / plan as the
+ CLI reported it), quota summary, rate-limit state, usage counters and bound
+ session counts. Two accounts resolving to the same email are flagged as
+ duplicates.
+- `GET /usage` — per-account per-day turn and token counters.
+- `GET /sessions` — session→account map (`?account=` filters).
+
+### Control panel
+
+A self-contained HTML page (no external assets) served at the proxy root
+(`/` or `/panel`): accounts, logins, quota windows, rate-limit state, usage,
+session→account map, plus add / rename / remove / set-default / move-session /
+refresh-quota. "Connect" shows the `CLAUDE_CONFIG_DIR=… claude auth login`
+command to run — the panel never handles credentials.
+
+- Mutating routes require a same-origin request.
+- The proxy binds loopback-only by default; `OPENCODE_CLAUDE_PANEL_HOST`
+ widens the bind for remote setups (put a reverse proxy in front —
+ `X-Forwarded-Prefix` is honoured for the base path).
+- `OPENCODE_CLAUDE_PANEL=0` disables the HTML page (the JSON API stays).
+
+### Management tools
+
+Two OpenCode tools manage the roster from inside a session, no panel needed
+(disable with `OPENCODE_CLAUDE_TOOLS=0`):
+
+- `claude_accounts` — list accounts with login, quota, usage, limits and
+ binding counts.
+- `claude_account_manage` — `add` (returns the connect command), `remove`,
+ `rename`, `set-default`, `bind-session`, `refresh-quota`.
+
+### Host history & transform plugins
+
+On follow-up turns the proxy resumes the sticky Claude-side session, so
+conversation history normally comes from Claude's own transcript — not from
+the message array OpenCode sends. Host plugins that rewrite history through
+`experimental.chat.messages.transform` (context pruning à la
+`@tarquinen/opencode-dcp`, message editing, etc.) would silently have no
+effect on resumed turns.
+
+The proxy therefore fingerprints the non-system messages of every turn
+(system messages are deliberately dropped — the Claude Code preset supplies
+the agent system prompt). When the incoming array is no longer an extension
+of what the host sent last turn — messages were dropped, replaced, or edited —
+the proxy logs a warning, abandons the Claude session, and rebuilds it from
+the transformed host array via history transfer, so the transform actually
+reaches Claude.
+
+- Default: divergence → rebuild from the host array (new Claude session,
+ transferred history).
+- `OPENCODE_CLAUDE_DIVERGENCE_REBUILD=0` — warn-only: the divergence is
+ logged but the Claude transcript still wins (pre-0.12 behavior).
+- `OPENCODE_CLAUDE_HOST_TRANSCRIPT=1` — the host owns the transcript: never
+ resume, rebuild from the (possibly transformed) host array every turn.
+ Guarantees transform plugins always apply, at the cost of Claude-side
+ cross-turn prompt caching and auto-compact continuity.
+
## Requirements
- [OpenCode](https://opencode.ai)
@@ -172,8 +297,15 @@ Optional knobs:
- `CLAUDE_CODE_OAUTH_TOKEN` — operator-provided subscription token passed through to the CLI unchanged (CI / headless hosts without an on-disk CLI login); the plugin never sets or rotates it
- `OPENCODE_CLAUDE_TURN_STALL_MS` — max Agent SDK silence before a turn is declared dead and killed (default `600000`)
- `OPENCODE_CLAUDE_RATE_LIMIT_FAST_FAIL` — `0` disables the 429 rate-limit gate
-- `OPENCODE_CLAUDE_RATE_LIMIT_STORE` — override the rate-limit store path (tests)
+- `OPENCODE_CLAUDE_ACCOUNTS` — account roster (`id:label:configDir` entries or JSON array); when set, panel/tool mutations are refused
+- `OPENCODE_CLAUDE_MODEL_QUOTA` — `0` removes the remaining-quota suffix from model names
+- `OPENCODE_CLAUDE_PANEL` — `0` disables the control-panel HTML page (JSON API stays)
+- `OPENCODE_CLAUDE_PANEL_HOST` — bind host for the proxy/panel (default loopback)
+- `OPENCODE_CLAUDE_TOOLS` — `0` disables the `claude_accounts` / `claude_account_manage` tools
+- `OPENCODE_CLAUDE_RATE_LIMIT_STORE`, `OPENCODE_CLAUDE_QUOTA_STORE`, `OPENCODE_CLAUDE_IDENTITY_STORE`, `OPENCODE_CLAUDE_USAGE_STORE` — override store paths (tests)
- `OPENCODE_CLAUDE_HISTORY_MAX_CHARS` — budget for transferred conversation history when a Claude session cannot be resumed (default `400000`; newest messages are kept, `0` disables transfer)
+- `OPENCODE_CLAUDE_HOST_TRANSCRIPT` — `1` makes the host own the transcript: Claude sessions are never resumed and the conversation is rebuilt from the (possibly transformed) host messages every turn
+- `OPENCODE_CLAUDE_DIVERGENCE_REBUILD` — `0` downgrades host-history divergence handling to warn-only (the Claude transcript keeps winning; transformed history does not reach Claude)
## Release
diff --git a/src/accounts.ts b/src/accounts.ts
new file mode 100644
index 0000000..12a4b47
--- /dev/null
+++ b/src/accounts.ts
@@ -0,0 +1,609 @@
+/**
+ * Multi-account registry for Claude Code subscriptions — CLI-owned auth.
+ *
+ * One OpenCode server can drive several Claude subscriptions at once, with a
+ * per-session binding: session A runs on the "work" account, session B on
+ * "personal". Each account is a `CLAUDE_CONFIG_DIR` — a self-contained Claude
+ * CLI home holding its own credentials, transcripts and settings.
+ *
+ * The plugin NEVER reads or writes credentials: signing an account in is
+ * `CLAUDE_CONFIG_DIR= claude auth login`, run by the operator. The
+ * plugin only points the spawned CLI at the right home, so exactly one owner
+ * (the CLI) holds each refresh-token chain and no rotation race can exist.
+ *
+ * Resolution order (first non-empty wins):
+ * 1. `OPENCODE_CLAUDE_ACCOUNTS` — JSON array, or `id:label:configDir` entries
+ * separated by commas.
+ * 2. `$XDG_DATA_HOME/opencode-claude/accounts.json` (panel/tool-managed)
+ * 3. Nothing configured → a single implicit account using the ambient Claude
+ * home. This is the single-account behaviour, byte for byte.
+ */
+import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
+import { homedir } from "node:os";
+import { dirname, isAbsolute, join } from "node:path";
+import { log } from "./log.js";
+import { countBoundSessions } from "./session-store.js";
+
+export type ClaudeAccount = {
+ /** Slug used in model ids, store keys and headers. */
+ id: string;
+ /** Human label shown in the model picker and panel. */
+ label: string;
+ /**
+ * CLAUDE_CONFIG_DIR for this account. Undefined means the ambient Claude
+ * home (`~/.claude` or an inherited CLAUDE_CONFIG_DIR) — at most one account
+ * may leave it undefined.
+ */
+ configDir?: string;
+ /** Account used when a request carries no account of its own. */
+ isDefault: boolean;
+};
+
+/** Id of the implicit single account — never appears in the UI. */
+export const AMBIENT_ACCOUNT_ID = "default";
+
+const ACCOUNT_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,31}$/;
+
+let accounts: ClaudeAccount[] | null = null;
+/** mtime of accounts.json the cache was built from, so panel edits land live. */
+let accountsFileStamp = 0;
+
+function accountsFileMtime(): number {
+ try {
+ return statSync(accountsFilePath()).mtimeMs;
+ } catch {
+ return 0;
+ }
+}
+
+function ambientAccount(): ClaudeAccount {
+ return { id: AMBIENT_ACCOUNT_ID, label: "Claude Code", isDefault: true };
+}
+
+function expandHome(value: string): string {
+ const trimmed = value.trim();
+ if (!trimmed) return trimmed;
+ if (trimmed === "~") return homedir();
+ if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
+ return trimmed;
+}
+
+function accountsFilePath(): string {
+ const xdg = process.env.XDG_DATA_HOME;
+ const base = xdg ? xdg : join(homedir(), ".local", "share");
+ return join(base, "opencode-claude", "accounts.json");
+}
+
+function parseAccountEntry(raw: unknown): ClaudeAccount | null {
+ if (!raw || typeof raw !== "object") return null;
+ const entry = raw as Record;
+ const id = typeof entry.id === "string" ? entry.id.trim().toLowerCase() : "";
+ if (!ACCOUNT_ID_PATTERN.test(id)) {
+ log.warn("[opencode-claude] ignoring account with invalid id", { id });
+ return null;
+ }
+ const configDirRaw =
+ typeof entry.configDir === "string"
+ ? entry.configDir
+ : typeof entry.claudeConfigDir === "string"
+ ? entry.claudeConfigDir
+ : "";
+ const configDir = configDirRaw ? expandHome(configDirRaw) : undefined;
+ if (configDir && !isAbsolute(configDir)) {
+ log.warn("[opencode-claude] ignoring account with relative configDir", {
+ id,
+ configDir,
+ });
+ return null;
+ }
+ const label =
+ typeof entry.label === "string" && entry.label.trim()
+ ? entry.label.trim()
+ : id;
+ return {
+ id,
+ label,
+ ...(configDir ? { configDir } : {}),
+ isDefault: entry.default === true || entry.isDefault === true,
+ };
+}
+
+/**
+ * Drop invalid entries and guarantee exactly one default. Two accounts sharing
+ * a config dir (or both inheriting the ambient one) would silently be the same
+ * subscription wearing two labels — the CLI-profile flavour of a duplicate
+ * login — so the duplicate is dropped with a warning.
+ */
+function normalize(entries: ClaudeAccount[]): ClaudeAccount[] {
+ const byId = new Map();
+ const seenDirs = new Set();
+ for (const entry of entries) {
+ if (byId.has(entry.id)) {
+ log.warn("[opencode-claude] duplicate account id ignored", { id: entry.id });
+ continue;
+ }
+ const dirKey = entry.configDir ?? "";
+ if (seenDirs.has(dirKey)) {
+ log.warn("[opencode-claude] account ignored: config dir already claimed", {
+ id: entry.id,
+ configDir: dirKey,
+ });
+ continue;
+ }
+ seenDirs.add(dirKey);
+ byId.set(entry.id, entry);
+ }
+ const list = [...byId.values()];
+ if (list.length === 0) return [ambientAccount()];
+ const defaults = list.filter((a) => a.isDefault);
+ if (defaults.length !== 1) {
+ // No explicit default (or several): the first entry wins, deterministically.
+ for (const account of list) account.isDefault = false;
+ list[0].isDefault = true;
+ if (defaults.length > 1) {
+ log.warn("[opencode-claude] several accounts marked default; using the first", {
+ chosen: list[0].id,
+ });
+ }
+ }
+ return list;
+}
+
+function fromEnv(): ClaudeAccount[] | null {
+ const raw = process.env.OPENCODE_CLAUDE_ACCOUNTS?.trim();
+ if (!raw) return null;
+ if (raw.startsWith("[")) {
+ try {
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return null;
+ const list = parsed
+ .map(parseAccountEntry)
+ .filter((a): a is ClaudeAccount => a !== null);
+ return list.length > 0 ? list : null;
+ } catch (err) {
+ log.warn("[opencode-claude] OPENCODE_CLAUDE_ACCOUNTS is not valid JSON", {
+ message: err instanceof Error ? err.message : String(err),
+ });
+ return null;
+ }
+ }
+ // Shorthand: "work:Work:~/.claude-work,personal:Personal:~/.claude-personal"
+ const list = raw
+ .split(",")
+ .map((chunk) => chunk.trim())
+ .filter(Boolean)
+ .map((chunk, index) => {
+ const [id, label, configDir] = chunk.split(":").map((p) => p.trim());
+ return parseAccountEntry({
+ id,
+ label: label || id,
+ configDir,
+ default: index === 0,
+ });
+ })
+ .filter((a): a is ClaudeAccount => a !== null);
+ return list.length > 0 ? list : null;
+}
+
+type FileRoster =
+ | { status: "absent" }
+ | { status: "valid"; accounts: ClaudeAccount[] }
+ | { status: "invalid" };
+
+function fromFile(): FileRoster {
+ const path = accountsFilePath();
+ if (!existsSync(path)) return { status: "absent" };
+ let text: string;
+ try {
+ text = readFileSync(path, "utf8");
+ } catch (err) {
+ log.warn("[opencode-claude] accounts.json unreadable; ignoring", {
+ path,
+ message: err instanceof Error ? err.message : String(err),
+ });
+ return { status: "invalid" };
+ }
+ try {
+ const parsed = JSON.parse(text);
+ const raw = Array.isArray(parsed)
+ ? parsed
+ : Array.isArray((parsed as { accounts?: unknown })?.accounts)
+ ? (parsed as { accounts: unknown[] }).accounts
+ : null;
+ if (!raw) {
+ log.warn("[opencode-claude] accounts.json has an invalid roster shape", { path });
+ return { status: "invalid" };
+ }
+ const list = raw
+ .map(parseAccountEntry)
+ .filter((a): a is ClaudeAccount => a !== null);
+ return { status: "valid", accounts: list };
+ } catch (err) {
+ log.warn("[opencode-claude] accounts.json is not valid JSON", {
+ path,
+ message: err instanceof Error ? err.message : String(err),
+ });
+ return { status: "invalid" };
+ }
+}
+
+/**
+ * Environment configuration is an explicit deployment choice and wins whole;
+ * otherwise the managed file is the complete roster (an account absent from
+ * it was deliberately removed). A present-but-malformed file fails closed to
+ * the ambient account instead of resurrecting removed entries.
+ */
+function resolveRegistry(): ClaudeAccount[] {
+ const fromEnvironment = fromEnv();
+ const fileRoster = fromFile();
+ const list =
+ fromEnvironment ??
+ (fileRoster.status === "valid" ? fileRoster.accounts : []);
+ accountsFileStamp = accountsFileMtime();
+ return normalize(list);
+}
+
+/** Test helper: forget the resolved registry so the next read re-resolves. */
+export function resetAccounts(): void {
+ accounts = null;
+ accountsFileStamp = 0;
+}
+
+export function getAccounts(): ClaudeAccount[] {
+ // Re-resolve when the panel/tools rewrote accounts.json, so a newly added
+ // account is usable without restarting the OpenCode server.
+ if (!accounts || accountsFileMtime() !== accountsFileStamp) {
+ accounts = resolveRegistry();
+ }
+ return accounts;
+}
+
+/** Path of the managed registry — surfaced in the UI for transparency. */
+export function getAccountsFilePath(): string {
+ return accountsFilePath();
+}
+
+function persistAccounts(list: ClaudeAccount[]): void {
+ const path = accountsFilePath();
+ mkdirSync(dirname(path), { recursive: true });
+ writeFileSync(
+ path,
+ JSON.stringify(
+ {
+ accounts: list.map((a) => ({
+ id: a.id,
+ label: a.label,
+ ...(a.configDir ? { configDir: a.configDir } : {}),
+ default: a.isDefault,
+ })),
+ },
+ null,
+ 2,
+ ) + "\n",
+ "utf8",
+ );
+ accounts = null; // force a re-resolve on next read
+}
+
+/**
+ * Turn a human label into an account id: "Work Shared" → "work-shared".
+ * Accents are folded rather than dropped so "Cuenta Diseño" stays legible
+ * as "cuenta-diseno".
+ */
+export function slugifyAccountId(label: string): string {
+ const base = label
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 32)
+ .replace(/-+$/, "");
+ return /^[a-z0-9]/.test(base) ? base : `account-${base}`.slice(0, 32);
+}
+
+/** First free id in the `base`, `base-2`, `base-3`… series. */
+function uniqueAccountId(base: string, taken: Set): string {
+ if (!taken.has(base)) return base;
+ for (let n = 2; n < 1000; n++) {
+ const candidate = `${base.slice(0, 29)}-${n}`;
+ if (!taken.has(candidate)) return candidate;
+ }
+ throw new AccountError("could not derive a free account id");
+}
+
+/** The email address written inside a label, if there is one. */
+export function labelEmail(label: string): string | null {
+ const match = /[^\s<>()[\],;:"]+@[^\s<>()[\],;:"]+\.[a-z]{2,}/i.exec(label || "");
+ return match ? match[0] : null;
+}
+
+/**
+ * A label must not name a login. The label is a string an operator types
+ * once; the login is resolved from the CLI (accountInfo) and can turn out to
+ * be — or become — somebody else. When they disagree the account card
+ * contradicts itself, and the half a human reads first is the label.
+ */
+export function assertLabelNamesNoLogin(label: string): void {
+ const email = labelEmail(label);
+ if (!email) return;
+ throw new AccountError(
+ `a label must not contain an email address (${email}) — the login is resolved ` +
+ `from the CLI and shown on its own line, so a hand-written one only ` +
+ `gets a chance to be wrong. Name the slot for its role instead, e.g. "Work".`,
+ );
+}
+
+export class AccountError extends Error {
+ status: number;
+ constructor(message: string, status = 400) {
+ super(message);
+ this.name = "AccountError";
+ this.status = status;
+ }
+}
+
+/**
+ * Registry mutations edit accounts.json — but OPENCODE_CLAUDE_ACCOUNTS wins
+ * whole over the file, so a mutation made while the env override is active
+ * would write state nobody ever reads. Refuse loudly instead.
+ */
+function assertRegistryMutable(): void {
+ if (process.env.OPENCODE_CLAUDE_ACCOUNTS?.trim()) {
+ throw new AccountError(
+ "accounts are configured via OPENCODE_CLAUDE_ACCOUNTS — edit that " +
+ "environment variable instead; registry changes made here would be ignored",
+ 409,
+ );
+ }
+}
+
+/**
+ * Register an account. The config dir is created on demand so the operator's
+ * `CLAUDE_CONFIG_DIR= claude auth login` has somewhere to write.
+ */
+export function addAccount(input: {
+ id?: unknown;
+ label?: unknown;
+ configDir?: unknown;
+ makeDefault?: boolean;
+}): ClaudeAccount {
+ assertRegistryMutable();
+ const existing = getAccounts();
+ const taken = new Set(existing.map((a) => a.id));
+ const givenId = typeof input.id === "string" ? input.id.trim().toLowerCase() : "";
+ const givenLabel =
+ typeof input.label === "string" && input.label.trim() ? input.label.trim() : "";
+
+ // An explicit id still wins — scripts rely on it — but the normal path is
+ // to name the account and let the id follow.
+ let id: string;
+ if (givenId) {
+ if (!ACCOUNT_ID_PATTERN.test(givenId)) {
+ throw new AccountError(
+ "id must be lowercase letters, digits, dot, dash or underscore (max 32 chars)",
+ );
+ }
+ if (taken.has(givenId)) {
+ throw new AccountError(`account "${givenId}" already exists`, 409);
+ }
+ id = givenId;
+ } else {
+ if (!givenLabel) throw new AccountError("give the account a name");
+ const slug = slugifyAccountId(givenLabel);
+ if (!ACCOUNT_ID_PATTERN.test(slug)) {
+ throw new AccountError(
+ `could not derive an id from "${givenLabel}" — give one explicitly`,
+ );
+ }
+ id = uniqueAccountId(slug, taken);
+ }
+ const label = givenLabel || id;
+ assertLabelNamesNoLogin(label);
+ const rawDir =
+ typeof input.configDir === "string" && input.configDir.trim()
+ ? input.configDir
+ : `~/.claude-${id}`;
+ const configDir = expandHome(rawDir);
+ if (!isAbsolute(configDir)) {
+ throw new AccountError("configDir must be an absolute path (or start with ~)");
+ }
+ if (existing.some((a) => accountConfigDir(a) === configDir)) {
+ throw new AccountError(
+ `another account already uses ${configDir} — one Claude home per account`,
+ 409,
+ );
+ }
+ mkdirSync(configDir, { recursive: true, mode: 0o700 });
+
+ // The pre-existing single account is implicit; persisting it alongside the
+ // new one keeps the ambient Claude home addressable instead of vanishing
+ // behind the first account somebody adds.
+ const baseline = existing.map((account) =>
+ account.id === AMBIENT_ACCOUNT_ID && !account.configDir
+ ? { ...account, configDir: accountConfigDir(account) }
+ : account,
+ );
+ const created: ClaudeAccount = {
+ id,
+ label,
+ configDir,
+ isDefault: false,
+ };
+ const next = [...baseline, created];
+ if (input.makeDefault) {
+ for (const account of next) account.isDefault = account.id === id;
+ }
+ persistAccounts(normalize(next));
+ log.info("[opencode-claude] account added", { id, configDir });
+ return created;
+}
+
+/** Forget an account. Its Claude home is left on disk — credentials are the operator's. */
+export function removeAccount(id: string, force = false): void {
+ assertRegistryMutable();
+ const wanted = id.trim().toLowerCase();
+ const existing = getAccounts();
+ const target = existing.find((a) => a.id === wanted);
+ if (!target) throw new AccountError(`unknown account "${wanted}"`, 404);
+ if (existing.length === 1) {
+ throw new AccountError("cannot remove the only account", 409);
+ }
+ // Conversations bound to this account do not disappear with it. They get
+ // swept onto the default account and lose the transcript that lived in
+ // this account's Claude home. Removing an account with live conversations
+ // is therefore a decision about THOSE conversations — make it deliberate.
+ const bound = countBoundSessions(wanted);
+ if (bound > 0 && !force) {
+ throw new AccountError(
+ `"${wanted}" still owns ${bound} conversation${bound === 1 ? "" : "s"}. ` +
+ `Removing it moves them to the default account and loses their Claude ` +
+ `transcript. Move them first, or pass force to accept that.`,
+ 409,
+ );
+ }
+ const next = existing.filter((a) => a.id !== wanted);
+ if (target.isDefault) next[0].isDefault = true;
+ persistAccounts(normalize(next));
+ log.info("[opencode-claude] account removed", { id: wanted, boundSessions: bound });
+}
+
+/**
+ * Change an account's display label and/or id. The label rides into the
+ * model name, so a stale one is actively misleading. Every per-account store
+ * is keyed by id, so an id change must migrate them (the caller passes
+ * `migrate`) or the account silently loses its quota, usage and bindings.
+ */
+export function renameAccount(
+ id: string,
+ label: unknown,
+ options?: { newId?: unknown; migrate?: (oldId: string, newId: string, label: string) => void },
+): ClaudeAccount {
+ assertRegistryMutable();
+ const wanted = id.trim().toLowerCase();
+ const existing = getAccounts();
+ const current = existing.find((a) => a.id === wanted);
+ if (!current) throw new AccountError(`unknown account "${wanted}"`, 404);
+
+ const labelGiven = typeof label === "string";
+ const trimmedLabel = labelGiven ? (label as string).trim() : "";
+ const changingId =
+ typeof options?.newId === "string" &&
+ options.newId.trim().toLowerCase() !== "" &&
+ options.newId.trim().toLowerCase() !== wanted;
+ if (labelGiven && !trimmedLabel && !changingId) {
+ throw new AccountError("label cannot be empty");
+ }
+ const clean = trimmedLabel || current.label;
+ if (!clean) throw new AccountError("label cannot be empty");
+ if (clean.length > 64) throw new AccountError("label is too long (max 64 chars)");
+ if (trimmedLabel) assertLabelNamesNoLogin(clean);
+
+ const rawNewId =
+ typeof options?.newId === "string" ? options.newId.trim().toLowerCase() : "";
+ const newId = rawNewId && rawNewId !== wanted ? rawNewId : null;
+ if (newId) {
+ if (!ACCOUNT_ID_PATTERN.test(newId)) {
+ throw new AccountError(
+ "id must be lowercase letters, digits, dot, dash or underscore (max 32 chars)",
+ );
+ }
+ if (existing.some((a) => a.id === newId)) {
+ throw new AccountError(`account "${newId}" already exists`, 409);
+ }
+ }
+
+ const next = existing.map((account) => ({
+ ...account,
+ // Persisting an implicit ambient account needs a concrete dir, as in add.
+ ...(account.id === AMBIENT_ACCOUNT_ID && !account.configDir
+ ? { configDir: accountConfigDir(account) }
+ : {}),
+ ...(account.id === wanted
+ ? { label: clean, ...(newId ? { id: newId } : {}) }
+ : {}),
+ }));
+ persistAccounts(normalize(next));
+ if (newId) options?.migrate?.(wanted, newId, clean);
+ log.info("[opencode-claude] account renamed", {
+ id: wanted,
+ ...(newId ? { newId } : {}),
+ label: clean,
+ });
+ return next.find((a) => a.id === (newId ?? wanted))!;
+}
+
+/** Which account new sessions land on when nothing else says otherwise. */
+export function setDefaultAccount(id: string): ClaudeAccount {
+ assertRegistryMutable();
+ const wanted = id.trim().toLowerCase();
+ const existing = getAccounts();
+ if (!existing.some((a) => a.id === wanted)) {
+ throw new AccountError(`unknown account "${wanted}"`, 404);
+ }
+ const next = existing.map((account) => ({
+ ...account,
+ ...(account.id === AMBIENT_ACCOUNT_ID && !account.configDir
+ ? { configDir: accountConfigDir(account) }
+ : {}),
+ isDefault: account.id === wanted,
+ }));
+ persistAccounts(normalize(next));
+ return next.find((a) => a.id === wanted)!;
+}
+
+export function getDefaultAccount(): ClaudeAccount {
+ const list = getAccounts();
+ return list.find((a) => a.isDefault) ?? list[0];
+}
+
+/** True once the operator configured more than one subscription. */
+export function isMultiAccount(): boolean {
+ return getAccounts().length > 1;
+}
+
+/** Resolve a caller-supplied account without silently changing subscriptions. */
+export function requireAccount(id: string): ClaudeAccount {
+ const wanted = id.trim().toLowerCase();
+ const match = getAccounts().find((a) => a.id === wanted);
+ if (match) return match;
+ throw new AccountError(`unknown account "${wanted}"`, 404);
+}
+
+export function findAccount(id: string | null | undefined): ClaudeAccount | null {
+ if (!id) return null;
+ const wanted = id.trim().toLowerCase();
+ return getAccounts().find((a) => a.id === wanted) ?? null;
+}
+
+/**
+ * Claude home for an account. Falls back to the ambient CLAUDE_CONFIG_DIR (or
+ * `~/.claude`) so single-account setups keep reading exactly what they did.
+ */
+export function accountConfigDir(account: ClaudeAccount): string {
+ if (account.configDir) return account.configDir;
+ const ambient = process.env.CLAUDE_CONFIG_DIR?.trim();
+ return ambient || join(homedir(), ".claude");
+}
+
+/**
+ * Child env pointing the Claude CLI at this account's home. Accounts without
+ * an explicit config dir inherit the parent env untouched. This is the only
+ * account-auth mechanism the plugin has — it never touches credentials.
+ *
+ * A scoped account also drops an ambient CLAUDE_CODE_OAUTH_TOKEN: the CLI
+ * prefers an env token over its credentials file, which would silently run
+ * the turn on whichever subscription the operator's shell token belongs to.
+ */
+export function applyAccountEnv(
+ account: ClaudeAccount,
+ env: Record,
+): Record {
+ if (!account.configDir) return env;
+ const scoped: Record = {
+ ...env,
+ CLAUDE_CONFIG_DIR: account.configDir,
+ };
+ delete scoped.CLAUDE_CODE_OAUTH_TOKEN;
+ return scoped;
+}
diff --git a/src/bridge-pool.ts b/src/bridge-pool.ts
index 9259084..00be716 100644
--- a/src/bridge-pool.ts
+++ b/src/bridge-pool.ts
@@ -15,6 +15,8 @@ export type ParkedToolCall = {
export type ParkedBridge = {
id: string;
conversationKey: string;
+ /** Claude account this turn runs on — scopes rate-limit/quota records. */
+ accountId?: string;
handle: ClaudeQueryHandle;
pendingTools: Map;
/** SDK assistant messages whose usage was already reported to OpenCode. */
diff --git a/src/constants.ts b/src/constants.ts
index 2519e57..f8dd87c 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -6,6 +6,15 @@ export const EFFORT_HEADER = "x-opencode-claude-effort";
export const SESSION_HEADER = "x-opencode-claude-session";
/** Active OpenCode project directory forwarded to the local Agent SDK proxy. */
export const DIRECTORY_HEADER = "x-opencode-claude-directory";
+/**
+ * Claude account a response ran on. Echoed on turn responses and errors in
+ * multi-account mode so the bound account is visible from the wire without
+ * reading any store.
+ */
+export const ACCOUNT_HEADER = "x-opencode-claude-account";
+
+/** Separates a model id from its account: `opus@work`. */
+export const ACCOUNT_MODEL_SEPARATOR = "@";
export const EFFORT_LEVELS = [
"low",
diff --git a/src/failure.ts b/src/failure.ts
index 7115f43..e655bfe 100644
--- a/src/failure.ts
+++ b/src/failure.ts
@@ -6,17 +6,30 @@
* Mapping:
* - auth → 401 (non-retryable: credentials must be fixed by a human)
* - rate_limit → 429 + Retry-After (the gate store already knows the reset)
+ * - overloaded → 529 + short Retry-After (Anthropic transient overload —
+ * retryable, but never recorded as a hard subscription limit)
* - unknown → 500
*/
-import { isClaudeRateLimitText } from "./rate-limit.js";
+import {
+ isClaudeOverloadedText,
+ isClaudeRateLimitText,
+} from "./rate-limit.js";
-export type ClaudeFailureKind = "auth" | "rate_limit" | "unknown";
+export type ClaudeFailureKind = "auth" | "rate_limit" | "overloaded" | "unknown";
const AUTH_FAILURE_PATTERN =
/invalid_grant|refresh token (not found|invalid|expired)|invalid[_ -]?api[_ -]?key|authentication_error|authentication failed|unauthorized|not logged in|not authenticated|please (run )?\/?login|oauth token (is )?(expired|invalid|revoked)|access token (is )?(expired|invalid|revoked)|credentials (are )?(expired|invalid|revoked)|token (has )?expired|\b401\b/i;
+/** Seconds a client should wait before retrying after a 529 overload. */
+export const OVERLOADED_RETRY_AFTER_SECONDS = 30;
+
export function classifyClaudeFailure(text: string): ClaudeFailureKind {
if (!text) return "unknown";
+ // Overload first: "529 overloaded" texts can also contain generic words
+ // that pattern-match the rate-limit detector, and treating a transient
+ // overload as a hard subscription limit would wrongly gate turns for
+ // minutes.
+ if (isClaudeOverloadedText(text)) return "overloaded";
if (isClaudeRateLimitText(text)) return "rate_limit";
if (AUTH_FAILURE_PATTERN.test(text)) return "auth";
return "unknown";
@@ -28,6 +41,8 @@ export function failureStatusFor(kind: ClaudeFailureKind): number {
return 401;
case "rate_limit":
return 429;
+ case "overloaded":
+ return 529;
default:
return 500;
}
@@ -39,6 +54,8 @@ export function failureTypeFor(kind: ClaudeFailureKind): string {
return "authentication_error";
case "rate_limit":
return "rate_limit_error";
+ case "overloaded":
+ return "overloaded_error";
default:
return "server_error";
}
@@ -51,6 +68,8 @@ export function failureHintFor(kind: ClaudeFailureKind): string {
return "Claude Code credentials are invalid or expired. Run `claude auth login`, then restart OpenCode — retrying is pointless until then.";
case "rate_limit":
return "Claude subscription limit is active; wait for the reset instead of retrying.";
+ case "overloaded":
+ return "Anthropic is temporarily overloaded; retry in about half a minute.";
default:
return "";
}
diff --git a/src/host-transcript.ts b/src/host-transcript.ts
new file mode 100644
index 0000000..0388f2d
--- /dev/null
+++ b/src/host-transcript.ts
@@ -0,0 +1,144 @@
+/**
+ * Host-transcript ownership and divergence detection.
+ *
+ * On resumed turns the proxy normally ignores the host's prior messages —
+ * history comes from the Claude-side session transcript that `resume` points
+ * at. Host plugins that rewrite conversation history through
+ * `experimental.chat.messages.transform` (e.g. @tarquinen/opencode-dcp) would
+ * silently have no effect after the first turn.
+ *
+ * This module fingerprints the non-system messages of each request so the
+ * proxy can detect when the incoming array is no longer an extension of what
+ * it saw last turn (messages dropped, replaced, or edited). On divergence the
+ * proxy abandons the Claude session and rebuilds from the host array, so the
+ * transformed history is what actually reaches Claude.
+ *
+ * System messages are excluded on purpose: the proxy deliberately drops them
+ * (the Claude Code preset supplies the agent system prompt), and hosts vary
+ * them between turns.
+ */
+import { createHash } from "node:crypto";
+import {
+ contentHasAttachments,
+ extractTextContent,
+ type ConversationHistoryMessage,
+} from "./prompt.js";
+
+export type HostTranscriptDigest = {
+ /** Non-system message count of the fingerprinted array. */
+ count: number;
+ /** Cumulative chain hash over all non-system messages. */
+ hash: string;
+};
+
+export type HostTranscriptFingerprint = HostTranscriptDigest & {
+ /** chain[i] = cumulative hash after non-system message i. */
+ chain: string[];
+};
+
+export type HostTranscriptDivergence =
+ | { diverged: false }
+ | {
+ diverged: true;
+ /** "shrunk": messages were dropped; "rewritten": content replaced. */
+ reason: "shrunk" | "rewritten";
+ sentCount: number;
+ incomingCount: number;
+ };
+
+function isTruthyFlag(raw: string | undefined): boolean {
+ const value = (raw || "").trim().toLowerCase();
+ return value === "1" || value === "true" || value === "always" || value === "on";
+}
+
+function isFalsyFlag(raw: string | undefined): boolean {
+ const value = (raw || "").trim().toLowerCase();
+ return value === "0" || value === "false" || value === "off" || value === "warn";
+}
+
+/**
+ * Opt-in "host owns the transcript" mode: never resume a Claude session —
+ * rebuild the conversation from the (possibly transformed) host array every
+ * turn. Guarantees transform plugins always take effect, at the cost of
+ * Claude-side context features (prompt caching across turns, auto-compact
+ * continuity) and a bigger prompt per turn.
+ */
+export function hostOwnsTranscript(): boolean {
+ return isTruthyFlag(process.env.OPENCODE_CLAUDE_HOST_TRANSCRIPT);
+}
+
+/**
+ * Default-on: a detected divergence rebuilds from the host array instead of
+ * resuming. `OPENCODE_CLAUDE_DIVERGENCE_REBUILD=0` downgrades to warn-only
+ * (the divergence is logged but the Claude transcript still wins).
+ */
+export function divergenceRebuildEnabled(): boolean {
+ return !isFalsyFlag(process.env.OPENCODE_CLAUDE_DIVERGENCE_REBUILD);
+}
+
+/**
+ * Per-message signature. Text content is normalized through
+ * extractTextContent + trim so string vs part-array shapes of the same text
+ * do not register as a rewrite.
+ */
+function messageSignature(msg: ConversationHistoryMessage): string {
+ const role = typeof msg.role === "string" ? msg.role : "";
+ const text = extractTextContent(msg.content).trim();
+ const attachments = contentHasAttachments(msg.content) ? "+attachments" : "";
+ const toolCalls = (msg.tool_calls ?? [])
+ .map((call) => `${call?.id ?? ""}:${call?.function?.name ?? ""}`)
+ .join(",");
+ const toolCallId =
+ typeof msg.tool_call_id === "string" ? msg.tool_call_id : "";
+ return [role, text, attachments, toolCalls, toolCallId].join("\u0000");
+}
+
+/**
+ * Cumulative chain hash over the non-system messages. chain[i] depends on
+ * messages 0..i, so "stored digest is a prefix of the incoming array" is a
+ * single comparison against chain[stored.count - 1].
+ */
+export function fingerprintHostMessages(
+ messages: ConversationHistoryMessage[],
+): HostTranscriptFingerprint {
+ const chain: string[] = [];
+ let acc = "";
+ for (const msg of messages) {
+ if (!msg || typeof msg !== "object" || msg.role === "system") continue;
+ acc = createHash("sha1")
+ .update(acc)
+ .update("\u0001")
+ .update(messageSignature(msg))
+ .digest("hex");
+ chain.push(acc);
+ }
+ return { count: chain.length, hash: acc, chain };
+}
+
+/**
+ * Compare what the host sent last turn against the incoming array. No stored
+ * digest (first turn, migrated store) never counts as divergence.
+ */
+export function detectHostTranscriptDivergence(
+ stored: HostTranscriptDigest | undefined,
+ incoming: HostTranscriptFingerprint,
+): HostTranscriptDivergence {
+ if (!stored || stored.count <= 0) return { diverged: false };
+ if (incoming.count < stored.count) {
+ return {
+ diverged: true,
+ reason: "shrunk",
+ sentCount: stored.count,
+ incomingCount: incoming.count,
+ };
+ }
+ if (incoming.chain[stored.count - 1] !== stored.hash) {
+ return {
+ diverged: true,
+ reason: "rewritten",
+ sentCount: stored.count,
+ incomingCount: incoming.count,
+ };
+ }
+ return { diverged: false };
+}
diff --git a/src/identity.ts b/src/identity.ts
new file mode 100644
index 0000000..295dc50
--- /dev/null
+++ b/src/identity.ts
@@ -0,0 +1,168 @@
+/**
+ * Who each account actually is — resolved by the CLI, not by the plugin.
+ *
+ * The Agent SDK control channel's `accountInfo()` reports the login behind
+ * the spawned CLI's credentials (email, organization, subscription type).
+ * The plugin records what the CLI says and never reads a token itself.
+ *
+ * This exists because "configured" is not the same as "a different
+ * subscription": two accounts whose CLI homes hold grants for the SAME
+ * claude.ai login are one quota pool wearing two labels. Showing the email
+ * makes that obvious instead of leaving it to be inferred.
+ *
+ * Store: $XDG_DATA_HOME/opencode-claude/identity.json
+ * Env: OPENCODE_CLAUDE_IDENTITY_STORE overrides the path (tests).
+ */
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { homedir } from "node:os";
+import { dirname, join } from "node:path";
+import { labelEmail } from "./accounts.js";
+
+export type AccountIdentity = {
+ email?: string;
+ organization?: string;
+ /** 'pro' | 'max' | 'team' | 'enterprise' as the CLI reports it. */
+ subscriptionType?: string;
+ fetchedAt: number;
+};
+
+type IdentityStore = { version: 1; accounts: Record };
+
+function normalizeKey(accountId?: string): string {
+ const key = accountId?.trim().toLowerCase();
+ return key || "default";
+}
+
+function storePath(): string {
+ const override = process.env.OPENCODE_CLAUDE_IDENTITY_STORE;
+ if (override && override.trim()) return override.trim();
+ const xdg = process.env.XDG_DATA_HOME;
+ const base = xdg ? xdg : join(homedir(), ".local", "share");
+ return join(base, "opencode-claude", "identity.json");
+}
+
+function readStore(): IdentityStore {
+ const path = storePath();
+ if (!existsSync(path)) return { version: 1, accounts: {} };
+ try {
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
+ const accounts = (parsed as { accounts?: unknown })?.accounts;
+ return {
+ version: 1,
+ accounts:
+ accounts && typeof accounts === "object"
+ ? (accounts as Record)
+ : {},
+ };
+ } catch {
+ return { version: 1, accounts: {} };
+ }
+}
+
+function writeStore(store: IdentityStore): void {
+ try {
+ const path = storePath();
+ mkdirSync(dirname(path), { recursive: true });
+ writeFileSync(path, JSON.stringify(store, null, 2) + "\n", "utf8");
+ } catch {
+ // identity is informational — never break a turn over it
+ }
+}
+
+function str(value: unknown): string | undefined {
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
+}
+
+/** Parse an Agent SDK `accountInfo()` payload into a stored identity. */
+export function parseAccountInfo(
+ payload: unknown,
+ now: number = Date.now(),
+): AccountIdentity | null {
+ if (!payload || typeof payload !== "object") return null;
+ const raw = payload as Record;
+ const email = str(raw.email);
+ const organization = str(raw.organization);
+ const subscriptionType = str(raw.subscriptionType);
+ if (!email && !organization && !subscriptionType) return null;
+ return {
+ ...(email ? { email } : {}),
+ ...(organization ? { organization } : {}),
+ ...(subscriptionType ? { subscriptionType } : {}),
+ fetchedAt: now,
+ };
+}
+
+export function recordAccountIdentity(
+ accountId: string | undefined,
+ payload: unknown,
+ now: number = Date.now(),
+): AccountIdentity | null {
+ const parsed = parseAccountInfo(payload, now);
+ if (!parsed) return null;
+ const store = readStore();
+ store.accounts[normalizeKey(accountId)] = parsed;
+ writeStore(store);
+ return parsed;
+}
+
+export function getAccountIdentity(
+ accountId?: string,
+): AccountIdentity | null {
+ return readStore().accounts[normalizeKey(accountId)] ?? null;
+}
+
+export function clearAccountIdentity(accountId: string): void {
+ const store = readStore();
+ delete store.accounts[normalizeKey(accountId)];
+ writeStore(store);
+}
+
+/** Move an account's identity to a new id (see renameAccount). */
+export function renameAccountIdentity(oldId: string, newId: string): void {
+ const store = readStore();
+ const entry = store.accounts[normalizeKey(oldId)];
+ if (!entry) return;
+ delete store.accounts[normalizeKey(oldId)];
+ store.accounts[normalizeKey(newId)] = entry;
+ writeStore(store);
+}
+
+/**
+ * Account ids that resolved to the same login as the given one — i.e. the
+ * same subscription signed in twice. Empty when nothing is known yet.
+ */
+export function accountsSharingLogin(accountId: string): string[] {
+ const all = readStore().accounts;
+ const email = all[normalizeKey(accountId)]?.email?.toLowerCase();
+ if (!email) return [];
+ return Object.entries(all)
+ .filter(
+ ([id, identity]) =>
+ id !== normalizeKey(accountId) &&
+ identity.email?.toLowerCase() === email,
+ )
+ .map(([id]) => id);
+}
+
+/**
+ * The label names a login that is not the one the CLI resolved. A slot
+ * titled "Work · alice@corp.com" whose credential belongs to bob@corp.com
+ * contradicts itself three lines apart — worth flagging on read, not only
+ * refusing on write.
+ */
+export function labelLoginMismatch(
+ accountId: string,
+ label: string,
+): { claimed: string; actual: string } | null {
+ const claimed = labelEmail(label);
+ if (!claimed) return null;
+ const actual = getAccountIdentity(accountId)?.email;
+ if (!actual) return null;
+ if (claimed.toLowerCase() === actual.toLowerCase()) return null;
+ return { claimed, actual };
+}
+
+/** Test helper. */
+export function __resetIdentityStore(): void {
+ writeStore({ version: 1, accounts: {} });
+}
diff --git a/src/index.ts b/src/index.ts
index fce6f96..5a238bc 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -39,6 +39,7 @@ import {
getProxyPort,
startProxy,
} from "./proxy.js";
+import { buildClaudeTools } from "./tools.js";
function zeroCost() {
return {
@@ -245,7 +246,11 @@ export const ClaudeCodePlugin: Plugin = async (
input: PluginInput,
): Promise => {
const cliPresent = await probeCliPresence();
+ const claudeTools = buildClaudeTools();
return {
+ // In-session account management (claude_accounts, claude_account_manage).
+ // Empty when disabled via OPENCODE_CLAUDE_TOOLS=0.
+ ...(Object.keys(claudeTools).length > 0 ? { tool: claudeTools } : {}),
async config(config) {
// Bind first (ephemeral port by default), then seed provider baseURL so
// OpenCode's static config matches the live listener for this process.
diff --git a/src/model-selection.ts b/src/model-selection.ts
index 7d54829..5d65737 100644
--- a/src/model-selection.ts
+++ b/src/model-selection.ts
@@ -1,10 +1,16 @@
import { EFFORT_HEADER, isClaudeEffort, type ClaudeEffort } from "./constants.js";
+import { parseAccountModelId } from "./models.js";
export { EFFORT_HEADER };
export type ClaudeModelSelection = {
modelId: string;
effort?: ClaudeEffort;
+ /**
+ * Claude account this turn belongs to, when the operator runs several
+ * subscriptions. Absent means "the session's account, else the default".
+ */
+ account?: string;
};
export function encodeClaudeModelSelection(
@@ -25,16 +31,29 @@ export function decodeClaudeModelSelection(
if (parsed.effort !== undefined && !isClaudeEffort(parsed.effort)) {
delete parsed.effort;
}
+ if (parsed.account !== undefined && typeof parsed.account !== "string") {
+ delete parsed.account;
+ }
return parsed;
} catch {
return null;
}
}
+/**
+ * Selection for a chosen model id. `opus@work` splits into the real model
+ * and the account, so the account travels with the model the operator
+ * picked — no separate switch to keep in sync.
+ */
export function resolveClaudeModelSelection(
modelId: string,
variant?: string,
): ClaudeModelSelection {
const effort = isClaudeEffort(variant) ? variant : undefined;
- return { modelId, ...(effort ? { effort } : {}) };
+ const { baseModelId, accountId } = parseAccountModelId(modelId);
+ return {
+ modelId: baseModelId || modelId,
+ ...(effort ? { effort } : {}),
+ ...(accountId ? { account: accountId } : {}),
+ };
}
diff --git a/src/models.ts b/src/models.ts
index 5960853..9417c26 100644
--- a/src/models.ts
+++ b/src/models.ts
@@ -1,7 +1,26 @@
/**
* Claude Code model catalog (from OpenChamber harness registry).
+ *
+ * In multi-account mode every model appears once per account as
+ * `@` (the default account keeps bare ids so single-account
+ * setups and pinned configs never see a rename). Model NAMES carry the
+ * account label and the remaining quota, because the name is the one string
+ * the host renders next to the composer — the place where "how much is left
+ * on the account I am about to use" can actually be read.
*/
-import { EFFORT_LEVELS, type ClaudeEffort } from "./constants.js";
+import {
+ ACCOUNT_MODEL_SEPARATOR,
+ EFFORT_LEVELS,
+ type ClaudeEffort,
+} from "./constants.js";
+import {
+ getAccounts,
+ getDefaultAccount,
+ isMultiAccount,
+ type ClaudeAccount,
+} from "./accounts.js";
+import { formatShortDuration, getAccountQuota } from "./quota.js";
+import { getRateLimitSnapshot } from "./rate-limit.js";
export type ClaudeModel = {
id: string;
@@ -70,13 +89,122 @@ function buildCatalog(): ClaudeModel[] {
export const CLAUDE_CODE_MODELS: ClaudeModel[] = buildCatalog();
+/**
+ * Split `opus@work` into its parts. A bare id carries no account, which means
+ * "whatever the session is already bound to, else the default account".
+ */
+export function parseAccountModelId(modelId: string): {
+ baseModelId: string;
+ accountId: string | null;
+} {
+ const raw = (modelId || "").trim();
+ const at = raw.lastIndexOf(ACCOUNT_MODEL_SEPARATOR);
+ if (at <= 0 || at === raw.length - 1) {
+ return { baseModelId: raw, accountId: null };
+ }
+ return {
+ baseModelId: raw.slice(0, at),
+ accountId: raw.slice(at + 1).toLowerCase(),
+ };
+}
+
+/**
+ * Model id for an account. The default account keeps bare ids so existing
+ * sessions, pinned configs and single-account setups never see a rename.
+ */
+export function composeAccountModelId(
+ baseModelId: string,
+ account: ClaudeAccount,
+): string {
+ if (!isMultiAccount() || account.isDefault) return baseModelId;
+ return `${baseModelId}${ACCOUNT_MODEL_SEPARATOR}${account.id}`;
+}
+
+function nameQuotaDisabled(): boolean {
+ const flag = (process.env.OPENCODE_CLAUDE_MODEL_QUOTA ?? "").toLowerCase();
+ return flag === "0" || flag === "false" || flag === "off";
+}
+
+/**
+ * One window as `