feat: remaining quota, account identity, control panel and management tools - #11
feat: remaining quota, account identity, control panel and management tools#11pocharlies wants to merge 25 commits into
Conversation
One OpenCode server can drive several Claude subscriptions at once, with each session pinned to one: this chat on `work`, that one on `personal`. Each account is a CLAUDE_CONFIG_DIR — a self-contained Claude CLI home with its own credentials, transcripts and settings. That shape is forced by the rotation constraint this codebase already documents in auth-login.ts: Anthropic rotates the refresh token on every use, and a chain with two owners gets the whole grant revoked for replay. Giving each account its own CLI home keeps exactly one owner per chain — the CLI — so accounts cannot race each other's rotation. The plugin reads those credentials and never rotates them. Selection rides the model id (`opus@personal`, named `Opus 5 · Personal`) so it flows through the existing chat.headers → EFFORT_HEADER path and appears in the host's model picker with no UI work. The first turn binds the session; later turns stay on that account even when the request carries none. Everything previously global is now keyed by account: - rate-limit state and its 429 fast-fail gate — an exhausted subscription used to block every other account, which defeats having them. Existing single-account stores migrate on read. - session bindings, and resume lookups against the owning account's transcript dir; moving accounts drops the stale resume target rather than continuing a foreign conversation. - the pre-flight credential probe and credential reads, which now refuse to fall back to the ambient ~/.claude for a scoped account — that would silently run the turn on the wrong subscription. Declaring no accounts leaves behaviour unchanged: same model ids, same stores, same everything. Configure via the `accounts` plugin option, OPENCODE_CLAUDE_ACCOUNTS, or accounts.json. Also isolates XDG_DATA_HOME for the test run. The suite was reading the operator's real accounts.json — so "nothing configured" stopped being true once they had accounts — writing fixture sessions into their store, and unlinking their debug.log.
… tools Everything built on top of multi-account support. Grouped into one PR because the pieces landed interleaved in the same files; each is described below and they can be taken separately if only some are wanted. **Remaining quota, from Anthropic.** Every Messages response carries `anthropic-ratelimit-unified-*` headers describing BOTH limit windows and naming which one binds. Probed against a live subscription: the five-hour window read 57% used while the seven-day window sat at 93% and was the representative claim — the Agent SDK's rate_limit_event reports one window at a time, so nothing could surface that. Harvested free from requests the plugin already makes (429s included, where the numbers matter most) and merged from SDK events during ordinary turns; an explicit refresh sends a minimal Messages call. `count_tokens` was the obvious free probe and returns zero rate-limit headers — verified, and noted in the code so nobody retries it. **Account identity.** `GET /api/oauth/profile` resolves which Claude login a token actually is. This matters because the OAuth consent screen is approved by whatever claude.ai session the browser already holds and never offers an account picker, so "connect a second account" routinely re-authorizes the first: separate grants, separate refresh chains, one quota pool. The check now runs between the token exchange and the disk write — the only point where refusing costs nothing — and holds the exchanged tokens for ten minutes so the operator can still commit them knowingly rather than repeat the browser round trip. **Control panel** at the proxy root: accounts, their logins, quota and usage, which account each session runs on, and add/connect/rename/remove. Self-contained (no external CSS, fonts or scripts) since the process holds subscription tokens. Mutating routes require a same-origin request. `OPENCODE_CLAUDE_PANEL_HOST` and `X-Forwarded-Prefix` support let it sit behind a reverse proxy; the automatic loopback OAuth callback is then offered only to browsers that arrived on 127.0.0.1, since the redirect would otherwise land on the operator's own machine. **Tools** `claude_accounts` and `claude_account_manage`, so accounts can be managed from a session instead of reaching the panel's port from whatever machine the operator is on. `OPENCODE_CLAUDE_TOOLS=0` removes them. `bun test` passes and `tsc` is clean.
The host groups the picker by provider, so putting every account's models in a single provider produced one flat list: twenty-four rows for four accounts, each row repeating the account label and truncating the quota that follows it. The grouping the UI already offers was going unused. Each account now declares its own provider — Claude Code · Personal — so the list becomes four labelled groups of six, and the model names inside carry only the quota since the group already says whose it is. The account is taken from the provider the model was picked from. The model@account form still resolves for anything pinned before this, and the default account keeps the bare claude-code id so single-account installs see no rename at all. enabled_providers needed handling: it is an allowlist, so an account provider missing from it is filtered out however well it is configured. Verified against the live server: four providers of six models each, and a real turn through claude-code-works-shared bound the session to that account.
|
Pushed one more commit: one provider per account in the model picker. The host groups the picker by provider, so putting every account's models in a Each account now declares its own provider, so the list reads as labelled Model names inside a group carry only the quota ( The account is taken from the provider the model was picked from. One thing worth flagging for review: Verified against a live server: four providers of six models each, and a real |
The picker group and the hover card read 'Claude Code · Current' — a label the operator chose, which does not answer the question being asked at that moment: which subscription is this about to spend? Labels cannot answer it. They are arbitrary, and they go stale precisely when it matters: re-log a Claude home to a different account and the label still names the old one. The email comes from the profile lookup and cannot drift. Appended to the provider name, skipped when the label already contains the address so it is not printed twice. The immediate payoff on a real setup: two accounts that had been connected as the same login now read 'Current · me@e-dani.com' and 'Personal · me@e-dani.com' side by side in the picker, which is where that mistake actually costs something.
|
One more: the provider name now carries the Claude login, not just the label. The host renders this under the model on hover and as the picker's group header Skipped when the label already contains the address, so it is not printed twice. The payoff shows in the example above: two accounts that had been connected as |
La etiqueta la escribe un humano una vez; el login se resuelve contra Anthropic y puede resultar ser -- o pasar a ser -- otro. Cuando discrepan, la tarjeta se contradice a tres lineas de distancia y gana la mitad que se lee primero: la etiqueta. Paso de verdad, con un slot titulado 'Work · Daniel.Ibanez@cloudblue.com' cuyo token era de daniel.speedo@cloudblue.com y con el login verdadero impreso justo debajo. - assertLabelNamesNoLogin: una etiqueta nueva con un email se rechaza, y el error dice por que. labelEmail vive en accounts.ts sin importar identity.ts porque identity.ts ya importa accounts.ts y el ciclo seria real. - labelLoginMismatch: el guard de escritura NO habria evitado el caso real -- la etiqueta coincidia con la identidad cacheada en el momento de escribirla y solo se volvio mentira al resolverse la identidad. Por eso tambien se comprueba en cada lectura, contra el email resuelto en vivo. - Identidad sin resolver es 'desconocido', no 'contradiccion': afirmar lo que no se puede probar es el mismo pecado al reves. - Se ve donde se mira: tag en el panel y linea WARNING en claude_accounts.
El nombre del modelo es lo que OpenChamber pinta en el selector y en la
cabecera de sesion, y ya llevaba modelo + dos cifras de cuota. Meter delante
una etiqueta de hasta 64 caracteres ("Works Shared") empujaba los numeros
fuera de la linea visible: se perdia justo el dato por el que la etiqueta
estaba ahi.
Ahora cada cuenta tiene una marca de un glifo. Se deriva de la etiqueta
(shared/personal/work/test/bot) y se resuelve para TODO el registro a la vez,
porque la unicidad es propiedad del conjunto: "Work personal" menciona
personal, pero si existe un "Personal" a secas la casa es suyo y el compuesto
se lleva un punto de color. Dos cuentas con el mismo glifo serian peor que no
tener glifo.
La etiqueta completa sigue en la cabecera del grupo de proveedor, que es donde
hay sitio y donde tambien va el login. El icono se puede fijar a mano
(claude_account_manage {action:"set-icon"}, boton Icon del panel, o campo
"icon" en accounts.json) y lo fijado nunca lo pisa la derivacion.
El tag del titulo era `[works-shared=daniel.speedo@cloudblue.com] `: 44 caracteres de prefijo delante de una lista de sesiones que enseña unos sesenta. El tag estaba ahi para poder leer de un vistazo en que cuenta corre cada sesion, y de paso hacia ilegible justamente eso, el titulo. Ahora es el glifo de la cuenta. withAccountTitleTag deja de fabricar el tag y delega en stripAccountTitleTag, que quita las DOS generaciones: el corchete viejo (`[work]`, `[work=correo]`) y el glifo. Sin eso, cada titulo escrito antes de este cambio acabaria con marca nueva y corchete viejo pegados. Solo se quita el corchete cuyo id es una cuenta del registro: un titulo que empieza por `[SC-52]` no es nuestro y no se toca. El login de una cuenta duplicada ya no se deletrea en el titulo; para eso estan el panel y la cabecera del proveedor, que es donde hay sitio.
opencode carga los plugins recorriendo TODO el namespace del modulo y exige
que cada export sea una funcion (o un {server: fn}); el primero que no lo sea
tumba el plugin entero:
function uk($){ for(let Y of Object.values($)){ let J=hk(Y);
if(!J) throw TypeError("Plugin export is not a function") } }
`CLAUDE_CODE_MODELS` es un ClaudeModel[], asi que el plugin no cargaba:
level=ERROR message="failed to load plugin"
path=file:///home/dibanez/src/opencode-claude/dist/index.js
error="Plugin export is not a function"
Sin plugin no hay provider claude-code, ni providers por cuenta, ni proxy en
:8799 — es decir: ni panel (claude.e-dani.com daba 502) ni poder elegir otra
cuenta cuando la default se queda sin tokens.
El unico consumidor era test/smoke.ts, que ya lo importa de ../src/models.ts.
bun test/smoke.ts: ok — opencode-claude smoke tests passed
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
OpenCode trata CADA export del entrypoint como un plugin independiente: los
recoge, exige que todos sean funciones, y llama a cada uno metiendo el
resultado en la lista de plugins.
function uk($){ for(let Y of Object.values($)){ let J=hk(Y);
if(!J) throw TypeError("Plugin export is not a function"); Q.push(J) } }
// luego: for(let J of uk($.mod)) Q.push(await J(Z,$.options))
Eso rompia de dos maneras distintas:
1. `CLAUDE_CODE_MODELS` es un ClaudeModel[], no una funcion, asi que el plugin
ni siquiera cargaba ("Plugin export is not a function"). Ya resuelto en
f93a7c0.
2. Con el plugin cargando, opencode invocaba tambien `detectClaudeCode`,
`getClaudeModels`, `startProxy`, `stopProxy`, `getClaudeProxyBaseUrl` y
`applyClaudeRequestContextHeaders` como si fueran plugins. `stopProxy()`
devuelve undefined, ese undefined acababa en la lista, y el constructor del
catalogo lo desreferenciaba:
for (let n of x) { let s = n.provider, k = s?.models; ... }
TypeError: undefined is not an object (evaluating 'n.provider')
Resultado: /config/providers respondia 500 y opencode se quedaba SIN NINGUN
provider — ni siquiera litellm. openchamber espera a litellm-auto en su
ExecStartPre, asi que chamber.e-dani.com se caia con ello.
`applyClaudeRequestContextHeaders` se va a src/request-context.ts, que es
donde debia estar; el resto de reexportaciones se eliminan. El entrypoint
queda exportando ClaudeCodePlugin y su default (el mismo valor, deduplicado).
Verificado en vivo tras reiniciar:
/config/providers 200
claude-code + claude-code-tercera + claude-code-works-shared
+ claude-code-personal 6 modelos cada uno
panel 8799 bindea en 0.0.0.0, 200 por loopback y Tailscale
claude.e-dani.com 200 (era 502)
chamber.e-dani.com 200
bun test/smoke.ts ok — opencode-claude smoke tests passed
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…echo
Nombrar la ventana ("5h", "7d") gastaba caracteres en lo unico que nunca
cambia. Lo que se decide al elegir cuenta es CUANDO se levanta el techo, asi
que la cuenta atras ocupa ese hueco y el color carga con la identidad:
antes: Opus 5 · 5h 95% · 7d 92%
ahora: Opus 5 · 🟢 95% 1h 56m · 🔵 92% 6d 3h
Verde = ventana corta (cinco horas), azul = ventana larga (siete dias).
Ademas, una ventana cuyo `resetsAt` ya paso se reporta al 100% y sin cuenta
atras, en vez de repetir una utilizacion caduca. El store se escribe desde
turnos reales, asi que "sin novedades desde el reset" es "sin gasto desde el
reset"; y el reset de la ventana siguiente no se conoce hasta que la cuenta
se vuelva a usar. Esto no era teorico: `tercera` y `works-shared` llevaban
horas enseñando el porcentaje de una ventana de cinco horas ya refrescada.
bun test/smoke.ts: ok — opencode-claude smoke tests passed
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
El SDK expone `usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET()` (control request `get_usage`), y es el unico sitio de la ruta del Agent SDK que devuelve LAS DOS ventanas a la vez. Se añade el parser y el grabado; el cableado NO, y el motivo esta medido: turno del proxy -> rate_limits_available: false, rate_limits: null sesion con solo CLAUDE_CONFIG_DIR -> available: true, 5h util 79, 7d util 20 La diferencia es proxy.ts:1324, `withClaudeOAuthToken(accessToken)`: los turnos autentican con un token inyectado, y a ese token le falta el scope de perfil que el endpoint de usage exige. No hay sitio en la ruta del turno donde esto funcione. (Y aunque lo hubiera: en el `finally` del turno el bucle de mensajes ya termino y el control request se queda sin respuesta — medido, timeout limpio.) Queda por tanto listo para el llamante que lo pueda usar: una sesion corta propia por cuenta, con CLAUDE_CONFIG_DIR y sin token inyectado. Latencia medida: 10,6 s con la sesion ya caliente; en frio hay que sumarle el spawn. Dos conversiones de unidad que el parser hace y el test fija, porque fallan en silencio: `utilization` viene 0-100 (el path de cabeceras es 0..1) y `resets_at` es ISO 8601 (el store guarda epoch ms). bun test/smoke.ts: ok — opencode-claude smoke tests passed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Las etiquetas del selector llevaban horas mintiendo: `tercera` marcaba la
ventana de 5h al 100% con datos de 10 horas antes, y `personal` decia 95%
mientras estaba al 2% (y sus turnos ya daban 429).
La causa no era el scope del OAuth, como parecia, sino el MODO de auth. A/B
sobre la misma cuenta, mismo configDir y mismo token:
CLAUDE_CONFIG_DIR solo -> rate_limits_available: true
CLAUDE_CONFIG_DIR + CLAUDE_CODE_OAUTH_TOKEN -> rate_limits_available: false
El turno leia el token del fichero de credenciales de la cuenta y se lo
inyectaba de vuelta al CLI por env. Misma credencial, pero la sesion pasa a
contar como auth por token y pierde el perfil de plan — y con el, las
ventanas de rate limit. El propio codigo ya prefería no inyectar ("so the CLI
can auto-refresh its own credentials file"), solo que esa rama nunca se
tomaba: exigia accessToken null.
Ahora una cuenta con configDir propio deja que el CLI lea su fichero, que es
de donde salia el token de todos modos. Sin re-login: las credenciales no se
tocan.
Con eso, `usage_EXPERIMENTAL_..._API_YET()` (control request `get_usage`)
responde durante el turno y trae LAS DOS ventanas. Se dispara al arrancar la
sesion, sin await y throttled a 60 s. No vale ponerlo en el `finally`: alli el
bucle de mensajes ya termino y la peticion se queda sin respuesta (medido).
Timeout 30 s — la respuesta tarda ~10 s en caliente.
Verificado en vivo, dos cuentas:
tercera source probe (14:57, 5h=1.00) -> plan-usage (01:30, 5h=0.95)
works-shared source headers -> plan-usage (01:32, 5h=0.87)
turnos correctos en ambas sin token inyectado
/config/providers 200 · claude.e-dani.com 200 · chamber.e-dani.com 200
bun test/smoke.ts: ok — opencode-claude smoke tests passed
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
El tooltip del modelo enseñaba COST a $0.00 en las cuatro filas porque el
plugin no declaraba `cost`. Un cero ahi no se lee como "no aplica": se lee
como un precio, y ademas es el campo del que el host calcula el coste de cada
respuesta, asi que cada turno se reportaba gratis.
Se publican las tarifas de lista de Anthropic ($/1M tokens) como REFERENCIA:
lo que costaria por API key. Con suscripcion no se paga eso — se paga cuota —
pero es la unica forma de responder "cuanto llevo gastado" y de que el gasto
por turno que pinta OpenChamber deje de ser 0.
Fable 5 10 / 50 Opus 5, Opus 4.8 5 / 25
Sonnet 5, Sonnet 4.6 3 / 15 Haiku 4.5 1 / 5
Las tarifas de cache salen de los multiplicadores publicados sobre el precio
de entrada (lecturas 0.1x, escrituras de 5 min 1.25x) en vez de repetir doce
numeros a mano.
Una asimetria de OpenCode que cuesta encontrar: del hook de provider en
runtime lee `cost.cache.{read,write}` anidado, pero de la CONFIG lee
`cost.cache_read` / `cost.cache_write` PLANOS. Mandar la forma anidada por la
ruta de config no da error: aterriza como 0. Por eso hay dos constructores.
`OPENCODE_CLAUDE_MODEL_COST=0` vuelve a publicar ceros.
Verificado en vivo tras reiniciar (claude-code-works-shared):
opus {"input":5,"output":25,"cache":{"read":0.5,"write":6.25}}
fable {"input":10,"output":50,"cache":{"read":1,"write":12.5}}
haiku {"input":1,"output":5,"cache":{"read":0.1,"write":1.25}}
bun test/smoke.ts: ok — opencode-claude smoke tests passed
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
El 20-08 se quitó la cuenta `work` (duplicaba a `personal`: mismo login, mismo accountUuid). Tenía 32 conversaciones atadas. El reconciliador las barrió todas a la cuenta por defecto, les borró el `foreignSessionId` y les puso `updatedAt` de hoy. Resultado: 32 conversaciones que nadie movió, encoladas para transferir hasta 400.000 caracteres de historial cada una, contra la única cuenta que quedaba con cuota — y reapareciendo arriba del listado como recién usadas. Cinco cambios, ninguno cambia la ruta normal (turno 1 abre sesión, turnos 2+ hacen `resume` y mandan solo el mensaje nuevo; el caché sigue igual de caliente): 1. Un binding movido por la máquina se marca `rebound`, y el siguiente turno arranca LIMPIO sin transferir historial. Arrastrarlo era pagar por un transcript que vive en el home de otra cuenta y ya era inalcanzable. 2. `removeAccount` se niega si la cuenta tiene conversaciones atadas, y dice cuántas. Con `force` se acepta a sabiendas. Esto es exactamente lo que faltó: se borró una cuenta sin mirar qué colgaba de ella. 3. El reconciliador ya no toca `updatedAt`. Un barrido no es actividad, y marcarlo como tal hacía resurgir conversaciones movidas hace días. 4. `refreshHostCatalog()` queda DESACTIVADO por defecto (`OPENCODE_CLAUDE_HOST_REFRESH=1` lo reactiva). Es un `PATCH /config` vacío que ya devolvió 503 abortando sesiones vivas, y se disparaba solo con abrir un panel. 5. Aviso `warn` cuando un traspaso pasa de 50.000 caracteres, ANTES de gastar el turno, con los tokens aproximados y la cuenta afectada. Los 32 bindings huérfanos se borraron del store (copia en el scratchpad). Las conversaciones no se tocaron. bun test/smoke.ts: ok — opencode-claude smoke tests passed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
La cuenta por defecto conserva el id de provider desnudo `claude-code`, asi que `accountIdFromProviderId` devolvia null para ella — indistinguible de "no pido cuenta". El proxy entonces caia al binding pegajoso de la sesion, y elegir esa cuenta en el selector no hacia absolutamente nada: el turno seguia gastando la cuenta a la que la conversacion estaba pinchada de antes. Y el selector lo tapaba: el nombre del modelo lleva la cuota de la cuenta POR DEFECTO, asi que se leia `🏠 Opus 5 · 🟢 68% · 🔵 73%` mientras el turno se cargaba contra otra cuenta al 0%. Dos casos reales hoy, con el timestamp del 429 coincidiendo al milisegundo con el `limitedUntil` de la cuenta pinchada: sesion "Aprovechar Unsloth" -> binding works-shared, error 22:22:08.057Z sesion "Logs de DeepSeek v4" -> binding tercera, error 21:21:39.423Z Elegir el provider desnudo ES elegir la cuenta por defecto, y ahora se manda explicita en la cabecera. Las cuentas no-default nunca estuvieron afectadas: su id viaja dentro del provider id. Solo aplica en multicuenta; en instalacion de una cuenta no hay nada a lo que cambiar. bun test/smoke.ts: ok — opencode-claude smoke tests passed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Anoche introduje una heuristica: si el `resetsAt` guardado ya paso, la ventana se relleno, luego 100% libre. El razonamiento era "el store se escribe desde turnos reales, asi que sin novedades desde el reset no ha habido gasto". Es una INFERENCIA, y hoy se leia asi: tercera util=1 rem=0 -> la etiqueta decia 🟢 100% works-shared util=1.09 rem=0 -> la etiqueta decia 🟢 100% Las dos a cero y rechazando turnos con 429. Un dato desconocido merece decirse; un numero equivocado no. Ahora una lectura caducada muestra `?`. Ademas, un bloqueo duro manda sobre cualquier porcentaje: si el gate va a rechazar el siguiente turno, eso es lo unico que importa al elegir cuenta. antes: 💼 Opus 5 · 🟢 100% · 🔵 10% 2d 5h ahora: 💼 Opus 5 · 🔴 bloqueada 54m Y `remaining` se acota a 0..1 al pintar: la ruta de `probe` no lo hacia y `works-shared` llego a mostrar -9% libre. bun test/smoke.ts: ok — opencode-claude smoke tests passed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Auditoria (CTO + SRE) sobre 192e3ea: al mandar la cuenta por defecto explicita en cada turno, una conversacion atada a otra cuenta entra por la rama "explicito manda" -> bindConversationAccount ve que el accountId cambia -> borra foreignSessionId -> y NO marcaba rebound -> traspaso completo. Es el mecanismo del incidente del 20-08, sobre 48 conversaciones en vez de 32, y estaba corriendo en produccion. Dos cambios, y el primero acota TODOS los caminos a la vez: 1. DEFAULT_HISTORY_MAX_CHARS 400.000 -> 50.000 (~100k -> ~12k tokens). El transcript se recorta por el final, asi que lo que se conserva es lo reciente, que es lo que sirve para seguir; pagar ocho veces mas por arrastrar el principio de una conversacion de 500 mensajes no compra ocho veces mas continuidad. Sigue subible con la variable de entorno. 2. `bindConversationAccount` marca `rebound` cuando mueve una conversacion de cuenta. b3f0b24 solo cubria el reconciliador; este era el segundo de los cuatro caminos que borran foreignSessionId. Quedan abiertos, no incluidos aqui: clearForeignSessionId y forgetDeadSession tampoco marcan (el tope de 50k ya los acota), los 29 bindings sin accountId que ninguna proteccion cubre, y `force` de removeAccount sin exponer. bun test/smoke.ts: ok — opencode-claude smoke tests passed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b3f0b24 añadio un guard 409 en removeAccount cuando la cuenta todavia tiene conversaciones atadas, pero dejo `force` sin exponer: ni el panel (proxy.ts, DELETE /accounts/<id>) ni la tool (tools.ts, action "remove") pasaban el segundo argumento. Un guard sin escape no protege: manda al operador a editar accounts.json a mano, que es el unico camino que se salta removeAccount entero — y es exactamente asi como desaparecio la cuenta con 32 conversaciones atadas el 20-08. El guard convertia el camino sin proteccion en el unico camino. Ahora: `DELETE /accounts/<id>?force=1` y `claude_account_manage {action: "remove", force: true}`. Sin force sigue respondiendo 409 diciendo cuantas conversaciones hay. bun test/smoke.ts: ok — opencode-claude smoke tests passed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cambiar de cuenta perdia el contexto SIEMPRE, incluido cuando lo pedias tu. `7ff5117` cerro los cuatro caminos que borran `foreignSessionId` marcando `rebound` en cualquier cambio de cuenta, porque desde `192e3ea` no habia forma de distinguir "el operador acaba de elegir otra cuenta" de "una sesion antigua atada a otra cuenta recibe la default explicita en cada turno". Los dos llegan al proxy con headers identicos. Cortar los dos evito el incidente del 20-08 y de paso rompio el caso legitimo, mientras `claude_account_manage use` seguia prometiendo por escrito que el historial se llevaba. El discriminante no es el estado (header contra disco: identico en ambos) sino el cambio: `liveSessionAccounts`, un Map en memoria de la cuenta que ESTE proceso sirvio en el turno anterior de esa conversacion. Si la cuenta cambia entre dos turnos servidos aqui, alguien la cambio, porque nada mas puede. Si el primer turno que vemos ya discrepa del store, es otra cosa -- resumida de la lista, barrida por el reconciliador, o pinchada a un provider cuyo significado se movio debajo -- y esas no pagan. Morir con el proceso es el objetivo: tras un reinicio nada esta "en la ventana activa" y la respuesta segura es la que no gasta cuota. Un switch pedido traspasa la conversacion ENTERA (`POSITIVE_INFINITY`, no los 50.000 chars). El tope acota los traspasos que nadie pidio; este ES la peticion, y media conversacion no es lo que significa "cambiar de cuenta". OpenCode manda la lista completa de mensajes en cada request de todos modos -- igual que la recibiria litellm, que no tiene sesion en el servidor que reanudar -- asi que no hay nada que reconstruir, solo una decision de dejar de truncar. Un switch pedido tambien anula el `rebound` que dejara el reconciliador: pedir esta conversacion en esta cuenta es una afirmacion posterior y mas fuerte que el barrido que la trajo. Y se quita de raiz el fabricante de switches no pedidos: la cuenta default era alcanzable SOLO por el id de provider pelado, o sea que "elegir esta cuenta" y "no decir nada" eran el mismo gesto (`accountIdFromProviderId` devuelve null para `claude-code`). Ahora cada cuenta tiene su `claude-code-<id>`, la default incluida, y un provider que no nombra cuenta es silencio: la sesion se queda donde estaba. Eso arregla lo que `192e3ea` intentaba arreglar -- elegir la default no hacia nada -- sin mandar la default en cada turno, que es lo que convirtio `set-default` en una reasignacion masiva. Traspaso deliberado = un turno caro, y el warn de traspaso grande lo dice. Los turnos siguientes son normales: hay resume vivo. npm run build: ok bun test/smoke.ts: ok -- opencode-claude smoke tests passed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… puede servir Una conversación sin cuenta propia se colocaba siempre en la cuenta por defecto del registry, aunque estuviera sin cuota. El 25-08 la personal agotó su ventana de 7 días a las 00:15 y hasta las 04:00 del día siguiente toda sesión nueva empezaba ahí: el primer turno se gastaba en cobrar "You've hit your weekly limit" mientras tercera y works-shared tenían el 69% y el 88% libres. Desde fuera eso no se lee como una cuota agotada, se lee como que Claude está caído. `resolveTurnAccount` solo coloca a quien no tiene ni cuenta pedida ni binding: la explícita se respeta aunque esté seca y la ligada sigue ligada, porque mover una conversación viva deja su transcript en el home de otra cuenta y obliga al turno siguiente a reconstruir el historial entero contra otra suscripción — el coste que vació dos el 20-08. Un primer turno no tiene transcript que abandonar ni historial que reconstruir, y por eso la elección es gratis ahí y en ningún otro sitio. Silencio no es agotamiento: una cuenta sin medir es candidata (va detrás de las medidas), y un cero sin fecha de reposición es una lectura vieja, no un veredicto. Solo un límite duro registrado o una ventana vacía que anuncia cuándo vuelve descartan una cuenta. Si no hay nada mejor, el turno se queda en la por defecto y falla con su propio mensaje en vez de culpar a otra suscripción. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
log.info está gateado por OPENCODE_CLAUDE_DEBUG, así que colocar una conversación en otra suscripción quedaba sin rastro en el journal. Solo ocurre cuando la cuenta por defecto no puede servir, y la colocación es pegajosa a partir de ahí: hay que poder leerlo después. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Builds on #10 — the diff shows that PR's commit too until it lands.
Grouped into one PR because these landed interleaved in the same files. Each
piece is independent in spirit; happy to split further if you want only some.
Remaining quota, from Anthropic
Every Messages response carries
anthropic-ratelimit-unified-*headersdescribing both limit windows and naming which one binds. Probed against a
live subscription: the five-hour window read 57% used while the seven-day window
sat at 93% and was the
representative-claim. The Agent SDK'srate_limit_eventreports one window at a time, so nothing here could surfacethat — and a healthy-looking 5h window is exactly what misleads you.
Harvested for free from requests the plugin already makes (429s included, where
the numbers matter most) and merged — not replaced — from SDK events during
ordinary turns, since an event carries a single window. An explicit refresh
sends a minimal Messages call.
count_tokenswas the obvious free probe andreturns zero rate-limit headers; verified, and noted in the code so nobody
retries it.
Surfaced in the model name, the in-stream rate-limit note, the 429 body and
/health, as percent left rather than percent used.Account identity
GET /api/oauth/profileresolves which Claude login a token actually is. Thismatters more than it sounds: the OAuth consent screen is approved by whatever
claude.ai session the browser already holds and never offers an account
picker, so "connect a second account" routinely re-authorizes the first —
separate grants, separate refresh chains, one quota pool, and every check short
of asking says two accounts.
The duplicate check runs between the token exchange and the disk write, the only
point where refusing costs nothing, and holds the exchanged tokens for ten
minutes so the operator can commit them knowingly instead of repeating the
browser round trip for a single-use code.
Control panel
At the proxy root: accounts, logins, quota, usage, which account each session
runs on, and add/connect/rename/remove. Self-contained — no external CSS, fonts
or scripts, since this process holds subscription tokens — and mutating routes
require a same-origin request.
OPENCODE_CLAUDE_PANEL_HOSTandX-Forwarded-Prefixlet it sit behind a reverse proxy; the automatic loopbackOAuth callback is then offered only to browsers that arrived on
127.0.0.1,because the redirect would otherwise land on the operator's own machine.
This is the most opinionated part — if you would rather not carry a web UI in
the plugin, the quota and identity work stands without it.
Tools
claude_accountsandclaude_account_manage, so accounts can be managed from asession rather than reaching the panel's port from whatever machine you are on.
OPENCODE_CLAUDE_TOOLS=0removes them.bun testpasses andtscis clean.Follow-up reliability fixes
The branch now also includes the production fixes from 2026-08-20:
resumeid to the Agent SDK.Your group's usage limit is set to $0as a rate limit and fail fast while that limit is active.Verification on the updated head:
npm run buildPATH=/home/dibanez/.bun/bin:$PATH bun test/smoke.tsgit diff --checkAll pass. The smoke suite explicitly covers a missing native transcript and verifies that the new Claude session receives
<conversation_history>from OpenCode rather than losing prior context.