Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,15 @@ Activate with `/skin cyberpunk` or `display.skin: cyberpunk` in config.yaml.
---

## Important Policies
### Hermes Continuity Protection

Hermes itself is long-lived infrastructure for this project. Protect it separately from any optional UI surface.

- If the user asks to remove, park, disable, or clean up a desktop app, native client, browser surface, wrapper, preview app, or local UI experiment, do **not** stop or uninstall the Hermes gateway, WhatsApp bridge, launchd service, or other Hermes runtime unless they explicitly say to take Hermes offline.
- Default interpretation: remove the client surface, preserve Hermes availability.
- Before stopping any Hermes service, state exactly which layer is being stopped: UI app, local desktop surface, gateway service, WhatsApp bridge, or Hermes runtime.
- If a request is ambiguous, preserve the gateway and messaging runtime by default.

### Prompt Caching Must Not Break

Hermes-Agent ensures caching remains valid throughout a conversation. **Do NOT implement changes that would:**
Expand Down
105 changes: 87 additions & 18 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -1352,6 +1352,36 @@ def _import_codex_cli_tokens() -> Optional[Dict[str, str]]:
return None


def _recover_codex_tokens_from_cli(
existing_tokens: Dict[str, Any],
*,
reason: str,
) -> Optional[Dict[str, str]]:
"""Recover Hermes Codex auth from a fresh Codex CLI session when needed."""
cli_tokens = _import_codex_cli_tokens()
if not cli_tokens:
return None

current_access = str(existing_tokens.get("access_token", "") or "").strip()
current_refresh = str(existing_tokens.get("refresh_token", "") or "").strip()
cli_access = str(cli_tokens.get("access_token", "") or "").strip()
cli_refresh = str(cli_tokens.get("refresh_token", "") or "").strip()
if not cli_access or not cli_refresh:
return None
if cli_access == current_access and cli_refresh == current_refresh:
return None

logger.info("Recovering Codex credentials from ~/.codex/auth.json after %s", reason)
_save_codex_tokens({
"access_token": cli_access,
"refresh_token": cli_refresh,
})
return {
"access_token": cli_access,
"refresh_token": cli_refresh,
}


def resolve_codex_runtime_credentials(
*,
force_refresh: bool = False,
Expand Down Expand Up @@ -1397,7 +1427,25 @@ def resolve_codex_runtime_credentials(
should_refresh = _codex_access_token_is_expiring(access_token, refresh_skew_seconds)

if should_refresh:
tokens = _refresh_codex_auth_tokens(tokens, refresh_timeout_seconds)
try:
tokens = _refresh_codex_auth_tokens(tokens, refresh_timeout_seconds)
refreshed_data = _read_codex_tokens(_lock=False)
refreshed_tokens = dict(refreshed_data["tokens"])
if refreshed_tokens == tokens:
data = refreshed_data
else:
data = dict(data)
data["tokens"] = dict(tokens)
except AuthError as exc:
recovered = _recover_codex_tokens_from_cli(
tokens,
reason=f"Codex refresh failure ({exc.code})",
)
if recovered is None:
raise
tokens = recovered
data = _read_codex_tokens(_lock=False)
tokens = dict(data["tokens"])
access_token = str(tokens.get("access_token", "") or "").strip()

base_url = (
Expand Down Expand Up @@ -2138,45 +2186,66 @@ def get_nous_auth_status() -> Dict[str, Any]:

def get_codex_auth_status() -> Dict[str, Any]:
"""Status snapshot for Codex auth.

Checks the credential pool first (where `hermes auth` stores credentials),
then falls back to the legacy provider state.

This must stay side-effect free. Status/doctor should not trigger live token
refresh attempts because refresh-token rotation can make health output
nondeterministic and can mark otherwise usable credentials as exhausted.
"""
# Check credential pool first — this is where `hermes auth` and
# `hermes model` store device_code tokens.
# `hermes model` store device_code tokens. Read the persisted entries
# directly instead of calling pool.select(), which can trigger refresh.
try:
from agent.credential_pool import load_pool
pool = load_pool("openai-codex")
if pool and pool.has_credentials():
entry = pool.select()
if entry is not None:
entries = getattr(pool, "_entries", None)
if not isinstance(entries, list):
try:
entries = pool.entries()
except Exception:
entries = []
for entry in entries or []:
api_key = (
getattr(entry, "runtime_api_key", None)
or getattr(entry, "access_token", "")
)
if api_key and not _codex_access_token_is_expiring(api_key, 0):
refresh_token = str(getattr(entry, "refresh_token", "") or "").strip()
if api_key or refresh_token:
return {
"logged_in": True,
"auth_store": str(_auth_file_path()),
"last_refresh": getattr(entry, "last_refresh", None),
"auth_mode": "chatgpt",
"source": f"pool:{getattr(entry, 'label', 'unknown')}",
"api_key": api_key,
"needs_refresh": (
_codex_access_token_is_expiring(api_key, 0)
if api_key else True
),
}
except Exception:
pass

# Fall back to legacy provider state
# Fall back to Hermes auth store without attempting refresh.
try:
creds = resolve_codex_runtime_credentials()
return {
"logged_in": True,
"auth_store": str(_auth_file_path()),
"last_refresh": creds.get("last_refresh"),
"auth_mode": creds.get("auth_mode"),
"source": creds.get("source"),
"api_key": creds.get("api_key"),
}
data = _read_codex_tokens()
tokens = dict(data.get("tokens") or {})
access_token = str(tokens.get("access_token", "") or "").strip()
refresh_token = str(tokens.get("refresh_token", "") or "").strip()
if access_token or refresh_token:
return {
"logged_in": True,
"auth_store": str(_auth_file_path()),
"last_refresh": data.get("last_refresh"),
"auth_mode": data.get("auth_mode") or "chatgpt",
"source": "hermes-auth-store",
"api_key": access_token,
"needs_refresh": (
_codex_access_token_is_expiring(access_token, 0)
if access_token else True
),
}
raise AuthError("No Codex credentials found.", code="codex_auth_missing")
except AuthError as exc:
return {
"logged_in": False,
Expand Down
18 changes: 15 additions & 3 deletions hermes_cli/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -1148,26 +1148,38 @@ def launchd_uninstall():
def launchd_start():
plist_path = get_launchd_plist_path()
label = get_launchd_label()
target = f"{_launchd_domain()}/{label}"

# Self-heal if the plist is missing entirely (e.g., manual cleanup, failed upgrade)
if not plist_path.exists():
print("↻ launchd plist missing; regenerating service definition")
plist_path.parent.mkdir(parents=True, exist_ok=True)
plist_path.write_text(generate_launchd_plist(), encoding="utf-8")
subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30)
subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30)
subprocess.run(["launchctl", "kickstart", target], check=True, timeout=30)
print("✓ Service started")
return

refresh_launchd_plist_if_needed()
recovered = False
try:
subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30)
subprocess.run(["launchctl", "kickstart", target], check=True, timeout=30)
except subprocess.CalledProcessError as e:
if e.returncode not in (3, 113):
raise
print("↻ launchd job was unloaded; reloading service definition")
subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30)
subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30)
subprocess.run(["launchctl", "kickstart", target], check=True, timeout=30)
recovered = True

# launchctl can return success for kickstart even when the job is still
# unloaded (for example after a stale/local plist repair path). Only probe
# after a clean kickstart; the explicit recovery path already reloaded the
# service definition and retried the start.
if not recovered and not _is_service_running():
print("↻ launchd accepted kickstart but the job is still unloaded; bootstrapping service definition")
subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30)
subprocess.run(["launchctl", "kickstart", target], check=True, timeout=30)
print("✓ Service started")

def launchd_stop():
Expand Down
4 changes: 2 additions & 2 deletions optional-skills/autonomous-ai-agents/DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
Optional autonomous AI agent integrations — external coding agent CLIs
that can be delegated to for independent coding tasks.
Optional autonomous AI agent integrations — delegation bridges, memory backends,
autonomy plugin stacks, and external agent companions.
107 changes: 107 additions & 0 deletions optional-skills/autonomous-ai-agents/autonomy-stack/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
---
name: autonomy-stack
description: Bootstrap a self-improving autonomy stack for Hermes by installing vetted community plugins, verifying plugin state, and leaning on Hermes's built-in skill creation and improvement loop.
version: 1.0.0
author: Hermes Agent
license: MIT
metadata:
hermes:
tags: [Autonomy, Plugins, Self-Improvement, Telemetry, Goals, Learning]
related_skills: [honcho, livekit-presence, screenpipe]
category: autonomous-ai-agents
prerequisites:
commands: [python3, git]
---

# Autonomy Stack

Use this optional skill when the goal is to make Hermes more autonomous, observable, and self-tuning without forking core.

This stack is intentionally pragmatic:

- install and refresh a curated subset of community plugins from `42-evey/hermes-plugins`
- verify what is already installed under `~/.hermes/plugins`
- rely on Hermes's **built-in** skill creation and self-improvement loop for the learning side instead of adding a second competing mechanism

## Why no separate skill-factory dependency?

Hermes already has a built-in learning loop that creates and improves skills from experience. This skill keeps the autonomy stack focused on pluginized autonomy, telemetry, and orchestration instead of pinning you to another unstable external repo.

## Install

```bash
hermes skills install official/autonomous-ai-agents/autonomy-stack
```

Locate the helper:

```bash
SCRIPT="$(find ~/.hermes/skills -path '*/autonomy-stack/scripts/autonomy_stack.py' -print -quit)"
```

## Quick Start

See current state first:

```bash
python3 "$SCRIPT" doctor
```

Install the recommended plugin subset:

```bash
python3 "$SCRIPT" install
```

Install a custom subset instead:

```bash
python3 "$SCRIPT" install --plugins evey-autonomy,evey-telemetry,evey-status,evey-goals
```

Refresh from upstream later:

```bash
python3 "$SCRIPT" update
```

## Recommended Starter Set

The helper defaults to a conservative set:

- `evey-autonomy`
- `evey-telemetry`
- `evey-status`
- `evey-reflect`
- `evey-learner`
- `evey-goals`

That gives you autonomy, introspection, and a learning loop without dumping the entire plugin pack into `~/.hermes/plugins` immediately.

## Workflow

1. Run `doctor`.
2. Install the recommended subset.
3. Restart Hermes.
4. Inspect plugin state with:

```bash
hermes plugins list
```

5. Let Hermes's built-in skill loop handle learned workflows and refinements over time.

## Notes

- Upstream plugin repo: `https://github.com/42-evey/hermes-plugins`
- The helper keeps a canonical local cache under `~/.hermes/.integrations/autonomy-stack/hermes-plugins`
- Installed plugins are copied into `~/.hermes/plugins`

## Verification

Good output from `doctor` should show:

- whether `git` is available
- which recommended plugins are installed
- whether `evey_utils.py` is present
- a note that Hermes's skill loop is already built in
Loading