# syntax=docker/dockerfile:1 # dockerfiles/Dockerfile.hermes-agent # # Multi-arch base: this digest is the upstream OCI image INDEX for tag # v2026.7.7.2 (revision 9de9c25f620ff7f1ce0fd5457d596052d5159596). The index # fans out to both native leaves of the same reviewed upstream version: # linux/arm64 -> sha256:47d4bd4cc420b70e40ed75efdade373e45b86b7382d4013a054208982bb6ba08 # linux/amd64 -> sha256:3db34ce19adfa080736a2a3feb0316dbcccc588faa9afe7fd8ae1c03b4f1a53a # The arm64 leaf is byte-for-byte the previously pinned single-arch base, so the # arm64 build is unchanged; Kaniko/containerd auto-selects the matching leaf per # build platform (arm64 rpi5 pod vs amd64 titan-24 pod). Do NOT replace this with # a per-arch leaf digest -- that would break the amd64 build leg. # # The reference below points at the in-cluster Harbor "mirror" project, NOT # docker.io. The identical upstream index (same content-addressed digest # 9c841866..., both leaves) is mirrored into Harbor with # skopeo copy --all docker://nousresearch/hermes-agent@sha256:9c841866... \ # docker://harbor-core.harbor.svc.cluster.local/mirror/hermes-agent@sha256:9c841866... # by the Flux-managed one-shot Job services/harbor/hermes-agent-base-mirror-job.yaml. # Because the digest is content-addressed, mirroring reproduces the exact index # and both leaf digests, so this remains fully digest-pinned and multi-arch while # Kaniko pulls it over the internal insecure registry (see the pipeline's # --insecure-registry/--registry-mirror flags) with no docker.io fallback. # Re-run that Job whenever this digest is bumped, before publishing the image. ARG HERMES_AGENT_BASE=harbor-core.harbor.svc.cluster.local/mirror/hermes-agent@sha256:9c841866021c54c4596849f6135717e8a4d52ba510b7f52c50aef1de1a283973 # Python 3.13 dynamically links libsqlite3.so.0, so a checked native library # replaces the affected Debian 3.46.1 runtime without rebuilding Python. This # target is also an emergency, small multi-arch layer when built with # ``--target sqlite-runtime`` and HERMES_AGENT_BASE set to the current agent. FROM ${HERMES_AGENT_BASE} AS sqlite-build USER root ARG SQLITE_AUTOCONF_VERSION=3510300 ARG SQLITE_AUTOCONF_SHA256=81f5be397049b0cae1b167f2225af7646fc0f82e4a9b3c48c9ea3a533e21d77a ARG SQLITE_AMALGAMATION_SHA3=32d5424f97e0a7fc5ed2f6335afbb58be4e0298bd7117a34e39d345ff13d859e RUN apt-get update \ && apt-get install -y --no-install-recommends build-essential ca-certificates curl \ && curl --fail --location --proto '=https' --tlsv1.2 \ --output /tmp/sqlite.tar.gz \ "https://www.sqlite.org/2026/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}.tar.gz" \ && echo "${SQLITE_AUTOCONF_SHA256} /tmp/sqlite.tar.gz" | sha256sum --check --status \ && tar -xzf /tmp/sqlite.tar.gz -C /tmp \ && /opt/hermes/.venv/bin/python -c 'import hashlib, pathlib, sys; expected=sys.argv[1]; actual=hashlib.sha3_256(pathlib.Path(sys.argv[2]).read_bytes()).hexdigest(); raise SystemExit(actual != expected)' "${SQLITE_AMALGAMATION_SHA3}" "/tmp/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}/sqlite3.c" \ && cd "/tmp/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}" \ && CFLAGS='-DSQLITE_ENABLE_COLUMN_METADATA -DSQLITE_ENABLE_PREUPDATE_HOOK -DSQLITE_ENABLE_UNLOCK_NOTIFY -DSQLITE_ENABLE_UPDATE_DELETE_LIMIT -DSQLITE_SOUNDEX' \ ./configure --prefix=/opt/sqlite --disable-static --all --scanstatus \ && make -j"$(nproc)" \ && make install \ && test -L /opt/sqlite/lib/libsqlite3.so.0 \ && /opt/sqlite/bin/sqlite3 ':memory:' 'select sqlite_version();' | grep -Fx '3.51.3' FROM ${HERMES_AGENT_BASE} AS sqlite-runtime USER root COPY --from=sqlite-build /opt/sqlite/lib/ /usr/local/lib/ # Debian's arm64 multiarch path sorts ahead of libc.conf, which otherwise picks # the older system SQLite before /usr/local/lib. Keep the checked library first. RUN printf '%s\n' /usr/local/lib > /etc/ld.so.conf.d/00-hermes-sqlite.conf \ && ldconfig \ && /opt/hermes/.venv/bin/python -c 'import sqlite3; connection=sqlite3.connect(":memory:"); connection.execute("CREATE VIRTUAL TABLE documents USING fts5(body)"); connection.execute("CREATE VIRTUAL TABLE ranges USING rtree(id, low, high)"); assert connection.execute("SELECT json_valid(?), sqrt(9)", ("[]",)).fetchone() == (1, 3.0); options={row[0] for row in connection.execute("PRAGMA compile_options")}; required={"ENABLE_COLUMN_METADATA", "ENABLE_FTS4", "ENABLE_FTS5", "ENABLE_MATH_FUNCTIONS", "ENABLE_PREUPDATE_HOOK", "ENABLE_RTREE", "ENABLE_SESSION", "ENABLE_UNLOCK_NOTIFY", "ENABLE_UPDATE_DELETE_LIMIT"}; assert required <= options, sorted(required - options); assert sqlite3.sqlite_version_info >= (3, 51, 3), sqlite3.sqlite_version' FROM sqlite-runtime USER root # Codex uses the distribution bubblewrap package when a bounded sandbox is # requested. tmux keeps the stock Hermes TUI alive across browser reconnects; # durable coding workers are dispatched by native Hermes Kanban lanes. RUN apt-get update \ && apt-get install -y --no-install-recommends bubblewrap tmux \ && rm -rf /var/lib/apt/lists/* # Keep a credential-free search provider available for private chat tenants. # Paid/provider-backed search remains selectable through normal Hermes config. RUN uv pip install --python /opt/hermes/.venv/bin/python ddgs==9.14.4 ARG HERMES_KANIKO_HEREDOC_COMPAT=0 COPY dockerfiles/Dockerfile.hermes-agent /tmp/hermes-agent.Dockerfile COPY dockerfiles/hermes-kaniko-heredoc-runner.py /tmp/hermes-kaniko-heredoc-runner.py # Keep dashboard chat sockets tied to the intended React mount and conversation. # A resumed conversation needs a different PTY attachment key from a fresh chat; # reconnects to that same conversation must keep using the same key. RUN node <<'NODE' const fs = require("node:fs"); const apiPath = "/opt/hermes/web/src/lib/api.ts"; let apiSource = fs.readFileSync(apiPath, "utf8"); const sessionHeaderBefore = [ 'function setSessionHeader(headers: Headers, token: string): void {', ' if (!headers.has(SESSION_HEADER)) {', ' headers.set(SESSION_HEADER, token);', ' }', '}', ].join("\n"); const sessionHeaderAfter = [ sessionHeaderBefore, '', '/**', ' * Recover an expired outer OAuth2 Proxy session without losing the SPA route.', ' * Background fetch redirects cannot complete an OIDC browser flow, so API', ' * routes deliberately return 401 and the dashboard performs this top-level', ' * navigation once instead.', ' */', 'function redirectToProxyLogin(): Promise {', ' const rd =', ' window.location.pathname + window.location.search + window.location.hash;', ' try {', ' sessionStorage.setItem("hermes.lastLocation", rd);', ' } catch {', ' /* privacy mode — the rd query parameter remains authoritative */', ' }', ' window.location.assign(', ' `${BASE}/oauth2/start?rd=${encodeURIComponent(rd)}`,', ' );', ' return new Promise(() => {});', '}', ].join("\n"); const plainUnauthorizedBefore = [ ' if (!window.__HERMES_AUTH_REQUIRED__ && !options?.allowUnauthorized) {', ].join("\n"); const plainUnauthorizedAfter = [ ' // OAuth2 Proxy API routes intentionally return a plain 401 when its', ' // session expires. A background fetch cannot complete OIDC, so promote', ' // it to a top-level navigation and return to this exact dashboard route.', ' if (window.__HERMES_AUTH_REQUIRED__ && !options?.allowUnauthorized) {', ' return redirectToProxyLogin();', ' }', '', plainUnauthorizedBefore, ].join("\n"); const ticketFailureBefore = [ ' if (!res.ok) {', ' throw new Error(`/api/auth/ws-ticket: HTTP ${res.status}`);', ' }', ].join("\n"); const ticketFailureAfter = [ ' if (res.status === 401) {', ' return redirectToProxyLogin<{ ticket: string; ttl_seconds: number }>();', ' }', ticketFailureBefore, ].join("\n"); for (const [before, after, label] of [ [sessionHeaderBefore, sessionHeaderAfter, "proxy login helper"], [plainUnauthorizedBefore, plainUnauthorizedAfter, "plain 401 recovery"], [ticketFailureBefore, ticketFailureAfter, "ticket 401 recovery"], ]) { if (!apiSource.includes(before)) { throw new Error(`Hermes API ${label} patch context changed`); } apiSource = apiSource.replace(before, after); } fs.writeFileSync(apiPath, apiSource); const path = "/opt/hermes/web/src/pages/ChatPage.tsx"; let source = fs.readFileSync(path, "utf8"); const socketBefore = [ ' const url = await api.buildWsUrl("/api/pty", params);', ' const ws = new WebSocket(url);', ].join("\n"); const socketAfter = [ ' let url: string;', ' try {', ' // WebSocket upgrades cannot expose an HTTP 401 reliably through', ' // every proxy. Probe a protected REST route first so fetchJSON can', ' // detect a rotated loopback token and reload this SPA route once.', ' await api.getSessions(1, 0, scopedProfile ?? "");', ' url = await api.buildWsUrl("/api/pty", params);', ' } catch {', ' if (!unmounting) scheduleReconnect(1006);', ' return;', ' }', ' if (unmounting) return;', ' const ws = new WebSocket(url);', ].join("\n"); const attachBefore = ' params.attach = ptyAttachToken(forceFresh);'; const attachAfter = [ ' const attachScope = resumeParam', ' ? `resume:${resumeParam}:${scopedProfile ?? ""}`', ' : `fresh:${scopedProfile ?? ""}`;', ' params.attach = `${ptyAttachToken(forceFresh)}:${attachScope}`;', ].join("\n"); if (!source.includes(socketBefore)) { throw new Error("Hermes ChatPage WebSocket patch context changed"); } if (!source.includes(attachBefore)) { throw new Error("Hermes ChatPage PTY attachment patch context changed"); } source = source.replace(socketBefore, socketAfter); source = source.replace(attachBefore, attachAfter); fs.writeFileSync(path, source); // The terminal socket already reconnects indefinitely after a transient // outage. The sidebar's events/gateway sockets did not: one deployment or // network flap left the page in a permanent manual-Reconnect state. Retry the // whole sidebar connection set with capped backoff while keeping auth/origin // rejections fail-closed. const sidebarPath = "/opt/hermes/web/src/components/ChatSidebar.tsx"; let sidebar = fs.readFileSync(sidebarPath, "utf8"); const retryStateBefore = ' const [version, setVersion] = useState(0);'; const retryStateAfter = [ retryStateBefore, ' const eventsRetryAttempt = useRef(0);', ].join("\n"); const socketStateBefore = [ ' let unmounting = false;', ' let ws: WebSocket | null = null;', ].join("\n"); const socketStateAfter = [ ' let unmounting = false;', ' let ws: WebSocket | null = null;', ' let reconnectTimer: ReturnType | null = null;', ].join("\n"); const ticketBefore = [ ' const url = await buildWsUrl("/api/events", { channel });', ' if (unmounting) {', ' return;', ' }', ' ws = new WebSocket(url);', ].join("\n"); const ticketAfter = [ ' let url: string;', ' try {', ' // Run the shared HTTP auth recovery before each socket attempt.', ' // A stale injected token otherwise appears as opaque WS code 1006', ' // and this reconnect loop can never obtain the replacement token.', ' await api.getSessions(1, 0, profile ?? "");', ' url = await buildWsUrl("/api/events", { channel });', ' } catch {', ' if (unmounting) return;', ' const attempt = Math.min(eventsRetryAttempt.current + 1, 6);', ' eventsRetryAttempt.current = attempt;', ' const delay = Math.min(500 * 2 ** (attempt - 1), 5000);', ' setError("events feed disconnected — tool calls may not appear; reconnecting…");', ' reconnectTimer = setTimeout(() => {', ' reconnectTimer = null;', ' if (!unmounting) setVersion((v) => v + 1);', ' }, delay);', ' return;', ' }', ' if (unmounting) return;', ' ws = new WebSocket(url);', ].join("\n"); const handlersBefore = [ ' ws.addEventListener("error", () => surface(DISCONNECTED));', '', ' ws.addEventListener("close", (ev) => {', ' if (ev.code === 4401 || ev.code === 4403) {', ' surface(`events feed rejected (${ev.code}) — reload the page`);', ' } else if (ev.code !== 1000) {', ' surface(DISCONNECTED);', ' }', ' });', ].join("\n"); const handlersAfter = [ ' ws.addEventListener("open", () => {', ' eventsRetryAttempt.current = 0;', ' setError(null);', ' });', '', ' ws.addEventListener("error", () => surface(DISCONNECTED));', '', ' ws.addEventListener("close", (ev) => {', ' if (unmounting || ev.code === 1000) return;', ' if (ev.code === 4401 || ev.code === 4403) {', ' surface(`events feed rejected (${ev.code}) — reload the page`);', ' return;', ' }', ' const attempt = Math.min(eventsRetryAttempt.current + 1, 6);', ' eventsRetryAttempt.current = attempt;', ' const delay = Math.min(500 * 2 ** (attempt - 1), 5000);', ' surface(`${DISCONNECTED}; reconnecting…`);', ' reconnectTimer = setTimeout(() => {', ' reconnectTimer = null;', ' if (!unmounting) setVersion((v) => v + 1);', ' }, delay);', ' });', ].join("\n"); const cleanupBefore = [ ' return () => {', ' unmounting = true;', ' ws?.close();', ' };', ].join("\n"); const cleanupAfter = [ ' return () => {', ' unmounting = true;', ' if (reconnectTimer) clearTimeout(reconnectTimer);', ' ws?.close();', ' };', ].join("\n"); const gatewayBefore = [ ' gw.connect()', ' .then(() => {', ].join("\n"); const gatewayAfter = [ ' // Give the ordinary HTTP client first chance to recover an expired', ' // OAuth session or a dashboard token rotated by a server restart.', ' api.getSessions(1, 0, profile ?? "")', ' .then(() => gw.connect())', ' .then(() => {', ].join("\n"); for (const [before, after, label] of [ [retryStateBefore, retryStateAfter, "retry state"], [socketStateBefore, socketStateAfter, "socket state"], [ticketBefore, ticketAfter, "ticket retry"], [handlersBefore, handlersAfter, "socket handlers"], [cleanupBefore, cleanupAfter, "socket cleanup"], [gatewayBefore, gatewayAfter, "gateway auth preflight"], ]) { if (!sidebar.includes(before)) { throw new Error(`Hermes ChatSidebar ${label} patch context changed`); } sidebar = sidebar.replace(before, after); } fs.writeFileSync(sidebarPath, sidebar); NODE RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 1 ;; *) exit 2 ;; esac # The upstream OIDC gate authenticates users but deliberately treats the # dashboard as one shared workstation. Allow a deployment to narrow that # workstation to explicit OIDC subjects. Enforce this after normal provider # verification so a denied account is a 403, not a misleading provider 503. RUN python - <<'PY' from pathlib import Path path = Path("/opt/hermes/hermes_cli/dashboard_auth/middleware.py") source = path.read_text() helper_before = '''def _client_ip(request: Request) -> str: fwd = request.headers.get("x-forwarded-for", "") if fwd: return fwd.split(",")[0].strip() return request.client.host if request.client else "" ''' helper_after = helper_before + '''def _dashboard_user_allowed(session) -> bool: """Apply an optional deployment-level OIDC-subject allowlist.""" import os allowed = { value.strip() for value in os.environ.get( "HERMES_DASHBOARD_OIDC_ALLOWED_USER_IDS", "" ).split(",") if value.strip() } return not allowed or session.user_id in allowed def _user_forbidden_response() -> Response: """Return an authorization failure without exposing identities.""" return JSONResponse( { "error": "forbidden", "detail": "This Atlas account is not authorized for this dashboard.", }, status_code=403, ) ''' refresh_before = ''' new_session, refreshing_provider = refreshed request.state.session = new_session response = await call_next(request) ''' refresh_after = ''' new_session, refreshing_provider = refreshed if not _dashboard_user_allowed(new_session): return _user_forbidden_response() request.state.session = new_session response = await call_next(request) ''' final_before = ''' request.state.session = session return await call_next(request) ''' final_after = ''' if not _dashboard_user_allowed(session): return _user_forbidden_response() request.state.session = session return await call_next(request) ''' for before, after, label in ( (helper_before, helper_after, "allowlist helper"), (refresh_before, refresh_after, "refreshed session"), (final_before, final_after, "verified session"), ): if before not in source: raise SystemExit(f"Hermes dashboard auth {label} patch context changed") source = source.replace(before, after, 1) path.write_text(source) PY RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 2 ;; *) exit 2 ;; esac # Give trusted plugins a pre-turn routing hook. It runs after fallback runtime # restoration but before Hermes builds its provider-specific system prompt. RUN python - <<'PY' from pathlib import Path plugins_path = Path("/opt/hermes/hermes_cli/plugins.py") plugins = plugins_path.read_text() hooks_before = ''' "pre_llm_call", "post_llm_call", ''' hooks_after = ''' "pre_llm_call", "pre_turn_route", "pre_internal_route", "pre_subagent_route", "post_llm_call", ''' if plugins.count(hooks_before) != 1: raise SystemExit( "Hermes pre-turn hook registry context changed: expected 1, " f"found {plugins.count(hooks_before)}" ) plugins_path.write_text(plugins.replace(hooks_before, hooks_after, 1)) turn_path = Path("/opt/hermes/agent/turn_context.py") turn = turn_path.read_text() turn_before = ''' agent._restore_primary_runtime() ''' turn_after = turn_before + ''' # Trusted coordinator plugins may select the provider/model/effort for this # turn. Run this before system-prompt restoration so the prompt and runtime # always describe the same selected provider. try: from hermes_cli.plugins import has_hook, invoke_hook if has_hook("pre_turn_route"): invoke_hook( "pre_turn_route", agent=agent, user_message=user_message, conversation_history=list(conversation_history or []), session_id=agent.session_id or "", platform=agent.platform or "", ) except Exception: logger.warning("pre_turn_route hook failed", exc_info=True) ''' if turn.count(turn_before) != 1: raise SystemExit( "Hermes pre-turn routing context changed: expected 1, " f"found {turn.count(turn_before)}" ) turn_path.write_text(turn.replace(turn_before, turn_after, 1)) loop_path = Path("/opt/hermes/agent/conversation_loop.py") loop = loop_path.read_text() loop_before = ''' # Prepare messages for API call ''' loop_after = ''' # Reclassify every internal tool-loop continuation before provider- # specific prompt construction. The first request was already routed by # pre_turn_route; later requests include the tool evidence accumulated # since that decision. if api_call_count > 1: try: from hermes_cli.plugins import has_hook, invoke_hook if has_hook("pre_internal_route"): invoke_hook( "pre_internal_route", agent=agent, user_message=original_user_message, conversation_history=list(messages), session_id=agent.session_id or "", platform=agent.platform or "", api_call_count=api_call_count, ) except Exception: logger.warning("pre_internal_route hook failed", exc_info=True) # Prepare messages for API call ''' if loop.count(loop_before) != 1: raise SystemExit( "Hermes internal routing context changed: expected 1, " f"found {loop.count(loop_before)}" ) loop_path.write_text(loop.replace(loop_before, loop_after, 1)) delegate_path = Path("/opt/hermes/tools/delegate_tool.py") delegate = delegate_path.read_text() delegate_before = ''' # Override with correct parent tool names (before child construction mutated global) child._delegate_saved_tool_names = _parent_tool_names children.append((i, t, child)) ''' delegate_after = ''' # Route each bounded child independently before its first LLM call. # This keeps one multi-part objective from pinning every leaf to # the parent coordinator's provider/model/effort. try: from hermes_cli.plugins import has_hook, invoke_hook if has_hook("pre_subagent_route"): invoke_hook( "pre_subagent_route", agent=child, parent_agent=parent_agent, goal=t["goal"], context=t.get("context"), task_index=i, task_count=n_tasks, ) except Exception: logger.warning("pre_subagent_route hook failed", exc_info=True) # Override with correct parent tool names (before child construction mutated global) child._delegate_saved_tool_names = _parent_tool_names children.append((i, t, child)) ''' if delegate.count(delegate_before) != 1: raise SystemExit( "Hermes subagent routing context changed: expected 1, " f"found {delegate.count(delegate_before)}" ) delegate_path.write_text(delegate.replace(delegate_before, delegate_after, 1)) delegate = delegate_path.read_text() background_before = ''' is_subagent = getattr(parent_agent, "_delegate_depth", 0) > 0 return not is_subagent ''' background_after = ''' is_subagent = getattr(parent_agent, "_delegate_depth", 0) > 0 # A one-shot process has no durable event loop to receive a detached # child's result after its parent exits. Keep delegation synchronous so # the child verdict is returned to—and persisted by—the parent turn. if bool(getattr(parent_agent, "_hermes_oneshot", False)): return False return not is_subagent ''' if delegate.count(background_before) != 1: raise SystemExit( "Hermes oneshot delegation context changed: expected 1, " f"found {delegate.count(background_before)}" ) delegate_path.write_text(delegate.replace(background_before, background_after, 1)) oneshot_path = Path("/opt/hermes/hermes_cli/oneshot.py") oneshot = oneshot_path.read_text() oneshot_before = ''' # Belt-and-braces: make sure AIAgent doesn't invoke any streaming # display callbacks that would bypass our stdout capture. agent.suppress_status_output = True ''' oneshot_after = ''' # Preserve a caller's explicit --model route across every plugin routing # boundary. Config/env defaults remain automatic policy inputs. agent._hermes_explicit_model_pick = bool((model or "").strip()) # Detached delegation cannot deliver back into a process that exits after # this turn; delegate_tool uses this marker to keep children synchronous. agent._hermes_oneshot = True # Belt-and-braces: make sure AIAgent doesn't invoke any streaming # display callbacks that would bypass our stdout capture. agent.suppress_status_output = True ''' if oneshot.count(oneshot_before) != 1: raise SystemExit( "Hermes oneshot explicit-model context changed: expected 1, " f"found {oneshot.count(oneshot_before)}" ) oneshot_path.write_text(oneshot.replace(oneshot_before, oneshot_after, 1)) PY RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 3 ;; *) exit 2 ;; esac # Hermes WebUI sends its model/provider/reasoning selection on /v1/runs. # Upstream currently applies only statically declared model_routes there, so # the UI can display one model while the gateway silently runs another. Honor # trusted first-party provider selections and cap all chat reasoning at xhigh. RUN python - <<'PY' from pathlib import Path path = Path("/opt/hermes/gateway/platforms/api_server.py") source = path.read_text() route_before = ''' def _resolve_route(self, model_alias: Any) -> Optional[Dict[str, Any]]: """Return the model_routes entry for *model_alias*, or None.""" if not self._model_routes or not isinstance(model_alias, str): return None return self._model_routes.get(model_alias) ''' route_after = route_before + ''' def _resolve_request_route(self, body: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Resolve a static route or a trusted WebUI provider/model selection.""" route = self._resolve_route(body.get("model")) if route is not None: return route provider = body.get("provider") model = body.get("model") # Every WebUI selection is a public Switchyard route. Direct provider # picks would create a second routing control plane and bypass failover. allowed_providers = {"atlas-switchyard"} if provider not in allowed_providers or not isinstance(model, str): return None model = model.strip() if not model or len(model) > 128 or any( char not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._:/+-" for char in model ): return None return {"provider": provider, "model": model} ''' signature_before = ''' gateway_session_key: Optional[str] = None, route: Optional[Dict[str, Any]] = None, ) -> Any: ''' signature_after = ''' gateway_session_key: Optional[str] = None, route: Optional[Dict[str, Any]] = None, reasoning_effort: Any = None, routing_priority: Any = None, explicit_model_pick: Any = None, explicit_reasoning_effort: Any = None, ) -> Any: ''' reasoning_before = ''' runtime_kwargs = _resolve_runtime_agent_kwargs() reasoning_config = GatewayRunner._load_reasoning_config() model = _resolve_gateway_model() ''' reasoning_after = ''' runtime_kwargs = _resolve_runtime_agent_kwargs() reasoning_config = GatewayRunner._load_reasoning_config() from hermes_constants import parse_reasoning_effort requested_reasoning = parse_reasoning_effort(reasoning_effort) if requested_reasoning is not None: reasoning_config = requested_reasoning if reasoning_config and reasoning_config.get("effort") == "max": reasoning_config = {"enabled": True, "effort": "xhigh"} model = _resolve_gateway_model() ''' runs_route_before = ''' # Per-client model routing for /v1/runs (see model_routes). route = self._resolve_route(body.get("model")) ''' runs_route_after = ''' # Honor both static routes and the WebUI's trusted provider/model pick. route = self._resolve_request_route(body) ''' runs_agent_before = ''' gateway_session_key=gateway_session_key, route=route, ) ''' runs_agent_after = ''' gateway_session_key=gateway_session_key, route=route, reasoning_effort=body.get("reasoning_effort"), routing_priority=body.get("routing_priority"), explicit_model_pick=body.get("explicit_model_pick"), explicit_reasoning_effort=body.get("explicit_reasoning_effort"), ) ''' agent_controls_before = ''' gateway_session_key=gateway_session_key, ) return agent ''' agent_controls_after = ''' gateway_session_key=gateway_session_key, ) priority = str(routing_priority or "").strip().lower() if priority not in {"fast", "balanced", "deep", "maximum"}: priority = "" explicit_effort = str(explicit_reasoning_effort or "").strip().lower() if explicit_effort not in {"none", "minimal", "low", "medium", "high", "xhigh"}: explicit_effort = "" agent._hermes_routing_priority = priority agent._hermes_explicit_model_pick = bool(explicit_model_pick) agent._hermes_explicit_reasoning_effort = explicit_effort return agent ''' for before, after, label, count in ( (route_before, route_after, "request route resolver", 1), (signature_before, signature_after, "agent reasoning argument", 1), (reasoning_before, reasoning_after, "reasoning clamp", 1), (runs_route_before, runs_route_after, "runs route", 1), (agent_controls_before, agent_controls_after, "request routing controls", 1), ): if source.count(before) != count: raise SystemExit( f"Hermes API {label} patch context changed: expected {count}, " f"found {source.count(before)}" ) source = source.replace(before, after, count) # The same argument tail appears in other handlers. Restrict replacement to # the /v1/runs section so non-WebUI API surfaces retain upstream behavior. runs_start = source.index(" async def _handle_runs(") runs_source = source[runs_start:] if runs_source.count(runs_agent_before) != 1: raise SystemExit( "Hermes API /v1/runs agent-call patch context changed: expected 1, " f"found {runs_source.count(runs_agent_before)}" ) runs_source = runs_source.replace(runs_agent_before, runs_agent_after, 1) source = source[:runs_start] + runs_source path.write_text(source) PY RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 4 ;; *) exit 2 ;; esac # ``create_task(initial_status="blocked")`` is an explicit operator park, but # upstream only treats later block_task() events as sticky. Recognize the # existing created(status=blocked) event too. This also protects tasks created # before this image patch without making dependency or circuit-breaker blocks # depend on a new persisted field. RUN python - <<'PY' from pathlib import Path db_path = Path("/opt/hermes/hermes_cli/kanban_db.py") db_source = db_path.read_text() # The rewritten sticky-block gate below classifies a task from the ``created`` # event payload that ``create_task`` writes. That producer is upstream code we # do not own, so anchor the exact contract the consumer depends on instead of # trusting it: an ``initial_status="blocked"`` park must resolve ``task_status`` # to ``"blocked"``, every other creation must resolve it to something else, and # the ``created`` event must carry that same variable under the ``"status"`` # key. If upstream renames the key, drops it, hardcodes it, or stops deriving it # from ``initial_status``, fail the build here rather than ship a consumer that # silently mis-classifies every task it reads. These assert only -- they # deliberately do not rewrite the producer. The regression suite copied in below # is the second net: it proves the same contract against the real create_task, # for the drift these textual anchors cannot see. producer_park_anchor = ''' if initial_status == "blocked": task_status = "blocked" ''' if db_source.count(producer_park_anchor) != 1: raise SystemExit( "Hermes Kanban create_task park semantics changed: expected 1, " f"found {db_source.count(producer_park_anchor)}" ) # The mirror of the park branch: every non-park creation must resolve to a # status the gate does *not* read as a park. Without this, drift that widened # the ladder to park ordinary work would leave both other anchors intact. producer_unparked_anchor = ''' elif triage: task_status = "triage" else: task_status = "ready" ''' if db_source.count(producer_unparked_anchor) != 1: raise SystemExit( "Hermes Kanban create_task non-park status ladder changed: expected 1, " f"found {db_source.count(producer_unparked_anchor)}" ) producer_event_anchor = ''' _append_event( conn, task_id, "created", { "assignee": assignee, "status": task_status, ''' if db_source.count(producer_event_anchor) != 1: raise SystemExit( "Hermes Kanban created-event status payload changed: expected 1, " f"found {db_source.count(producer_event_anchor)}" ) sticky_doc_before = ''' The cheapest signal that distinguishes the two is the most recent ``"blocked"`` / ``"unblocked"`` event for the task. If the most recent one is ``"blocked"`` (or there is a ``"blocked"`` event and no ``"unblocked"`` event has fired since), the task is sticky and ``recompute_ready`` must *not* auto-promote it. Returns ``False`` when there is no such event at all (e.g. the task ''' sticky_doc_after = ''' The cheapest signal that distinguishes the two is the most recent ``"created"`` / ``"blocked"`` / ``"unblocked"`` event for the task. A ``"created"`` event whose payload has ``status="blocked"`` is the explicit park requested by ``create_task(initial_status="blocked")``. A later ``"unblocked"`` event clears either kind of sticky block. Returns ``False`` when there is no such event at all (e.g. the task ''' if db_source.count(sticky_doc_before) != 1: raise SystemExit( "Hermes Kanban sticky-block documentation changed: expected 1, " f"found {db_source.count(sticky_doc_before)}" ) db_source = db_source.replace(sticky_doc_before, sticky_doc_after, 1) sticky_before = ''' row = conn.execute( "SELECT kind FROM task_events " "WHERE task_id = ? AND kind IN ('blocked', 'unblocked') " "ORDER BY id DESC LIMIT 1", (task_id,), ).fetchone() return bool(row) and row["kind"] == "blocked" ''' sticky_after = ''' row = conn.execute( "SELECT kind, payload FROM task_events " "WHERE task_id = ? AND kind IN ('created', 'blocked', 'unblocked') " "ORDER BY id DESC LIMIT 1", (task_id,), ).fetchone() if not row or row["kind"] == "unblocked": return False if row["kind"] == "blocked": return True try: payload = json.loads(row["payload"] or "{}") except (TypeError, ValueError): return False if not isinstance(payload, dict): return False return payload.get("status") == "blocked" ''' if db_source.count(sticky_before) != 1: raise SystemExit( "Hermes Kanban sticky-block gate changed: expected 1, " f"found {db_source.count(sticky_before)}" ) db_source = db_source.replace(sticky_before, sticky_after, 1) # A manual evidence-based close must not expose a parked task to the dispatcher # between separate `unblock` and `complete` commands. Upstream already permits # direct completion from `blocked`; extend the same atomic path to `scheduled` # and make that contract visible in CLI help so agents do not create a ready # window that can launch redundant work. state_before = ''' WHERE id = ? AND status IN ('running', 'ready', 'blocked') ''' state_after = ''' WHERE id = ? AND status IN ('running', 'ready', 'blocked', 'scheduled') ''' if db_source.count(state_before) != 2: raise SystemExit( "Hermes Kanban completion state gate changed: expected 2, " f"found {db_source.count(state_before)}" ) # Only the manual path lacks an expected run id. A worker-owned completion must # continue to match its live run and may not revive an already scheduled run. db_path.write_text(db_source.replace(state_before, state_after, 1)) cli_path = Path("/opt/hermes/hermes_cli/kanban.py") cli_source = cli_path.read_text() help_before = 'p_complete = sub.add_parser("complete", help="Mark one or more tasks done")' help_after = ( 'p_complete = sub.add_parser(' '"complete", ' 'help="Atomically mark running, ready, blocked, or scheduled tasks done"' ')' ) if cli_source.count(help_before) != 1: raise SystemExit( "Hermes Kanban complete help changed: expected 1, " f"found {cli_source.count(help_before)}" ) cli_path.write_text(cli_source.replace(help_before, help_after, 1)) PY RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 5 ;; *) exit 2 ;; esac COPY dockerfiles/hermes-kanban-blocked-regression.py /tmp/hermes-kanban-blocked-regression.py RUN /opt/hermes/.venv/bin/python /tmp/hermes-kanban-blocked-regression.py \ && rm /tmp/hermes-kanban-blocked-regression.py COPY dockerfiles/hermes-python-sandbox-tool.py /opt/hermes/tools/python_sandbox_tool.py COPY dockerfiles/hermes-public-extract/__init__.py /opt/hermes/plugins/web/public_extract/__init__.py COPY dockerfiles/hermes-public-extract/plugin.yaml /opt/hermes/plugins/web/public_extract/plugin.yaml COPY dockerfiles/hermes-public-extract/provider.py /opt/hermes/plugins/web/public_extract/provider.py # Per-capability custom backends are resolved before upstream discovers web # plugins, causing extract_backend to fall through to the shared search-only # backend. Discover bundled plugins before checking a custom capability name. RUN python - <<'PY' from pathlib import Path path = Path("/opt/hermes/tools/web_tools.py") source = path.read_text() before = ''' cfg = _load_web_config() specific = (cfg.get(f"{capability}_backend") or "").lower().strip() if specific and _is_backend_available(specific): return specific ''' after = ''' cfg = _load_web_config() specific = (cfg.get(f"{capability}_backend") or "").lower().strip() if specific and specific not in _LEGACY_WEB_BACKENDS: _ensure_web_plugins_loaded() if specific and _is_backend_available(specific): return specific ''' if source.count(before) != 1: raise SystemExit( "Hermes custom web capability patch context changed: expected 1, " f"found {source.count(before)}" ) path.write_text(source.replace(before, after, 1)) PY RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 6 ;; *) exit 2 ;; esac # Keep the dashboard's account cards aligned with the managed provider lanes. # Switchyard receives Claude Code subscription OAuth through the environment; # that credential must not be presented as a metered Anthropic API key. RUN python - <<'PY' from pathlib import Path path = Path("/opt/hermes/hermes_cli/web_server.py") source = path.read_text() registry_before = ''' try: from hermes_cli.auth import PROVIDER_REGISTRY env_var_order = PROVIDER_REGISTRY["anthropic"].api_key_env_vars except (ImportError, KeyError): pass ''' registry_after = ''' try: from hermes_cli.auth import PROVIDER_REGISTRY env_var_order = PROVIDER_REGISTRY["anthropic"].api_key_env_vars except (ImportError, KeyError): pass # Claude Code subscription OAuth is reported by its dedicated card below. env_var_order = tuple( var for var in env_var_order if var != "CLAUDE_CODE_OAUTH_TOKEN" ) ''' managed_before = ''' try: from agent.anthropic_adapter import read_claude_code_credentials creds = read_claude_code_credentials() ''' managed_after = ''' managed_token = os.getenv("CLAUDE_CODE_OAUTH_TOKEN") if managed_token: return { "logged_in": True, "source": "managed_claude_code_subscription", "source_label": "Switchyard-managed Claude Code subscription", "token_preview": _truncate_token(managed_token), "expires_at": None, "has_refresh_token": False, } try: from agent.anthropic_adapter import read_claude_code_credentials creds = read_claude_code_credentials() ''' replacements = ( (registry_before, registry_after, "Anthropic credential separation"), (managed_before, managed_after, "managed Claude Code status"), ('"name": "Anthropic API Key",', '"name": "Anthropic API / direct OAuth (not used by Switchyard)",', "Anthropic account label"), ('"name": "Anthropic OAuth: Required Extra Usage Credits to Use Subscription",', '"name": "Claude Code subscription (Switchyard)",', "Claude Code account label"), ) for before, after, label in replacements: if source.count(before) != 1: raise SystemExit( f"Hermes {label} patch context changed: expected 1, " f"found {source.count(before)}" ) source = source.replace(before, after, 1) path.write_text(source) PY RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 7 ;; *) exit 2 ;; esac # Make parent-linked API workers first-class live dashboard sessions. They stay # active until the API runner closes them, even during a long final model call; # normal interactive sessions retain the stock five-minute recency window. RUN python - <<'PY' from pathlib import Path path = Path("/opt/hermes/hermes_cli/web_server.py") source = path.read_text() route_marker = '@app.get("/api/sessions")\n' helper = '''def _dashboard_session_is_active(session: Dict[str, Any], now: float) -> bool: """Return liveness for interactive sessions and durable API workers.""" if session.get("ended_at") is not None: return False if session.get("source") == "api_server" and session.get("parent_session_id"): return True last_active = session.get("last_active") or session.get("started_at") or 0 try: return (now - float(last_active)) < 300 except (TypeError, ValueError): return False ''' if source.count(route_marker) != 1: raise SystemExit("Hermes sessions route marker changed") source = source.replace(route_marker, helper + route_marker, 1) signature_before = ''' cwd_prefix: str = None, profile: Optional[str] = None, ): ''' signature_after = ''' cwd_prefix: str = None, profile: Optional[str] = None, include_children: bool = False, ): ''' sessions_start = source.index('async def get_sessions(') sessions_end = source.index('\n\n@app.get("/api/profiles/sessions")', sessions_start) sessions_source = source[sessions_start:sessions_end] if sessions_source.count(signature_before) != 1: raise SystemExit("Hermes sessions signature changed") sessions_source = sessions_source.replace(signature_before, signature_after, 1) query_before = ''' offset=offset, min_message_count=min_message_count, ''' query_after = ''' offset=offset, include_children=include_children, min_message_count=min_message_count, ''' if sessions_source.count(query_before) != 1: raise SystemExit("Hermes sessions query changed") sessions_source = sessions_source.replace(query_before, query_after, 1) source = source[:sessions_start] + sessions_source + source[sessions_end:] status_before = ''' sessions = db.list_sessions_rich(limit=50) now = time.time() active_sessions = sum( 1 for s in sessions if s.get("ended_at") is None and (now - s.get("last_active", s.get("started_at", 0))) < 300 ) ''' status_after = ''' sessions = db.list_sessions_rich( limit=500, include_children=True, order_by_last_active=True, ) now = time.time() active_sessions = sum( 1 for session in sessions if _dashboard_session_is_active(session, now) ) ''' if source.count(status_before) != 1: raise SystemExit("Hermes active session status block changed") source = source.replace(status_before, status_after, 1) row_before = ''' s["is_active"] = ( s.get("ended_at") is None and (now - s.get("last_active", s.get("started_at", 0))) < 300 )''' row_after = ''' s["is_active"] = _dashboard_session_is_active(s, now)''' if source.count(row_before) != 2: raise SystemExit( "Hermes session activity rows changed: expected 2, " f"found {source.count(row_before)}" ) source = source.replace(row_before, row_after) path.write_text(source) PY RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 8 ;; *) exit 2 ;; esac # Keep the browser terminal reliable across GPU context loss, make durable # worker lineage visible, expose an accessible API-worker transcript, and # expose the family Telegram-linking entry point. COPY dockerfiles/hermes-session-activity-panel.tsx /opt/hermes/web/src/components/SessionActivityPanel.tsx RUN node <<'NODE' const fs = require("node:fs"); function replaceOnce(source, before, after, label) { const count = source.split(before).length - 1; if (count !== 1) { throw new Error(`${label} patch context changed: expected 1, found ${count}`); } return source.replace(before, after); } // The dashboard bundle takes a few seconds to hydrate on slower clients. A // resumed worker must announce that it is connecting instead of exposing an // unexplained black terminal canvas during that interval. { const path = "/opt/hermes/web/index.html"; let source = fs.readFileSync(path, "utf8"); source = replaceOnce( source, ' \n
', `
`, "resumed activity bootstrap", ); fs.writeFileSync(path, source); } // A WebGL context can be discarded while a browser tab sleeps or loses its // GPU process. xterm's canvas renderer is fast enough for this single PTY and // can be repainted deterministically from the server-side replay buffer. { const path = "/opt/hermes/web/src/pages/ChatPage.tsx"; let source = fs.readFileSync(path, "utf8"); source = replaceOnce( source, 'import { ChatSessionList } from "@/components/ChatSessionList";\n', 'import { ChatSessionList } from "@/components/ChatSessionList";\n' + 'import { SessionActivityPanel } from "@/components/SessionActivityPanel";\n', "accessible activity import", ); source = replaceOnce( source, 'import { WebglAddon } from "@xterm/addon-webgl";\n', "", "xterm WebGL import", ); source = replaceOnce( source, ' const resumeParam = searchParams.get("resume");\n', ' const resumeParam = searchParams.get("resume");\n' + ' const lineageRoot = searchParams.get("lineage_root") || resumeParam;\n', "durable lineage root", ); source = replaceOnce( source, String.raw` useEffect(() => { if (!resumeParam) return; let cancelled = false; api .getSessionLatestDescendant(resumeParam, scopedProfile) .then((res) => { if (cancelled || !res.session_id || res.session_id === resumeParam) { return; } const next = new URLSearchParams(searchParams); next.set("resume", res.session_id); setSearchParams(next, { replace: true }); }) .catch(() => { // Best-effort: old servers or missing sessions should not block chat. }); return () => { cancelled = true; }; }, [resumeParam, scopedProfile, searchParams, setSearchParams]); `, String.raw` useEffect(() => { if (!lineageRoot) return; let cancelled = false; const followActiveLineage = async () => { try { const res = await api.getSessionLatestDescendant( lineageRoot, scopedProfile, ); if (cancelled || !res.session_id) return; setSearchParams( (previous) => { const next = new URLSearchParams(previous); const current = next.get("resume"); if (current === res.session_id) return previous; next.set("lineage_root", lineageRoot); next.set("resume", res.session_id); return next; }, { replace: true }, ); } catch { // Best-effort: old servers or missing sessions should not block chat. } }; void followActiveLineage(); const timer = window.setInterval(() => void followActiveLineage(), 2_000); return () => { cancelled = true; window.clearInterval(timer); }; }, [lineageRoot, scopedProfile, setSearchParams]); `, "active lineage follower", ); source = replaceOnce( source, ' next.delete("resume");\n', ' next.delete("resume");\n next.delete("lineage_root");\n', "fresh chat lineage reset", ); const webglStart = source.indexOf(" // WebGL draws from"); const webglEnd = source.indexOf( "\n\n // Initial fit + resize observer", webglStart, ); if (webglStart < 0 || webglEnd < 0) { throw new Error("xterm WebGL setup patch context changed"); } source = source.slice(0, webglStart) + ` // Use xterm's canvas renderer. It survives browser sleep and GPU-process // resets, while the server-side PTY replay restores any missed output. ` + source.slice(webglEnd + 2); source = replaceOnce( source, " term.open(host);\n", ` term.open(host); // Coalesce terminal repaints so buffered replay and visibility changes // cannot leave a valid xterm buffer hidden behind a stale renderer frame. let paintRaf = 0; const scheduleTerminalPaint = () => { if (paintRaf) return; paintRaf = requestAnimationFrame(() => { paintRaf = 0; if (!host.isConnected || term.rows <= 0) return; try { term.refresh(0, term.rows - 1); } catch { /* terminal disposed during a navigation */ } }); }; const refreshVisibleTerminal = () => { if (document.hidden) return; syncMetricsRef.current?.(); scheduleTerminalPaint(); }; document.addEventListener("visibilitychange", refreshVisibleTerminal); `, "xterm repaint scheduler", ); source = replaceOnce( source, ' ws.send(`\\x1b[RESIZE:${term.cols};${term.rows}]`);\n', ' ws.send(`\\x1b[RESIZE:${term.cols};${term.rows}]`);\n' + " scheduleTerminalPaint();\n", "xterm open repaint", ); source = replaceOnce( source, ` ws.onmessage = (ev) => { if (typeof ev.data === "string") { term.write(ev.data); } else { term.write(new Uint8Array(ev.data as ArrayBuffer)); } }; `, ` ws.onmessage = (ev) => { if (typeof ev.data === "string") { term.write(ev.data, scheduleTerminalPaint); } else { term.write(new Uint8Array(ev.data as ArrayBuffer), scheduleTerminalPaint); } }; `, "xterm replay repaint", ); source = replaceOnce( source, " ro.disconnect();\n", ` ro.disconnect(); document.removeEventListener("visibilitychange", refreshVisibleTerminal); if (paintRaf) cancelAnimationFrame(paintRaf); `, "xterm repaint cleanup", ); source = replaceOnce( source, `
{/* NS-504:`, `
{resumeParam && ( )} {/* NS-504:`, "accessible activity mount", ); source = source.replace( "@xterm/xterm Terminal (WebGL renderer, Unicode 11 widths)", "@xterm/xterm Terminal (repaintable canvas + accessible activity, Unicode 11 widths)", ); fs.writeFileSync(path, source); } // Child API workers are hidden from the stock list by default. Let the chat // tree opt into them without changing the Sessions page's root-only contract. { const path = "/opt/hermes/web/src/lib/api.ts"; let source = fs.readFileSync(path, "utf8"); source = replaceOnce( source, ` order: "created" | "recent" = "created", ) => fetchJSON( appendProfileParam( \`/api/sessions?limit=\${limit}&offset=\${offset}&order=\${order}\`, profile, ), ),`, ` order: "created" | "recent" = "created", includeChildren = false, ) => fetchJSON( appendProfileParam( \`/api/sessions?limit=\${limit}&offset=\${offset}&order=\${order}&include_children=\${includeChildren}\`, profile, ), ),`, "session child query option", ); source = replaceOnce( source, `export interface SessionMessagesResponse { session_id: string; messages: SessionMessage[]; }`, `export interface SessionMessagesResponse { session_id: string; messages: SessionMessage[]; total_messages?: number; }`, "session message total", ); source = replaceOnce( source, ` getSessionMessages: (id: string, profile = getManagementProfile()) => fetchJSON( appendProfileParam(\`/api/sessions/\${encodeURIComponent(id)}/messages\`, profile), ),`, ` getSessionMessages: ( id: string, profile = getManagementProfile(), limit?: number, ) => fetchJSON( appendProfileParam( \`/api/sessions/\${encodeURIComponent(id)}/messages\${ limit ? \`?limit=\${Math.max(1, Math.floor(limit))}\` : "" }\`, profile, ), ),`, "bounded session message query", ); fs.writeFileSync(path, source); } // The API already stores parent_session_id. Present it as a collapsible tree // instead of making durable Codex/Claude/API workers look like root chats. { const path = "/opt/hermes/web/src/components/ChatSessionList.tsx"; let source = fs.readFileSync(path, "utf8"); source = replaceOnce( source, 'import { AlertCircle, MessageSquarePlus, RefreshCw } from "lucide-react";', 'import { AlertCircle, ChevronDown, ChevronRight, GitBranch, MessageSquarePlus, RefreshCw } from "lucide-react";', "session-tree icons", ); source = replaceOnce( source, "const SESSION_LIMIT = 30;", "const SESSION_LIMIT = 100;", "session-tree list limit", ); source = replaceOnce( source, '.getSessions(SESSION_LIMIT, 0, scopeKey, "recent")', '.getSessions(SESSION_LIMIT, 0, scopeKey, "recent", true)', "session-tree child query", ); source = replaceOnce( source, ' next.set("resume", id);\n', ' next.delete("lineage_root");\n next.set("resume", id);\n', "manual session lineage reset", ); source = replaceOnce( source, ' next.delete("resume");\n', ' next.delete("resume");\n next.delete("lineage_root");\n', "new session lineage reset", ); source = replaceOnce( source, " const [reloadNonce, setReloadNonce] = useState(0);\n", ` const [reloadNonce, setReloadNonce] = useState(0); const [expandedParents, setExpandedParents] = useState>( () => new Set(), ); `, "session-tree expansion state", ); source = replaceOnce( source, " const content = useMemo(() => {\n", ` const sessionTree = useMemo(() => { const listed = sessions ?? []; const sessionIds = new Set(listed.map((session) => session.id)); const childrenByParent = new Map(); const roots: SessionInfo[] = []; for (const session of listed) { const parent = session.parent_session_id; if (parent && sessionIds.has(parent)) { const children = childrenByParent.get(parent) ?? []; children.push(session); childrenByParent.set(parent, children); } else { roots.push(session); } } return { childrenByParent, roots }; }, [sessions]); useEffect(() => { if (!activeSessionId) return; const active = sessions?.find((session) => session.id === activeSessionId); if (!active?.parent_session_id) return; setExpandedParents((previous) => { if (previous.has(active.parent_session_id!)) return previous; const next = new Set(previous); next.add(active.parent_session_id!); return next; }); }, [activeSessionId, sessions]); const toggleParent = useCallback((id: string) => { setExpandedParents((previous) => { const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const content = useMemo(() => { `, "session-tree derivation", ); const listStart = source.indexOf(" {sessions.map((s) => {"); const listEndMarker = " })}\n
"; const listEnd = source.indexOf(listEndMarker, listStart); if (listStart < 0 || listEnd < 0) { throw new Error("session-tree list rendering patch context changed"); } const listReplacement = String.raw` {sessionTree.roots.flatMap((root) => { const children = sessionTree.childrenByParent.get(root.id) ?? []; const rows: Array<{ session: SessionInfo; nested: boolean }> = [ { session: root, nested: false }, ]; if (expandedParents.has(root.id)) { rows.push(...children.map((session) => ({ session, nested: true }))); } return rows.map(({ session: s, nested }) => { const isActive = s.id === activeSessionId; const childCount = sessionTree.childrenByParent.get(s.id)?.length ?? 0; const expanded = expandedParents.has(s.id); return (
{childCount > 0 ? ( ) : nested ? ( ) : ( )} pick(s.id)} aria-current={isActive ? "true" : undefined} className={cn( "min-w-0 flex-1 flex-col items-start gap-0.5 rounded px-2 py-1.5", "normal-case tracking-normal", isActive ? "bg-primary/10 text-foreground border-l-2 border-primary" : "text-text-secondary hover:bg-midground/5 hover:text-foreground", )} > {rowLabel(s, t.sessions.untitledSession)} {childCount > 0 && ( {childCount} workers )} {timeAgo(s.last_active)} {s.message_count > 0 && ( <> · {s.message_count} msgs )} {s.source && s.source !== "cli" && ( <> · {s.source} )}
); }); })}`; source = source.slice(0, listStart) + listReplacement + source.slice(listEnd + " })}".length); source = replaceOnce( source, " }, [activeSessionId, error, loading, pick, reload, sessions, t]);", " }, [activeSessionId, error, expandedParents, loading, pick, reload, sessionTree, sessions, t, toggleParent]);", "session-tree memo dependencies", ); fs.writeFileSync(path, source); } // Telegram has one operator-managed bot, but every Keycloak user links their // own account. Put that workflow in normal navigation instead of a floating // button or a URL users have to remember. { const path = "/opt/hermes/web/src/App.tsx"; let source = fs.readFileSync(path, "utf8"); source = replaceOnce( source, "function RootRedirect() {\n", `function TelegramSetupPage() { return (

Telegram

Atlas uses one operator-managed Hermes bot. Each Keycloak user links their own Telegram account once; conversations remain attached to that user's isolated family-chat workspace.

Link this account

Open the family-chat linking page, create a one-time code, then send it to the shared Hermes bot from your Telegram account.

Open Telegram setup
); } function RootRedirect() { `, "Telegram setup page", ); source = replaceOnce( source, ' "/channels": ChannelsPage,\n', ' "/channels": ChannelsPage,\n "/telegram": TelegramSetupPage,\n', "Telegram setup route", ); source = replaceOnce( source, ' { path: "/channels", label: "Channels", icon: Radio },\n', ' { path: "/channels", label: "Channels", icon: Radio },\n { path: "/telegram", label: "Telegram", icon: MessageSquare },\n', "Telegram setup navigation", ); fs.writeFileSync(path, source); } NODE RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 9 ;; *) exit 2 ;; esac # Reattachment reuses a live PTY, so a dashboard-only SIGCONT restores its # alternate screen and current viewport after a bounded replay. COPY dockerfiles/patch-hermes-terminal-replay.py /tmp/patch-hermes-terminal-replay.py COPY dockerfiles/hermes-terminal-replay-regression.py /tmp/hermes-terminal-replay-regression.py COPY dockerfiles/hermes-terminal-resume-regression.js /tmp/hermes-terminal-resume-regression.js RUN /opt/hermes/.venv/bin/python /tmp/patch-hermes-terminal-replay.py \ && HERMES_SOURCE_ROOT=/opt/hermes /opt/hermes/.venv/bin/python \ /tmp/hermes-terminal-replay-regression.py \ && node /tmp/hermes-terminal-resume-regression.js \ && node --check /opt/hermes/ui-tui/dist/entry.js # The dashboard keeps keyboard and paste handling unchanged, but forwards only # bounded SGR wheel reports when the TUI has explicitly enabled mouse tracking. COPY dockerfiles/patch-hermes-dashboard-wheel.js /tmp/patch-hermes-dashboard-wheel.js COPY dockerfiles/hermes-dashboard-terminal-input.ts /opt/hermes/web/src/lib/dashboard-terminal-input.ts COPY dockerfiles/hermes-dashboard-terminal-input.test.ts /opt/hermes/web/src/lib/dashboard-terminal-input.test.ts RUN node /tmp/patch-hermes-dashboard-wheel.js COPY dockerfiles/patch-hermes-execution-safety.py /tmp/patch-hermes-execution-safety.py COPY dockerfiles/hermes-execution-safety-regression.py /tmp/hermes-execution-safety-regression.py COPY dockerfiles/hermes_execution_patch_support.py /tmp/hermes_execution_patch_support.py COPY dockerfiles/patch_hermes_run_safety.py /tmp/patch_hermes_run_safety.py COPY dockerfiles/patch_hermes_decomposition_safety.py /tmp/patch_hermes_decomposition_safety.py COPY dockerfiles/hermes_execution_regression_support.py /tmp/hermes_execution_regression_support.py COPY dockerfiles/hermes_run_safety_regression.py /tmp/hermes_run_safety_regression.py COPY dockerfiles/hermes_decomposition_safety_regression.py /tmp/hermes_decomposition_safety_regression.py COPY dockerfiles/hermes_lane_compatibility_regression.py /tmp/hermes_lane_compatibility_regression.py COPY services/hermes/scripts/*.py /tmp/hermes-lane-regression/ RUN HERMES_CLI_LANE_SOURCE=/tmp/hermes-lane-regression \ HERMES_COMPATIBILITY_MODE=legacy \ /opt/hermes/.venv/bin/python /tmp/hermes_lane_compatibility_regression.py \ && /opt/hermes/.venv/bin/python /tmp/patch-hermes-execution-safety.py \ && HERMES_CLI_LANE_SOURCE=/tmp/hermes-lane-regression \ HERMES_COMPATIBILITY_MODE=patched \ /opt/hermes/.venv/bin/python /tmp/hermes_lane_compatibility_regression.py \ && HERMES_CLI_LANE_SOURCE=/tmp/hermes-lane-regression \ /opt/hermes/.venv/bin/python /tmp/hermes-execution-safety-regression.py \ && rm /tmp/patch-hermes-execution-safety.py \ /tmp/hermes-execution-safety-regression.py \ /tmp/hermes_execution_patch_support.py \ /tmp/patch_hermes_run_safety.py \ /tmp/patch_hermes_decomposition_safety.py \ /tmp/hermes_execution_regression_support.py \ /tmp/hermes_run_safety_regression.py \ /tmp/hermes_decomposition_safety_regression.py \ /tmp/hermes_lane_compatibility_regression.py \ && find /tmp/hermes-lane-regression -depth -delete COPY dockerfiles/hermes-session-migrate.py /opt/hermes/bin/hermes-session-migrate RUN rm -f /tmp/hermes-agent.Dockerfile /tmp/hermes-kaniko-heredoc-runner.py RUN cd /opt/hermes/web \ && npm test -- src/lib/dashboard-terminal-input.test.ts \ && npm run build \ && grep -Fq 'await api.getSessions(1, 0' src/pages/ChatPage.tsx \ && grep -Fq 'api.getSessions(1, 0' src/components/ChatSidebar.tsx \ && grep -Fq 'if (unmounting) return;' src/pages/ChatPage.tsx \ && grep -Fq 'resume:${resumeParam}' src/pages/ChatPage.tsx \ && grep -Fq 'followActiveLineage' src/pages/ChatPage.tsx \ && grep -Fq 'lineage_root' src/components/ChatSessionList.tsx \ && grep -Fq 'eventsRetryAttempt.current' src/components/ChatSidebar.tsx \ && grep -Fq 'reconnecting…' src/components/ChatSidebar.tsx \ && grep -Fq 'redirectToProxyLogin' src/lib/api.ts \ && grep -Fq '/oauth2/start?rd=' src/lib/api.ts \ && grep -Fq 'HERMES_DASHBOARD_OIDC_ALLOWED_USER_IDS' \ /opt/hermes/hermes_cli/dashboard_auth/middleware.py \ && grep -Fq '_resolve_request_route' \ /opt/hermes/gateway/platforms/api_server.py \ && grep -Fq 'reasoning_effort=body.get("reasoning_effort")' \ /opt/hermes/gateway/platforms/api_server.py \ && grep -Fq "status IN ('running', 'ready', 'blocked', 'scheduled')" \ /opt/hermes/hermes_cli/kanban_db.py \ && grep -Fq 'Atomically mark running, ready, blocked, or scheduled tasks done' \ /opt/hermes/hermes_cli/kanban.py \ && grep -Fq 'routing_priority=body.get("routing_priority")' \ /opt/hermes/gateway/platforms/api_server.py \ && grep -Fq 'agent._hermes_explicit_model_pick' \ /opt/hermes/gateway/platforms/api_server.py \ && grep -Fq 'managed_claude_code_subscription' \ /opt/hermes/hermes_cli/web_server.py \ && grep -Fq 'Claude Code subscription (Switchyard)' \ /opt/hermes/hermes_cli/web_server.py \ && ! grep -Fq 'WebglAddon' src/pages/ChatPage.tsx \ && grep -Fq 'scheduleTerminalPaint' src/pages/ChatPage.tsx \ && ! grep -Fq 'attachCustomWheelEventHandler' src/pages/ChatPage.tsx \ && grep -Fq 'isSgrWheelReport(data)' src/pages/ChatPage.tsx \ && grep -Fq 'sessionTree.childrenByParent' \ src/components/ChatSessionList.tsx \ && grep -Fq 'getSessions(SESSION_LIMIT, 0, scopeKey, "recent", true)' \ src/components/ChatSessionList.tsx \ && grep -Fq 'SessionActivityPanel' src/pages/ChatPage.tsx \ && grep -Fq 'Poll-backed transcript; terminal rendering is not required.' \ src/components/SessionActivityPanel.tsx \ && grep -Fq 'include_children=${includeChildren}' src/lib/api.ts \ && grep -Fq '_dashboard_session_is_active' \ /opt/hermes/hermes_cli/web_server.py \ && grep -Fq 'Open Telegram setup' src/App.tsx \ && grep -Fq '"pre_turn_route"' /opt/hermes/hermes_cli/plugins.py \ && grep -Fq '"pre_internal_route"' /opt/hermes/hermes_cli/plugins.py \ && grep -Fq '"pre_subagent_route"' /opt/hermes/hermes_cli/plugins.py \ && grep -Fq 'invoke_hook(' /opt/hermes/agent/turn_context.py \ && grep -Fq 'pre_internal_route hook failed' \ /opt/hermes/agent/conversation_loop.py \ && grep -Fq 'pre_subagent_route hook failed' \ /opt/hermes/tools/delegate_tool.py \ && /opt/hermes/.venv/bin/python -m py_compile \ /opt/hermes/gateway/platforms/api_server.py \ /opt/hermes/agent/turn_context.py \ /opt/hermes/agent/conversation_loop.py \ /opt/hermes/hermes_cli/oneshot.py \ /opt/hermes/hermes_cli/kanban_decompose.py \ /opt/hermes/gateway/kanban_watchers.py \ /opt/hermes/tools/delegate_tool.py \ /opt/hermes/tools/web_tools.py \ /opt/hermes/tools/python_sandbox_tool.py \ /opt/hermes/plugins/web/public_extract/provider.py \ && /opt/hermes/.venv/bin/python -c 'import ddgs' \ && chmod 0755 /opt/hermes/bin/hermes-session-migrate