Skip to content

feat(pool): surface pool events in OpenCode - #17

Merged
Dakuaisu merged 35 commits into
ElhaanDaud:mainfrom
Dakuaisu:feat/pool-notifications
Oct 19, 2026
Merged

Dakuaisu merged 35 commits into
ElhaanDaud:mainfrom
Dakuaisu:feat/pool-notifications

Conversation

@Dakuaisu

Copy link
Copy Markdown
Collaborator

Depends on #16. This PR must target main because it comes from a fork, so GitHub also shows the commits of the PRs below it in the stack. Its own changes are the last 3 commit(s), listed below. After #16 merges, only these remain.

Summary

  • The pool-exhausted message is now one line of at most ~80 characters. OpenCode shows it as a 5-second toast, and the old multi-sentence message was unreadable in that time.
  • The following are also shown as deduplicated, fire-and-forget toasts:
    • an account disabled
    • the pool exhausted
    • a mid-request account switch
    • an auto-enrollment
    • a shared Claude Code login
  • Enrolling the first account, or removing the last, takes effect without a restart. The pool status is re-checked at most every 30s.

Commits

  • fix(pool): make the pool-exhausted message a single short line
  • feat(pool): show pool events as OpenCode toasts
  • feat(pool): pick up enrolled or removed accounts without a restart

Each commit builds and passes the full suite on its own, and the commit messages explain the reasoning.

Testing

  • npm run typecheck clean and npm test passing at every commit in this PR
  • Tests: exhaustion messages and formatWait, toast deduplication, disabled-account callback (a throwing listener can't undo the disable)

…tdin

Token refreshes and code exchanges ran curl through execFileSync with the
JSON payload in argv. That had two problems:

- The refresh token and PKCE verifier were visible to any local user via
  `ps` for the duration of the request.
- The synchronous child process (and Atomics.wait backoff) blocked the event
  loop for up to ~100s, stalling every stream in the long-lived OpenCode
  server while one refresh ran.

curl now reads the payload from stdin (`--data-binary @-`) and runs through
async execFile. Errors are rebuilt from the exit status rather than rethrown,
because execFile's message embeds the full command line. An early curl exit
(DNS/TLS failure) no longer raises an unhandled EPIPE on stdin.

getClaudeTokens() no longer refreshes an expired Claude Code login: that
refresh token belongs to Claude Code, and spending it here logs Claude Code
out. Every caller already passed refreshExpired: false.
The old refresh token is invalid as soon as the server issues a new pair,
so if persisting the new pair to OpenCode's auth store fails, those tokens
are the only valid credentials the process has. Assign them before saving
and log (without the token) if the save fails, instead of throwing them
away and failing the request.
…dential

isCredentialRejected() matched "(400)", "(401)" or "(403)" anywhere in the
error text. A gateway error page containing "(403)", or a network error
that happened to mention "(401)", therefore marked a healthy account as
permanently disabled, which needs manual re-enrollment to undo.

OAuth error codes (invalid_grant, ...) still count wherever they appear; a
bare status now only counts when it comes from the "Token refresh failed
(NNN)" prefix that parseTokenResponse() writes.
parseTokenResponse() trusted any HTTP 200: a body missing either token
produced credentials with `undefined` fields, and a missing expires_in made
`expires` NaN. Errors also quoted the response body, and a 200 body is
exactly where the tokens are.

- A 200 must carry both tokens; a malformed one is reported by size only.
- A missing or invalid expires_in keeps the tokens with a 5-minute lifetime,
  so they are refreshed again soon instead of being lost.
- A non-JSON error body (e.g. a Cloudflare challenge served to a datacenter
  IP) is summarised by its <title> and is deliberately not phrased as
  "failed (403)", so it cools the account instead of disabling it.
- OAuth error text is capped at 300 characters.
The login flow generated an OAuth `state` but never checked it, so a code
from another authorization request (a forged localhost callback, or a
stale code pasted from an earlier attempt) was exchanged as if it were the
user's own.

- The localhost callback answers 400 to any hit without the expected state
  and keeps waiting, so a forged request cannot consume the one-shot
  promise and block the genuine redirect.
- A pasted `code#state` or callback URL whose state differs is refused.
  A bare code with no state is still accepted, as before.
`security dump-keychain` prints an attribute as a quoted string only when
it is printable ASCII; anything else (an apostrophe from a macOS device
name, a non-ASCII label) is printed as `0x<hex>  "<escaped>"`. The parser
matched only the quoted form, so such items were invisible to list(): the
account was never selected and was later disabled as "credentials missing".

Both forms are decoded now. Hex that is not valid UTF-8 is skipped rather
than turned into a wrong label.
The keychain backend's get() mapped every `security` failure to "not
found". A locked keychain, a denied access prompt or a timeout therefore
looked like deleted credentials, and acquire() permanently disabled the
account.

- get() returns null only for "could not be found" and throws otherwise.
- acquire() cools an account whose credentials cannot be read, so rotation
  moves on and retries it after the transient cooldown.
- listAccounts() skips an unreadable entry instead of failing the listing.
- get() drops the trailing newline `security -w` appends to the secret.
Keychain writes passed the account's token JSON to `security
add-generic-password -w <secret>`, exposing both tokens to any local user
via `ps` while the command ran.

Writes now go through `security -i`, which reads the command from stdin,
with the secret hex-encoded (`-X`) so no quoting is involved. `security -i`
silently stores a truncated secret for command lines over 4095 bytes and
only then reports an error, so anything near that limit is refused before
it is sent; the previous value stays intact.
The version sent in the user agent and billing header was detected once at
startup from `claude` on PATH, and the billing header preferred whatever
version the captured system prompt was recorded with. Both go stale:
Claude Code auto-updates underneath a long-running OpenCode, and the API
gates newer models on this version, so requests for them were rejected.

- Look for the binary in the installer's locations (~/.local/bin,
  ~/.claude/local) as well as PATH, which OpenCode's environment often lacks.
- Re-check in the background every 10 minutes; never block a request on it.
- Always use the detected version in the billing header.
- Warn once when no CLI is found and the hard-coded fallback is used.
  ANTHROPIC_CLI_VERSION still overrides everything.
metadata.user_id was never sent in practice: the OAuth profile was fetched
from claude.ai, which serves that path behind a Cloudflare challenge that
always returns 403. It was also looked up with OpenCode's stored token
rather than the token actually used, so with the account pool it would
have named the wrong account.

- Fetch the profile from api.anthropic.com, the host Claude Code uses. The
  pool's enrollment code already used that URL; it now shares the constant.
- Derive user_id from the access token sent with the request, and re-derive
  it when the pool rotates to another account mid-request. A caller-supplied
  user_id is left untouched.
- Don't cache a failed profile lookup for the life of the process.
buildBetaFlags() replaced the caller's anthropic-beta header with the
bridge's fixed list. Body fields gated by a flag only the caller sends were
then rejected by the API. Merge both lists, keeping the required Claude
Code and OAuth flags first and dropping duplicates.
… 401

Without an account pool, the stored token was refreshed only after it had
expired, so a request sent in its last seconds could arrive with a dead
token. And a 401 (e.g. a token revoked by a login elsewhere) was passed
straight to the user even when a refresh would fix it.

Refresh when the token has less than a minute left, and on a 401 refresh
and retry once. If the refresh fails, return the usual re-login guidance.
Cancelling a request in OpenCode did not reach Anthropic: the streaming
Response wrapper had no cancel() handler, so the upstream body kept being
read to the end, and retry back-offs kept sleeping and then re-sent the
request.

Forward cancel() to the upstream reader, make the back-off sleeps honour
the request's AbortSignal, and release the previous 429 response body
before each retry.
The Messages API accepts `system` as a string or as an array of blocks.
The rewrite assumed an array, so a string threw inside a catch-all that
silently sent the body unmodified, without the billing header or Claude
Code system prompt.

Wrap a string system prompt in a text block before rewriting, and log
(without the body) when the rewrite fails for any other reason.
The captured Claude Code system prompt was read once when the plugin
loaded, so re-capturing it (e.g. after a Claude Code update) had no effect
until OpenCode restarted. Re-read it when the file's mtime changes; the
per-request cost is one stat().
The SSE processor's debug sink was never connected, so malformed frames
and failed tool-argument translations were dropped silently. Connect it to
the server log. Because that log now receives these messages, the JSON
parse failure no longer includes V8's error text, which quotes the frame
(model output, which can contain file contents or secrets); it reports the
frame's size and event type instead.
selectNext() and acquire() take an optional set of labels to exclude,
including the sticky active account. The request path uses this to try
another account for the same request without looping back to one that
just failed it.
A 401 or 403 cooled or permanently disabled the account that served the
request, then rotated. When the fault lay with the request (a model or
feature the plan can't use, a malformed body), every account failed the
same way, so a single request walked the whole pool into cooldown or
disabled it, and disabled accounts need manual re-enrollment.

For 401/403 the penalty is now deferred:

- The request is retried on another account first (never one already
  tried for it).
- The penalty is applied only if a later account succeeds.
- If the next account fails with the same error (same status, type and
  message), the request is at fault: the error is passed through and
  neither account is penalised.
- If no other account is available, or the next one fails differently
  (429, 5xx), the error is passed through without a penalty.

Other failures (429 and friends) are penalised immediately, as before.

Because a deferred penalty lands after the pool has switched, applying it
only clears the active account if that account is the one penalised.
The journal holds tokens minted by a refresh whose result was never
persisted, i.e. the only valid refresh token for that account. Replaying
it ran outside the cross-process lock, so another OpenCode process could
read the keychain's already-spent token in the meantime, refresh with it,
get invalid_grant, and disable a healthy account.

Replay now commits under the lock (checking for a journal first, so the
common case still takes no lock), and refreshLabel() commits a pending
journal before refreshing rather than spending the dead keychain token.
acquire() calls ensureAccount() for every enrolled account on every
request, and each call took the cross-process lock (~10ms) just to find the
account already present. withLock() also rewrote and fsynced the state file
on every call, including token refreshes that only use the lock for mutual
exclusion.

Check for the account before locking (re-checked under the lock), and save
the state only when the callback changed it.
A token refresh holds the cross-process lock for up to ~100s. Another
OpenCode process that timed out waiting for it treated the timeout like
any refresh failure, cooling a healthy account, and the timeout could also
escape from success/penalty bookkeeping and turn a completed 200 into an
error.

Lock timeouts now raise a dedicated LockTimeoutError, and:

- acquire() re-reads the account, since the lock holder was most likely
  refreshing it, and uses the fresh token if it landed; otherwise it tries
  another account, and throws only when none is usable. The account is
  never penalised for the wait.
- The request path answers a retryable 503 (`retry-after: 5`) that does
  not expose the lock path, pid or host.
- Marking the active account, and recording a success or a penalty, are
  best-effort: they are logged and skipped when the lock stays busy.
The rotation lock lived next to the state file. The lock also serialises
token refreshes, so two processes pointed at different state files but
sharing the same accounts could refresh the same single-use token at once,
and whichever lost was left holding a dead token.

The state file can now be relocated with CLAUDE_POOL_STATE_PATH (the QA
harness uses this), while the lock stays at a fixed path beside the
credentials store. Stores accept an explicit lockPath, and create its
directory if needed.
The harness drove the real plugin against the live rotation-state file,
deleting and rewriting it while a running OpenCode wrote to the same file.
The two raced, and scenario penalties leaked into the live pool despite
the snapshot/restore on exit.

Scenarios now point CLAUDE_POOL_STATE_PATH at a temporary file. The lock
and refresh journal stay shared on purpose: they guard the real
credentials the harness uses.

New scenarios cover the 401/403 corroboration rules (identical failures
penalise nobody; distinct failures are charged to their own account) and
metadata.user_id following the account that serves the request.
…ccount

Enrollment matched existing accounts by refresh token. The pool's copy and
Claude Code's copy of a login stop sharing a refresh token as soon as
either one refreshes, so re-enrolling after a re-login was refused as "a
different account" (manual enroll) or added as a duplicate `name-2`
(auto-enroll) while the stale entry kept failing with invalid_grant.

- enroll and auto-enroll match by refresh token, then case-insensitively
  by email, and refresh the existing entry.
- Enrolling an already-pooled email under a second label is refused with
  the existing label in the message.
- `claude-pool enroll` resets health for, and reports, the label actually
  written, which differs from the argument when it matched by email.
Labels are derived from the email's local part, so alice@work.io and
alice@home.io both derive "alice". The second one was refused, and the
error could not tell a genuinely different account from an existing entry
whose email was never resolved (which may well be the same account).

- A derived label held by a known, different email gets the next free
  `-N` suffix; both accounts belong in the pool.
- An explicit label held by someone else, or a label held by an account of
  unknown email, raises LabelConflictError, which says which case it is
  and that --replace overwrites it.
Enrolling copies Claude Code's current login into the pool, so both hold
the same refresh token. Refresh tokens are single-use: whichever side
refreshes first silently logs the other out, which shows up later as
invalid_grant on the pool account or an unexpected logout in Claude Code.

`enroll`, `auto`, `list` and `status` now warn when a pooled account holds
Claude Code's current refresh token, and say how to fix it (log Claude
Code in again rather than re-enrolling). Only tokens are compared; none
is printed.
On Linux (and Windows) Claude Code stores its login in
$CLAUDE_CONFIG_DIR/.credentials.json, or ~/.claude/.credentials.json when
the variable is unset, instead of the macOS keychain. Enrollment refused to
run anywhere but macOS, and the bridge's own fallback ignored
CLAUDE_CONFIG_DIR.

`enroll`, `auto` and `whoami` now read that file off macOS, and name the
path they checked when there is no login.
Off macOS, accounts live in ~/.local/share/opencode-claude-bridge/accounts.
The backend created that directory when it was constructed, which happens
while the plugin loads, so a read-only or unwritable home made the plugin
fail to load even with no accounts enrolled.

The directory (0700) is now created on the first write. Listing a store
that was never written returns no accounts, which leaves the pool inactive
and the single-account path in use.
A lock older than the stale threshold is reclaimed when its holder's pid
is dead, checked with `kill(pid, 0)`. That call fails with EPERM for a live
process owned by another user (e.g. OpenCode running as a systemd service
next to an interactive shell), which was read as "dead", so the lock was
stolen mid-refresh. EPERM now counts as a live holder.
A 429 carries reset times for the 5-hour and the 7-day window, plus which
one is binding, but only the binding reset was kept (as the cooldown), so
nothing could show how long each window has left.

The classifier now returns both windows when the headers disclose them,
and the rotation state stores them per account. A window that a response
did not mention is left as it was, and clearing an account's health keeps
them, since that does not restore server-side quota. Rotation still keys
only off the cooldown.
Add a client for GET /api/oauth/usage, the endpoint behind Claude Code's
/usage: utilisation and reset time for the 5-hour and 7-day windows (and
the model-specific window some plans have). Rate-limit headers can't
provide this: they carry reset times but not how much of a window is
spent, and only once the account is already throttled.

The request has a 10s timeout and never refreshes a token itself.
`claude-pool dashboard` opens a full-screen manager for the account pool:
every account with its status, cooldown, token expiry and both usage
windows, plus a detail pane for the selected account. From it you can
enroll the current Claude Code login (e), set the active account (a),
re-enable (u) or remove (x) an account, refresh its token (f) or its usage
(s), clear all cooldowns (c) and prune stale duplicates (p). Removing,
pruning and overwriting a label ask for confirmation. Without a TTY it
prints the same summary as a static report.

Design notes:
- Node built-ins only (raw stdin and ANSI escapes); no dependency is
  added to a tool that handles credentials.
- Usage is fetched lazily per account and cached. An expired token is
  reported rather than refreshed behind the user's back, because a
  refresh rotates the refresh token; refreshing is an explicit action.
- Every frame is written in one call with exactly one line per terminal
  row, each clipped below the terminal width and ending in erase-to-EOL,
  so a shorter frame never leaves remnants of a longer one.
- Quitting while an action is in flight restores the terminal immediately
  and draws nothing afterwards; the process exits when the action ends,
  so a token refresh is never cut off halfway.
- The manager never writes to OpenCode's auth slot; that stays the
  plugin's job.

Supporting changes: RotationManager.snapshot() (read-only, exposes token
expiry but never a token) and RotationStateStore.forget().
When every account is unavailable, the bridge answers 429 with a message
that OpenCode shows as a 5-second toast over the prompt. The message was
several sentences listing every account with ISO timestamps, so it could
not be read before it disappeared.

It is now one line of at most ~80 characters, naming the account that
frees up first and when ("next account free in 1h 59m (bob); 1 disabled"),
or what to do when every account is disabled. The per-account breakdown
is in `claude-pool dashboard`.
Pool events that change what the user should do next were only written
to the server log, which OpenCode users rarely see: an account being
disabled, the pool running out, a switch to another account mid-request,
an account being auto-enrolled, or a pooled account sharing Claude Code's
login.

Each is now also shown as a toast where the host exposes the TUI API. Toasts
are fire-and-forget (a slow or missing TUI never delays or fails a request),
and a repeated toast is shown once a minute, keyed by the condition rather
than the text so a countdown doesn't defeat it.

RotationManager gains an onAccountDisabled callback, invoked after the
state change; a throwing listener cannot undo the disable.
Whether the pool is used was decided once when the plugin loaded, so
enrolling the first account, or removing the last one, had no effect
until OpenCode restarted.

The request path re-checks at most every 30s (listing the keychain on
every request is too slow) and announces a switch between pool and
single-account mode in the log and as a toast. If the re-check fails
(e.g. a locked keychain) the last known mode is kept, and the failure is
logged once rather than on every request.
@Dakuaisu
Dakuaisu merged commit 359c401 into ElhaanDaud:main Oct 19, 2026
Dakuaisu added a commit that referenced this pull request Oct 20, 2026
Ports dotCipher PRs #17, dotCipher#35 and dotCipher#36, reworked:
- OpenCode's system blocks (agent prompt, AGENTS.md) now follow the cached Claude Code prompt instead of being discarded; the billing header is sent once.
- Any assistant-tailed request gets a minimal user turn, and the tail message's cache_control breakpoint is kept so text-tailed turns read the prompt cache.
- The single-account path retries 429/529 per Retry-After and hands back waits over 10s; the token endpoint retries 529 and returns its real last answer.
Dakuaisu added a commit that referenced this pull request Oct 20, 2026
Ports dotCipher PRs #17, dotCipher#35 and dotCipher#36, reworked. The system-prompt merge and assistant-tail handling live in the shared request rewrite, so they also apply on OpenCode 2; the 429/529 retry is the V1 single-account path.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants