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 `