2026-08-02 15:53:30 -03:00
|
|
|
# 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
|
2026-08-02 15:53:30 -03:00
|
|
|
|
|
|
|
|
USER root
|
|
|
|
|
|
2026-08-10 02:15:34 -03:00
|
|
|
# Codex uses the distribution bubblewrap package when a bounded sandbox is
|
2026-08-10 05:45:36 -03:00
|
|
|
# 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.
|
2026-08-10 02:15:34 -03:00
|
|
|
RUN apt-get update \
|
2026-08-10 05:45:36 -03:00
|
|
|
&& apt-get install -y --no-install-recommends bubblewrap tmux \
|
2026-08-10 02:15:34 -03:00
|
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
|
|
2026-08-08 23:24:58 -03:00
|
|
|
# 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
|
|
|
|
|
|
2026-08-16 20:04:17 -03:00
|
|
|
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.
|
2026-08-02 15:53:30 -03:00
|
|
|
RUN node <<'NODE'
|
|
|
|
|
const fs = require("node:fs");
|
2026-08-11 02:23:15 -03:00
|
|
|
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);
|
|
|
|
|
|
2026-08-02 15:53:30 -03:00
|
|
|
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 = [
|
2026-08-02 15:53:30 -03:00
|
|
|
' 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 = [
|
2026-08-10 23:57:32 -03:00
|
|
|
' let url: string;',
|
|
|
|
|
' try {',
|
2026-08-11 03:50:13 -03:00
|
|
|
' // WebSocket upgrades cannot expose an HTTP 401 reliably through',
|
2026-08-11 04:05:46 -03:00
|
|
|
' // 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 ?? "");',
|
2026-08-10 23:57:32 -03:00
|
|
|
' url = await api.buildWsUrl("/api/pty", params);',
|
|
|
|
|
' } catch {',
|
|
|
|
|
' if (!unmounting) scheduleReconnect(1006);',
|
|
|
|
|
' return;',
|
|
|
|
|
' }',
|
2026-08-02 15:53:30 -03:00
|
|
|
' 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 15:53:30 -03:00
|
|
|
|
2026-08-02 16:46:21 -03:00
|
|
|
if (!source.includes(socketBefore)) {
|
2026-08-02 15:53:30 -03:00
|
|
|
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);
|
2026-08-10 23:44:56 -03:00
|
|
|
|
|
|
|
|
// 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");
|
2026-08-10 23:57:32 -03:00
|
|
|
const ticketBefore = [
|
|
|
|
|
' const url = await buildWsUrl("/api/events", { channel });',
|
|
|
|
|
' if (unmounting) {',
|
|
|
|
|
' return;',
|
|
|
|
|
' }',
|
|
|
|
|
' ws = new WebSocket(url);',
|
|
|
|
|
].join("\n");
|
|
|
|
|
const ticketAfter = [
|
|
|
|
|
' let url: string;',
|
|
|
|
|
' try {',
|
2026-08-11 03:50:13 -03:00
|
|
|
' // 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.',
|
2026-08-11 04:05:46 -03:00
|
|
|
' await api.getSessions(1, 0, profile ?? "");',
|
2026-08-10 23:57:32 -03:00
|
|
|
' 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");
|
2026-08-10 23:44:56 -03:00
|
|
|
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");
|
2026-08-11 03:50:13 -03:00
|
|
|
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.',
|
2026-08-11 04:05:46 -03:00
|
|
|
' api.getSessions(1, 0, profile ?? "")',
|
2026-08-11 03:50:13 -03:00
|
|
|
' .then(() => gw.connect())',
|
|
|
|
|
' .then(() => {',
|
|
|
|
|
].join("\n");
|
2026-08-10 23:44:56 -03:00
|
|
|
for (const [before, after, label] of [
|
|
|
|
|
[retryStateBefore, retryStateAfter, "retry state"],
|
|
|
|
|
[socketStateBefore, socketStateAfter, "socket state"],
|
2026-08-10 23:57:32 -03:00
|
|
|
[ticketBefore, ticketAfter, "ticket retry"],
|
2026-08-10 23:44:56 -03:00
|
|
|
[handlersBefore, handlersAfter, "socket handlers"],
|
|
|
|
|
[cleanupBefore, cleanupAfter, "socket cleanup"],
|
2026-08-11 03:50:13 -03:00
|
|
|
[gatewayBefore, gatewayAfter, "gateway auth preflight"],
|
2026-08-10 23:44:56 -03:00
|
|
|
]) {
|
|
|
|
|
if (!sidebar.includes(before)) {
|
|
|
|
|
throw new Error(`Hermes ChatSidebar ${label} patch context changed`);
|
|
|
|
|
}
|
|
|
|
|
sidebar = sidebar.replace(before, after);
|
|
|
|
|
}
|
|
|
|
|
fs.writeFileSync(sidebarPath, sidebar);
|
2026-08-02 15:53:30 -03:00
|
|
|
NODE
|
2026-08-16 20:04:17 -03:00
|
|
|
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
|
2026-08-02 15:53:30 -03:00
|
|
|
|
2026-08-02 17:00:40 -03:00
|
|
|
# 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
|
|
|
|
|
|
2026-08-02 17:00:40 -03:00
|
|
|
path = Path("/opt/hermes/hermes_cli/dashboard_auth/middleware.py")
|
2026-08-02 16:46:21 -03:00
|
|
|
source = path.read_text()
|
2026-08-02 17:00:40 -03:00
|
|
|
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
|
|
|
|
|
|
|
|
'''
|
2026-08-02 17:00:40 -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
|
|
|
|
|
|
|
|
|
|
|
|
|
'''
|
2026-08-02 17:00:40 -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
|
2026-08-16 20:04:17 -03:00
|
|
|
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
|
|
|
|
2026-08-09 02:42:11 -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",
|
2026-08-10 04:31:41 -03:00
|
|
|
"pre_internal_route",
|
2026-08-10 05:16:30 -03:00
|
|
|
"pre_subagent_route",
|
2026-08-09 02:42:11 -03:00
|
|
|
"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))
|
2026-08-10 04:31:41 -03:00
|
|
|
|
|
|
|
|
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))
|
2026-08-10 05:16:30 -03:00
|
|
|
|
|
|
|
|
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))
|
2026-08-15 21:48:30 -03:00
|
|
|
|
|
|
|
|
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))
|
2026-08-09 02:42:11 -03:00
|
|
|
PY
|
2026-08-16 20:04:17 -03:00
|
|
|
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
|
2026-08-09 02:42:11 -03:00
|
|
|
|
2026-08-08 23:24:58 -03:00
|
|
|
# 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")
|
2026-08-11 20:22:26 -03:00
|
|
|
# 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"}
|
2026-08-08 23:24:58 -03:00
|
|
|
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,
|
2026-08-11 16:22:19 -03:00
|
|
|
routing_priority: Any = None,
|
|
|
|
|
explicit_model_pick: Any = None,
|
|
|
|
|
explicit_reasoning_effort: Any = None,
|
2026-08-08 23:24:58 -03:00
|
|
|
) -> 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"),
|
2026-08-11 16:22:19 -03:00
|
|
|
routing_priority=body.get("routing_priority"),
|
|
|
|
|
explicit_model_pick=body.get("explicit_model_pick"),
|
|
|
|
|
explicit_reasoning_effort=body.get("explicit_reasoning_effort"),
|
2026-08-08 23:24:58 -03:00
|
|
|
)
|
|
|
|
|
'''
|
|
|
|
|
|
2026-08-11 16:22:19 -03:00
|
|
|
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
|
|
|
|
|
'''
|
|
|
|
|
|
2026-08-08 23:24:58 -03:00
|
|
|
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),
|
2026-08-11 16:22:19 -03:00
|
|
|
(agent_controls_before, agent_controls_after, "request routing controls", 1),
|
2026-08-08 23:24:58 -03:00
|
|
|
):
|
|
|
|
|
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
|
2026-08-16 20:04:17 -03:00
|
|
|
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
|
2026-08-08 23:24:58 -03:00
|
|
|
|
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.
|
2026-08-16 04:31:00 -03:00
|
|
|
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)}"
|
|
|
|
|
)
|
|
|
|
|
|
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
|
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.
|
2026-08-16 04:31:00 -03:00
|
|
|
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
|
2026-08-16 20:04:17 -03:00
|
|
|
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
|
2026-08-16 04:31:00 -03:00
|
|
|
|
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
|
|
|
|
|
|
2026-08-08 23:24:58 -03:00
|
|
|
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
|
|
|
|
|
|
2026-08-08 23:59:05 -03:00
|
|
|
# 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
|
2026-08-16 20:04:17 -03:00
|
|
|
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
|
2026-08-08 23:59:05 -03:00
|
|
|
|
2026-08-13 00:12:02 -03:00
|
|
|
# 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
|
2026-08-16 20:04:17 -03:00
|
|
|
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
|
2026-08-13 00:12:02 -03:00
|
|
|
|
2026-08-15 13:58:42 -03:00
|
|
|
# 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
|
2026-08-16 20:04:17 -03:00
|
|
|
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
|
2026-08-15 13:58:42 -03:00
|
|
|
|
2026-08-13 02:49:41 -03:00
|
|
|
# Keep the browser terminal reliable across GPU context loss, make durable
|
2026-08-15 13:58:42 -03:00
|
|
|
# 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
|
|
|
|
|
|
2026-08-13 02:49:41 -03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 03:33:41 -03:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 02:49:41 -03:00
|
|
|
// 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");
|
2026-08-15 13:58:42 -03:00
|
|
|
source = replaceOnce(
|
|
|
|
|
source,
|
|
|
|
|
'import { ChatSessionList } from "@/components/ChatSessionList";\n',
|
|
|
|
|
'import { ChatSessionList } from "@/components/ChatSessionList";\n' +
|
|
|
|
|
'import { SessionActivityPanel } from "@/components/SessionActivityPanel";\n',
|
|
|
|
|
"accessible activity import",
|
|
|
|
|
);
|
2026-08-13 02:49:41 -03:00
|
|
|
source = replaceOnce(
|
|
|
|
|
source,
|
|
|
|
|
'import { WebglAddon } from "@xterm/addon-webgl";\n',
|
|
|
|
|
"",
|
|
|
|
|
"xterm WebGL import",
|
|
|
|
|
);
|
2026-08-16 01:10:47 -03:00
|
|
|
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",
|
|
|
|
|
);
|
2026-08-13 02:49:41 -03:00
|
|
|
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",
|
|
|
|
|
);
|
2026-08-15 13:58:42 -03:00
|
|
|
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",
|
|
|
|
|
);
|
2026-08-13 02:49:41 -03:00
|
|
|
source = source.replace(
|
|
|
|
|
"@xterm/xterm Terminal (WebGL renderer, Unicode 11 widths)",
|
2026-08-15 13:58:42 -03:00
|
|
|
"@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",
|
2026-08-13 02:49:41 -03:00
|
|
|
);
|
2026-08-16 01:10:47 -03:00
|
|
|
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",
|
|
|
|
|
);
|
2026-08-13 02:49:41 -03:00
|
|
|
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",
|
|
|
|
|
);
|
2026-08-15 13:58:42 -03:00
|
|
|
source = replaceOnce(
|
|
|
|
|
source,
|
|
|
|
|
'.getSessions(SESSION_LIMIT, 0, scopeKey, "recent")',
|
|
|
|
|
'.getSessions(SESSION_LIMIT, 0, scopeKey, "recent", true)',
|
|
|
|
|
"session-tree child query",
|
|
|
|
|
);
|
2026-08-16 01:10:47 -03:00
|
|
|
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",
|
|
|
|
|
);
|
2026-08-13 02:49:41 -03:00
|
|
|
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
|
2026-08-21 06:43:18 +00:00
|
|
|
href="https://chat.bstein.dev/telegram"
|
2026-08-13 02:49:41 -03:00
|
|
|
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
|
2026-08-16 20:04:17 -03:00
|
|
|
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
|
2026-08-13 02:49:41 -03:00
|
|
|
|
2026-08-16 19:57:54 -03:00
|
|
|
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
|
2026-08-16 21:55:28 -03:00
|
|
|
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
|
2026-08-16 21:55:28 -03:00
|
|
|
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 \
|
2026-08-16 21:55:28 -03:00
|
|
|
&& HERMES_CLI_LANE_SOURCE=/tmp/hermes-lane-regression \
|
|
|
|
|
/opt/hermes/.venv/bin/python /tmp/hermes-execution-safety-regression.py \
|
2026-08-16 19:57:54 -03:00
|
|
|
&& rm /tmp/patch-hermes-execution-safety.py \
|
2026-08-16 21:55:28 -03:00
|
|
|
/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 \
|
2026-08-16 21:55:28 -03:00
|
|
|
&& find /tmp/hermes-lane-regression -depth -delete
|
2026-08-16 19:57:54 -03:00
|
|
|
|
2026-08-02 16:46:21 -03:00
|
|
|
COPY dockerfiles/hermes-session-migrate.py /opt/hermes/bin/hermes-session-migrate
|
|
|
|
|
|
2026-08-16 20:04:17 -03:00
|
|
|
RUN rm -f /tmp/hermes-agent.Dockerfile /tmp/hermes-kaniko-heredoc-runner.py
|
|
|
|
|
|
2026-08-02 15:53:30 -03:00
|
|
|
RUN cd /opt/hermes/web \
|
|
|
|
|
&& npm run build \
|
2026-08-11 04:05:46 -03:00
|
|
|
&& 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 \
|
2026-08-16 01:10:47 -03:00
|
|
|
&& grep -Fq 'followActiveLineage' src/pages/ChatPage.tsx \
|
|
|
|
|
&& grep -Fq 'lineage_root' src/components/ChatSessionList.tsx \
|
2026-08-10 23:44:56 -03:00
|
|
|
&& grep -Fq 'eventsRetryAttempt.current' src/components/ChatSidebar.tsx \
|
|
|
|
|
&& grep -Fq 'reconnecting…' src/components/ChatSidebar.tsx \
|
2026-08-11 02:23:15 -03:00
|
|
|
&& 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' \
|
2026-08-02 17:00:40 -03:00
|
|
|
/opt/hermes/hermes_cli/dashboard_auth/middleware.py \
|
2026-08-08 23:24:58 -03:00
|
|
|
&& 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 \
|
2026-08-16 04:31:00 -03:00
|
|
|
&& 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 \
|
2026-08-11 16:22:19 -03:00
|
|
|
&& 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 \
|
2026-08-13 00:12:02 -03:00
|
|
|
&& 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 \
|
2026-08-13 02:49:41 -03:00
|
|
|
&& ! grep -Fq 'WebglAddon' src/pages/ChatPage.tsx \
|
|
|
|
|
&& grep -Fq 'scheduleTerminalPaint' src/pages/ChatPage.tsx \
|
|
|
|
|
&& grep -Fq 'sessionTree.childrenByParent' \
|
|
|
|
|
src/components/ChatSessionList.tsx \
|
2026-08-15 13:58:42 -03:00
|
|
|
&& 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 \
|
2026-08-13 02:49:41 -03:00
|
|
|
&& grep -Fq 'Open Telegram setup' src/App.tsx \
|
2026-08-09 02:42:11 -03:00
|
|
|
&& grep -Fq '"pre_turn_route"' /opt/hermes/hermes_cli/plugins.py \
|
2026-08-10 04:31:41 -03:00
|
|
|
&& grep -Fq '"pre_internal_route"' /opt/hermes/hermes_cli/plugins.py \
|
2026-08-10 05:16:30 -03:00
|
|
|
&& grep -Fq '"pre_subagent_route"' /opt/hermes/hermes_cli/plugins.py \
|
2026-08-09 02:42:11 -03:00
|
|
|
&& grep -Fq 'invoke_hook(' /opt/hermes/agent/turn_context.py \
|
2026-08-10 04:31:41 -03:00
|
|
|
&& grep -Fq 'pre_internal_route hook failed' \
|
|
|
|
|
/opt/hermes/agent/conversation_loop.py \
|
2026-08-10 05:16:30 -03:00
|
|
|
&& grep -Fq 'pre_subagent_route hook failed' \
|
|
|
|
|
/opt/hermes/tools/delegate_tool.py \
|
2026-08-08 23:24:58 -03:00
|
|
|
&& /opt/hermes/.venv/bin/python -m py_compile \
|
|
|
|
|
/opt/hermes/gateway/platforms/api_server.py \
|
2026-08-09 02:42:11 -03:00
|
|
|
/opt/hermes/agent/turn_context.py \
|
2026-08-10 04:31:41 -03:00
|
|
|
/opt/hermes/agent/conversation_loop.py \
|
2026-08-15 21:48:30 -03:00
|
|
|
/opt/hermes/hermes_cli/oneshot.py \
|
2026-08-16 19:57:54 -03:00
|
|
|
/opt/hermes/hermes_cli/kanban_decompose.py \
|
|
|
|
|
/opt/hermes/gateway/kanban_watchers.py \
|
2026-08-10 05:16:30 -03:00
|
|
|
/opt/hermes/tools/delegate_tool.py \
|
2026-08-08 23:59:05 -03:00
|
|
|
/opt/hermes/tools/web_tools.py \
|
2026-08-08 23:24:58 -03:00
|
|
|
/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
|