From 009336fb6478baeaf0472e57f637bf6c373e843d Mon Sep 17 00:00:00 2001 From: jenkins Date: Thu, 13 Aug 2026 02:49:41 -0300 Subject: [PATCH] hermes: restore terminal rendering and worker lineage --- dockerfiles/Dockerfile.hermes-agent | 343 ++++++++++++++++++ services/hermes/agent-deployment.yaml | 7 +- services/hermes/kustomization.yaml | 2 +- .../hermes/oauth2-proxy-templates/error.html | 37 +- testing/tests/test_hermes_chat_quality.py | 29 +- 5 files changed, 410 insertions(+), 8 deletions(-) diff --git a/dockerfiles/Dockerfile.hermes-agent b/dockerfiles/Dockerfile.hermes-agent index 1bf51ae27..806a721ae 100644 --- a/dockerfiles/Dockerfile.hermes-agent +++ b/dockerfiles/Dockerfile.hermes-agent @@ -685,6 +685,344 @@ for before, after, label in replacements: path.write_text(source) PY +# Keep the browser terminal reliable across GPU context loss, make durable +# worker lineage visible, and expose the family Telegram-linking entry point. +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); +} + +// 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 { WebglAddon } from "@xterm/addon-webgl";\n', + "", + "xterm WebGL import", + ); + 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 = source.replace( + "@xterm/xterm Terminal (WebGL renderer, Unicode 11 widths)", + "@xterm/xterm Terminal (repaintable canvas, Unicode 11 widths)", + ); + 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, + " const [reloadNonce, setReloadNonce] = useState(0);\n", + ` const [reloadNonce, setReloadNonce] = useState(0); + const [expandedParents, setExpandedParents] = useState>( + () => new Set(), + ); +`, + "session-tree expansion state", + ); + source = replaceOnce( + source, + " const content = useMemo(() => {\n", + ` const sessionTree = useMemo(() => { + const listed = sessions ?? []; + const sessionIds = new Set(listed.map((session) => session.id)); + const childrenByParent = new Map(); + const roots: SessionInfo[] = []; + for (const session of listed) { + const parent = session.parent_session_id; + if (parent && sessionIds.has(parent)) { + const children = childrenByParent.get(parent) ?? []; + children.push(session); + childrenByParent.set(parent, children); + } else { + roots.push(session); + } + } + return { childrenByParent, roots }; + }, [sessions]); + + useEffect(() => { + if (!activeSessionId) return; + const active = sessions?.find((session) => session.id === activeSessionId); + if (!active?.parent_session_id) return; + setExpandedParents((previous) => { + if (previous.has(active.parent_session_id!)) return previous; + const next = new Set(previous); + next.add(active.parent_session_id!); + return next; + }); + }, [activeSessionId, sessions]); + + const toggleParent = useCallback((id: string) => { + setExpandedParents((previous) => { + const next = new Set(previous); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const content = useMemo(() => { +`, + "session-tree derivation", + ); + const listStart = source.indexOf(" {sessions.map((s) => {"); + const listEndMarker = " })}\n "; + const listEnd = source.indexOf(listEndMarker, listStart); + if (listStart < 0 || listEnd < 0) { + throw new Error("session-tree list rendering patch context changed"); + } + const listReplacement = String.raw` {sessionTree.roots.flatMap((root) => { + const children = sessionTree.childrenByParent.get(root.id) ?? []; + const rows: Array<{ session: SessionInfo; nested: boolean }> = [ + { session: root, nested: false }, + ]; + if (expandedParents.has(root.id)) { + rows.push(...children.map((session) => ({ session, nested: true }))); + } + return rows.map(({ session: s, nested }) => { + const isActive = s.id === activeSessionId; + const childCount = sessionTree.childrenByParent.get(s.id)?.length ?? 0; + const expanded = expandedParents.has(s.id); + return ( +
+ {childCount > 0 ? ( + + ) : nested ? ( + + + + ) : ( + + )} + pick(s.id)} + aria-current={isActive ? "true" : undefined} + className={cn( + "min-w-0 flex-1 flex-col items-start gap-0.5 rounded px-2 py-1.5", + "normal-case tracking-normal", + isActive + ? "bg-primary/10 text-foreground border-l-2 border-primary" + : "text-text-secondary hover:bg-midground/5 hover:text-foreground", + )} + > + + {rowLabel(s, t.sessions.untitledSession)} + {childCount > 0 && ( + + {childCount} workers + + )} + + + {timeAgo(s.last_active)} + {s.message_count > 0 && ( + <> + · + {s.message_count} msgs + + )} + {s.source && s.source !== "cli" && ( + <> + · + {s.source} + + )} + + +
+ ); + }); + })}`; + source = + source.slice(0, listStart) + + listReplacement + + source.slice(listEnd + " })}".length); + source = replaceOnce( + source, + " }, [activeSessionId, error, loading, pick, reload, sessions, t]);", + " }, [activeSessionId, error, expandedParents, loading, pick, reload, sessionTree, sessions, t, toggleParent]);", + "session-tree memo dependencies", + ); + fs.writeFileSync(path, source); +} + +// Telegram has one operator-managed bot, but every Keycloak user links their +// own account. Put that workflow in normal navigation instead of a floating +// button or a URL users have to remember. +{ + const path = "/opt/hermes/web/src/App.tsx"; + let source = fs.readFileSync(path, "utf8"); + source = replaceOnce( + source, + "function RootRedirect() {\n", + `function TelegramSetupPage() { + return ( +
+
+

Telegram

+

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

+
+
+

Link this account

+

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

+ + Open Telegram setup + +
+
+ ); +} + +function RootRedirect() { +`, + "Telegram setup page", + ); + source = replaceOnce( + source, + ' "/channels": ChannelsPage,\n', + ' "/channels": ChannelsPage,\n "/telegram": TelegramSetupPage,\n', + "Telegram setup route", + ); + source = replaceOnce( + source, + ' { path: "/channels", label: "Channels", icon: Radio },\n', + ' { path: "/channels", label: "Channels", icon: Radio },\n { path: "/telegram", label: "Telegram", icon: MessageSquare },\n', + "Telegram setup navigation", + ); + fs.writeFileSync(path, source); +} +NODE + COPY dockerfiles/hermes-session-migrate.py /opt/hermes/bin/hermes-session-migrate RUN cd /opt/hermes/web \ @@ -711,6 +1049,11 @@ RUN cd /opt/hermes/web \ /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 '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 \ diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index 7b373944b..81c9ec2da 100644 --- a/services/hermes/agent-deployment.yaml +++ b/services/hermes/agent-deployment.yaml @@ -25,7 +25,7 @@ spec: ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available - ai.bstein.dev/config-rev: "20260812-provider-history-lineage-cleanup" + ai.bstein.dev/config-rev: "20260813-terminal-lineage-oauth" vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/role: hermes-agent vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens @@ -521,6 +521,7 @@ spec: - --scope=openid profile email - --email-domain=* - --authenticated-emails-file=/etc/oauth2-proxy/allowed-emails + - --custom-templates-dir=/etc/oauth2-proxy/templates - --set-xauthrequest=true - --pass-user-headers=true - --pass-basic-auth=false @@ -567,6 +568,7 @@ spec: limits: {cpu: 250m, memory: 256Mi} volumeMounts: - {name: allowlist, mountPath: /etc/oauth2-proxy, readOnly: true} + - {name: oauth-templates, mountPath: /etc/oauth2-proxy/templates, readOnly: true} - {name: oauth-tmp, mountPath: /tmp} - name: terminal image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107 @@ -942,6 +944,9 @@ spec: - name: allowlist configMap: name: hermes-owner-allowlist + - name: oauth-templates + configMap: + name: hermes-chat-oauth-templates - name: ttyd-index emptyDir: sizeLimit: 2Mi diff --git a/services/hermes/kustomization.yaml b/services/hermes/kustomization.yaml index 085c506ca..b4cffbc58 100644 --- a/services/hermes/kustomization.yaml +++ b/services/hermes/kustomization.yaml @@ -4,7 +4,7 @@ kind: Kustomization namespace: hermes images: - name: registry.bstein.dev/bstein/hermes-agent - digest: sha256:ce106ccb408684677176cc9477b785fbbe5b6171427c4931b86dac7f51d5c2b3 + digest: sha256:3154e1df5f6941583f4a369fcc9581bbf9cb9d502676e91b557e90cc576923e1 resources: - namespace.yaml - vault-serviceaccount.yaml diff --git a/services/hermes/oauth2-proxy-templates/error.html b/services/hermes/oauth2-proxy-templates/error.html index 88398f11d..5a3632768 100644 --- a/services/hermes/oauth2-proxy-templates/error.html +++ b/services/hermes/oauth2-proxy-templates/error.html @@ -5,7 +5,36 @@ {{.StatusCode}} {{.Title}} - {{if or (eq .StatusCode 500) (eq .Message "Login Failed: Unable to find a valid CSRF token. Please try again.")}}{{end}} + {{if or (eq .StatusCode 500) (eq .Message "Login Failed: Unable to find a valid CSRF token. Please try again.")}} + + {{end}}