diff --git a/agent/credential_pool.py b/agent/credential_pool.py
index 4fe173a965..a311cae1e3 100644
--- a/agent/credential_pool.py
+++ b/agent/credential_pool.py
@@ -885,6 +885,10 @@ _TOKENS_SINGLETON_PROVIDERS: Dict[str, Tuple[str, str, str, str]] = {
"xai-oauth": ("xAI OAuth", "xAI", "refresh_xai_oauth_pure", "_is_terminal_xai_oauth_refresh_error"),
}
+# Providers whose pooled OAuth entries ``_refresh_entry_impl`` can actually refresh. Any other
+# provider is returned unchanged by that path, so callers must not report a refresh for them.
+REFRESHABLE_OAUTH_PROVIDERS = frozenset({"anthropic", "nous", *_TOKENS_SINGLETON_PROVIDERS})
+
# Providers whose refresh tokens are single-use: the sync -> POST -> write-back
# sequence must be serialized across processes under the auth-store flock.
_SINGLE_USE_REFRESH_PROVIDERS = ("openai-codex", "xai-oauth", "anthropic")
@@ -1592,7 +1596,10 @@ class CredentialPool(CredentialPoolAdminMixin):
updated = replace(updated, **_MARK_OK)
self._replace_entry(entry, updated)
- self._persist()
+ # Declare the cleared id: a borrowed row carries no access_token on disk, so
+ # the merge's token-change bypass cannot apply and a plain persist would copy
+ # the still-binding cooldown back over this success.
+ self._persist(status_cleared_ids=[updated.id])
# Sync back so _seed_from_singletons() on the next load_pool() sees
# fresh state instead of re-seeding consumed tokens.
self._sync_device_code_entry_to_auth_store(updated)
diff --git a/hermes_cli/_parser.py b/hermes_cli/_parser.py
index 97bacacc5e..35a48e2a3c 100644
--- a/hermes_cli/_parser.py
+++ b/hermes_cli/_parser.py
@@ -75,6 +75,7 @@ Examples:
hermes auth remove
Remove pooled credential by index, id, or label
hermes auth reset [t] Clear exhaustion status for a provider, or one credential
hermes auth priority
Move a pooled credential to priority n (0 = tried first)
+ hermes auth refresh [t] Refresh a pooled OAuth credential and clear its cooldown
hermes model Select default model
hermes fallback [list] Show fallback provider chain
hermes fallback add Add a fallback provider (same picker as `hermes model`)
diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py
index 7daf2f8097..f77ffbec68 100644
--- a/hermes_cli/auth_commands.py
+++ b/hermes_cli/auth_commands.py
@@ -14,7 +14,7 @@ import uuid
from agent.credential_pool import (
AUTH_TYPE_API_KEY, AUTH_TYPE_OAUTH, CUSTOM_POOL_PREFIX, SOURCE_MANUAL,
SOURCE_MANUAL_DEVICE_CODE, STATUS_EXHAUSTED, STRATEGY_FILL_FIRST, STRATEGY_ROUND_ROBIN,
- STRATEGY_RANDOM, STRATEGY_LEAST_USED, PooledCredential, _exhausted_until,
+ STRATEGY_RANDOM, STRATEGY_LEAST_USED, PooledCredential, REFRESHABLE_OAUTH_PROVIDERS, _exhausted_until,
_normalize_custom_pool_name, get_pool_strategy, label_from_token, list_custom_pool_providers,
load_pool)
import hermes_cli.auth as auth_mod
@@ -527,6 +527,52 @@ def auth_reset_command(args) -> None:
print(f"Reset status on {provider} credential #{index} ({cleared.label})")
+def auth_refresh_command(args) -> None:
+ """`hermes auth refresh [target]`: force one pooled OAuth entry to refresh.
+
+ A successful refresh rotates the stored tokens and clears the entry's local
+ exhaustion block, returning it to rotation before its persisted
+ ``last_error_reset_at`` elapses. It proves the grant is alive, not that the
+ provider's quota is back: if the account is still capped, the next request
+ 429s and benches it again. Failure leaves the pool's own verdict in place.
+ """
+ provider = _normalize_provider(getattr(args, "provider", ""))
+ target = getattr(args, "target", None)
+ pool = load_pool(provider)
+ entries = pool.entries()
+ if not entries:
+ raise SystemExit(f"No {provider} credentials in the pool.")
+ if target is None or not str(target).strip():
+ if len(entries) != 1:
+ raise SystemExit(
+ f"{provider} has {len(entries)} credentials; pass an index, entry id, or exact "
+ f"label (see `hermes auth list {provider}`).")
+ index, matched = 1, entries[0]
+ else:
+ index, matched, error = pool.resolve_target(target)
+ if matched is None or index is None:
+ raise SystemExit(f"{error} Provider: {provider}.")
+ if (provider not in REFRESHABLE_OAUTH_PROVIDERS or matched.auth_type != AUTH_TYPE_OAUTH
+ or not matched.refresh_token):
+ raise SystemExit(
+ f"{provider} credential #{index} ({matched.label}) is not a refreshable OAuth "
+ f"credential.")
+ refreshed = pool.try_refresh_matching(credential_id=matched.id)
+ if refreshed is None:
+ after = next((e for e in pool.entries() if e.id == matched.id), None)
+ state = "removed from pool" if after is None else (after.last_status or "unknown")
+ raise SystemExit(
+ f"Refresh failed for {provider} credential #{index} ({matched.label}); "
+ f"status now: {state}.")
+ status = refreshed.last_status or "ok"
+ if status == "ok":
+ print(f"Refreshed {provider} credential #{index} ({refreshed.label}); status: ok")
+ else:
+ # A peer already rotated this grant and the pool adopted it without clearing status.
+ print(f"Adopted current tokens for {provider} credential #{index} ({refreshed.label}); "
+ f"status still: {status}")
+
+
def auth_status_command(args) -> None:
provider = _normalize_provider(getattr(args, "provider", "") or "")
if not provider:
@@ -731,7 +777,7 @@ def _interactive_strategy() -> None:
_AUTH_ACTIONS = {
"add": auth_add_command, "list": auth_list_command, "remove": auth_remove_command,
- "reset": auth_reset_command, "priority": auth_priority_command, "status": auth_status_command,
+ "reset": auth_reset_command, "priority": auth_priority_command, "refresh": auth_refresh_command, "status": auth_status_command,
"logout": auth_logout_command,
"spotify": auth_spotify_command}
diff --git a/hermes_cli/console_engine.py b/hermes_cli/console_engine.py
index 1e4778c7b0..6d6b23380e 100644
--- a/hermes_cli/console_engine.py
+++ b/hermes_cli/console_engine.py
@@ -292,7 +292,7 @@ _CLI_FAMILIES: dict[str, tuple[_CliSurface, str]] = {
"memory": (_sub("memory", "build_memory_parser", "cmd_memory"), "status, *off, *reset"),
"auth": (
_sub("auth", "build_auth_parser", "cmd_auth"),
- "list, status, *reset, *priority, *add, *remove, *logout, spotify status, *spotify login, "
+ "list, status, *reset, *priority, *refresh, *add, *remove, *logout, spotify status, *spotify login, "
"*spotify logout"),
"pairing": (
_sub("pairing", "build_pairing_parser", "cmd_pairing"),
diff --git a/hermes_cli/subcommands/auth.py b/hermes_cli/subcommands/auth.py
index 61cb8c953f..70df5fec23 100644
--- a/hermes_cli/subcommands/auth.py
+++ b/hermes_cli/subcommands/auth.py
@@ -48,6 +48,12 @@ def build_auth_parser(subparsers, *, cmd_auth: Callable) -> None:
auth_priority.add_argument("provider", help="Provider id")
auth_priority.add_argument("target", help="Credential index, entry id, or exact label")
auth_priority.add_argument("priority", type=int, help="New priority; others are renumbered")
+ auth_refresh = auth_subparsers.add_parser(
+ "refresh", help="Refresh a pooled OAuth credential's tokens and clear its cooldown")
+ auth_refresh.add_argument("provider", help="Provider id")
+ auth_refresh.add_argument(
+ "target", nargs="?",
+ help="Credential index, entry id, or exact label (required when the pool holds more than one)")
auth_status = auth_subparsers.add_parser("status", help="Show auth status for a provider")
auth_status.add_argument("provider", help="Provider id")
auth_logout = auth_subparsers.add_parser(
diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md
index c7c0a3d4b6..b5e6093e6e 100644
--- a/website/docs/reference/cli-commands.md
+++ b/website/docs/reference/cli-commands.md
@@ -586,12 +586,13 @@ hermes auth remove openrouter 2 # Remove by index
hermes auth priority openrouter backup-key 0 # Move a credential to the front of fill_first order
hermes auth reset openrouter # Clear cooldowns
hermes auth reset openrouter 2 # Clear the cooldown on one credential
+hermes auth refresh openai-codex work # Refresh one OAuth credential and clear its cooldown
hermes auth status anthropic # Show auth status for a provider
hermes auth logout anthropic # Log out and clear stored auth state
hermes auth spotify # Authenticate Hermes with Spotify via PKCE
```
-Subcommands: `add`, `list`, `remove`, `reset`, `priority`, `status`, `logout`, `spotify`. When called with no subcommand, launches the interactive management wizard.
+Subcommands: `add`, `list`, `remove`, `reset`, `priority`, `refresh`, `status`, `logout`, `spotify`. When called with no subcommand, launches the interactive management wizard.
## `hermes status`
diff --git a/website/docs/user-guide/features/credential-pools.md b/website/docs/user-guide/features/credential-pools.md
index 1c9aee9860..d256693698 100644
--- a/website/docs/user-guide/features/credential-pools.md
+++ b/website/docs/user-guide/features/credential-pools.md
@@ -121,6 +121,7 @@ Type [1/2]:
| `hermes auth remove ` | Remove credential by 1-based index |
| `hermes auth reset ` | Clear all cooldowns/exhaustion status |
| `hermes auth reset ` | Clear the cooldown on one credential by index, id, or label |
+| `hermes auth refresh [target]` | Refresh one OAuth credential's tokens and return it to rotation (proves the grant is alive; the next request re-checks quota) |
## Rotation Strategies