Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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=<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=<id>` 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
Expand Down
142 changes: 137 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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=<id>`
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=<id>` 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)
Expand All @@ -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

Expand Down
Loading