From ec9d10404440621ec4e693003a016b3b07bcfc26 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Mon, 24 Aug 2026 11:28:15 +0000 Subject: [PATCH 01/10] fix: don't forward effort when thinking is disabled Meta requests (session title, context summary) force-disable thinking but still forwarded the selected effort level, and the API rejects that combination: 400 output_config.effort 'max' is not supported when thinking is disabled. The proxy no longer sends effort on meta requests, and startClaudeQuery drops effort defensively whenever the caller disables thinking, so no future call site can reintroduce the 400. Fixes #4 Co-authored-by: serkraser --- src/proxy.ts | 5 ++++- src/query.ts | 6 +++++- test/smoke.ts | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/proxy.ts b/src/proxy.ts index 8aae174..467d371 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -604,7 +604,10 @@ async function handleChatCompletions( cwd, model, resume: isMetaRequest ? undefined : resume, - effort: selection.effort, + // Meta requests force-disable thinking; the API rejects effort levels + // like "max" when thinking is disabled (400 output_config.effort), so + // effort must not be forwarded alongside them. + effort: isMetaRequest ? undefined : selection.effort, env, mcpServers: isMetaRequest ? undefined : mcpServers, autoCompactEnabled: !isMetaRequest, diff --git a/src/query.ts b/src/query.ts index e458a71..7ae8cff 100644 --- a/src/query.ts +++ b/src/query.ts @@ -226,7 +226,11 @@ export async function startClaudeQuery( } const effort = trimmedString(params.effort); - if (isClaudeEffort(effort)) options.effort = effort; + // The API rejects effort (e.g. "max") when thinking is disabled: + // "400 output_config.effort 'max' is not supported when thinking is + // disabled". Drop effort defensively rather than fail the whole turn. + const thinkingDisabled = params.thinking?.type === "disabled"; + if (isClaudeEffort(effort) && !thinkingDisabled) options.effort = effort; if (params.thinking) { options.thinking = params.thinking; diff --git a/test/smoke.ts b/test/smoke.ts index 4ac4ede..e04c2ce 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -861,7 +861,16 @@ async function main() { `http://127.0.0.1:${port}/v1/chat/completions`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { + "content-type": "application/json", + // Selected effort must NOT reach the meta turn: thinking is + // force-disabled there, and the API rejects effort+disabled + // (400 output_config.effort 'max' is not supported...). + "x-opencode-claude-effort": encodeClaudeModelSelection({ + modelId: "haiku", + effort: "max", + }), + }, body: JSON.stringify({ model: "claude-haiku-4-5", stream: true, @@ -901,6 +910,11 @@ async function main() { assert.equal(titleOptions!.maxTurns, 1); assert.equal(titleOptions!.autoCompactEnabled, false); assert.deepEqual(titleOptions!.thinking, { type: "disabled" }); + assert.equal( + titleOptions!.effort, + undefined, + "meta requests must not forward effort while thinking is disabled", + ); assert.equal(titleOptions!.resume, undefined); assert.equal( titleOptions!.systemPrompt, @@ -922,6 +936,48 @@ async function main() { } } + // startClaudeQuery defensively drops effort when thinking is disabled — + // the API rejects that combination (400 output_config.effort ... is not + // supported when thinking is disabled). + { + const { startClaudeQuery } = await import("../src/query.ts"); + const captureOptions = async ( + extra: Record, + ): Promise> => { + let captured: Record | null = null; + const handle = await startClaudeQuery({ + prompt: "hi", + cwd: "/tmp", + pathToClaudeCodeExecutable: "/bin/true", + ...extra, + queryImpl: + () => + (input: { options: Record }) => { + captured = input.options; + return (async function* () {})(); + }, + } as never); + handle.close(); + assert.ok(captured, "queryImpl was invoked"); + return captured!; + }; + + const disabledThinking = await captureOptions({ + effort: "max", + thinking: { type: "disabled" }, + }); + assert.equal( + disabledThinking.effort, + undefined, + "effort must be dropped when thinking is disabled", + ); + assert.deepEqual(disabledThinking.thinking, { type: "disabled" }); + + const adaptive = await captureOptions({ effort: "max" }); + assert.equal(adaptive.effort, "max"); + assert.deepEqual(adaptive.thinking, { type: "adaptive" }); + } + // ---- Rate-limit tracker + tool/plan behavior (mocked Agent SDK) ---- { const { From 085df161fbfc2fa9802bb1b8fdbf3c0057155884 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Mon, 24 Aug 2026 11:28:57 +0000 Subject: [PATCH 02/10] feat: respect host history transforms on resumed turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On resumed turns the proxy ignored the host's prior messages entirely — history came from the Claude-side session transcript that resume points at, so plugins rewriting conversation history via experimental.chat.messages.transform (e.g. @tarquinen/opencode-dcp) had no effect on claude-code/* models after turn 2. The proxy now fingerprints the non-system messages of every turn (chain hash, persisted next to the session binding). When the incoming array is no longer an extension of what the host sent last turn — messages dropped, replaced, or edited — it logs a warning, abandons the Claude session, and rebuilds from the transformed host array via history transfer, so the transform actually reaches Claude. System messages stay excluded from the fingerprint: the proxy drops them deliberately (the Claude Code preset supplies the agent system prompt) and hosts vary them between turns. Knobs: - OPENCODE_CLAUDE_DIVERGENCE_REBUILD=0 downgrades divergence handling to warn-only (previous behavior: the Claude transcript wins). - OPENCODE_CLAUDE_HOST_TRANSCRIPT=1 opts into host-owned transcripts: never resume, rebuild from the host array every turn. Co-authored-by: serkraser --- src/host-transcript.ts | 144 ++++++++++++++++++++++++ src/proxy.ts | 60 ++++++++++ src/session-store.ts | 35 +++++- test/smoke.ts | 249 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 src/host-transcript.ts 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/proxy.ts b/src/proxy.ts index 467d371..761ebd5 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -40,8 +40,16 @@ import { conversationKeyFromMessages, findClaudeSessionFile, getForeignSessionId, + getHostTranscriptDigest, setForeignSessionId, + setHostTranscriptDigest, } from "./session-store.js"; +import { + detectHostTranscriptDivergence, + divergenceRebuildEnabled, + fingerprintHostMessages, + hostOwnsTranscript, +} from "./host-transcript.js"; import { log } from "./log.js"; import { getRateLimitSnapshot, @@ -527,6 +535,58 @@ async function handleChatCompletions( resume = undefined; } + // Resume replays history from the Claude-side transcript, which ignores + // any host-side edits to prior messages (experimental.chat.messages.transform + // plugins such as DCP). Fingerprint what the host sends each turn; when the + // incoming array stops being an extension of the last one — or the operator + // opted into host-owned transcripts — rebuild from the host array instead. + if (!isMetaRequest) { + const fingerprint = fingerprintHostMessages(messages); + if (hostOwnsTranscript()) { + if (resume) { + log.info( + "[opencode-claude] host-transcript mode: skipping Claude session resume", + { conversationKey }, + ); + resume = undefined; + } + } else if (resume) { + const divergence = detectHostTranscriptDivergence( + getHostTranscriptDigest(conversationKey), + fingerprint, + ); + if (divergence.diverged) { + if (divergenceRebuildEnabled()) { + log.warn( + "[opencode-claude] host messages diverged from last turn (history transform detected); rebuilding from host array instead of resuming", + { + conversationKey, + reason: divergence.reason, + sentCount: divergence.sentCount, + incomingCount: divergence.incomingCount, + }, + ); + clearForeignSessionId(conversationKey); + resume = undefined; + } else { + log.warn( + "[opencode-claude] host messages diverged from last turn but divergence rebuild is disabled — resuming the Claude transcript; transformed history will NOT reach Claude", + { + conversationKey, + reason: divergence.reason, + sentCount: divergence.sentCount, + incomingCount: divergence.incomingCount, + }, + ); + } + } + } + setHostTranscriptDigest(conversationKey, { + count: fingerprint.count, + hash: fingerprint.hash, + }); + } + // No resumable Claude session (first claude-code turn of this chat, model // switch mid-conversation, lost store): serialize the prior OpenCode // messages into the prompt so Claude sees the whole conversation. diff --git a/src/session-store.ts b/src/session-store.ts index 7c0c4de..ff8fcf5 100644 --- a/src/session-store.ts +++ b/src/session-store.ts @@ -5,12 +5,16 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; +import type { HostTranscriptDigest } from "./host-transcript.js"; export type ClaudeSessionBinding = { conversationKey: string; - foreignSessionId: string; + /** Absent while only a host-transcript digest has been recorded. */ + foreignSessionId?: string; modelId?: string; cwd?: string; + /** Fingerprint of the host messages array sent last turn. */ + hostDigest?: HostTranscriptDigest; updatedAt: number; }; @@ -53,7 +57,10 @@ export function setForeignSessionId( meta?: { modelId?: string; cwd?: string }, ): void { const store = readStore(); + // Merge so the host-transcript digest recorded at turn start survives the + // session_id events that arrive later in the same turn. store[conversationKey] = { + ...store[conversationKey], conversationKey, foreignSessionId, modelId: meta?.modelId, @@ -70,6 +77,32 @@ export function clearForeignSessionId(conversationKey: string): void { writeStore(store); } +export function getHostTranscriptDigest( + conversationKey: string, +): HostTranscriptDigest | undefined { + const digest = readStore()[conversationKey]?.hostDigest; + return digest && + Number.isInteger(digest.count) && + digest.count >= 0 && + typeof digest.hash === "string" + ? digest + : undefined; +} + +export function setHostTranscriptDigest( + conversationKey: string, + digest: HostTranscriptDigest, +): void { + const store = readStore(); + store[conversationKey] = { + ...store[conversationKey], + conversationKey, + hostDigest: digest, + updatedAt: Date.now(), + }; + writeStore(store); +} + /** * Stable key from OpenAI messages so follow-ups resume the same Claude session. * Hashes the first user message only — including the message count made the key diff --git a/test/smoke.ts b/test/smoke.ts index e04c2ce..81e6532 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -585,6 +585,112 @@ async function main() { assert.equal((mwContent[2] as { type: string }).type, "image"); } + // ---- Host-transcript fingerprinting + divergence detection ---- + { + const { + detectHostTranscriptDivergence, + divergenceRebuildEnabled, + fingerprintHostMessages, + hostOwnsTranscript, + } = await import("../src/host-transcript.ts"); + + const turn1 = [ + { role: "system", content: "internal prompt v1" }, + { role: "user", content: "remember AXIOM" }, + { role: "assistant", content: "noted" }, + { role: "user", content: "next question" }, + ]; + const fp1 = fingerprintHostMessages(turn1); + assert.equal(fp1.count, 3, "system messages are excluded"); + assert.equal(fp1.chain.length, 3); + + // System prompt churn between turns must never register as divergence + // (the proxy drops system messages deliberately). + const systemChanged = fingerprintHostMessages([ + { role: "system", content: "internal prompt v2 CHANGED" }, + ...turn1.slice(1), + ]); + assert.equal(systemChanged.hash, fp1.hash); + + // Same text as a string vs a part array is not a rewrite. + const partArray = fingerprintHostMessages([ + turn1[0]!, + { role: "user", content: [{ type: "text", text: "remember AXIOM" }] }, + ...turn1.slice(2), + ]); + assert.equal(partArray.hash, fp1.hash); + + const stored = { count: fp1.count, hash: fp1.hash }; + + // Normal growth (assistant reply + new user turn) extends the prefix. + assert.deepEqual( + detectHostTranscriptDivergence( + stored, + fingerprintHostMessages([ + ...turn1, + { role: "assistant", content: "an answer" }, + { role: "user", content: "another question" }, + ]), + ), + { diverged: false }, + ); + + // A retry with the identical array is not a divergence. + assert.deepEqual(detectHostTranscriptDivergence(stored, fp1), { + diverged: false, + }); + + // Messages dropped → shrunk. + const shrunk = detectHostTranscriptDivergence( + stored, + fingerprintHostMessages(turn1.slice(0, 2)), + ); + assert.equal(shrunk.diverged, true); + assert.equal(shrunk.diverged && shrunk.reason, "shrunk"); + + // A prior message replaced (DCP-style pruning) → rewritten. + const rewritten = detectHostTranscriptDivergence( + stored, + fingerprintHostMessages([ + turn1[0]!, + { role: "user", content: "[[pruned]]" }, + ...turn1.slice(2), + ]), + ); + assert.equal(rewritten.diverged, true); + assert.equal(rewritten.diverged && rewritten.reason, "rewritten"); + + // No stored digest (first turn / migrated store) → never diverged. + assert.deepEqual(detectHostTranscriptDivergence(undefined, fp1), { + diverged: false, + }); + + // Env flags: host mode is opt-in, divergence rebuild is default-on. + const prevHost = process.env.OPENCODE_CLAUDE_HOST_TRANSCRIPT; + const prevDivergence = process.env.OPENCODE_CLAUDE_DIVERGENCE_REBUILD; + try { + delete process.env.OPENCODE_CLAUDE_HOST_TRANSCRIPT; + delete process.env.OPENCODE_CLAUDE_DIVERGENCE_REBUILD; + assert.equal(hostOwnsTranscript(), false); + assert.equal(divergenceRebuildEnabled(), true); + process.env.OPENCODE_CLAUDE_HOST_TRANSCRIPT = "1"; + process.env.OPENCODE_CLAUDE_DIVERGENCE_REBUILD = "0"; + assert.equal(hostOwnsTranscript(), true); + assert.equal(divergenceRebuildEnabled(), false); + } finally { + if (prevHost === undefined) { + delete process.env.OPENCODE_CLAUDE_HOST_TRANSCRIPT; + } else { + process.env.OPENCODE_CLAUDE_HOST_TRANSCRIPT = prevHost; + } + if (prevDivergence === undefined) { + delete process.env.OPENCODE_CLAUDE_DIVERGENCE_REBUILD; + } else { + process.env.OPENCODE_CLAUDE_DIVERGENCE_REBUILD = prevDivergence; + } + } + } + // Usage + compact helpers const { usageFromSdkResult, formatCompactNote } = await import( "../src/usage.ts" @@ -1603,6 +1709,149 @@ async function main() { assert.match(String(seen3.params!.prompt ?? ""), //); assert.equal(getForeignSessionId("smoke-history-dead"), undefined); + // 4. Host-side history transform between turns (DCP-style pruning via + // experimental.chat.messages.transform) → divergence detected, + // resume abandoned, the TRANSFORMED history is injected. + const prevHostEnv = process.env.OPENCODE_CLAUDE_HOST_TRANSCRIPT; + const prevDivergenceEnv = process.env.OPENCODE_CLAUDE_DIVERGENCE_REBUILD; + delete process.env.OPENCODE_CLAUDE_HOST_TRANSCRIPT; + delete process.env.OPENCODE_CLAUDE_DIVERGENCE_REBUILD; + clearForeignSessionId("smoke-history-diverge"); + const divProjectsDir = joinPath( + homedir(), + ".claude", + "projects", + "opencode-claude-smoke-div", + ); + mkdirSync(divProjectsDir, { recursive: true }); + writeFileSync(joinPath(divProjectsDir, "mock-sess-div.jsonl"), "{}\n"); + try { + // Turn A: binding exists, no digest yet → resumes, records digest. + setForeignSessionId("smoke-history-diverge", "mock-sess-div"); + const seenA = { params: null as Record | null }; + mockTurn(seenA, "mock-sess-div"); + const resA = await postChat("smoke-history-diverge", historyMessages); + assert.equal(resA.status, 200); + await resA.text(); + assert.equal(seenA.params!.resume, "mock-sess-div"); + + // Turn B: a transform plugin replaced a prior user message while the + // conversation grew — the stored digest is no longer a prefix. + const transformed = [ + { role: "system", content: "internal system prompt" }, + { role: "user", content: "[[pruned by DCP]]" }, + { role: "assistant", content: "Codename AXIOM-9042 noted." }, + { role: "user", content: "what is the codename?" }, + { role: "assistant", content: "It is AXIOM-9042." }, + { role: "user", content: "and what did I originally say?" }, + ]; + const seenB = { params: null as Record | null }; + mockTurn(seenB, "mock-sess-div"); + const resB = await postChat("smoke-history-diverge", transformed); + assert.equal(resB.status, 200); + await resB.text(); + assert.equal( + seenB.params!.resume, + undefined, + "diverged history must rebuild instead of resuming", + ); + const promptB = String(seenB.params!.prompt ?? ""); + assert.match(promptB, //); + assert.match( + promptB, + /\[\[pruned by DCP\]\]/, + "the transformed history is what reaches Claude", + ); + + // Turn C: extends turn B's transformed array → no divergence, resume + // returns (turn B's init event re-established the binding). + const seenC = { params: null as Record | null }; + mockTurn(seenC, "mock-sess-div"); + const resC = await postChat("smoke-history-diverge", [ + ...transformed, + { role: "assistant", content: "You asked me to remember it." }, + { role: "user", content: "great, thanks" }, + ]); + assert.equal(resC.status, 200); + await resC.text(); + assert.equal(seenC.params!.resume, "mock-sess-div"); + assert.doesNotMatch( + String(seenC.params!.prompt ?? ""), + //, + ); + + // 5. Shrunk host array (messages dropped outright) → also a rebuild. + const seenShrunk = { params: null as Record | null }; + mockTurn(seenShrunk, "mock-sess-div"); + const resShrunk = await postChat( + "smoke-history-diverge", + transformed.slice(0, 4), + ); + assert.equal(resShrunk.status, 200); + await resShrunk.text(); + assert.equal( + seenShrunk.params!.resume, + undefined, + "shrunk host array must rebuild instead of resuming", + ); + + // 6. Warn-only mode: divergence is logged but resume is kept. + process.env.OPENCODE_CLAUDE_DIVERGENCE_REBUILD = "0"; + const warnMessages = [ + { role: "system", content: "internal system prompt" }, + { role: "user", content: "[[a different transform]]" }, + { role: "assistant", content: "noted" }, + { role: "user", content: "next" }, + ]; + const seenWarn = { params: null as Record | null }; + mockTurn(seenWarn, "mock-sess-div"); + const resWarn = await postChat("smoke-history-diverge", warnMessages); + assert.equal(resWarn.status, 200); + await resWarn.text(); + assert.equal( + seenWarn.params!.resume, + "mock-sess-div", + "warn-only mode must keep resuming despite divergence", + ); + delete process.env.OPENCODE_CLAUDE_DIVERGENCE_REBUILD; + + // 7. Host-transcript mode: never resume, rebuild every turn — even + // when the incoming array extends the previous one cleanly. + process.env.OPENCODE_CLAUDE_HOST_TRANSCRIPT = "1"; + const seenHost = { params: null as Record | null }; + mockTurn(seenHost, "mock-sess-div"); + const resHost = await postChat("smoke-history-diverge", [ + ...warnMessages, + { role: "assistant", content: "ok" }, + { role: "user", content: "continue" }, + ]); + assert.equal(resHost.status, 200); + await resHost.text(); + assert.equal( + seenHost.params!.resume, + undefined, + "host-transcript mode must never resume", + ); + assert.match( + String(seenHost.params!.prompt ?? ""), + //, + ); + delete process.env.OPENCODE_CLAUDE_HOST_TRANSCRIPT; + } finally { + if (prevHostEnv === undefined) { + delete process.env.OPENCODE_CLAUDE_HOST_TRANSCRIPT; + } else { + process.env.OPENCODE_CLAUDE_HOST_TRANSCRIPT = prevHostEnv; + } + if (prevDivergenceEnv === undefined) { + delete process.env.OPENCODE_CLAUDE_DIVERGENCE_REBUILD; + } else { + process.env.OPENCODE_CLAUDE_DIVERGENCE_REBUILD = prevDivergenceEnv; + } + rmSync(divProjectsDir, { recursive: true, force: true }); + clearForeignSessionId("smoke-history-diverge"); + } + clearForeignSessionId("smoke-history-fresh"); clearForeignSessionId("smoke-history-resume"); clearForeignSessionId("smoke-history-dead"); From 7c749162ca3a287acb24e08904a684d07caf7f99 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Mon, 24 Aug 2026 11:29:08 +0000 Subject: [PATCH 03/10] docs: host history transforms, divergence knobs, meta effort fix Document the divergence-rebuild behavior and the two new knobs (OPENCODE_CLAUDE_HOST_TRANSCRIPT, OPENCODE_CLAUDE_DIVERGENCE_REBUILD) in the README, and add changelog entries for both fixes. Co-authored-by: serkraser --- CHANGELOG.md | 21 +++++++++++++++++++++ README.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25d344e..d8d12e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## Unreleased + +- **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..4a3d465 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ 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. | +| **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 with its reset time; `GET /v1/rate-limit` answers "when are limits back", and doomed turns fail fast with 429 + `Retry-After`. | | **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. | @@ -148,6 +149,32 @@ errors (including the parsed reset time) to - `OPENCODE_CLAUDE_RATE_LIMIT_FAST_FAIL=0` disables the 429 gate (turns are attempted and error normally). +### 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) @@ -174,6 +201,8 @@ Optional knobs: - `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_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 From 72be866e42d1d7167bfccad010f6ca769b020688 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Mon, 24 Aug 2026 12:09:25 +0000 Subject: [PATCH 04/10] feat: per-account rate-limit store, 529/$0-group classification, local title heuristic Co-authored-by: serkraser --- src/failure.ts | 23 ++++++- src/rate-limit.ts | 150 ++++++++++++++++++++++++++++++++++++-------- src/request-kind.ts | 12 ++++ 3 files changed, 158 insertions(+), 27 deletions(-) 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/rate-limit.ts b/src/rate-limit.ts index 4488290..39ce52d 100644 --- a/src/rate-limit.ts +++ b/src/rate-limit.ts @@ -13,6 +13,11 @@ * active instead of spawning a doomed Agent SDK turn, * - append the reset countdown to the streamed error note. * + * Limits are per subscription, so the store is keyed by account: one account + * hitting its five-hour window must not gate turns on another. Legacy flat + * files (single-account installs) are migrated on read into the default + * bucket. + * * Store: $XDG_DATA_HOME/opencode-claude/rate-limit.json * Env: * - OPENCODE_CLAUDE_RATE_LIMIT_STORE — override store path (tests) @@ -45,6 +50,22 @@ export type ClaudeRateLimitState = { /** When a hard limit error carries no reset time, block new turns briefly. */ const FALLBACK_BLOCK_MS = 10 * 60 * 1000; +/** + * On-disk shape (v2). `accounts` maps account id → state; v1 files held a + * single bare ClaudeRateLimitState at the root and are migrated on read. + */ +type RateLimitStore = { + version: 2; + accounts: Record; +}; + +const DEFAULT_ACCOUNT_KEY = "default"; + +function normalizeAccountKey(accountId?: string): string { + const key = accountId?.trim().toLowerCase(); + return key || DEFAULT_ACCOUNT_KEY; +} + function storePath(): string { const override = process.env.OPENCODE_CLAUDE_RATE_LIMIT_STORE; if (override && override.trim()) return override.trim(); @@ -53,28 +74,79 @@ function storePath(): string { return join(base, "opencode-claude", "rate-limit.json"); } -function readState(): ClaudeRateLimitState | null { +function readStore(): RateLimitStore { const path = storePath(); - if (!existsSync(path)) return null; + if (!existsSync(path)) return { version: 2, accounts: {} }; try { const parsed = JSON.parse(readFileSync(path, "utf8")); - if (!parsed || typeof parsed !== "object") return null; - return parsed as ClaudeRateLimitState; + if (!parsed || typeof parsed !== "object") { + return { version: 2, accounts: {} }; + } + const raw = parsed as Record; + if (raw.accounts && typeof raw.accounts === "object") { + return { + version: 2, + accounts: raw.accounts as Record, + }; + } + // v1: a bare ClaudeRateLimitState at the root. + if (typeof raw.updatedAt === "number" || typeof raw.limited === "boolean") { + return { + version: 2, + accounts: { [DEFAULT_ACCOUNT_KEY]: raw as ClaudeRateLimitState }, + }; + } + return { version: 2, accounts: {} }; } catch { - return null; + return { version: 2, accounts: {} }; + } +} + +function readState(accountId?: string): ClaudeRateLimitState | null { + return readStore().accounts[normalizeAccountKey(accountId)] ?? null; +} + +function writeState(state: ClaudeRateLimitState, accountId?: string): void { + try { + const path = storePath(); + const store = readStore(); + store.accounts[normalizeAccountKey(accountId)] = state; + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(store, null, 2) + "\n", "utf8"); + } catch { + // never let the tracker break the proxy } } -function writeState(state: ClaudeRateLimitState): void { +/** Move an account's limit state to a new id (see renameAccount). */ +export function renameAccountRateLimit(oldId: string, newId: string): void { + const store = readStore(); + const entry = store.accounts[normalizeAccountKey(oldId)]; + if (!entry) return; + delete store.accounts[normalizeAccountKey(oldId)]; + store.accounts[normalizeAccountKey(newId)] = entry; try { const path = storePath(); mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, JSON.stringify(state, null, 2) + "\n", "utf8"); + writeFileSync(path, JSON.stringify(store, null, 2) + "\n", "utf8"); } catch { // never let the tracker break the proxy } } +/** Snapshot for every account that has state — backs the accounts view. */ +export function getAllRateLimitSnapshots( + now: number = Date.now(), +): Record { + const store = readStore(); + return Object.fromEntries( + Object.keys(store.accounts).map((accountId) => [ + accountId, + getRateLimitSnapshot(now, accountId), + ]), + ); +} + function asNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } @@ -88,10 +160,13 @@ function asString(value: unknown): string | undefined { * on its own — the SDK reports "rejected" for turns that still complete * (overage pool rejection); only a hard error result confirms the limit. */ -export function recordRateLimitInfo(info: unknown): ClaudeRateLimitState | null { +export function recordRateLimitInfo( + info: unknown, + accountId?: string, +): ClaudeRateLimitState | null { if (!info || typeof info !== "object") return null; const raw = info as Record; - const prev = readState() ?? { limited: false, updatedAt: 0 }; + const prev = readState(accountId) ?? { limited: false, updatedAt: 0 }; const resetsAtSec = asNumber(raw.resetsAt); const next: ClaudeRateLimitState = { ...prev, @@ -116,7 +191,7 @@ export function recordRateLimitInfo(info: unknown): ClaudeRateLimitState | null : prev.resetsAt, updatedAt: Date.now(), }; - writeState(next); + writeState(next, accountId); return next; } @@ -125,12 +200,25 @@ export function isClaudeRateLimitText(text: string): boolean { return ( /hit your (session|usage) limit/i.test(text) || /usage limit reached/i.test(text) || + // Org admin set the overage/group budget to $0 — every extra request is + // refused until the window resets. Fail fast like any other hard limit + // instead of letting the host retry a doomed turn. + /(?:group's|group) usage limit is set to \$0/i.test(text) || /rate[ -]?limit/i.test(text) || /too many requests/i.test(text) || /\b429\b/.test(text) ); } +/** + * Anthropic 529 "overloaded" — a transient server condition, not a + * subscription limit. Callers should answer with a retryable status and a + * short Retry-After, and must NOT set the hard-limit gate for it. + */ +export function isClaudeOverloadedText(text: string): boolean { + return /overloaded_error|\b529\b|api is temporarily overloaded/i.test(text); +} + /** * Parse "resets 1:10am (Europe/Kyiv)" / "reset at 2026-08-09T01:10:00" into * epoch ms. Returns undefined when no reset hint is present. @@ -186,13 +274,16 @@ export function parseResetTimeFromText( /** * Record a hard-limit error message. Returns the updated state, or null when - * the text is not a limit error. + * the text is not a limit error. Transient 529 overloads are deliberately + * ignored — they clear on their own in seconds and must not activate the gate. */ export function recordRateLimitErrorText( text: string, + accountId?: string, ): ClaudeRateLimitState | null { if (!text || !isClaudeRateLimitText(text)) return null; - const prev = readState() ?? { limited: false, updatedAt: 0 }; + if (isClaudeOverloadedText(text)) return null; + const prev = readState(accountId) ?? { limited: false, updatedAt: 0 }; const resetsAt = parseResetTimeFromText(text) ?? prev.resetsAt; const now = Date.now(); const next: ClaudeRateLimitState = { @@ -204,7 +295,7 @@ export function recordRateLimitErrorText( message: text.trim().slice(0, 300), updatedAt: now, }; - writeState(next); + writeState(next, accountId); return next; } @@ -245,13 +336,16 @@ export type RateLimitSnapshot = { }; /** Current snapshot; auto-clears an expired hard block (self-healing). */ -export function getRateLimitSnapshot(now: number = Date.now()): RateLimitSnapshot { - const state = readState(); +export function getRateLimitSnapshot( + now: number = Date.now(), + accountId?: string, +): RateLimitSnapshot { + const state = readState(accountId); if (!state) return { limited: false }; let { limited, limitedUntil } = state; if (limited && limitedUntil !== undefined && now >= limitedUntil) { limited = false; - writeState({ ...state, limited: false, updatedAt: now }); + writeState({ ...state, limited: false, updatedAt: now }, accountId); } const resetsAt = state.resetsAt; const resetInSeconds = @@ -291,16 +385,19 @@ export type RateLimitGate = /** * Gate for new Agent SDK turns. Only a confirmed hard limit blocks, and only - * until the known/estimated reset. OPENCODE_CLAUDE_RATE_LIMIT_FAST_FAIL=0 - * disables the gate entirely. + * until the known/estimated reset. Scoped by account — limits are per + * subscription. OPENCODE_CLAUDE_RATE_LIMIT_FAST_FAIL=0 disables the gate. */ -export function rateLimitGate(now: number = Date.now()): RateLimitGate { +export function rateLimitGate( + now: number = Date.now(), + accountId?: string, +): RateLimitGate { const flag = (process.env.OPENCODE_CLAUDE_RATE_LIMIT_FAST_FAIL ?? "") .toLowerCase(); if (flag === "0" || flag === "false" || flag === "off") { return { blocked: false }; } - const snap = getRateLimitSnapshot(now); + const snap = getRateLimitSnapshot(now, accountId); if (!snap.limited) return { blocked: false }; const until = snap.limitedUntil ?? snap.resetsAt; const retryAfterSeconds = @@ -323,7 +420,8 @@ export function rateLimitGate(now: number = Date.now()): RateLimitGate { // Stream note dedupe — one rate-limit note per status/threshold per process. // --------------------------------------------------------------------------- -let lastNoteSignature: string | null = null; +/** Per-account: a warning on one subscription must not silence another's. */ +const lastNoteSignatures = new Map(); /** * Build a short user-facing note for a structured event, but only when the @@ -339,7 +437,9 @@ let lastNoteSignature: string | null = null; export function maybeRateLimitNote( state: ClaudeRateLimitState | null, fresh?: Record, + accountId?: string, ): string | null { + const noteKey = normalizeAccountKey(accountId); if (!state || !state.status) return null; const freshStatus = fresh ? asString(fresh.status) : undefined; const freshUtil = fresh ? asNumber(fresh.utilization) : undefined; @@ -354,7 +454,7 @@ export function maybeRateLimitNote( (fresh ? freshUtil !== undefined && freshUtil >= 0.9 : util !== undefined && util >= 0.9); if (!interesting) { - lastNoteSignature = null; + lastNoteSignatures.delete(noteKey); return null; } const bucket = @@ -366,8 +466,8 @@ export function maybeRateLimitNote( ? "u95" : "u90"; const signature = `${status}:${bucket}:${state.resetsAt ?? ""}`; - if (signature === lastNoteSignature) return null; - lastNoteSignature = signature; + if (signature === lastNoteSignatures.get(noteKey)) return null; + lastNoteSignatures.set(noteKey, signature); const parts = ["[rate-limit] Claude"]; if (state.rateLimitType) parts.push(state.rateLimitType.replace(/_/g, " ")); @@ -387,5 +487,5 @@ export function maybeRateLimitNote( /** Test helper: reset process-local note dedupe. */ export function __resetRateLimitNoteDedupe(): void { - lastNoteSignature = null; + lastNoteSignatures.clear(); } diff --git a/src/request-kind.ts b/src/request-kind.ts index de5296b..075b945 100644 --- a/src/request-kind.ts +++ b/src/request-kind.ts @@ -75,3 +75,15 @@ export function requestKeyNamespace(kind: MetaRequestKind): string { if (kind === "summary") return "summary:"; return ""; } + +/** + * Local, zero-API-call session title derived from the user's request text. + * Used when the subscription is rate-limited: titles are auxiliary, and + * spending a turn (or failing one) on them during a limit is the worst trade + * available. + */ +export function heuristicTitle(userText: string): string { + const line = (userText || "").replace(/\s+/g, " ").trim().slice(0, 60); + if (!line) return "New session"; + return line.length < (userText || "").trim().length ? `${line}...` : line; +} From c5e8c0c72b6bed84c67a22108d6dc6deeaa08771 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Mon, 24 Aug 2026 12:09:25 +0000 Subject: [PATCH 05/10] feat: multi-account registry (CLI-owned config dirs) with per-account quota, identity and usage stores Co-authored-by: serkraser --- src/accounts.ts | 590 +++++++++++++++++++++++++++++++++++++++++ src/bridge-pool.ts | 2 + src/constants.ts | 9 + src/identity.ts | 168 ++++++++++++ src/model-selection.ts | 21 +- src/models.ts | 136 +++++++++- src/query.ts | 44 ++- src/quota.ts | 293 ++++++++++++++++++++ src/session-store.ts | 191 ++++++++++++- src/usage-store.ts | 227 ++++++++++++++++ 10 files changed, 1659 insertions(+), 22 deletions(-) create mode 100644 src/accounts.ts create mode 100644 src/identity.ts create mode 100644 src/quota.ts create mode 100644 src/usage-store.ts diff --git a/src/accounts.ts b/src/accounts.ts new file mode 100644 index 0000000..1c8cb2f --- /dev/null +++ b/src/accounts.ts @@ -0,0 +1,590 @@ +/** + * 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; + } +} + +/** + * 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 { + 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 { + 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 { + 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 { + 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/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/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 `