atlas-iac/dockerfiles/Dockerfile.hermes-agent

1668 lines
66 KiB
Docker
Raw Normal View History

# syntax=docker/dockerfile:1
# dockerfiles/Dockerfile.hermes-agent
build(hermes-agent): multi-arch image via two native kaniko legs Repoint the hermes-agent base FROM at the upstream multi-arch OCI INDEX digest (tag v2026.7.7.2, revision 9de9c25f) whose arm64 leaf is byte-for-byte the previously pinned single-arch base, so the arm64 build is unchanged while the same reviewed version now also resolves an amd64 leaf. Kaniko selects the matching leaf per build platform. Rework the release pipeline to build both arches natively and promote a multi-arch image without switching off kaniko or weakening any existing security assertion: - Keep the arm64 kaniko leg on the unchanged rpi5 coordinating pod; it now pushes an arch-suffixed candidate tag (...-build-<N>-arm64). - Add a second native amd64 kaniko leg on a titan-24-pinned, tolerating, resource-capped pod (ceiling strictly below the arm64 leg) that independently re-verifies the reviewed revision and stashes its leaf evidence (...-build-<N>-amd64). - Add ci/scripts/hermes_multiarch_combine.py: a pure-python, fail-closed combiner that re-reads each per-arch leaf from the registry, proves its digest AND its config architecture, assembles a Docker manifest LIST (already inside the promote allow-list), refuses to overwrite an existing final tag, publishes the arch-less ...-build-<N> tag, and re-verifies the registry resolved the exact index referencing exactly the two leaves. It emits the index digest in the SAME digest-file/image-file format the single-arch step produced, so render/verify-evidence/hermes_oci_promote.py promote the INDEX with no change to those scripts. Tests: add test_hermes_multiarch_combine.py (full hash/verification chain); strengthen the image-builder suites for the two-arch topology (both kaniko legs carry the reviewed heredoc-compat build-arg; amd64 leg pinned+capped+ boundary-checked; combine stage wiring; expanded evidence archive) without weakening the arm64-leg assertions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-25 11:05:29 -03:00
#
# 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.
FROM nousresearch/hermes-agent@sha256:9c841866021c54c4596849f6135717e8a4d52ba510b7f52c50aef1de1a283973
USER root
# Codex uses the distribution bubblewrap package when a bounded sandbox is
# requested. tmux keeps the stock Hermes TUI alive across browser reconnects;
2026-08-10 17:05:14 -03:00
# 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
2026-08-02 16:46:21 -03:00
# 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<T>(): Promise<T> {',
' 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<T>(() => {});',
'}',
].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<T>();',
' }',
'',
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";
2026-08-02 16:46:21 -03:00
let source = fs.readFileSync(path, "utf8");
const socketBefore = [
' const url = await api.buildWsUrl("/api/pty", params);',
' const ws = new WebSocket(url);',
].join("\n");
2026-08-02 16:46:21 -03:00
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");
2026-08-02 16:46:21 -03:00
const attachBefore = ' params.attach = ptyAttachToken(forceFresh);';
const attachAfter = [
' const attachScope = resumeParam',
' ? `resume:${resumeParam}:${scopedProfile ?? ""}`',
' : `fresh:${scopedProfile ?? ""}`;',
' params.attach = `${ptyAttachToken(forceFresh)}:${attachScope}`;',
].join("\n");
2026-08-02 16:46:21 -03:00
if (!source.includes(socketBefore)) {
throw new Error("Hermes ChatPage WebSocket patch context changed");
}
2026-08-02 16:46:21 -03:00
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<typeof setTimeout> | 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.
2026-08-02 16:46:21 -03:00
RUN python - <<'PY'
from pathlib import Path
path = Path("/opt/hermes/hermes_cli/dashboard_auth/middleware.py")
2026-08-02 16:46:21 -03:00
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 ""
2026-08-02 16:46:21 -03:00
'''
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,
)
2026-08-02 16:46:21 -03:00
'''
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)
2026-08-02 16:46:21 -03:00
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
2026-08-02 16:46:21 -03:00
# 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
hermes: harden worker isolation and blocked-task semantics Three narrowly scoped Hermes reliability fixes backed by live evidence from the Cassandra/titan-iac proof run. Worker concurrency. Three simultaneous direct CLI workers on the 4-core hermes-agent node drove load to ~45 and made the hermes and oauth2-proxy containers fail their probes, leaving the pod 8/10 Ready; two workers stayed at 10/10. Cap HERMES_CLI_LANE_CONCURRENCY at 2 and lower the cli-lane-runner CPU limit from 3 to 2 so the dashboard and auth sidecars keep a guaranteed share of the node. Requests are unchanged: the pod still asks for 745m total, so placement does not move. Service links. Kubernetes injects a service-link variable pair for every service in the namespace, and hermes-claude-broker produces HERMES_CLAUDE_BROKER_PORT=tcp://10.43.31.76:9006 — a value the broker parses as an int. That contaminated worker and test environments even though the deployment already addresses every service by DNS name. Set enableServiceLinks: false on the hermes-agent pod spec. Blocked-task scheduling. create_task(initial_status="blocked") records a created event carrying status=blocked but never a blocked event, while _has_sticky_block() only inspects blocked/unblocked events. recompute_ready() considers blocked tasks, so an explicitly parked task with no incomplete parent auto-promoted on the next dispatcher cycle. Teach _has_sticky_block() to also recognize a created event whose payload status is blocked, which covers tasks created before this image patch without adding a persisted field. Dependency-driven promotion and the circuit-breaker failure-limit guard are untouched; unblock_task() still releases either kind of block. hermes-kanban-blocked-regression.py runs against the real upstream kanban_db API during the image build, so the build fails if any of these semantics regress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:53:22 +00:00
# ``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()
hermes: fail closed on kanban created-event producer drift The sticky-block gate added in the previous commit classifies a task from the `created` event payload that upstream `create_task` writes. That producer is code we do not own, so trusting it silently was the gap: if upstream renamed the key, dropped it, or stopped deriving it from `initial_status`, the image would still build and ship a consumer that mis-classifies every task it reads. Anchor the producer contract at build time, before the regression suite runs, with three assert-only preconditions: the `initial_status="blocked"` park resolves `task_status` to `"blocked"`, every non-park creation resolves it to something else, and the `created` event carries that same variable under `"status"`. None of them rewrite the producer. Textual anchors cannot see dataflow, so add the runtime net the reviewer asked for. The suite now drives the real API: create + claim an ordinary task, trip the circuit breaker once at failure_limit=1 so it parks with a `gave_up` event (leaving its own `created` event as the most recent create/block/unblock row), then recompute at failure_limit=2 and require promotion to ready. That case is red under an unconditional-true created predicate and red under producer drift that labels every created event blocked, while the explicit block/unblock, dependency-promotion and circuit-breaker-at-current-limit cases stay green. Non-blocked and malformed created payloads are pinned as controls, and the gate now rejects non-dict payloads rather than trusting `.get`. Also make the live placement correction durable: titan-04 is cordoned after repeated kernel undervoltage and kubelet failure and titan-19 was probe/Longhorn unstable under worker load, so both join the hard NotIn list; titan-05 is healthy but sits at 3592m/3600m requested CPU, so the main hermes container gives back 50m (350m -> 300m) to schedule there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:42:18 +00:00
# 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)}"
)
hermes: harden worker isolation and blocked-task semantics Three narrowly scoped Hermes reliability fixes backed by live evidence from the Cassandra/titan-iac proof run. Worker concurrency. Three simultaneous direct CLI workers on the 4-core hermes-agent node drove load to ~45 and made the hermes and oauth2-proxy containers fail their probes, leaving the pod 8/10 Ready; two workers stayed at 10/10. Cap HERMES_CLI_LANE_CONCURRENCY at 2 and lower the cli-lane-runner CPU limit from 3 to 2 so the dashboard and auth sidecars keep a guaranteed share of the node. Requests are unchanged: the pod still asks for 745m total, so placement does not move. Service links. Kubernetes injects a service-link variable pair for every service in the namespace, and hermes-claude-broker produces HERMES_CLAUDE_BROKER_PORT=tcp://10.43.31.76:9006 — a value the broker parses as an int. That contaminated worker and test environments even though the deployment already addresses every service by DNS name. Set enableServiceLinks: false on the hermes-agent pod spec. Blocked-task scheduling. create_task(initial_status="blocked") records a created event carrying status=blocked but never a blocked event, while _has_sticky_block() only inspects blocked/unblocked events. recompute_ready() considers blocked tasks, so an explicitly parked task with no incomplete parent auto-promoted on the next dispatcher cycle. Teach _has_sticky_block() to also recognize a created event whose payload status is blocked, which covers tasks created before this image patch without adding a persisted field. Dependency-driven promotion and the circuit-breaker failure-limit guard are untouched; unblock_task() still releases either kind of block. hermes-kanban-blocked-regression.py runs against the real upstream kanban_db API during the image build, so the build fails if any of these semantics regress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:53:22 +00:00
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
hermes: fail closed on kanban created-event producer drift The sticky-block gate added in the previous commit classifies a task from the `created` event payload that upstream `create_task` writes. That producer is code we do not own, so trusting it silently was the gap: if upstream renamed the key, dropped it, or stopped deriving it from `initial_status`, the image would still build and ship a consumer that mis-classifies every task it reads. Anchor the producer contract at build time, before the regression suite runs, with three assert-only preconditions: the `initial_status="blocked"` park resolves `task_status` to `"blocked"`, every non-park creation resolves it to something else, and the `created` event carries that same variable under `"status"`. None of them rewrite the producer. Textual anchors cannot see dataflow, so add the runtime net the reviewer asked for. The suite now drives the real API: create + claim an ordinary task, trip the circuit breaker once at failure_limit=1 so it parks with a `gave_up` event (leaving its own `created` event as the most recent create/block/unblock row), then recompute at failure_limit=2 and require promotion to ready. That case is red under an unconditional-true created predicate and red under producer drift that labels every created event blocked, while the explicit block/unblock, dependency-promotion and circuit-breaker-at-current-limit cases stay green. Non-blocked and malformed created payloads are pinned as controls, and the gate now rejects non-dict payloads rather than trusting `.get`. Also make the live placement correction durable: titan-04 is cordoned after repeated kernel undervoltage and kubelet failure and titan-19 was probe/Longhorn unstable under worker load, so both join the hard NotIn list; titan-05 is healthy but sits at 3592m/3600m requested CPU, so the main hermes container gives back 50m (350m -> 300m) to schedule there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:42:18 +00:00
if not isinstance(payload, dict):
return False
hermes: harden worker isolation and blocked-task semantics Three narrowly scoped Hermes reliability fixes backed by live evidence from the Cassandra/titan-iac proof run. Worker concurrency. Three simultaneous direct CLI workers on the 4-core hermes-agent node drove load to ~45 and made the hermes and oauth2-proxy containers fail their probes, leaving the pod 8/10 Ready; two workers stayed at 10/10. Cap HERMES_CLI_LANE_CONCURRENCY at 2 and lower the cli-lane-runner CPU limit from 3 to 2 so the dashboard and auth sidecars keep a guaranteed share of the node. Requests are unchanged: the pod still asks for 745m total, so placement does not move. Service links. Kubernetes injects a service-link variable pair for every service in the namespace, and hermes-claude-broker produces HERMES_CLAUDE_BROKER_PORT=tcp://10.43.31.76:9006 — a value the broker parses as an int. That contaminated worker and test environments even though the deployment already addresses every service by DNS name. Set enableServiceLinks: false on the hermes-agent pod spec. Blocked-task scheduling. create_task(initial_status="blocked") records a created event carrying status=blocked but never a blocked event, while _has_sticky_block() only inspects blocked/unblocked events. recompute_ready() considers blocked tasks, so an explicitly parked task with no incomplete parent auto-promoted on the next dispatcher cycle. Teach _has_sticky_block() to also recognize a created event whose payload status is blocked, which covers tasks created before this image patch without adding a persisted field. Dependency-driven promotion and the circuit-breaker failure-limit guard are untouched; unblock_task() still releases either kind of block. hermes-kanban-blocked-regression.py runs against the real upstream kanban_db API during the image build, so the build fails if any of these semantics regress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:53:22 +00:00
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
hermes: harden worker isolation and blocked-task semantics Three narrowly scoped Hermes reliability fixes backed by live evidence from the Cassandra/titan-iac proof run. Worker concurrency. Three simultaneous direct CLI workers on the 4-core hermes-agent node drove load to ~45 and made the hermes and oauth2-proxy containers fail their probes, leaving the pod 8/10 Ready; two workers stayed at 10/10. Cap HERMES_CLI_LANE_CONCURRENCY at 2 and lower the cli-lane-runner CPU limit from 3 to 2 so the dashboard and auth sidecars keep a guaranteed share of the node. Requests are unchanged: the pod still asks for 745m total, so placement does not move. Service links. Kubernetes injects a service-link variable pair for every service in the namespace, and hermes-claude-broker produces HERMES_CLAUDE_BROKER_PORT=tcp://10.43.31.76:9006 — a value the broker parses as an int. That contaminated worker and test environments even though the deployment already addresses every service by DNS name. Set enableServiceLinks: false on the hermes-agent pod spec. Blocked-task scheduling. create_task(initial_status="blocked") records a created event carrying status=blocked but never a blocked event, while _has_sticky_block() only inspects blocked/unblocked events. recompute_ready() considers blocked tasks, so an explicitly parked task with no incomplete parent auto-promoted on the next dispatcher cycle. Teach _has_sticky_block() to also recognize a created event whose payload status is blocked, which covers tasks created before this image patch without adding a persisted field. Dependency-driven promotion and the circuit-breaker failure-limit guard are untouched; unblock_task() still releases either kind of block. hermes-kanban-blocked-regression.py runs against the real upstream kanban_db API during the image build, so the build fails if any of these semantics regress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:53:22 +00:00
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,
' <body>\n <div id="root"></div>',
` <body>
<div
id="hermes-resume-bootstrap"
role="status"
aria-live="polite"
aria-busy="true"
hidden
style="position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;background:#000;color:#f4f0df;font:14px/1.5 ui-monospace,monospace;text-align:center"
>
<div>
<strong style="display:block;margin-bottom:8px">Opening Hermes worker activity…</strong>
<span>Connecting to the accessible, durable transcript.</span>
</div>
</div>
<script>
(() => {
const params = new URLSearchParams(window.location.search);
const loading = document.getElementById("hermes-resume-bootstrap");
if (loading && window.location.pathname === "/chat" && params.has("resume")) {
loading.hidden = false;
}
})();
</script>
<div id="root"></div>`,
"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,
` <div
ref={hostRef}
className="hermes-chat-xterm-host min-h-0 min-w-0 flex-1"
/>
{/* NS-504:`,
` <div
ref={hostRef}
className="hermes-chat-xterm-host min-h-0 min-w-0 flex-1"
/>
{resumeParam && (
<SessionActivityPanel
key={resumeParam + "\\0" + (scopedProfile ?? "")}
sessionId={resumeParam}
profile={scopedProfile}
/>
)}
{/* 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<PaginatedSessions>(
appendProfileParam(
\`/api/sessions?limit=\${limit}&offset=\${offset}&order=\${order}\`,
profile,
),
),`,
` order: "created" | "recent" = "created",
includeChildren = false,
) =>
fetchJSON<PaginatedSessions>(
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<SessionMessagesResponse>(
appendProfileParam(\`/api/sessions/\${encodeURIComponent(id)}/messages\`, profile),
),`,
` getSessionMessages: (
id: string,
profile = getManagementProfile(),
limit?: number,
) =>
fetchJSON<SessionMessagesResponse>(
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<Set<string>>(
() => 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<string, SessionInfo[]>();
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 </div>";
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 (
<div
key={s.id}
className={cn(
"flex min-w-0 items-stretch gap-0.5",
nested && "ml-4 border-l border-current/15 pl-1",
)}
>
{childCount > 0 ? (
<button
type="button"
aria-label={expanded ? "Collapse worker sessions" : "Expand worker sessions"}
aria-expanded={expanded}
title={childCount + " worker session" + (childCount === 1 ? "" : "s")}
onClick={() => toggleParent(s.id)}
className="flex w-5 shrink-0 items-center justify-center text-text-tertiary hover:text-foreground"
>
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
) : nested ? (
<span className="flex w-5 shrink-0 items-center justify-center text-text-tertiary" aria-hidden>
<GitBranch className="h-3 w-3" />
</span>
) : (
<span className="w-5 shrink-0" aria-hidden />
)}
<ListItem
onClick={() => 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",
)}
>
<span className="flex w-full items-center gap-1 truncate text-sm font-medium">
<span className="truncate">{rowLabel(s, t.sessions.untitledSession)}</span>
{childCount > 0 && (
<span className="shrink-0 text-[0.625rem] text-text-tertiary">
{childCount} workers
</span>
)}
</span>
<span className="flex w-full items-center gap-1.5 text-[0.6875rem] text-text-tertiary">
<span>{timeAgo(s.last_active)}</span>
{s.message_count > 0 && (
<>
<span aria-hidden>·</span>
<span>{s.message_count} msgs</span>
</>
)}
{s.source && s.source !== "cli" && (
<>
<span aria-hidden>·</span>
<span className="truncate">{s.source}</span>
</>
)}
</span>
</ListItem>
</div>
);
});
})}`;
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 (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-5 p-6 lg:p-10">
<div>
<h2 className="text-2xl font-bold">Telegram</h2>
<p className="mt-2 text-sm text-text-secondary">
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.
</p>
</div>
<div className="rounded border border-current/20 bg-midground/5 p-5">
<h3 className="text-sm font-bold tracking-wider">Link this account</h3>
<p className="mt-2 text-sm text-text-secondary">
Open the family-chat linking page, create a one-time code, then send
it to the shared Hermes bot from your Telegram account.
</p>
<a
href="https://chat.bstein.dev/telegram"
className="mt-4 inline-flex rounded border border-current/30 px-4 py-2 text-sm font-medium text-midground hover:bg-midground/10"
>
Open Telegram setup
</a>
</div>
</div>
);
}
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
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
2026-08-17 08:16:35 -03:00
COPY dockerfiles/hermes_lane_compatibility_regression.py /tmp/hermes_lane_compatibility_regression.py
COPY services/hermes/scripts/cli_lane_*.py /tmp/hermes-lane-regression/
2026-08-17 08:16:35 -03:00
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 \
2026-08-17 08:16:35 -03:00
/tmp/hermes_lane_compatibility_regression.py \
&& find /tmp/hermes-lane-regression -depth -delete
2026-08-02 16:46:21 -03:00
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 run build \
&& grep -Fq 'await api.getSessions(1, 0' src/pages/ChatPage.tsx \
&& grep -Fq 'api.getSessions(1, 0' src/components/ChatSidebar.tsx \
2026-08-02 16:46:21 -03:00
&& 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 \
2026-08-02 16:46:21 -03:00
&& 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 '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' \
2026-08-02 16:46:21 -03:00
&& chmod 0755 /opt/hermes/bin/hermes-session-migrate