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:
co-authored by
Claude Opus 4.8
parent
8308fb87f3
commit
efa51dcb55
@@ -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).
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Vendored
+8
-8
File diff suppressed because one or more lines are too long
@@ -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."""
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user