feat(relay): per-profile enable helper + plugin update discovery

Fixes two productization gaps from the phone Threads work, reusing the
existing update mechanisms (no new updater).

Profiles (install-once / enable-per-profile / pair-once):
- plugin/profiles.py + `hermes relay profiles list|enable [--all|NAME]`
  enumerate the default config + every profiles/<name>/config.yaml and
  bulk-enable hermes-relay in plugins.enabled (backing up each rewritten
  file to .bak, skipping already-enabled configs). Pairing is unaffected
  — one relay, pair once.

Update discovery:
- plugin/update_check.py + `hermes relay update-check` + a dashboard
  "Plugin version" card compare plugin.relay.__version__ against the
  latest plugin-v* GitHub release and surface the right command
  (hermes-relay-update vs `hermes plugins update hermes-relay`). Dashboard
  route GET /api/plugins/hermes-relay/update-check caches the GitHub
  fetch 1h and degrades softly offline.

Docs: configuration.md gains "Profiles & the relay" + "Keeping the relay
plugin updated".

Tests: +39 (profiles discover/state/enable; semver/tag-pick/command-
detect/build-result; dashboard update-check available/up-to-date/error).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bailey Dixon
2026-06-30 17:48:42 -04:00
co-authored by Claude Opus 4.8
parent 8308fb87f3
commit efa51dcb55
13 changed files with 893 additions and 8 deletions
+62
View File
@@ -1,5 +1,67 @@
# Hermes-Relay — Dev Log
## 2026-06-30 — Per-profile enablement helper + update discovery
**Why.** Two gaps surfaced while productizing the phone Threads work: (1) Hermes installs a plugin's *code* once but enables it *per profile*, so a multi-agent host hand-edits N `config.yaml` files to expose the relay's tools everywhere — and docs didn't explain the install-once / enable-per-profile / **pair-once** split. (2) Updating works (`hermes plugins update` / `hermes-relay-update`) but nothing *tells* an operator a newer plugin release exists, and the app/plugin/CLI version tracks aren't surfaced together.
**What.**
- **`plugin/profiles.py` + `hermes relay profiles list|enable [--all|NAME]`.** Enumerates the default config + every `profiles/<name>/config.yaml`, reports `hermes-relay` enablement, and bulk-adds it to `plugins.enabled` (removing it from `disabled`), backing up each rewritten file to `.bak` and skipping already-enabled configs (comments/order preserved in the common case). Pairing is untouched — one relay, pair once.
- **`plugin/update_check.py` + `hermes relay update-check` + dashboard "Plugin version" card.** Compares the installed `plugin.relay.__version__` against the latest `plugin-v*` GitHub release, detects the right update command (full-relay shim present → `hermes-relay-update`, else `hermes plugins update hermes-relay`), surfaced in the Management tab via loopback `GET /api/plugins/hermes-relay/update-check` (GitHub fetch cached 1h; degrades to "couldn't check" offline — never a 5xx). Reuses the existing update mechanisms; no new updater.
- **Docs.** New `configuration.md` subsections: "Profiles & the relay" (the two axes + the `profiles` commands) and "Keeping the relay plugin updated" (update-check + the two update commands).
**Verification.** `python -m unittest plugin.tests.test_profiles plugin.tests.test_update_check plugin.dashboard.test_plugin_api` — 39 pass. Dashboard bundle rebuilt (esbuild). App-side version display + soft "relay outdated" banner deferred (needs an app build + a relay-side update-check route).
## 2026-06-30 — Connections restructure + animated, dismissible status banner
**Why.** Two pain points in the connection manager: (1) the reconnect/handoff status
read as static — the non-error path used `ConnectionStatusBanner`, which capped at
two flat text lines, while the richer animated per-step stepper (spinner → green ✓ →
red ✕) existed only in `ConnectionStatusToast`, shown for Error tone only; and (2)
the Connections settings screen was overloaded — the active connection's entire deep
body (Features / Routes / Advanced / Security + a 5-button action row + stacked route
nudges) was crammed into one card inside the list, so the list wasn't scannable.
**Animated, take-space, dismissible banner (`ui/components/ConnectionHandoffBanner.kt`,
`ui/RelayApp.kt`).** `ConnectionStatusBanner` now renders the same `ConnectionStepRow`/
`StepGlyph` animated stepper the toast uses (capped at the last 3 entries, up from 2
flat lines); per-step state already comes stamped from `buildGlobalConnectionProbeEntries`,
so "checking → green dot → red ✕" tracks real health. The non-error banner moved out of
the floating overlay into the persistent take-space stack above the Scaffold (expand/
shrinkVertically + the surface's `animateContentSize` keep the push-down smooth, not a
hard snap; the error toast still floats). The banner gained an explicit close (×) control
and a swipe-up gesture, both wired to the existing `dismissedStatusKey` so a dismissed
status stays hidden until its content identity changes.
**No misleading "Connection changed" on resume (`viewmodel/ConnectionViewModel.kt`).**
The `Reconnecting` handoff was renamed from "Connection changed" (which implied a
different connection) to a neutral "Reconnecting"; change-implying copy is reserved for
the genuine route-switch branch. A foreground-resume timestamp (from `AppForegroundTracker`)
now suppresses the transient reconnect banner within `RELAY_RECONNECT_GRACE_MS` — a quick
same-connection re-handshake after returning to the app shows nothing (and its
"Connection restored" pair stays silent too); only a reconnect still down past the grace
window surfaces "Reconnecting".
**Connections list + tabbed detail (`ui/screens/ConnectionsSettingsScreen.kt`,
`ui/screens/ConnectionDetailScreen.kt`, `ui/components/ActiveConnectionSections.kt`,
`ui/RelayApp.kt`).** `ConnectionsSettingsScreen` is now a scannable list — each card is
label + an `Active` badge (active connection) + a one-line status + the capability
timeline summary (the dot/label/value rows users liked), and tapping drills into a new
`ConnectionDetailScreen`. The detail is a 4-tab screen (Overview / Routes / Advanced /
Security) with a `⋮` overflow menu for rename / re-pair / revoke / remove. Overview leads
with the steps/timeline (`ActiveCardFeaturesSection`); Routes hosts the relocated ADR-24
route block (extracted verbatim into `ActiveCardRoutesSection`, reusing `EndpointsCard` +
`RouteEditorDialog`); Advanced/Security reuse the existing section composables. A new
`Screen.ConnectionDetail` route (`settings/connections/{connectionId}`) is wired in the
NavHost. Non-active connections show an Overview-only "Switch to this connection" preview.
Relay sessions are surfaced in the Security tab (`ActiveCardSecurityPosture` already shows
the active-session count). The `Active` badge is preserved on both the list card and the
detail's top bar.
**Verification.** `:app:compileSideloadDebugKotlin` BUILD SUCCESSFUL; `:app:lintSideloadDebug`
run locally. Store screenshot mock (`StoreScreenshotTest.ConnectionsScene`) updated to the
new list design (Active badge kept). On-device verification (banner animation/dismissal,
resume copy, the tabbed flow) is owner-driven from Android Studio.
## 2026-06-30 — Phone home channel: auto-config + dashboard name (silence upstream /sethome nudge)
**Why.** Sending into a phone Thread surfaced an upstream onboarding notice on every new Thread's first message — "📬 No home channel is set for Phone … /sethome". Upstream's nudge (`gateway/run.py`) checks the `PHONE_HOME_CHANNEL` *env var* directly, not the adapter's seeded config, and a single paired phone has exactly one logical home — so the prompt is friction with no decision behind it (unlike Telegram/Discord, where `/sethome` picks among many chats).
+22
View File
@@ -6,6 +6,26 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
---
## Connections UI / status banner (2026-06-30 restructure follow-ups)
The Connections screen was split into a scannable list + a tabbed detail screen
(Overview / Routes / Advanced / Security), and the non-error status banner became
take-space + animated + dismissible (see DEVLOG 2026-06-30). Deferred:
- **Resume suppression covers only the handoff path.** `recordConnectionHandoff`'s
reconnect is now withheld during the foreground-resume grace window, but the
*health* producer (`buildGlobalConnectionStatus` → "Checking Hermes connection" /
"Connecting to Hermes") can still flash a brief banner on resume. Copy is
accurate, so left as-is; if it reads as noisy, extend the same just-resumed
guard to the health snapshots.
- **Non-active connection detail is Overview-only.** Routes/Advanced/Security tabs
appear only for the active connection (they read the single active-connection VM
state); a non-active connection shows a "Switch to this connection" CTA. A future
read-only preview of a non-active connection's saved routes could be nice.
- **Store screenshot regeneration.** The `07_connections` scene mock was updated to
the new list design; confirm the regenerated PNG + Play-graphics export at
release-prep (only auto-publishes on a `main` release merge).
## Phone as a Hermes platform (proactive agent → phone)
Phase 1 (end-to-end spine) shipped on `Codename-11/phone-platform` — `send_message target=phone` → loopback `/phone/message` → relay `ProactiveChannel` → phone WSS → system notification, gated off by default (`PHONE_ENABLED` server-side + "Let Hermes message me" app-side + pairing). Remaining:
@@ -273,6 +293,8 @@ Things to look into:
- **Skill distribution as separate from plugin distribution** — right now skills ride along with the plugin install via `external_dirs`. Should skills be installable independently (e.g. `hermes skill install <git-url>`)? Would that fragment maintenance or improve reuse?
- **Tool registration discoverability** — `android_*` tools register at gateway import time. There's no canonical "list installed plugin tools" API. Would adding one to upstream make sense, or is `gateway tool list` already enough?
- **Versioning + compatibility ranges** — `pip install -e` doesn't enforce version pins between hermes-agent and our plugin. A breaking change in upstream's plugin loader could silently break us. Do we need a `hermes_compat: ">=0.8.0,<1.0.0"` field somewhere?
- **Update discovery (shipped 2026-06-30) + app-side follow-up.** `hermes relay update-check` + a dashboard "Plugin version" card now compare the installed plugin against the latest `plugin-v*` release and surface the right update command (`hermes plugins update hermes-relay` vs `hermes-relay-update`). **Remaining (app-side, needs an app build):** an About/Settings version readout showing the app version + the connected-relay version (already read from `/health` as `RelayHealth.version`), plus a soft, dismissible "relay is older than this app" nudge. That needs a relay-side `GET /relay/update-check` route the app can poll on `:8767` — the dashboard route is dashboard-auth-gated and not reachable from the app. Keep it capability-first: prefer "this feature needs a newer relay" at the point of use over a global version nag.
- **Per-profile enablement (shipped 2026-06-30).** `hermes relay profiles list|enable [--all|NAME]` + `plugin/profiles.py` resolve the install-once/enable-per-profile papercut; docs now cover the pair-once/one-relay model. Possible follow-up: an `install.sh` / `hermes plugins install` prompt offering "enable for all existing profiles" so new installs don't need the manual `profiles enable --all`.
- `**hermes-relay-self-setup` SKILL.md as a precedent** — we just shipped a self-installing skill that an LLM can fetch from a raw GitHub URL and execute. Does this pattern generalize? Could it become a recommended way for any third-party Hermes project to ship setup automation?
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla upstream. This is intentional but feels like a hack. Upstream PR #8556 (`feat/session-api`) will eventually let us delete it — verified 2026-04-15 that its scope covers the full bootstrap surface (sessions, memory, skills, config, available-models). Track that PR's status periodically.
- **Gateway slash-command preprocessor — upstream Stage 1 PR.** Sibling follow-up to #8556. Intercepts known gateway commands on `/v1/runs` + `/v1/chat/completions`, dispatches the stateless ones (`/help`, `/commands`) via `gateway_help_lines()`, returns a deterministic "use a channel with session state" notice for the stateful majority. Currently being prepared in `C:/Users/Bailey/Desktop/Open-Projects/hermes-agent-pr-prep/` on branch `feat/api-server-gateway-commands`; awaiting subagent's code + draft PR body before pushing. See `docs/upstream-contributions.md` §5.
+123
View File
@@ -251,6 +251,44 @@ def register_relay_cli(subparser) -> None:
insecure.add_argument("--json", action="store_true", help="Print raw JSON")
insecure.set_defaults(func=relay_insecure_api_key_command)
# ── profiles ──────────────────────────────────────────────────────────
# Plugin *code* installs once (global symlink); plugin *enablement* is
# per-profile (each profile's config.yaml plugins.enabled). This group
# lists and bulk-enables hermes-relay across profiles so a multi-agent
# user doesn't hand-edit N configs. Pairing is unaffected (one relay).
profiles = sub.add_parser(
"profiles",
help="List or manage which profiles enable the hermes-relay plugin",
)
profiles_sub = profiles.add_subparsers(dest="profiles_cmd")
profiles.set_defaults(func=relay_profiles_command)
prof_list = profiles_sub.add_parser("list", help="Show hermes-relay enablement per profile")
prof_list.add_argument("--json", action="store_true", help="Emit JSON")
prof_list.set_defaults(func=relay_profiles_command)
prof_enable = profiles_sub.add_parser(
"enable", help="Enable hermes-relay in profile configs (default: all profiles)"
)
prof_enable.add_argument("names", nargs="*", help="Profile names to enable (default: all)")
prof_enable.add_argument(
"--all", action="store_true", help="Enable in the default config + every profile"
)
prof_enable.add_argument(
"--dry-run", action="store_true", help="Show what would change without writing"
)
prof_enable.add_argument("--json", action="store_true", help="Emit JSON")
prof_enable.set_defaults(func=relay_profiles_command)
# ── update-check ──────────────────────────────────────────────────────
update = sub.add_parser(
"update-check",
help="Check whether a newer hermes-relay plugin release is available",
)
update.add_argument("--json", action="store_true", help="Emit JSON")
update.add_argument("--timeout", type=float, default=4.0, help="GitHub fetch timeout (s)")
update.set_defaults(func=relay_update_check_command)
def relay_command(args):
"""Dispatch `hermes relay` subcommands when the host CLI calls one handler."""
@@ -278,6 +316,91 @@ def relay_compat_command(args) -> None:
raise SystemExit(code)
def relay_profiles_command(args) -> None:
"""List or bulk-enable per-profile hermes-relay enablement."""
from . import profiles as profmod
configs = profmod.discover_profile_configs()
if not configs:
print(f"No Hermes config files found under {profmod.hermes_home()}")
return
cmd = getattr(args, "profiles_cmd", None)
if cmd == "enable":
requested = [n.lower() for n in getattr(args, "names", []) or []]
want_all = getattr(args, "all", False) or not requested
dry = getattr(args, "dry_run", False)
results = []
for label, path in configs:
keys = {label.lower(), label.strip("()").lower()}
if not want_all and keys.isdisjoint(requested):
continue
before = profmod.relay_state(path)
changed = profmod.enable_relay(path, dry_run=dry)
results.append(
{"profile": label, "path": str(path), "was": before, "changed": changed}
)
if getattr(args, "json", False):
print(json.dumps({"action": "enable", "dry_run": dry, "results": results}, indent=2))
return
verb = "Would enable" if dry else "Enabled"
any_changed = False
for r in results:
if r["changed"]:
any_changed = True
print(f" {verb} in {r['profile']} ({r['path']})")
else:
print(f" already enabled in {r['profile']}")
if not any_changed:
print("hermes-relay is already enabled everywhere — nothing to do.")
elif not dry:
print(
"\nRestart the affected gateway(s) to load it: "
"systemctl --user restart hermes-gateway"
)
return
# default / "list"
states = [(label, str(path), profmod.relay_state(path)) for label, path in configs]
if getattr(args, "json", False):
print(json.dumps(
{"profiles": [{"profile": l, "path": p, "state": s} for l, p, s in states]},
indent=2,
))
return
print("hermes-relay enablement by profile:\n")
width = max(len(l) for l, _, _ in states)
for label, _path, state in states:
mark = {"enabled": "✓", "disabled": "✗", "absent": "·"}.get(state, "?")
print(f" {mark} {label.ljust(width)} {state}")
if any(s != "enabled" for _, _, s in states):
print("\nEnable everywhere with: hermes relay profiles enable --all")
def relay_update_check_command(args) -> None:
"""Report whether a newer hermes-relay plugin release is available."""
from . import update_check
result = update_check.check(timeout=getattr(args, "timeout", 4.0))
if getattr(args, "json", False):
print(json.dumps(result, indent=2))
return
cur = result.get("current")
if result.get("error"):
print(f"hermes-relay {cur} — could not check for updates ({result['error']})")
return
latest = result.get("latest")
if result.get("update_available"):
print(f"Update available: {cur} → {latest}")
print(f" Run: {result.get('update_command')}")
else:
suffix = f" (latest: {latest})" if latest else ""
print(f"hermes-relay {cur} is up to date{suffix}.")
def relay_start_command(args) -> None:
"""Run the relay server in the foreground.
+8 -8
View File
File diff suppressed because one or more lines are too long
+46
View File
@@ -222,6 +222,52 @@ async def get_phone_config() -> dict[str, Any]:
}
# Update-check cache — a GitHub round-trip per dashboard poll would be wasteful
# and risks rate-limiting. Cache the resolved latest tag for an hour; the
# current/compare/command are recomputed cheaply each call.
_UPDATE_CACHE: dict[str, Any] = {"latest": None, "fetched_at": 0.0, "error": None}
_UPDATE_CACHE_TTL: float = 3600.0
@router.get("/update-check")
async def get_update_check(refresh: Optional[bool] = Query(default=False)) -> dict[str, Any]:
"""Report whether a newer hermes-relay plugin release is available.
Compares the installed ``plugin.relay.__version__`` against the latest
``plugin-v*`` GitHub release. The GitHub fetch is cached for an hour
(``?refresh=true`` forces it) so a polling dashboard doesn't hammer the
releases API. Network failures degrade to ``update_available=false`` with
an ``error`` string — never a 5xx — so the card can show "couldn't check".
"""
from plugin import update_check
now = time.time()
stale = (now - _UPDATE_CACHE["fetched_at"]) > _UPDATE_CACHE_TTL
if refresh or stale or _UPDATE_CACHE["fetched_at"] == 0.0:
latest, error = None, None
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
resp = await client.get(
update_check.GITHUB_RELEASES_URL,
headers={
"Accept": "application/vnd.github+json",
"User-Agent": "hermes-relay-update-check",
},
)
if resp.status_code == 200:
latest = update_check.pick_latest_plugin_tag(resp.json())
else:
error = f"GitHub returned {resp.status_code}"
except Exception as exc: # network down, rate-limited, bad JSON
error = str(exc)
_UPDATE_CACHE.update(latest=latest, error=error, fetched_at=now)
result = update_check.build_result(update_check.current_version(), _UPDATE_CACHE["latest"])
result["error"] = _UPDATE_CACHE["error"]
result["checked_at"] = int(_UPDATE_CACHE["fetched_at"])
return result
@router.get("/push")
async def get_push() -> dict[str, Any]:
"""Push console stub — no network call until FCM lands."""
+4
View File
@@ -52,6 +52,10 @@ export function getPhoneConfig() {
return fetchJSON("/phone/config");
}
export function getUpdateCheck({ refresh = false } = {}) {
return fetchJSON(`/update-check${refresh ? "?refresh=true" : ""}`);
}
export function putEnvSetting(key, value) {
return fetchHostJSON("/api/env", {
method: "PUT",
@@ -7,6 +7,7 @@ import {
getOverview,
getPhoneConfig,
getSessions,
getUpdateCheck,
putEnvSetting,
revokeSession,
} from "../lib/api.js";
@@ -392,11 +393,83 @@ function HomeChannelCard({ config, onSaved }) {
);
}
function UpdateCheckCard({ info, onRefresh }) {
const [refreshing, setRefreshing] = useState(false);
const [copied, setCopied] = useState(false);
const doRefresh = useCallback(async () => {
setRefreshing(true);
try {
await onRefresh(true);
} finally {
setRefreshing(false);
}
}, [onRefresh]);
const cmd = info && info.update_command;
const copyCmd = useCallback(async () => {
if (!cmd) return;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(cmd);
} else {
window.prompt("Copy update command", cmd);
}
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch (_err) {
window.prompt("Copy update command", cmd);
}
}, [cmd]);
if (!info) return null;
const current = info.current || "—";
const available = !!info.update_available;
const description = available
? `Update available — you're on ${current}.`
: info.error
? `On ${current}. Couldn't reach GitHub to check.`
: `On ${current}${info.latest ? ` — latest is ${info.latest}.` : "."}`;
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardTitle>Plugin version</CardTitle>
<CardDescription>{description}</CardDescription>
</div>
<Button size="sm" variant="outline" onClick={doRefresh} disabled={refreshing}>
{refreshing ? "Checking…" : "Check"}
</Button>
</CardHeader>
{available ? (
<CardContent className="space-y-2">
<Badge variant="secondary" className="w-fit text-xs">
{current} → {info.latest}
</Badge>
<div className="flex items-center justify-between gap-2 rounded-md border border-border/70 bg-muted/30 p-2 font-mono text-xs">
<span className="truncate">{cmd}</span>
<Button size="sm" variant="ghost" onClick={copyCmd}>
{copied ? "Copied" : "Copy"}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Run it on your Hermes host, then restart the gateway to load the new plugin.
</p>
</CardContent>
) : info.error ? (
<CardContent className="pt-0 text-xs text-muted-foreground">{info.error}</CardContent>
) : null}
</Card>
);
}
export default function RelayManagement({ autoRefresh }) {
const [overview, setOverview] = useState(null);
const [sessions, setSessions] = useState(null);
const [agentContext, setAgentContext] = useState(null);
const [phoneConfig, setPhoneConfig] = useState(null);
const [updateInfo, setUpdateInfo] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [pairOpen, setPairOpen] = useState(false);
@@ -430,6 +503,21 @@ export default function RelayManagement({ autoRefresh }) {
load();
}, [load]);
// Update check runs independently of the main load — a GitHub round-trip
// shouldn't block (or fail) the management tab. Cached server-side for an
// hour; the "Check" button forces a refresh.
const loadUpdate = useCallback(async (refresh = false) => {
try {
setUpdateInfo(await getUpdateCheck({ refresh }));
} catch (err) {
setUpdateInfo({ error: err && err.message ? err.message : String(err) });
}
}, []);
useEffect(() => {
loadUpdate(false);
}, [loadUpdate]);
useEffect(() => {
if (!autoRefresh) return undefined;
const id = setInterval(load, 10000);
@@ -521,6 +609,8 @@ export default function RelayManagement({ autoRefresh }) {
/>
</div>
<UpdateCheckCard info={updateInfo} onRefresh={loadUpdate} />
<AgentContextCard
data={agentContext}
saving={contextSaving}
+41
View File
@@ -361,5 +361,46 @@ class PhoneConfigTests(PluginApiTestCase):
self.assertEqual(body["home_channel_id"], "phone")
class UpdateCheckTests(PluginApiTestCase):
"""``GET /update-check`` compares installed vs latest GitHub plugin release."""
def setUp(self) -> None:
super().setUp()
# Reset the module cache so every test triggers a (mocked) fetch.
plugin_api._UPDATE_CACHE.update(latest=None, fetched_at=0.0, error=None)
def test_update_available(self) -> None:
def handler(_req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=[{"tag_name": "plugin-v99.0.0"}])
_install_mock_transport(self, handler)
body = self.client.get("/update-check").json()
self.assertTrue(body["update_available"])
self.assertEqual(body["latest"], "99.0.0")
self.assertIn("hermes", body["update_command"])
def test_up_to_date(self) -> None:
from plugin import update_check
cur = update_check.current_version()
def handler(_req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=[{"tag_name": f"plugin-v{cur}"}])
_install_mock_transport(self, handler)
body = self.client.get("/update-check").json()
self.assertFalse(body["update_available"])
self.assertEqual(body["latest"], cur)
def test_github_error_degrades_softly(self) -> None:
def handler(_req: httpx.Request) -> httpx.Response:
return httpx.Response(503, text="nope")
_install_mock_transport(self, handler)
body = self.client.get("/update-check").json()
self.assertFalse(body["update_available"])
self.assertIsNotNone(body["error"])
if __name__ == "__main__": # pragma: no cover
unittest.main()
+122
View File
@@ -0,0 +1,122 @@
"""Per-profile plugin-enablement helpers.
Hermes installs a plugin's *code* once — a single global symlink at
``~/.hermes/plugins/<name>`` — but *enables* it **per profile**: the root
``~/.hermes/config.yaml`` and every ``~/.hermes/profiles/<name>/config.yaml``
carry their own ``plugins.enabled`` / ``plugins.disabled`` lists. So a
multi-agent user who wants the relay's tools (and proactive phone messaging)
available to *every* agent otherwise hand-edits N config files.
This module enumerates those configs and toggles ``hermes-relay`` in them. It
backs every config it rewrites (``.bak`` next to the original) because
``yaml.safe_dump`` does not preserve comments or key order — the same tradeoff
``install.sh`` already makes for the skills ``external_dirs`` edit. Configs that
already have the plugin enabled are left untouched (no needless rewrite).
Pairing is **not** affected by any of this: the relay is a single shared
service, so a device pairs once regardless of how many profiles exist.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import List, Optional, Tuple
PLUGIN_NAME = "hermes-relay"
# Enablement state of the plugin within one config file.
STATE_ENABLED = "enabled"
STATE_DISABLED = "disabled"
STATE_ABSENT = "absent" # neither list mentions it — inherits nothing explicit
def hermes_home(explicit: Optional[str] = None) -> Path:
"""Resolve the Hermes home dir (``$HERMES_HOME`` or ``~/.hermes``)."""
return Path(explicit or os.environ.get("HERMES_HOME") or (Path.home() / ".hermes"))
def discover_profile_configs(home: Optional[str] = None) -> List[Tuple[str, Path]]:
"""Return ``[(label, config_path)]`` for the default config + each profile.
The root ``config.yaml`` is labelled ``"(default)"``; each
``profiles/<name>/config.yaml`` is labelled by ``<name>``. Only files that
actually exist are returned, sorted with the default first then profiles
alphabetically.
"""
base = hermes_home(home)
out: List[Tuple[str, Path]] = []
root = base / "config.yaml"
if root.is_file():
out.append(("(default)", root))
pdir = base / "profiles"
if pdir.is_dir():
for child in sorted(pdir.iterdir(), key=lambda p: p.name):
cfg = child / "config.yaml"
if cfg.is_file():
out.append((child.name, cfg))
return out
def _load(path: Path) -> dict:
"""Parse a YAML config to a dict; malformed / non-dict → ``{}``."""
import yaml
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except Exception:
return {}
return data if isinstance(data, dict) else {}
def relay_state(path: Path, plugin: str = PLUGIN_NAME) -> str:
"""Return ``enabled`` / ``disabled`` / ``absent`` for *plugin* in *path*."""
plugins = _load(path).get("plugins")
if not isinstance(plugins, dict):
return STATE_ABSENT
enabled = plugins.get("enabled")
disabled = plugins.get("disabled")
if isinstance(enabled, list) and plugin in enabled:
return STATE_ENABLED
if isinstance(disabled, list) and plugin in disabled:
return STATE_DISABLED
return STATE_ABSENT
def enable_relay(path: Path, plugin: str = PLUGIN_NAME, *, dry_run: bool = False) -> bool:
"""Ensure *plugin* is in ``plugins.enabled`` (and not ``plugins.disabled``).
Returns ``True`` when a change was needed (and applied, unless ``dry_run``).
A no-op when already enabled — the file is not rewritten, so comments and
ordering survive for the common case. When a write *is* needed, the original
is copied to ``<path>.bak`` first.
"""
import yaml
data = _load(path)
plugins = data.get("plugins")
if not isinstance(plugins, dict):
plugins = {}
data["plugins"] = plugins
enabled = plugins.get("enabled")
if not isinstance(enabled, list):
enabled = []
plugins["enabled"] = enabled
disabled = plugins.get("disabled")
changed = False
if isinstance(disabled, list) and plugin in disabled:
if not dry_run:
disabled.remove(plugin)
changed = True
if plugin not in enabled:
if not dry_run:
enabled.append(plugin)
changed = True
if changed and not dry_run:
backup = path.with_suffix(path.suffix + ".bak")
backup.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
return changed
+105
View File
@@ -0,0 +1,105 @@
"""Tests for per-profile plugin-enablement helpers (``plugin/profiles.py``).
Runs under plain ``unittest``. Skips cleanly when pyyaml is absent from the
interpreter (the live hermes venv always has it).
python -m unittest plugin.tests.test_profiles
"""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
try:
import yaml
except ImportError: # pragma: no cover - guarded by skip
yaml = None
from plugin import profiles
def _write(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
@unittest.skipIf(yaml is None, "pyyaml not installed")
class DiscoverTests(unittest.TestCase):
def test_discovers_root_and_profiles(self) -> None:
with tempfile.TemporaryDirectory() as d:
home = Path(d)
_write(home / "config.yaml", {"plugins": {"enabled": ["hermes-relay"]}})
_write(home / "profiles" / "gary" / "config.yaml", {"plugins": {"enabled": []}})
_write(home / "profiles" / "lucy" / "config.yaml", {"plugins": {}})
configs = profiles.discover_profile_configs(str(home))
labels = [label for label, _ in configs]
self.assertEqual(labels[0], "(default)") # default first
self.assertIn("gary", labels)
self.assertIn("lucy", labels)
def test_missing_home_is_empty(self) -> None:
with tempfile.TemporaryDirectory() as d:
self.assertEqual(profiles.discover_profile_configs(str(Path(d) / "nope")), [])
@unittest.skipIf(yaml is None, "pyyaml not installed")
class StateTests(unittest.TestCase):
def test_states(self) -> None:
with tempfile.TemporaryDirectory() as d:
home = Path(d)
en = home / "a.yaml"
_write(en, {"plugins": {"enabled": ["hermes-relay"]}})
dis = home / "b.yaml"
_write(dis, {"plugins": {"disabled": ["hermes-relay"]}})
ab = home / "c.yaml"
_write(ab, {"plugins": {"enabled": ["other"]}})
none = home / "d.yaml"
_write(none, {"model": "x"})
self.assertEqual(profiles.relay_state(en), "enabled")
self.assertEqual(profiles.relay_state(dis), "disabled")
self.assertEqual(profiles.relay_state(ab), "absent")
self.assertEqual(profiles.relay_state(none), "absent")
@unittest.skipIf(yaml is None, "pyyaml not installed")
class EnableTests(unittest.TestCase):
def test_enable_adds_and_backs_up(self) -> None:
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "config.yaml"
_write(p, {"plugins": {"enabled": ["other"], "disabled": ["hermes-relay"]}})
self.assertTrue(profiles.enable_relay(p))
data = yaml.safe_load(p.read_text(encoding="utf-8"))
self.assertIn("hermes-relay", data["plugins"]["enabled"])
self.assertNotIn("hermes-relay", data["plugins"].get("disabled", []))
self.assertTrue(p.with_suffix(".yaml.bak").exists()) # backed up
def test_enable_idempotent_preserves_file(self) -> None:
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "config.yaml"
original = "plugins:\n enabled:\n - hermes-relay # keep me\n"
p.write_text(original, encoding="utf-8")
self.assertFalse(profiles.enable_relay(p)) # already enabled → no change
self.assertEqual(p.read_text(encoding="utf-8"), original) # comment intact
self.assertFalse(p.with_suffix(".yaml.bak").exists()) # no needless rewrite
def test_dry_run_does_not_write(self) -> None:
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "config.yaml"
_write(p, {"plugins": {"enabled": []}})
before = p.read_text(encoding="utf-8")
self.assertTrue(profiles.enable_relay(p, dry_run=True)) # would change
self.assertEqual(p.read_text(encoding="utf-8"), before) # but didn't
def test_creates_plugins_section_when_absent(self) -> None:
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "config.yaml"
_write(p, {"model": "x"})
self.assertTrue(profiles.enable_relay(p))
data = yaml.safe_load(p.read_text(encoding="utf-8"))
self.assertIn("hermes-relay", data["plugins"]["enabled"])
if __name__ == "__main__":
unittest.main()
+84
View File
@@ -0,0 +1,84 @@
"""Tests for update-discovery helpers (``plugin/update_check.py``).
Pure helpers only — no network. Run with::
python -m unittest plugin.tests.test_update_check
"""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from plugin import update_check as uc
class SemverTests(unittest.TestCase):
def test_parse(self) -> None:
self.assertEqual(uc.parse_semver("1.2.3"), (1, 2, 3))
self.assertEqual(uc.parse_semver("plugin-v1.2.0"), (1, 2, 0))
self.assertEqual(uc.parse_semver("v2.0.1-rc1"), (2, 0, 1))
self.assertIsNone(uc.parse_semver("nope"))
self.assertIsNone(uc.parse_semver(""))
def test_compare(self) -> None:
self.assertEqual(uc.compare_versions("1.2.1", "1.3.0"), -1)
self.assertEqual(uc.compare_versions("1.3.0", "1.2.1"), 1)
self.assertEqual(uc.compare_versions("1.2.1", "1.2.1"), 0)
# Unparseable → 0 so a bad tag never spuriously nags.
self.assertEqual(uc.compare_versions("bad", "1.2.1"), 0)
class TagPickTests(unittest.TestCase):
def test_picks_highest_plugin_tag(self) -> None:
releases = [
{"tag_name": "plugin-v1.2.0"},
{"tag_name": "android-v1.5.0"}, # different track — ignored
{"tag_name": "plugin-v1.3.0"},
{"tag_name": "cli-v0.4.0"},
{"tag_name": "plugin-v1.9.0", "draft": True}, # draft — ignored
]
self.assertEqual(uc.pick_latest_plugin_tag(releases), "1.3.0")
def test_no_plugin_releases(self) -> None:
self.assertIsNone(uc.pick_latest_plugin_tag([{"tag_name": "android-v1.0.0"}]))
self.assertIsNone(uc.pick_latest_plugin_tag("not-a-list"))
self.assertIsNone(uc.pick_latest_plugin_tag([]))
class CommandDetectTests(unittest.TestCase):
def test_full_relay_shim_present(self) -> None:
with tempfile.TemporaryDirectory() as d:
shim = Path(d) / ".local" / "bin" / "hermes-relay-update"
shim.parent.mkdir(parents=True)
shim.write_text("#!/bin/sh\n")
self.assertEqual(uc.detect_update_command(home=d), "hermes-relay-update")
def test_native_when_no_shim(self) -> None:
with tempfile.TemporaryDirectory() as d:
self.assertEqual(
uc.detect_update_command(home=d), "hermes plugins update hermes-relay"
)
class BuildResultTests(unittest.TestCase):
def test_update_available(self) -> None:
with tempfile.TemporaryDirectory() as d:
r = uc.build_result("1.2.1", "1.3.0", home=d)
self.assertTrue(r["update_available"])
self.assertEqual(r["update_command"], "hermes plugins update hermes-relay")
def test_up_to_date(self) -> None:
r = uc.build_result("1.2.1", "1.2.1")
self.assertFalse(r["update_available"])
self.assertIsNone(r["update_command"])
def test_no_latest(self) -> None:
r = uc.build_result("1.2.1", None)
self.assertFalse(r["update_available"])
self.assertIsNone(r["update_command"])
if __name__ == "__main__":
unittest.main()
+153
View File
@@ -0,0 +1,153 @@
"""Update discovery for the relay plugin.
Hermes already ships the update *mechanism* — ``hermes plugins update
hermes-relay`` for native plugin installs, ``hermes-relay-update`` for the
full-relay installer. What was missing is *discovery*: telling the operator a
newer release exists. This module compares the installed version against the
latest ``plugin-v*`` GitHub release and picks the right update command for how
this host was installed.
Pure helpers (version parsing/compare, tag selection, command detection) carry
no network or framework dependency so they unit-test in isolation; ``check()``
adds a synchronous GitHub fetch for the CLI. The dashboard reuses the same pure
helpers with its own async ``httpx`` fetch.
"""
from __future__ import annotations
import json
import os
import re
import urllib.request
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
GITHUB_RELEASES_URL = "https://api.github.com/repos/Codename-11/hermes-relay/releases"
PLUGIN_TAG_PREFIX = "plugin-v"
# Native-plugin update command vs. full-relay installer shim. Detected per host.
_NATIVE_UPDATE_CMD = "hermes plugins update hermes-relay"
_FULL_RELAY_UPDATE_CMD = "hermes-relay-update"
def current_version() -> str:
"""The installed relay/plugin version (``plugin.relay.__version__``)."""
try:
from .relay import __version__
return str(__version__)
except Exception:
return "0.0.0"
_SEMVER_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)")
def parse_semver(value: str) -> Optional[Tuple[int, int, int]]:
"""Extract ``(major, minor, patch)`` from a version-ish string.
Tolerates a leading ``v`` / ``plugin-v`` prefix and any pre-release suffix
(``1.2.0-rc1`` → ``(1, 2, 0)``). Returns ``None`` when no ``X.Y.Z`` core is
present.
"""
if not value:
return None
m = _SEMVER_RE.search(value)
if not m:
return None
return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
def compare_versions(current: str, latest: str) -> int:
"""Return ``-1`` if current < latest, ``0`` if equal/unknown, ``1`` if newer.
Unparseable inputs compare as equal (``0``) so a bad tag never spuriously
nags the user to "update".
"""
cur = parse_semver(current)
lat = parse_semver(latest)
if cur is None or lat is None:
return 0
if cur < lat:
return -1
if cur > lat:
return 1
return 0
def pick_latest_plugin_tag(releases: Any) -> Optional[str]:
"""Pick the highest ``plugin-v*`` version from a GitHub releases payload.
``releases`` is the decoded JSON list from the GitHub releases API. Drafts
are ignored; pre-releases are considered (the plugin ships ``-alpha``/``-rc``
tags). Returns the bare version (``"1.3.0"``), or ``None`` when no plugin
release is present.
"""
if not isinstance(releases, list):
return None
best: Optional[Tuple[int, int, int]] = None
best_name: Optional[str] = None
for rel in releases:
if not isinstance(rel, dict) or rel.get("draft"):
continue
tag = rel.get("tag_name") or ""
if not isinstance(tag, str) or not tag.startswith(PLUGIN_TAG_PREFIX):
continue
ver = parse_semver(tag)
if ver is None:
continue
if best is None or ver > best:
best = ver
best_name = ".".join(str(p) for p in ver)
return best_name
def detect_update_command(home: Optional[str] = None) -> str:
"""Pick the update command for how this host installed the relay.
Presence of the ``hermes-relay-update`` shim (dropped only by the
full-relay ``install.sh``) means the installer path; otherwise the host
used the native ``hermes plugins install`` path. Honors ``$HOME`` so the
check works under test isolation.
"""
home_dir = Path(home or os.environ.get("HOME") or Path.home())
shim = home_dir / ".local" / "bin" / "hermes-relay-update"
return _FULL_RELAY_UPDATE_CMD if shim.exists() else _NATIVE_UPDATE_CMD
def build_result(current: str, latest: Optional[str], *, home: Optional[str] = None) -> Dict[str, Any]:
"""Assemble the structured update-check result from resolved versions."""
update_available = bool(latest) and compare_versions(current, latest) < 0
return {
"current": current,
"latest": latest,
"update_available": update_available,
"update_command": detect_update_command(home) if update_available else None,
}
def _fetch_releases_sync(timeout: float) -> List[Any]:
"""Fetch + decode the GitHub releases list synchronously (CLI path)."""
req = urllib.request.Request(
GITHUB_RELEASES_URL,
headers={"Accept": "application/vnd.github+json", "User-Agent": "hermes-relay-update-check"},
)
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 - fixed https URL
return json.loads(resp.read().decode("utf-8"))
def check(timeout: float = 4.0, home: Optional[str] = None) -> Dict[str, Any]:
"""Synchronous update check for the CLI.
Network/parse failures are returned as ``{"error": ...}`` alongside the
known current version — never raised — so ``hermes relay update-check`` is
safe to run offline.
"""
cur = current_version()
try:
releases = _fetch_releases_sync(timeout)
latest = pick_latest_plugin_tag(releases)
except Exception as exc: # network down, rate-limited, bad JSON, etc.
return {"current": cur, "latest": None, "update_available": False,
"update_command": None, "error": str(exc)}
return build_result(cur, latest, home=home)
+33
View File
@@ -214,6 +214,39 @@ PHONE_ENABLED=1
Name changes apply after the next gateway restart. The underlying channel id stays fixed (`phone`) so existing Threads are never orphaned.
### Profiles & the relay
If you run multiple Hermes **profiles** (agents), two things are easy to conflate:
- **You pair once.** The relay is a single shared service (`:8767`) with one pairing store. A device pairs with the relay, not with a profile — chat, bridge, terminal, and voice all ride that one relay regardless of which agent you talk to.
- **The plugin is installed once, enabled per profile.** The plugin *code* lives at a single global path (`~/.hermes/plugins/hermes-relay`). But each profile's `config.yaml` has its own `plugins.enabled` list, so an agent only sees the relay's tools (and can proactively message the phone) if *its* profile enables `hermes-relay`.
So a multi-agent host never re-pairs, but may want the plugin enabled for every agent. Check and fix that in one step:
```bash
hermes relay profiles list # show enablement per profile
hermes relay profiles enable --all # enable hermes-relay in every profile
hermes relay profiles enable gary # …or just one profile
```
Each edited config is backed up to `<config>.yaml.bak`. Restart the affected gateway afterward (`systemctl --user restart hermes-gateway`) to load the plugin.
### Keeping the relay plugin updated
The app, the relay plugin, and the desktop CLI version **independently** — they do not need to match. To see whether a newer plugin release exists:
```bash
hermes relay update-check # compares your version to the latest plugin release
```
The dashboard's **Relay → Management** tab shows the same as a "Plugin version" card with a **Check** button. When an update is available, apply it with whichever matches your install, then restart the gateway:
```bash
hermes plugins update hermes-relay # native plugin install
hermes-relay-update # full-relay installer (install.sh)
systemctl --user restart hermes-gateway
```
### Compatibility Hook
The legacy `hermes_relay_bootstrap.pth` hook is optional. It fills route gaps for