Skip to content

feat: remaining quota, account identity, control panel and management tools - #11

Open
pocharlies wants to merge 25 commits into
otto-assistant:mainfrom
pocharlies-org:feat/subscription-quota
Open

feat: remaining quota, account identity, control panel and management tools#11
pocharlies wants to merge 25 commits into
otto-assistant:mainfrom
pocharlies-org:feat/subscription-quota

Conversation

@pocharlies

@pocharlies pocharlies commented Aug 15, 2026

Copy link
Copy Markdown

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-* 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 here could surface
that — 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_tokens was the obvious free probe and
returns 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/profile resolves which Claude login a token actually is. This
matters 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_HOST and
X-Forwarded-Prefix 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,
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_accounts and claude_account_manage, so accounts can be managed from a
session rather than reaching the panel's port from whatever machine you are on.
OPENCODE_CLAUDE_TOOLS=0 removes them.


bun test passes and tsc is clean.

Follow-up reliability fixes

The branch now also includes the production fixes from 2026-08-20:

  • Reject unknown account ids instead of silently routing them to the default subscription.
  • Reconcile persisted bindings when an account is removed, and clear resume targets that belong to another Claude home.
  • Detect missing Claude transcript files before passing a dead resume id to the Agent SDK.
  • When a native Claude transcript is lost or the account changes, start a fresh Claude session and transfer the conversation history still held by OpenCode, preserving context without resuming another account's transcript.
  • Classify Your group's usage limit is set to $0 as a rate limit and fail fast while that limit is active.
  • Single-flight and back off plan-usage probes so concurrent turns do not duplicate the expensive control request.
  • Fall back to local title/summary generation while an account is rate-limited.
  • Isolate the smoke-test proxy from a live pinned production port.

Verification on the updated head:

  • npm run build
  • PATH=/home/dibanez/.bun/bin:$PATH bun test/smoke.ts
  • git diff --check

All 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.

dibanez added 3 commits August 15, 2026 19:56
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.
@pocharlies

Copy link
Copy Markdown
Author

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
single provider produced one flat list — twenty-four rows for four accounts,
each repeating the account label and truncating the quota that followed it. The
grouping the UI already offers was going unused.

Each account now declares its own provider, so the list reads as labelled
groups:

Claude Code · Current            claude-code                6 models
Claude Code · Personal           claude-code-personal       6 models
Claude Code · Work personal      claude-code-tercera        6 models
Claude Code · Works Shared       claude-code-works-shared   6 models

Model names inside a group carry only the quota (Opus 5 · 5h 94% · 7d 15%),
since the group already says whose it is.

The account is taken from the provider the model was picked from.
model@account still resolves for anything pinned earlier, and the default
account keeps the bare claude-code id — single-account installs see no rename
at all.

One thing worth flagging for review: enabled_providers is an allowlist, so an
account provider missing from it is filtered out however well it is configured.
The config hook adds them, but only when claude-code is already listed —
it never enables the plugin in a config that had not opted in.

Verified against a live server: four providers of six models each, and a real
turn through claude-code-works-shared bound the session to that account.
bun test passes and tsc is clean.

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.
@pocharlies

Copy link
Copy Markdown
Author

One more: the provider name now carries the Claude login, not just the label.

Claude Code · Current · me@e-dani.com
Claude Code · Personal · me@e-dani.com
Claude Code · Work personal · someone@company.com

The host renders this under the model on hover and as the picker's group header
— the one moment where "which subscription is this about to spend" can still be
answered. A label alone cannot answer it: labels are operator-chosen, and they
go stale precisely when it matters, since re-logging a Claude home to a
different account leaves the old label in place. The email comes from the
profile lookup and cannot drift.

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
the same login now sit side by side in the picker saying so, which is where
that mistake actually costs something.

dibanez and others added 18 commits August 18, 2026 01:12
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>
dibanez and others added 3 commits August 25, 2026 13:29
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>
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.

1 participant