# Changelog

Notable changes to **pi-keyrouter**.

Versions `0.1.0` – `0.4.0` were published by the original author
([lowern1ght](https://github.com/lowern1ght)) as part of the
[pi-soly](https://github.com/lowern1ght/pi-soly) monorepo under
`packages/pi-keyrouter`. `1.0.0` onward is the fork maintained at
[jeryjs/pi-keyrouter](https://github.com/jeryjs/pi-keyrouter).

Entries for `0.1.0` – `0.4.0` are reconstructed from the published npm
artifacts: the `pi-keyrouter` registry metadata (versions, publish dates,
`gitHead`) and the `0.4.0` package contents. Version-level attribution is
therefore firm; which change landed in which exact minor version is inferred
from those artifacts and marked as such. Entries from `1.0.0` on are written
from the repository history directly.

The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [1.8.0] — 2026-11-03

### Added

- **`sweepRefresh` — a per-pool opt-in for the idle-account sweep.** The session-start sweep refreshes
  the accounts of a pool that no request has touched for a while, so their tokens do not sit expired
  until the day one is needed. It is now enabled per pool with `"sweepRefresh": true` in that
  provider's config, and it is **off everywhere else**: the exchange is not race-free yet (the open
  races are written up in `TODO.md`), so it must not run unless a pool asks for it. `keys` pools never
  sweep — they have no tokens — and say so when the option is set on one.

### Fixed

- **`/keyrouter add` and `/keyrouter remove` no longer pick the wrong entry.** Entry names are now
  matched case-insensitively — `Backup` and `backup` are the same name to you — and a name that
  matches two entries is **refused** instead of silently taking the first one. A keys pool also
  refuses a key the pool already holds under another name, so the pool cannot rotate onto one
  credential twice.

- **A captured OAuth account stays active.** The account `/keyrouter add` captures from pi's own
  store becomes the pool's active account, so the reload that follows does not switch pi back to the
  account it had before. `add` and `remove` no longer activate the pools before they capture, which
  used to overwrite the login the user had just made.

- **An empty pool is kept instead of being dropped.** A pool whose entries all fail to expand (a
  missing `$ENV`, for example) is still listed, so `/keyrouter add` can put the first entry into it.
  Two pools that share a name are a config mistake: the first one is used, the second is reported, and
  a write touches only the first.

- **A lease belongs to its entry, not to its position.** The claim file under
  `~/.pi/keyrouter-leases/` is now named after the entry, so removing or reordering entries in a pool no
  longer leaves a claim protecting a different credential (or none at all).

- **A lease is claimed before the credential is installed**, not after, so another process cannot pick
  the same entry in between. A failed install puts the claim back on the entry that is still in use.
  A manual `/keyrouter account` pin now takes the claim, and a pool that runs out of usable keys
  releases it instead of leaving the entry blocked for every other process.

- **Reloading the pools no longer forgets what the session learned.** Cooldowns, failure counts,
  usage counts and the last status of an unchanged entry survive `/keyrouter add`, `/keyrouter remove`
  and every other reload.

- **Config writers no longer share one temp file.** The temp file carries the process id, so two pi
  processes writing the same config cannot rename each other's bytes over the real file.

- **Config writes are serialised across processes.** Every write-back now takes a short-lived lock in the
  lease directory, so two pi processes rotating at the same time cannot lose each other's update. A lock
  left behind by a writer that died is reclaimed; if one still cannot be taken the write goes ahead
  anyway — a config write must never block a session.

## [1.7.0] — 2026-10-31

### Added

- **`/keyrouter notifications [all|quiet|silent]` — show or set the notification level without
  hand-editing `~/.pi/keyrouter.json`.** With no operand it reports the current level and what it
  means; with a level it writes the top-level `notifications` field and applies it to the running
  session immediately, so no `/reload` is needed. An unknown level is refused with the list of
  valid ones and writes nothing.

- **`/keyrouter status [provider]` — the status view, optionally narrowed to one pool.** Naming a
  provider shows just that pool in the usual format; an unknown provider is refused with the list
  of configured pools, and nothing is changed.

- **Bare `/keyrouter` and `/keyrouter --help` print the full command list** with every command's
  operand syntax, so the commands are discoverable without memorizing them. A help request is
  strictly observational — it never activates the pools or touches credentials.

- **Account additions are identity-aware.** For an `accounts` pool, `/keyrouter add <provider>`
  now recognizes pi's current login by the account's *identity* — the provider's `identityPath`, or
  the identity claims a token carries — instead of only comparing the bytes of the credential: a
  token that was refreshed since the pool entry was written is still the same account. When the
  login matches an entry, the command asks whether to update that entry, add the login as a new
  account, or cancel; an ambiguous match (two entries claiming one identity) is refused and asks for
  an explicit name or an `identityPath`.

### Changed

- **`/keyrouter add <provider> <name>` updates an existing entry in place**, for both pool kinds:
  a `keys` entry gets the new key value, an `accounts` entry gets the credential pi holds right now.
  When the named entry holds a different account identity, the replacement is confirmed first. An
  entry backed by an external source (`$ENV`, `${ENV}` or `@file`) is not edited in place — the
  command points at the environment variable or file instead. A generated name that is already taken
  gets a numbered suffix and a note; a name you typed yourself is kept as typed.

- **The completion menu offers the pool's entries after `add`**, since an existing name is now an
  update target, and offers `all`, `quiet` and `silent` after `notifications`.

- **The "pi is already using this account" notice now says what to do next** — run `/login` to sign
  in to another account first, then call `/keyrouter add` again — instead of only reporting the
  clash.

## [1.6.1] — 2026-10-30

### Fixed

- `/keyrouter reset` now tells you that pi is using its own credentials again until the next session, and that keyrouter stays off until you call `/keyrouter reload` (or start a new session).

## [1.6.0] — 2026-10-30

### Added

- **Credential leases: concurrent pi processes no longer pile onto the same credential.** A lease is
  an exclusive claim on one entry of a pool. The claim is a file under `~/.pi/keyrouter-leases/`,
  created with an exclusive create, so the filesystem — not a lock protocol — decides the winner:
  the first process to install an entry holds the claim, and another process sees it and picks a
  different entry instead. The claim is released when the entry is replaced, when the pools are
  handed back (`reset`, `reload`), and at shutdown; a claim whose owner process is gone is stale
  and is taken over. Within one process a request holds its entry for the duration of that
  request, so a rotation never walks onto a credential that is already serving one — and a lease is
  a preference, never a lock, so an all-leased pool still rotates rather than failing the request.
  `/keyrouter status` shows the holder of every entry (`this-session`, another process's pid, or
  `in-flight`). Leases apply to API-key pools only: pi's OAuth store holds one credential per
  provider id, shared by every process, so there is no slot to lease there.

- **`/keyrouter add <provider> [name]` and `/keyrouter remove <provider> <name|index>` manage a
  pool's entries without hand-editing `~/.pi/keyrouter.json`.** `add` asks for an API key in a
  prompt and writes it to the file — keyrouter never runs a login of its own. For an `accounts`
  pool it captures the credential pi is holding right now, so the flow stays pi's own: `/login`
  first, then `/keyrouter add <provider>`; when pi is already holding one of the pool's accounts
  the command says so instead of adding the same credential twice. `remove` confirms, deletes the
  entry, and forgets the pool's `active` position when that was the entry removed. Both write
  through the same in-place, atomic writer the other write-backs use, and the completion menu
  offers the pools and their entries.

## [1.5.0] — 2026-10-28

### Added

- **`"notifications"` config setting — `"all"` (default), `"quiet"`, or `"silent"` — tones down the
  status lines without losing the events that matter.** `quiet` silences the routine ones —
  rate-limited rotations (e.g. `🔑 keyrouter: nvidia jsjery123 → jery99961 (HTTP 429, rate-limited)`)
  and provider-overload notices — but keeps rotations that need you to act: an unauthorized key, an
  account out of quota, a dead refresh token. `silent` drops those too. Pool exhaustion is never
  silenced — it still notifies in every mode, because it is the one event where the request actually
  fails. `/keyrouter status` shows the active level, and the trace sink records every decision
  regardless of level.

- **`"strategy"` config setting per provider — `"failover"` (default), `"round-robin"`,
  `"stalest-first"`, or `"random"` — decides which pool entry serves each turn.** The default is
  the historical behavior: stay on the current entry until it fails. `round-robin` advances at every
  turn boundary, spreading load and keeping every OAuth account exercised (and so refreshed by the
  normal capture machinery); `stalest-first` picks the available account whose token expires soonest,
  so the account most in need of a refresh serves the turn; `random` picks a uniformly random
  available entry, for when a fixed rotation pattern is unwanted. Advancement happens only at turn
  boundaries — never mid-turn — and rotation on failure is unchanged and still applies on top of
  every strategy. Cooldowns bind for strategies exactly as they do for failure rotation, and
  `"stalest-first"` on a `keys` pool (which has no token expiry to order by) warns and behaves as
  `"round-robin"`.

- **`/keyrouter strategy <provider> [strategy]` shows or changes a pool's strategy live.** With
  no arguments it lists every pool's strategy; naming a pool reports it; naming a pool and a value
  sets it. A change never rotates immediately — strategies act only at turn boundaries — and it is
  saved to `~/.pi/keyrouter.json`, so it survives a reload and a restart; setting `"failover"`
  removes the field, since absent already means failover. On a `keys` pool the command reuses the
  loader's own degradation (`stalest-first` → `round-robin` with a warning) and persists the
  effective value so a reload does not warn again. A non-default strategy now also appears in
  `/keyrouter status`, and the completion menu offers pools and the strategy values valid for each
  pool's kind.

- **Idle OAuth accounts are refreshed at session start, through pi's own refresh implementation.**
  An account that sits in the pool unused is never touched by pi, so its stored copy keeps an
  expired access token and an unexercised refresh token — whether that token still redeems after
  weeks of disuse is up to the provider, which is exactly how pooled logins quietly die. At
  `session_start`, keyrouter now renews every *idle* account whose token has less than ~5 minutes
  of validity left, calling the provider's `oauth.refresh` — the same function pi's request path
  calls — with pi's own 15-second exchange timeout, and persists the result through the usual
  write-back rules. keyrouter still implements no part of OAuth. The active account is skipped:
  pi refreshes it at request time and the existing capture machinery syncs it. The sweep is
  non-blocking (it never touches `auth.json`, only the pool and `keyrouter.json`), and an account
  whose refresh fails is marked like a request-time refresh failure and named in one notice —
  reviving a dead login still needs an interactive `/login`.

## [1.4.0] — 2026-10-15

### Fixed

- **A refreshed credential is no longer written into the wrong pool account.** This was a data-loss
  bug, and it destroyed two of five accounts in the reporter's own pool.

  pi stores exactly one credential per provider, so `auth.json` can hold a credential keyrouter did
  not install — logging in as account A while the saved `active` field still names slot B is enough.
  The write-back then assumed the credential belonged to the slot the caller believed was active and
  stamped A's tokens into B. B was aliased onto A, B's own refresh token was orphaned, and B failed
  with `invalid_grant` on its next use even though nothing had been wrong with it. Since a refresh
  token cannot be recovered once dropped, the loss was permanent.

  Attribution is now a **search** rather than an assumption, and the caller's belief is no longer
  trusted. Signals are tried strongest-first: the new `identityPath` setting, then an identity claim
  decoded from a token field, then a known credential field, then exact refresh-token equality, and
  only last the `active` pointer — and that last one solely when keyrouter itself installed the
  credential currently in pi.

  Two safeguards back it up. An **ambiguous** match (two slots sharing the identity) writes nothing. A
  **mismatched** target slot — already holding a different account — is refused outright, because the
  credential stored there holds the refresh token that cannot be recovered. Both are surfaced as a
  warning and recorded in the trace sink instead of failing silently.
- **A refreshed credential is no longer discarded in favour of another account.** The alias fix above
  introduced a second, opposite failure: a credential is only `IN-SYNC` when its slot identity
  equals the **active** slot's, but attribution matched whichever slot shared the identity. When pi
  refreshed the active account, the identity match still resolved to that same slot, so the two
  agreed — yet the caller was handed a *different* slot, the source slot kept its expired token, the
  tripwire then correctly refused the foreign write, and **nothing was persisted at all**.

  The result was that a healthy account appeared dead on the next lap: pi refreshed it successfully,
  the refreshed token was never recorded, and the next rotation presented the expired one again. That
  made the 403 self-sustaining, and the pool looped until it was exhausted.

  The installed slot is now preferred **when the credential's own identity agrees it belongs there** —
  preference rather than the assumption that caused the original corruption. An ordinary refresh is
  recorded in place; a genuine identity mismatch is still refused.

### Fixed

- **Identity warnings raised during bootstrap were silently discarded.** An OAuth pool bootstraps
  inside `before_agent_start`, which runs *before* `ui.notify` is usable — `uiCtx` is assigned later in
  the same function. Every refusal introduced above therefore reached the user as a silent no-op. They
  are now buffered and flushed once the UI exists, oldest first, and a repeated message is collapsed
  rather than re-queued.

### Added

- **`identityPath`** — an optional per-provider setting naming where a provider's account identity
  lives, for providers whose credentials the automatic detection cannot identify. Format is
  `level1:level2`: `level1` is a key of the credential object and `level2` is a path into that value
  after decoding it (`"refresh"`, `"access:email"`, `"access:user.email"`, `"accountId"`,
  `"accountId:sub"`). Decoding is automatic — JWT, JSON, or a base64-wrapped payload — and a plain
  string that merely happens to be base64-decodable is deliberately left alone, so an opaque token
  cannot be misread as JSON and reported as an identity. A path that resolves nowhere warns once and
  falls back to detection, so a typo cannot stop refreshes from being persisted.
- `test/unit-identity.mjs`, 75 cases: codec detection, path resolution, signal ordering, ambiguity
  and tripwire refusal, and a replay of the incident's own account values. The attribution and
  tripwire assertions in the regression block all fail against the previous implementation.

### Changed

- The attribution decision and any refusal are reported through the existing `PI_KEYROUTER_TRACE`
  sink (`identity ...`, `identity skip ...`, `identity tripwire ...`), so a credential that stopped
  persisting can be diagnosed from the trace alone.

## [1.3.3] — 2026-10-09

### Changed

- **The hidden retry message no longer claims a credential rotation.** When a key
  fails with an error pi does not retry — 401, 403, quota, a dead refresh token —
  keyrouter rotates and appends a hidden message so `agent_before_settle` will
  permit one more assistant turn. Without it the rotated credential would not be
  used until the following turn.

  That message was `"pi-keyrouter: the API key was rotated after the last failed
  request. Retry the previous turn using the new credentials."` Pi maps a custom
  message to a **user** turn in `convertToLlm`, so the model reads it — `display:
  false` only hides it from the TUI. It was therefore telling the model that an
  external credential change had occurred, which it cannot observe or verify, and
  inviting it to narrate that back to the user. The message is now `"Retrying."`,
  which still prompts the model to resume the turn, which is the part it needed,
  without asserting anything untrue.

  The mechanism is unaffected: pi's `canContinue` checks only the final message's
  role, never its text.

## [1.3.2] — 2026-10-09

### Changed

- **The publish workflow targets current runner and toolchain versions.**
  `actions/checkout` and `actions/setup-node` move to `v6` and Node to `24`.
  Trusted publishing requires npm >= 11.5.1 and Node >= 22.14.0, so pinning `24`
  clears both floors deliberately rather than relying on whatever an unpinned
  `22` happens to resolve to. `package-manager-cache: false` drops a cache that
  is keyed off the lockfile and buys nothing in a job whose only real step is
  `npm publish`.

  This release exists to validate the trusted publisher configured for this
  package, which only binds to the repository on a successful OIDC publish —
  and to carry provenance, which trusted publishing generates automatically but
  which was absent from `1.3.1` because that version did not go through the
  workflow.

## [1.3.1] — 2026-10-09

### Changed

- **The npm package is now `@jeryjs/pi-keyrouter`.** The unscoped `pi-keyrouter`
  name on npm belongs to the original author (`lowern1ght`, from the pi-soly
  monorepo) and has been frozen at `0.4.0`, so the `1.x` line could never be
  published under it. Install with `pi install npm:@jeryjs/pi-keyrouter`.

- **Description and keywords now match the GitHub repository**, so the two do not
  drift: the same one-line summary, and the repo's topics as package keywords.

- **The test suite is no longer published.** It stays in git but is out of the npm
  tarball. That halves the package (273 KB → 132 KB) and keeps files like
  `test/fixture/agent/auth.json` out of a credential tool's published artifact —
  they only ever held fixtures, but the name is not worth shipping.

- **`repository.directory` dropped.** The repo root *is* the package; the
  `packages/pi-keyrouter` path was inherited from the old monorepo and rendered
  a broken link on the package page.

- **`@earendil-works/pi-coding-agent` peer range is now `*`**, matching the other
  host-provided packages. pi suppresses automatic peer installation, so a
  `>=1.0.0` constraint bought nothing and could emit a peer warning.

### Added

- **`LICENSE.md`.** The package declared MIT in `package.json` but shipped no
  license file, so npm's page and any tarball consumer had no terms. The original
  project's notice — `Copyright (c) 2026 pi-extensions contributors` — is
  retained, since MIT requires the notice to travel with the code, with the
  fork's copyright added beneath it.

## [1.3.0] — 2026-10-09

### Added

- **Tab-completion for `/keyrouter`.** The command now offers its four
  subcommands, then the configured pool names after `account`, then that pool's
  entries — so `/keyrouter account <TAB> <TAB>` reaches a credential without
  typing or remembering its name. Entry matching is case-insensitive, pools are
  labelled by kind (`key pool` / `oauth pool`), and the pool's current `active`
  entry is marked. Completion stops once the command is complete, so `status`,
  `reload` and `reset` never offer operands they do not take.

  Pools come from the already-loaded config when there is one, and are read from
  disk otherwise, so the menu works before the first turn of a session.

## [1.2.0] — 2026-10-09

### Fixed

- **Rotation now survives a session, so a rotated pool no longer restarts on its
  first key.** `activate()` bootstrapped from a hardcoded preferred index of `0`
  and nothing was written to disk, so every new session began on entry #1 and had
  to rediscover the failures that had moved the pool off it. Each pool now
  records the entry it ended on in an `active` field, and the next session
  resumes there — falling forward to the next available entry when that one is on
  cooldown.

  A **name** rather than an index, because the config moves under you: reordering
  or inserting entries shifts every index after it, while a name still identifies
  the intended credential. An entry that has since been renamed or deleted is
  unknown, and the pool starts at its first entry — the previous behaviour.

  keyrouter writes only the name, never a key value or token. The write is atomic
  (temp file + rename), is skipped when the position is unchanged so a session
  that never rotates never touches the file, and leaves the rest of the config —
  including key order and formatting — exactly as it found it. A failure warns
  once and rotation continues, as with the credential write-back.

  Cooldowns are deliberately *not* carried over: they are per-session facts, and
  honouring a 60-second cooldown from a session hours ago would only keep a
  healthy key out of the pool.

- **`/keyrouter reset` now clears the saved position.** Without this it was a
  no-op for the next session, which resumed straight back onto the credential the
  command had just handed back to pi.

- **`/keyrouter account <provider> <name>` now survives a restart**, since a
  manual pin is saved as the pool's `active` like any other rotation.

## [1.1.1] — 2026-10-06

### Fixed

- **Refreshed credentials are now persisted, so pooled accounts stop going
  stale.** pi refreshes lazily and writes the rotated access/refresh pair to
  `auth.json`; keyrouter keeps its own copy of each account, and installing the
  next account overwrote what pi had refreshed. The pool therefore kept the
  access token from an account's *last login* — and because access tokens are
  short-lived (60 minutes for WorkOS/Cline), any real use left the pool
  installing an expired token. Every account then failed with an OAuth refresh
  error and looked dead. Fixes
  `OAUTH refresh failed for <provider>` repeating across all accounts in a pool.
- `UNAUTHORIZED_RE` matched `401` inside pi's `OAuth refresh failed for …`
  wrapper, mislabelling a dead credential as a plain unauthorized error. The
  refresh-failure classification now runs first, since it is strictly more
  specific.

### Added

- `writeBackCredentials()` in `config.ts`: merges a refreshed credential back
  into `keyrouter.json` in place. Only accounts written as an **inline object**
  are eligible (`$ENV` / `@file` references are the user's own indirection and
  are left alone), only the credential fields change, the write is atomic
  (temp file + rename) so an interrupted write cannot truncate a file holding
  live credentials, it is skipped entirely when nothing changed, and a failure
  warns once instead of throwing.
- `test/cline-refresh-probe.mjs`: probes a provider's token-refresh endpoint per
  stored account and reports which tokens are genuinely dead versus merely
  stale, without persisting anything. This is the tool that distinguishes
  "the credential is dead" from "we sent a stale one" from "the request was
  malformed" — distinctions pi's single-sentence error collapses.

### Changed

- `ProviderRuntime` gained `configName`. Persisting has to match the config
  entry by the name the user wrote, because `resolveProviderId` may have
  canonicalized it (`z-ai` → `zai`) and the config is never rewritten to the
  canonical form.
- `captureAccount()` now takes the runtime and writes through to disk;
  `sameCredential()` decides whether a write is needed by comparing **both**
  secrets and the expiry, since providers differ in which part of the pair
  rotates.

### Tests

- `oauth-persists-refreshed-credential-to-config` asserts the refreshed value
  reaches the config **file**, not just memory. Verified to be a real test:
  temporarily disabling the write makes it fail on exactly the write-back
  assertions and nothing else.
- `oauth-does-not-churn-config-when-nothing-changed` guards the no-op path.
- 20 new unit checks covering `writeBackCredentials` (provider-specific extras,
  sibling accounts, unknown top-level fields, no-op behaviour, unknown pool) and
  `sameCredential` (both secrets, moved expiry, null safety).
- Suite: 29/29 e2e, 100/100 unit, `tsc` clean. The real `~/.pi/agent/auth.json`
  is unchanged and the real `keyrouter.json` is byte-identical after a run
  (correctly a no-op).

## [1.1.0] — 2026-10-03

### Added

- **`/keyrouter account <provider> [name|index]`** — pin a pool to a specific
  API key or OAuth account. Works for either pool kind. Accepts an exact name, a
  case-insensitive name, or a 1-based ordinal; with no name it reports the pool
  and the available choices.
- A pinned credential has its cooldown cleared and any pending retry dropped — a
  manual pick is deliberate, so it must not be one the automatic picker would
  immediately skip. Rotation continues from the pinned position on the next
  failure, making this a nudge rather than a lock.

### Changed

- The install path is **shared with automatic rotation**
  (`applyAccount` / `applyKey`, with the capture step before leaving the
  previous credential). A second install path would have been the easiest way to
  lose pi's rotated refresh token.
- The `/keyrouter` command description and usage line list the new subcommand.

### Tests

- `parseCommandArgs()` extracted as a pure exported function with 16 unit checks
  (bare invocation, unknown subcommand, case-insensitivity, ordinals, operand
  case preservation) — slash commands only exist in the interactive TUI, so
  print-mode tests cannot reach the handler.
- The handler was verified **live in the TUI**: status rendering, the no-arg
  listing, switch by name, switch by ordinal, unknown provider, unknown name,
  and a real `auth.json` showing the newly installed credential with the
  unrelated provider entry intact, followed by
  `oauth restore … -> session-start credential` on exit.

## [1.0.0] — 2026-10-03

### Added

- **OAuth account pools (`accounts`).** A provider can declare several stored
  OAuth logins and keyrouter rotates between them on the same signals that
  rotate API keys. This is the case pi itself cannot express: `auth.json` is a
  `Record<providerId, Credential>` holding exactly one credential per provider
  id, so accounts 2..N have nowhere to live.
  - Installs go through pi's own credential store
    (`runtime.credentials.modify`), so pi's file lock, shared `readState` cache
    and revision counter stay coherent. Writing `auth.json` directly would risk
    clobbering a concurrent `/login`.
  - pi keeps owning OAuth entirely: the flow, `/login`, refresh, and headers.
    keyrouter never refreshes a token and never writes `auth.json` itself.
  - Because pi rotates refresh tokens in place, the pool reads the live
    credential back both when it leaves an account and after installing one.
  - `credential` accepts an inline object, `$ENV` holding a JSON string, or
    `@file`. A credential is used only if it has `type: "oauth"`, a non-empty
    `access`, a `refresh`, and a numeric `expires`; anything else is dropped with
    a warning naming the account only.
  - `rotateOnQuota` (default `true`) decides whether an account-level limit
    rotates the pool. Deliberately the opposite default from API-key pools:
    each OAuth account is a separate subscription, so a limit is exactly when
    the next account is wanted, whereas a different API key on the same account
    cannot lift an account-wide limit.
  - On clean shutdown the credential that was stored at session start is put
    back. If there was none, the active account is left installed, because that
    is still a valid login and logging the user out would be worse.
- Dead-refresh-token errors (`OAuth refresh failed`, `invalid_grant`) are
  classified as a dead account and rotated away from.
- `/keyrouter status` shows the pool kind, active entry, install state, pi's own
  `source` for the provider, and each entry's expiry — never a token.
- Status output shows credential expiry as a relative time, and free of the
  credential value.
- `test/verify-runtime-credentials.mjs`, `test/oauth-fixture-smoke.mjs`,
  `test/oauth-cases.mjs`, `test/fixture/oauth-provider.ts` and
  `test/unit-oauth.mjs`.
- The test runner picks an OS-assigned free port instead of guessing in a fixed
  range, which intermittently landed on a Windows reserved port and failed as
  `EACCES`.

### Changed

- **Migrated to pi 1.0.0.** `ModelRegistry.authStorage` was removed upstream: the
  `ModelRegistry` given to extensions is now a read-oriented facade over the
  internal `ModelRuntime`, and its credential writers are not part of that
  facade. keyrouter reaches the runtime overlay through the facade's `runtime`
  field, behind a capability check, and awaits the now-async setters.
- **Never registers its own provider.** Pools point at provider ids that already
  exist — built-ins, `models.json` ids, and ids owned by other extensions such
  as `cx-providers`. Nothing is shadowed, merged or unregistered.
- Provider ids are resolved case-insensitively against the ids pi actually knows
  and stored in canonical form, so `z-ai` / `Z.AI` map to the real id.
- `keys` and `accounts` are mutually exclusive for one provider; specifying both
  is reported and `keys` wins.
- API-key pools now bootstrap their first key at session start. Previously the
  index was tracked but the key was never actually installed.
- A pool with no usable keys after `$ENV` expansion is dropped with a warning
  instead of silently doing nothing.

### Fixed

- The runtime api_key overlay is never used for an `accounts` pool.
  `setRuntimeApiKey` stores only a string and pi's resolver returns early on any
  override: on an OAuth-only provider (e.g. `openai-codex`) that yields **no auth
  at all**, and on a dual-auth provider it **freezes the token** so pi stops
  refreshing it. Verified against pi 1.0.0 — with an overlay installed on
  `openai-codex`, `getProviderAuthStatus()` still reports `source=runtime` while
  `getAuth()` resolves to `undefined`.
- Activation clears any leftover api_key overlay before installing an account,
  so an earlier API-key configuration cannot shadow the OAuth credential.
- `401` / `403` and quota errors are not in pi's retryable set, so a rotation
  triggered by one of them was never used. Such swaps now request exactly one
  more request at pi's `agent_before_settle` boundary, appending a hidden
  message to make the continuation valid, and are bounded by `maxRetries` per
  provider so an all-bad pool cannot loop.
- The per-provider continuation budget is respected when the picked entry is
  still cooling, rather than spending a request on another cooldown.

### Tests

- Full e2e suite against a scriptable fake OpenAI-compatible server, asserting on
  three independent signals: which `Authorization` header each request carried
  (the proof rotation happened), keyrouter's trace log, and pi's final output.
- The suite sha256s the real `~/.pi/agent/auth.json` before and after the run and
  **fails if it changed**, because the OAuth cases write credentials.
- The OAuth cases load `test/fixture/oauth-provider.ts`, which registers
  `kroauth` — an OAuth-only provider mirroring `openai-codex`. An api-key
  overlay on such a provider resolves to nothing, so every request proves
  keyrouter used the credential store rather than the overlay. The fixture also
  models refresh-token rotation, so a credential that is not synced back is
  rejected as already-used instead of silently working.

## [0.4.0] — 2026-06-24

The last release of the original line, and the version this fork started from.

- Switched from wrapping `globalThis.fetch` to pi's native
  `authStorage.setRuntimeApiKey()`. The fetch wrapper did not work: pi-ai's
  OpenAI SDK captures the `fetch` reference when the client is created, before
  extensions load, so the SDK kept calling the original function.
- `/keyrouter enable` / `disable` subcommands, and a Box widget notification on
  every key switch.
- 33 tests.
- *Inferred version attribution:* the `0.4.0` package contents are as listed
  here; the `enable`/`disable` split and the widget are attributed to this
  release from those contents.

## [0.3.1] — 2026-06-17

- Packaging fix: file count went from 6 to 7 and unpacked size grew to 28.9 kB.
  *Inferred from the published artifact metadata; the exact content of the
  added file is not recorded here.*

## [0.3.0] — 2026-06-17

- Reported as a feature release by its version bump. *Details not recoverable
  from the published artifacts.*

## [0.2.3] — 2026-06-17

- Patch release. *Details not recoverable from the published artifacts.*

## [0.2.2] — 2026-06-17

- Patch release. *Details not recoverable from the published artifacts.*

## [0.2.1] — 2026-06-17

- Patch release. *Details not recoverable from the published artifacts.*

## [0.2.0] — 2026-06-17

- Reported as a feature release by its version bump. *Details not recoverable
  from the published artifacts.*

## [0.1.0] — 2026-06-17

- Initial published release: API key rotation for pi-coding-agent. Multiple keys
  per provider, automatic 429/401 fallback, and a max-retries guard.
- Config at `~/.pi/keyrouter.json`, deliberately user-level only — project-local
  config files are ignored so a cloned repository cannot override real keys.
- `match` URL substrings per provider, `maxRetries`, and `cooldownMs`.
- Docs at this release describe rotating the current key on 429 and marking keys
  `rate-limited` or `unauthorized` with a cooldown, plus clearing the override
  and surfacing the real error once all keys are exhausted.

[Unreleased]: https://github.com/jeryjs/pi-keyrouter/compare/v1.8.0...HEAD
[1.8.0]: https://github.com/jeryjs/pi-keyrouter/compare/v1.7.0...v1.8.0
[1.7.0]: https://github.com/jeryjs/pi-keyrouter/compare/v1.6.1...v1.7.0
[1.6.1]: https://github.com/jeryjs/pi-keyrouter/compare/v1.6.0...v1.6.1
[1.6.0]: https://github.com/jeryjs/pi-keyrouter/compare/v1.5.0...v1.6.0
[1.5.0]: https://github.com/jeryjs/pi-keyrouter/compare/v1.4.0...v1.5.0
[1.4.0]: https://github.com/jeryjs/pi-keyrouter/compare/v1.3.3...v1.4.0
[1.3.3]: https://github.com/jeryjs/pi-keyrouter/compare/v1.3.2...v1.3.3
[1.3.2]: https://github.com/jeryjs/pi-keyrouter/compare/v1.3.1...v1.3.2
[1.3.1]: https://github.com/jeryjs/pi-keyrouter/compare/v1.3.0...v1.3.1
[1.2.0]: https://github.com/jeryjs/pi-keyrouter/compare/v1.1.1...v1.2.0
[1.1.1]: https://github.com/jeryjs/pi-keyrouter/compare/v1.1.0...v1.1.1
[1.1.0]: https://github.com/jeryjs/pi-keyrouter/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/jeryjs/pi-keyrouter/compare/v0.4.0...v1.0.0
[0.4.0]: https://www.npmjs.com/package/pi-keyrouter/v/0.4.0
[0.3.1]: https://www.npmjs.com/package/pi-keyrouter/v/0.3.1
[0.3.0]: https://www.npmjs.com/package/pi-keyrouter/v/0.3.0
[0.2.3]: https://www.npmjs.com/package/pi-keyrouter/v/0.2.3
[0.2.2]: https://www.npmjs.com/package/pi-keyrouter/v/0.2.2
[0.2.1]: https://www.npmjs.com/package/pi-keyrouter/v/0.2.1
[0.2.0]: https://www.npmjs.com/package/pi-keyrouter/v/0.2.0
[0.1.0]: https://www.npmjs.com/package/pi-keyrouter/v/0.1.0