diff --git a/dockerfiles/hermes-webui-router.js b/dockerfiles/hermes-webui-router.js
index 94a28c12d..471c049cf 100644
--- a/dockerfiles/hermes-webui-router.js
+++ b/dockerfiles/hermes-webui-router.js
@@ -14,6 +14,7 @@
'atlas/manual/codex/terra':'Codex · Terra',
'atlas/manual/codex/sol':'Codex · SOL',
'atlas/manual/claude/haiku':'Claude · Haiku',
+ 'atlas/manual/claude/fable':'Claude · Fable',
'atlas/manual/claude/sonnet':'Claude · Sonnet',
'atlas/manual/claude/opus':'Claude · Opus',
'atlas/manual/local/qwen-14b':'Local · Qwen 14B'
diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml
index 0e6b2687e..a860a5700 100644
--- a/services/hermes/agent-deployment.yaml
+++ b/services/hermes/agent-deployment.yaml
@@ -25,7 +25,7 @@ spec:
ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers
ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback
ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available
- ai.bstein.dev/config-rev: "20260812-stream-recovery"
+ ai.bstein.dev/config-rev: "20260812-native-claude-subscription"
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: hermes-agent
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens
@@ -103,6 +103,7 @@ spec:
/opt/data/home/.kube \
/opt/data/cli-lanes \
/opt/data/logs \
+ /opt/data/provider-health \
/opt/data/tools/bin \
/opt/data/workspace/coordinator \
/opt/data/workspace/projects \
@@ -153,6 +154,7 @@ spec:
/opt/data/home/.kube \
/opt/data/cli-lanes \
/opt/data/logs \
+ /opt/data/provider-health \
/opt/data/tools \
/opt/data/tools/bin \
/opt/data/workspace \
@@ -273,6 +275,29 @@ spec:
resources:
requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 100m, memory: 128Mi}
+ - name: patch-api-server-sessions
+ image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
+ imagePullPolicy: IfNotPresent
+ command:
+ - /opt/hermes/.venv/bin/python
+ - /opt/coordinator/patch_api_server_sessions.py
+ - /opt/hermes/gateway/platforms/api_server.py
+ - /patched/api_server.py
+ securityContext:
+ allowPrivilegeEscalation: false
+ runAsUser: 10000
+ runAsGroup: 10000
+ seccompProfile:
+ type: RuntimeDefault
+ volumeMounts:
+ - name: coordinator
+ mountPath: /opt/coordinator
+ readOnly: true
+ - name: api-server-patch
+ mountPath: /patched
+ resources:
+ requests: {cpu: 25m, memory: 64Mi}
+ limits: {cpu: 100m, memory: 128Mi}
- name: patch-codex-runtime
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent
@@ -350,6 +375,7 @@ spec:
set +a
/opt/hermes/.venv/bin/python /opt/coordinator/configure_agent_clients.py
/opt/hermes/.venv/bin/python /opt/coordinator/migrate_herdr_state.py
+ /opt/hermes/.venv/bin/python /opt/coordinator/migrate_api_session_lineage.py
# Client configuration restores the persisted Codex CLI login.
# Refresh routing afterwards so AUTO sees the app-server lane on
# the first request instead of waiting for the hourly steward.
@@ -445,6 +471,7 @@ spec:
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/conversation_loop.py, subPath: conversation_loop.py}
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/auxiliary_client.py, subPath: auxiliary_client.py}
- {name: tui-gateway-patch, mountPath: /opt/hermes/tui_gateway/server.py, subPath: server.py}
+ - {name: api-server-patch, mountPath: /opt/hermes/gateway/platforms/api_server.py, subPath: api_server.py}
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
- {name: tmp, mountPath: /tmp}
@@ -800,6 +827,60 @@ spec:
resources:
requests: {cpu: 50m, memory: 128Mi}
limits: {cpu: "1", memory: 1Gi}
+ - name: claude-broker
+ image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
+ imagePullPolicy: IfNotPresent
+ command: [/bin/sh, -ec]
+ args:
+ - |
+ set -a
+ . /opt/data/.env
+ set +a
+ unset ANTHROPIC_API_KEY CLAUDE_API_KEY
+ exec /opt/hermes/.venv/bin/python /opt/coordinator/claude_oauth_broker.py
+ ports:
+ - {name: claude-broker, containerPort: 9006, protocol: TCP}
+ env:
+ - {name: HERMES_HOME, value: /opt/data}
+ - {name: HERMES_AUTH_FILE, value: /shared-auth/auth.json}
+ - {name: HOME, value: /opt/data/home}
+ - {name: CODEX_HOME, value: /opt/data/home/.codex}
+ - {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude}
+ - {name: PYTHONPATH, value: /opt/hermes}
+ - {name: PATH, value: /opt/coordinator:/opt/data/tools/bin:/opt/data/home/.local/bin:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin}
+ - {name: HERMES_CLAUDE_BIN, value: /opt/coordinator/claude}
+ - {name: HERMES_CLAUDE_BROKER_PORT, value: "9006"}
+ - {name: HERMES_CLAUDE_BROKER_READ_TIMEOUT, value: "1800"}
+ - {name: HERMES_CLAUDE_HEALTH_PATH, value: /opt/data/provider-health/claude.json}
+ - {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
+ readinessProbe:
+ httpGet: {path: /health, port: claude-broker}
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ livenessProbe:
+ httpGet: {path: /health, port: claude-broker}
+ initialDelaySeconds: 30
+ periodSeconds: 30
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop: [ALL]
+ readOnlyRootFilesystem: true
+ runAsNonRoot: true
+ runAsUser: 10000
+ runAsGroup: 10000
+ seccompProfile:
+ type: RuntimeDefault
+ volumeMounts:
+ - {name: home, mountPath: /opt/data}
+ - {name: provider-auth, mountPath: /shared-auth}
+ - {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
+ - {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
+ - {name: tmp, mountPath: /tmp}
+ - {name: routing-catalog, mountPath: /routing-catalog, readOnly: true}
+ resources:
+ requests: {cpu: 100m, memory: 256Mi}
+ limits: {cpu: "3", memory: 3Gi}
volumes:
- name: home
persistentVolumeClaim:
@@ -825,6 +906,8 @@ spec:
emptyDir: {}
- name: tui-gateway-patch
emptyDir: {}
+ - name: api-server-patch
+ emptyDir: {}
- name: codex-runtime-patch
emptyDir: {}
- name: auto-router-plugin
diff --git a/services/hermes/chat-configmap.yaml b/services/hermes/chat-configmap.yaml
index e96ed2edf..75f2ae524 100644
--- a/services/hermes/chat-configmap.yaml
+++ b/services/hermes/chat-configmap.yaml
@@ -63,6 +63,7 @@ data:
atlas/manual/codex/terra: {provider: atlas-switchyard, model: atlas/manual/codex/terra}
atlas/manual/codex/sol: {provider: atlas-switchyard, model: atlas/manual/codex/sol}
atlas/manual/claude/haiku: {provider: atlas-switchyard, model: atlas/manual/claude/haiku}
+ atlas/manual/claude/fable: {provider: atlas-switchyard, model: atlas/manual/claude/fable}
atlas/manual/claude/sonnet: {provider: atlas-switchyard, model: atlas/manual/claude/sonnet}
atlas/manual/claude/opus: {provider: atlas-switchyard, model: atlas/manual/claude/opus}
atlas/manual/local/qwen-14b: {provider: atlas-switchyard, model: atlas/manual/local/qwen-14b}
diff --git a/services/hermes/chat-router.yaml b/services/hermes/chat-router.yaml
index 314892924..fa9a4c347 100644
--- a/services/hermes/chat-router.yaml
+++ b/services/hermes/chat-router.yaml
@@ -20,7 +20,7 @@ spec:
app: hermes-chat-router
annotations:
ai.bstein.dev/role: privacy-preserving-chat-tenant-router
- ai.bstein.dev/config-rev: "20260812-keycloak-image-continuation"
+ ai.bstein.dev/config-rev: "20260812-session-sidebar"
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/agent-pre-populate-only: "true"
vault.hashicorp.com/agent-init-first: "true"
@@ -62,7 +62,7 @@ spec:
values: [rpi5]
containers:
- name: router
- image: registry.bstein.dev/bstein/hermes-chat-router@sha256:72fbba10d108b1086e5620137f7549a838438ba510abb8cc8884bc592e7fd6bf
+ image: registry.bstein.dev/bstein/hermes-chat-router@sha256:4e318a35353772cf16e39b2038209abd8b774cd065f39d7a99b6dcc6e28e2474
imagePullPolicy: IfNotPresent
ports:
- {name: http, containerPort: 8080, protocol: TCP}
diff --git a/services/hermes/chat-statefulset.yaml b/services/hermes/chat-statefulset.yaml
index 63cd15396..ae3f13756 100644
--- a/services/hermes/chat-statefulset.yaml
+++ b/services/hermes/chat-statefulset.yaml
@@ -260,7 +260,7 @@ spec:
requests: {cpu: 250m, memory: 512Mi}
limits: {cpu: "1", memory: 2Gi}
- name: webui
- image: registry.bstein.dev/bstein/hermes-webui@sha256:fb06acc864509d9aa367d1d3635c82c383dc14458bc8db69a917e1ddf4f71f72
+ image: registry.bstein.dev/bstein/hermes-webui@sha256:9c2fe8341c7b650e08d10acead3151b19e2af737863268bafb39b3d9517575b1
imagePullPolicy: IfNotPresent
command: [/bin/sh, -ec]
args:
diff --git a/services/hermes/deployment.yaml b/services/hermes/deployment.yaml
index b6a326779..26e215e82 100644
--- a/services/hermes/deployment.yaml
+++ b/services/hermes/deployment.yaml
@@ -351,7 +351,7 @@ spec:
cpu: "2"
memory: 4Gi
- name: webui
- image: registry.bstein.dev/bstein/hermes-webui@sha256:fb06acc864509d9aa367d1d3635c82c383dc14458bc8db69a917e1ddf4f71f72
+ image: registry.bstein.dev/bstein/hermes-webui@sha256:9c2fe8341c7b650e08d10acead3151b19e2af737863268bafb39b3d9517575b1
imagePullPolicy: IfNotPresent
command: [/bin/sh, -ec]
args:
diff --git a/services/hermes/kustomization.yaml b/services/hermes/kustomization.yaml
index d6f4ecd86..ef06b792f 100644
--- a/services/hermes/kustomization.yaml
+++ b/services/hermes/kustomization.yaml
@@ -70,6 +70,8 @@ configMapGenerator:
- image_broker.py=scripts/image_broker.py
- install_agent_tools.sh=scripts/install_agent_tools.sh
- migrate_herdr_state.py=scripts/migrate_herdr_state.py
+ - migrate_api_session_lineage.py=scripts/migrate_api_session_lineage.py
+ - patch_api_server_sessions.py=scripts/patch_api_server_sessions.py
- patch_hermes_auth.py=scripts/patch_hermes_auth.py
- patch_codex_runtime.py=scripts/patch_codex_runtime.py
- patch_stream_recovery.py=scripts/patch_stream_recovery.py
diff --git a/services/hermes/networkpolicy.yaml b/services/hermes/networkpolicy.yaml
index ce5402919..570fc1d94 100644
--- a/services/hermes/networkpolicy.yaml
+++ b/services/hermes/networkpolicy.yaml
@@ -109,6 +109,7 @@ spec:
app: hermes-switchyard
ports:
- {protocol: TCP, port: 9003}
+ - {protocol: TCP, port: 9006}
# agent.hermes.bstein.dev is an owner-only engineering workstation. The
# browser boundary remains OAuth-protected, while its workers need to reach
# every cluster namespace, Atlas LAN service, and hosted provider endpoint.
@@ -356,6 +357,7 @@ spec:
app: hermes-agent
ports:
- {protocol: TCP, port: 9003}
+ - {protocol: TCP, port: 9006}
- to:
- ipBlock:
cidr: 0.0.0.0/0
diff --git a/services/hermes/plugins/auto-router/__init__.py b/services/hermes/plugins/auto-router/__init__.py
index ef49f5fc0..b79c4fb48 100644
--- a/services/hermes/plugins/auto-router/__init__.py
+++ b/services/hermes/plugins/auto-router/__init__.py
@@ -42,6 +42,7 @@ MANUAL_ROUTES = frozenset(
"atlas/manual/codex/terra",
"atlas/manual/codex/sol",
"atlas/manual/claude/haiku",
+ "atlas/manual/claude/fable",
"atlas/manual/claude/sonnet",
"atlas/manual/claude/opus",
"atlas/manual/local/qwen-14b",
@@ -110,6 +111,8 @@ def _normalise_manual_route(provider: str, model: str = "") -> str:
"claude": {
"haiku": "atlas/manual/claude/haiku",
"claude-haiku-4-5-20251001": "atlas/manual/claude/haiku",
+ "fable": "atlas/manual/claude/fable",
+ "claude-fable-5": "atlas/manual/claude/fable",
"sonnet": "atlas/manual/claude/sonnet",
"claude-sonnet-5": "atlas/manual/claude/sonnet",
"opus": "atlas/manual/claude/opus",
@@ -166,6 +169,14 @@ def _boundary_selection(agent: Any) -> tuple[str, str, str]:
return str(policy["auto_route"]), ui_effort, "auto"
+def _resolved_route(route: str, effort: str) -> str:
+ """Bind a manual family and UI effort to an exact Switchyard route."""
+ if route not in MANUAL_ROUTES or route.endswith("/local/qwen-14b"):
+ return route
+ provider_effort = effort if effort in {"low", "medium", "high", "xhigh"} else "low"
+ return f"{route}/{provider_effort}"
+
+
def _switch_agent(ctx: Any, agent: Any, route: str, effort: str) -> None:
"""Point one live Hermes agent at Switchyard and remove local failover."""
runtime_agent = _runtime_agent(ctx)
@@ -268,7 +279,8 @@ def _route_boundary(ctx: Any, scope: str, **kwargs: Any) -> None:
agent = kwargs.get("agent") or kwargs.get("child") or _runtime_agent(ctx)
if agent is None:
return
- route, effort, source = _boundary_selection(agent)
+ requested_route, effort, source = _boundary_selection(agent)
+ route = _resolved_route(requested_route, effort)
_switch_agent(ctx, agent, route, effort)
policy = _load_policy()
_record_boundary(policy, route, effort, source, scope)
diff --git a/services/hermes/plugins/auto-router/dashboard/dist/index.js b/services/hermes/plugins/auto-router/dashboard/dist/index.js
index 1af4c9727..9cf509c85 100644
--- a/services/hermes/plugins/auto-router/dashboard/dist/index.js
+++ b/services/hermes/plugins/auto-router/dashboard/dist/index.js
@@ -29,6 +29,7 @@
function ProviderCard(props) {
const item = props.item || {};
const account = item.account || null;
+ const nativeHealth = item.native_health || null;
const authLabel = account && account.access_token_live === false && account.refreshable
? "Authentication refreshable"
: account && account.authenticated
@@ -47,6 +48,10 @@
h("span", { className: account.authenticated ? "is-good" : "is-bad" }, authLabel),
account.rate_limit_tier && account.rate_limit_tier !== "unknown" ? h("span", null, "Tier: " + account.rate_limit_tier) : null,
h("span", null, "Access token: " + when(account.token_expires_at)),
+ nativeHealth && nativeHealth.transport ? h("span", null, "Transport: native Claude Code subscription") : null,
+ nativeHealth && nativeHealth.rate_limit && nativeHealth.rate_limit.utilization != null
+ ? h("span", null, "Observed utilization: " + Math.round(Number(nativeHealth.rate_limit.utilization) * 100) + "%")
+ : null,
account.usage_url ? h("a", {
className: "provider-status-usage-link",
href: account.usage_url,
@@ -60,7 +65,20 @@
h(Stat, { label: "Tokens", value: number(item.total_tokens) }),
h(Stat, { label: "Avg latency", value: item.avg_latency_ms ? number(item.avg_latency_ms) + " ms" : "—" })
),
+ item.configured_models && item.configured_models.length ? h("div", { className: "provider-status-models" },
+ h("strong", null, "Available models"),
+ item.supported_efforts && item.supported_efforts.length
+ ? h("p", null, "Supported effort: " + item.supported_efforts.join(" · "))
+ : null,
+ item.configured_models.map(function (model) {
+ return h("div", { className: "provider-status-model", key: "configured-" + model },
+ h("code", null, model),
+ h("span", null, "selectable through AUTO or a manual override")
+ );
+ })
+ ) : null,
item.models && item.models.length ? h("div", { className: "provider-status-models" },
+ h("strong", null, "Observed routes"),
item.models.map(function (model) {
return h("div", { className: "provider-status-model", key: model.id },
h("code", null, model.id),
diff --git a/services/hermes/plugins/auto-router/provider_status.py b/services/hermes/plugins/auto-router/provider_status.py
index ee9a48aea..cb4dff0fc 100644
--- a/services/hermes/plugins/auto-router/provider_status.py
+++ b/services/hermes/plugins/auto-router/provider_status.py
@@ -25,6 +25,14 @@ CODEX_AUTH_PATH = Path(
CLAUDE_AUTH_PATH = Path(
os.environ.get("CLAUDE_CONFIG_DIR", "/opt/data/home/.claude")
) / ".credentials.json"
+ROUTING_CATALOG_PATH = Path(
+ os.environ.get("HERMES_ROUTING_CATALOG_PATH", "/routing-catalog/catalog.json")
+)
+CLAUDE_HEALTH_PATH = Path(
+ os.environ.get(
+ "HERMES_CLAUDE_HEALTH_PATH", "/opt/data/provider-health/claude.json"
+ )
+)
def _read_json(path: Path) -> dict[str, Any]:
@@ -104,9 +112,14 @@ def _codex_account() -> dict[str, Any]:
subscription_until, subscription_live = _timestamp(
claims.get("chatgpt_subscription_active_until")
)
- authenticated = bool(tokens.get("access_token")) and token_live is not False
+ refreshable = bool(tokens.get("refresh_token"))
+ authenticated = (
+ bool(tokens.get("access_token")) and token_live is not False
+ ) or refreshable
return {
"authenticated": authenticated,
+ "access_token_live": token_live,
+ "refreshable": refreshable,
"auth_mode": auth.get("auth_mode") or "unknown",
"plan": claims.get("chatgpt_plan_type") or "unknown",
"token_expires_at": expires_at,
@@ -193,7 +206,11 @@ def _provider_summary(name: str, models: dict[str, Any]) -> dict[str, Any]:
calls = totals["calls"]
errors = totals["errors"]
- if calls and errors:
+ # Switchyard counters span the router process lifetime. One old transient
+ # failure must not leave an otherwise healthy provider permanently yellow.
+ total_boundaries = calls + errors
+ error_ratio = errors / total_boundaries if total_boundaries else 0.0
+ if calls and error_ratio >= 0.05:
state = "degraded"
elif calls:
state = "available"
@@ -211,6 +228,29 @@ def _provider_summary(name: str, models: dict[str, Any]) -> dict[str, Any]:
}
+def _configured_models(provider: str) -> list[str]:
+ """Return the stewarded model catalog independently of observed traffic."""
+ catalog = _read_json(ROUTING_CATALOG_PATH)
+ providers = catalog.get("providers")
+ providers = providers if isinstance(providers, dict) else {}
+ record = providers.get(provider)
+ record = record if isinstance(record, dict) else {}
+ models = record.get("models")
+ if not isinstance(models, list):
+ return []
+ return sorted({str(model) for model in models if isinstance(model, str)})
+
+
+def _fresh_health(path: Path, maximum_age: float = 86400.0) -> dict[str, Any]:
+ """Read recent broker health without treating stale state as authoritative."""
+ value = _read_json(path)
+ try:
+ age = time.time() - path.stat().st_mtime
+ except OSError:
+ return {}
+ return value if age <= maximum_age else {}
+
+
def provider_status_payload() -> dict[str, Any]:
"""Build the owner-safe status document shared by dashboard and TUI."""
health = _get_json(f"{SWITCHYARD_ROOT}/health")
@@ -221,8 +261,24 @@ def provider_status_payload() -> dict[str, Any]:
name: _provider_summary(name, models)
for name in ("codex", "claude", "local")
}
+ for name, item in providers.items():
+ item["configured_models"] = _configured_models(name)
+ item["supported_efforts"] = (
+ ["low", "medium", "high", "xhigh"]
+ if name in {"codex", "claude"}
+ else ["medium"]
+ )
providers["codex"]["account"] = _codex_account()
providers["claude"]["account"] = _claude_account()
+ claude_health = _fresh_health(CLAUDE_HEALTH_PATH)
+ providers["claude"]["native_health"] = claude_health
+ native_state = claude_health.get("state")
+ if native_state == "available":
+ providers["claude"]["state"] = "available"
+ elif native_state == "capacity-limited":
+ providers["claude"]["state"] = "degraded"
+ elif native_state == "unavailable":
+ providers["claude"]["state"] = "unavailable"
classifier = stats.get("classifier")
classifier = classifier if isinstance(classifier, dict) else {}
fallbacks = stats.get("routing_fallbacks")
@@ -231,9 +287,11 @@ def provider_status_payload() -> dict[str, Any]:
"generated_at": datetime.now(timezone.utc).isoformat(),
"window": "Since the last Switchyard restart",
"quota_note": (
- "Codex and Claude subscription balances are not exposed to this "
- "router. Open the provider usage page for authoritative remaining "
- "capacity; the counters here show actual work observed by Switchyard."
+ "Codex uses the owner's ChatGPT Codex OAuth and Claude uses the "
+ "owner's native first-party Claude Code subscription; the Claude "
+ "lane does not use the metered Anthropic API key. Open each official "
+ "usage page for authoritative remaining capacity. The counters here "
+ "show actual work observed by Switchyard."
),
"router": {
"state": "available" if router_ok else "unavailable",
diff --git a/services/hermes/router/main_test.go b/services/hermes/router/main_test.go
index 67264b8c2..0cf9e16d0 100644
--- a/services/hermes/router/main_test.go
+++ b/services/hermes/router/main_test.go
@@ -126,6 +126,17 @@ func TestRouterProxiesWebUIAndAddsTelegramShortcut(t *testing.T) {
if !strings.Contains(response.Body.String(), "hermes-chat-bridge.js") || !strings.Contains(response.Body.String(), "hermes-chat-bridge.css") {
t.Fatal("Telegram shortcut assets were not injected")
}
+ assetRequest := httptest.NewRequest(http.MethodGet, "/hermes-chat-bridge.js", nil)
+ assetRequest.Header.Set("X-Forwarded-User", "subject")
+ assetResponse := httptest.NewRecorder()
+ router.ServeHTTP(assetResponse, assetRequest)
+ asset := assetResponse.Body.String()
+ if !strings.Contains(asset, "hermes-files-sidebar") || !strings.Contains(asset, "hermes-telegram-sidebar") {
+ t.Fatal("Files and Telegram were not integrated into the existing sidebar")
+ }
+ if strings.Contains(asset, "position:fixed") || strings.Contains(asset, "hermes-chat-tools") {
+ t.Fatal("legacy floating chat controls remain in the mobile bridge")
+ }
}
func TestRouterRedirectsNativeLoginToSafeChatDestination(t *testing.T) {
diff --git a/services/hermes/router/web.go b/services/hermes/router/web.go
index 58d168ac2..7c0334cfa 100644
--- a/services/hermes/router/web.go
+++ b/services/hermes/router/web.go
@@ -17,13 +17,13 @@ const telegramPage = `
Hermes on Telegram
-
+
← Back to Hermes
Hermes on Telegram
- Link this Keycloak account to a private Telegram chat. Messages will use the same isolated Hermes tenant as the WebUI.
+ The operator configures one shared Hermes bot. Link your own Telegram account once so direct messages use this Keycloak account's isolated Hermes tenant.
Checking Telegram…
Create one-time link
@@ -32,7 +32,7 @@ const telegramPage = `
Codes expire after 10 minutes. Only direct messages are accepted; group messages are ignored.
-
+