diff --git a/packages/ui/src/components/sections/integrations/HermesIntegration.test.tsx b/packages/ui/src/components/sections/integrations/HermesIntegration.test.tsx
new file mode 100644
index 00000000..64706062
--- /dev/null
+++ b/packages/ui/src/components/sections/integrations/HermesIntegration.test.tsx
@@ -0,0 +1,74 @@
+import React from "react";
+import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n";
+import { useUIStore } from "@/stores/useUIStore";
+
+import { HermesIntegration } from "./HermesIntegration";
+
+const originalFetch = globalThis.fetch;
+
+const mockStatus = {
+ ok: true,
+ server: { version: "1.23.0", uptimeMs: 5000 },
+ integrations: {
+ agentActivity: { available: true },
+ steer: { available: true },
+ planGate: { available: true },
+ },
+ config: {
+ planGateDefault: false,
+ activityRateLimitMs: 2000,
+ },
+};
+
+const renderComponent = () =>
+ renderToStaticMarkup(
+
+
+ ,
+ );
+
+describe("HermesIntegration", () => {
+ beforeEach(() => {
+ // SAFETY: mock() returns a callable mock; the test harness only needs a fetch-like function.
+ globalThis.fetch = mock(() =>
+ Promise.resolve(new Response(JSON.stringify(mockStatus), { status: 200 }))
+ ) as typeof fetch;
+ });
+
+ afterEach(() => {
+ globalThis.fetch = originalFetch;
+ useUIStore.setState({ hermesPlanGateDefault: false });
+ });
+
+ test("renders the Hermes title and description", () => {
+ const markup = renderComponent();
+ expect(markup).toContain("Hermes");
+ expect(markup).toContain("Agent-to-agent integration");
+ });
+
+ test("renders the collapsible trigger with status indicator", () => {
+ const markup = renderComponent();
+ expect(markup).toContain("integrations.hermes");
+ expect(markup).toContain("Checking…");
+ });
+
+ test("does not render collapsed content in SSR", () => {
+ const markup = renderComponent();
+ expect(markup).not.toContain("Activity stream");
+ expect(markup).not.toContain("Require plan approval by default");
+ });
+
+ test("toggling plan gate default updates the store", () => {
+ const state = useUIStore.getState();
+ expect(state.hermesPlanGateDefault).toBe(false);
+
+ state.setHermesPlanGateDefault(true);
+ expect(useUIStore.getState().hermesPlanGateDefault).toBe(true);
+
+ state.setHermesPlanGateDefault(false);
+ expect(useUIStore.getState().hermesPlanGateDefault).toBe(false);
+ });
+});
diff --git a/packages/ui/src/components/sections/integrations/HermesIntegration.tsx b/packages/ui/src/components/sections/integrations/HermesIntegration.tsx
new file mode 100644
index 00000000..5d3d35c9
--- /dev/null
+++ b/packages/ui/src/components/sections/integrations/HermesIntegration.tsx
@@ -0,0 +1,192 @@
+import React from 'react';
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
+import {
+ SettingsCheckboxRow,
+ SETTINGS_OPTION_STACK_CLASS,
+} from '@/components/sections/shared/SettingsSection';
+import { recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart';
+import { updateDesktopSettings } from '@/lib/persistence';
+import { useUIStore } from '@/stores/useUIStore';
+import { useI18n } from '@/lib/i18n';
+import { cn } from '@/lib/utils';
+import { Icon } from '@/components/icon/Icon';
+
+const STATUS_POLL_INTERVAL_MS = 30_000;
+
+type HermesStatus = {
+ ok: boolean;
+ server: { version: string; uptimeMs: number };
+ integrations: {
+ agentActivity: { available: boolean };
+ steer: { available: boolean };
+ planGate: { available: boolean };
+ };
+ config: {
+ planGateDefault: boolean;
+ activityRateLimitMs: number;
+ };
+} | null;
+
+function StatusDot({ available }: { available: boolean }) {
+ return (
+
+ );
+}
+
+function StatusRow({
+ label,
+ available,
+ detail,
+}: {
+ label: string;
+ available: boolean;
+ detail?: string;
+}) {
+ return (
+
+
+ {label}
+ {detail ? (
+ {detail}
+ ) : null}
+
+ );
+}
+
+export const HermesIntegration: React.FC = () => {
+ const { t } = useI18n();
+ const [open, setOpen] = React.useState(false);
+ const [status, setStatus] = React.useState(null);
+ const [fetchError, setFetchError] = React.useState(false);
+
+ const hermesPlanGateDefault = useUIStore((state) => state.hermesPlanGateDefault);
+ const setHermesPlanGateDefault = useUIStore((state) => state.setHermesPlanGateDefault);
+
+ const fetchStatus = React.useCallback(async () => {
+ try {
+ const response = await fetch('/api/openchamber/hermes/status');
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const data: HermesStatus = await response.json();
+ setStatus(data);
+ setFetchError(false);
+ } catch {
+ setStatus(null);
+ setFetchError(true);
+ }
+ }, []);
+
+ React.useEffect(() => {
+ void fetchStatus();
+ const timer = window.setInterval(fetchStatus, STATUS_POLL_INTERVAL_MS);
+ return () => window.clearInterval(timer);
+ }, [fetchStatus]);
+
+ const handlePlanGateDefaultChange = React.useCallback((enabled: boolean) => {
+ setHermesPlanGateDefault(enabled);
+ void updateDesktopSettings({ hermesPlanGateDefault: enabled });
+ recordDeferredOpenCodeRestart('cli', { id: 'hermes-plan-gate' });
+ }, [setHermesPlanGateDefault]);
+
+ const connected = status?.ok === true && !fetchError;
+ const statusLabel = fetchError
+ ? t('settings.integrations.hermes.status.unreachable')
+ : connected
+ ? t('settings.integrations.hermes.status.connected')
+ : t('settings.integrations.hermes.status.checking');
+ const statusClassName = fetchError
+ ? 'bg-[var(--status-error)]/15 text-[var(--status-error)]'
+ : connected
+ ? 'bg-[var(--status-success)]/15 text-[var(--status-success)]'
+ : 'bg-[var(--surface-muted)] text-muted-foreground';
+
+ const activityRateLimitMs = status?.config?.activityRateLimitMs ?? 2000;
+ const activityRateLabel = `${Math.round(activityRateLimitMs / 1000)}s`;
+
+ return (
+
+
+
+
+
+
+
+
+ {t('settings.integrations.hermes.title')}
+
+
+ {t('settings.integrations.hermes.description')}
+
+
+
+ {statusLabel}
+
+
+
+
+
+
+
+
+
+
+
+ {t('settings.integrations.hermes.section.status')}
+
+
+
+
+
+
+ {status?.server ? (
+
+ v{status.server.version} · {t('settings.integrations.hermes.field.uptime')}{' '}
+ {Math.round(status.server.uptimeMs / 1000)}s
+
+ ) : null}
+
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx b/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx
index ff9fa654..efcffdb6 100644
--- a/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx
+++ b/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx
@@ -6,6 +6,7 @@ import { isVSCodeRuntime } from '@/lib/desktop';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { GitHubIntegration } from './GitHubIntegration';
import { LinearSettings } from './LinearSettings';
+import { HermesIntegration } from './HermesIntegration';
export const IntegrationsPage: React.FC = () => {
const { t } = useI18n();
@@ -29,6 +30,7 @@ export const IntegrationsPage: React.FC = () => {
>
{hasGitHub ? : null}
{hasLinear ? : null}
+
);
diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts
index 4ec5a138..4527849d 100644
--- a/packages/ui/src/lib/i18n/messages/en.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/en.settings.ts
@@ -2367,5 +2367,5 @@ export const settingsDict = {
'settings.openchamber.visual.field.inputHistoryLimitUnit': 'prompts',
...linearIntegrationI18n.en,
'settings.page.integrations.title': 'Integrations',
- 'settings.page.integrations.description': 'Connect GitHub and Linear so OpenChamber can work with your issues and pull requests.',
+ 'settings.page.integrations.description': 'Connect GitHub, Linear, and Hermes so OpenChamber can work with your issues, pull requests, and agent integrations.',
} as const;
diff --git a/packages/ui/src/lib/i18n/messages/linear-integration.i18n.ts b/packages/ui/src/lib/i18n/messages/linear-integration.i18n.ts
index 44deca07..ae3979bf 100644
--- a/packages/ui/src/lib/i18n/messages/linear-integration.i18n.ts
+++ b/packages/ui/src/lib/i18n/messages/linear-integration.i18n.ts
@@ -45,6 +45,20 @@ export const linearIntegrationI18n = {
'settings.integrations.linear.sessionComments.info': 'Adds a comment to the issue when a session starts, finishes, or fails. Comments are only posted when this server has a public address, so the link opens the session for everyone on the issue.',
'settings.integrations.linear.sessionComments.aria': 'Post session status comments to Linear',
'settings.integrations.linear.sessionComments.loadFailed': 'Could not load Linear comment settings.',
+ 'settings.integrations.hermes.title': 'Hermes',
+ 'settings.integrations.hermes.description': 'Agent-to-agent integration for activity streaming, session steering, and plan gates.',
+ 'settings.integrations.hermes.status.connected': 'Connected',
+ 'settings.integrations.hermes.status.unreachable': 'Unreachable',
+ 'settings.integrations.hermes.status.checking': 'Checking…',
+ 'settings.integrations.hermes.field.planGateDefault': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultAria': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultInfo': 'When enabled, new agent sessions wait for plan approval before making changes. Requires a server restart.',
+ 'settings.integrations.hermes.section.status': 'Integration status',
+ 'settings.integrations.hermes.field.activityStream': 'Activity stream',
+ 'settings.integrations.hermes.field.activityStreamDetail': 'Rate limited per session',
+ 'settings.integrations.hermes.field.steerChannel': 'Steer channel',
+ 'settings.integrations.hermes.field.planGate': 'Plan gate',
+ 'settings.integrations.hermes.field.uptime': 'Server uptime',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue Review',
'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue Review',
@@ -95,6 +109,20 @@ export const linearIntegrationI18n = {
'settings.integrations.linear.sessionComments.info': 'Kommentiert das Issue, wenn eine Sitzung startet, endet oder fehlschlägt. Kommentare werden nur gepostet, wenn dieser Server eine öffentliche Adresse hat, damit der Link die Sitzung für alle Beteiligten öffnet.',
'settings.integrations.linear.sessionComments.aria': 'Statuskommentare zu Sitzungen in Linear posten',
'settings.integrations.linear.sessionComments.loadFailed': 'Linear-Kommentareinstellungen konnten nicht geladen werden.',
+ 'settings.integrations.hermes.title': 'Hermes',
+ 'settings.integrations.hermes.description': 'Agent-to-agent integration for activity streaming, session steering, and plan gates.',
+ 'settings.integrations.hermes.status.connected': 'Connected',
+ 'settings.integrations.hermes.status.unreachable': 'Unreachable',
+ 'settings.integrations.hermes.status.checking': 'Checking…',
+ 'settings.integrations.hermes.field.planGateDefault': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultAria': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultInfo': 'When enabled, new agent sessions wait for plan approval before making changes. Requires a server restart.',
+ 'settings.integrations.hermes.section.status': 'Integration status',
+ 'settings.integrations.hermes.field.activityStream': 'Activity stream',
+ 'settings.integrations.hermes.field.activityStreamDetail': 'Rate limited per session',
+ 'settings.integrations.hermes.field.steerChannel': 'Steer channel',
+ 'settings.integrations.hermes.field.planGate': 'Plan gate',
+ 'settings.integrations.hermes.field.uptime': 'Server uptime',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue-Review',
'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue-Review',
@@ -145,6 +173,20 @@ export const linearIntegrationI18n = {
'settings.integrations.linear.sessionComments.info': 'Ajoute un commentaire au ticket quand une session démarre, se termine ou échoue. Les commentaires ne sont publiés que si ce serveur a une adresse publique, afin que le lien ouvre la session pour tout le monde.',
'settings.integrations.linear.sessionComments.aria': 'Publier les commentaires d’état de session dans Linear',
'settings.integrations.linear.sessionComments.loadFailed': 'Impossible de charger les réglages de commentaires Linear.',
+ 'settings.integrations.hermes.title': 'Hermes',
+ 'settings.integrations.hermes.description': 'Agent-to-agent integration for activity streaming, session steering, and plan gates.',
+ 'settings.integrations.hermes.status.connected': 'Connected',
+ 'settings.integrations.hermes.status.unreachable': 'Unreachable',
+ 'settings.integrations.hermes.status.checking': 'Checking…',
+ 'settings.integrations.hermes.field.planGateDefault': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultAria': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultInfo': 'When enabled, new agent sessions wait for plan approval before making changes. Requires a server restart.',
+ 'settings.integrations.hermes.section.status': 'Integration status',
+ 'settings.integrations.hermes.field.activityStream': 'Activity stream',
+ 'settings.integrations.hermes.field.activityStreamDetail': 'Rate limited per session',
+ 'settings.integrations.hermes.field.steerChannel': 'Steer channel',
+ 'settings.integrations.hermes.field.planGate': 'Plan gate',
+ 'settings.integrations.hermes.field.uptime': 'Server uptime',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Revue d’issue',
'settings.magicPrompts.page.group.linearIssueReview.title': 'Revue d’issue',
@@ -195,6 +237,20 @@ export const linearIntegrationI18n = {
'settings.integrations.linear.sessionComments.info': 'Añade un comentario a la incidencia cuando una sesión empieza, termina o falla. Los comentarios solo se publican si este servidor tiene una dirección pública, para que el enlace abra la sesión a todos.',
'settings.integrations.linear.sessionComments.aria': 'Publicar comentarios de estado de sesión en Linear',
'settings.integrations.linear.sessionComments.loadFailed': 'No se pudieron cargar los ajustes de comentarios de Linear.',
+ 'settings.integrations.hermes.title': 'Hermes',
+ 'settings.integrations.hermes.description': 'Agent-to-agent integration for activity streaming, session steering, and plan gates.',
+ 'settings.integrations.hermes.status.connected': 'Connected',
+ 'settings.integrations.hermes.status.unreachable': 'Unreachable',
+ 'settings.integrations.hermes.status.checking': 'Checking…',
+ 'settings.integrations.hermes.field.planGateDefault': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultAria': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultInfo': 'When enabled, new agent sessions wait for plan approval before making changes. Requires a server restart.',
+ 'settings.integrations.hermes.section.status': 'Integration status',
+ 'settings.integrations.hermes.field.activityStream': 'Activity stream',
+ 'settings.integrations.hermes.field.activityStreamDetail': 'Rate limited per session',
+ 'settings.integrations.hermes.field.steerChannel': 'Steer channel',
+ 'settings.integrations.hermes.field.planGate': 'Plan gate',
+ 'settings.integrations.hermes.field.uptime': 'Server uptime',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Revisión de issue',
'settings.magicPrompts.page.group.linearIssueReview.title': 'Revisión de issue',
@@ -245,6 +301,20 @@ export const linearIntegrationI18n = {
'settings.integrations.linear.sessionComments.info': 'セッションの開始・完了・失敗時にイシューへコメントします。リンクを誰でも開けるよう、このサーバーが公開アドレスを持つ場合のみ投稿します。',
'settings.integrations.linear.sessionComments.aria': 'セッション状態のコメントを Linear に投稿',
'settings.integrations.linear.sessionComments.loadFailed': 'Linear のコメント設定を読み込めませんでした。',
+ 'settings.integrations.hermes.title': 'Hermes',
+ 'settings.integrations.hermes.description': 'Agent-to-agent integration for activity streaming, session steering, and plan gates.',
+ 'settings.integrations.hermes.status.connected': 'Connected',
+ 'settings.integrations.hermes.status.unreachable': 'Unreachable',
+ 'settings.integrations.hermes.status.checking': 'Checking…',
+ 'settings.integrations.hermes.field.planGateDefault': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultAria': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultInfo': 'When enabled, new agent sessions wait for plan approval before making changes. Requires a server restart.',
+ 'settings.integrations.hermes.section.status': 'Integration status',
+ 'settings.integrations.hermes.field.activityStream': 'Activity stream',
+ 'settings.integrations.hermes.field.activityStreamDetail': 'Rate limited per session',
+ 'settings.integrations.hermes.field.steerChannel': 'Steer channel',
+ 'settings.integrations.hermes.field.planGate': 'Plan gate',
+ 'settings.integrations.hermes.field.uptime': 'Server uptime',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue レビュー',
'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue レビュー',
@@ -295,6 +365,20 @@ export const linearIntegrationI18n = {
'settings.integrations.linear.sessionComments.info': '세션이 시작, 완료, 실패할 때 이슈에 댓글을 남깁니다. 링크를 모두가 열 수 있도록 이 서버에 공개 주소가 있을 때만 게시합니다.',
'settings.integrations.linear.sessionComments.aria': '세션 상태 댓글을 Linear에 게시',
'settings.integrations.linear.sessionComments.loadFailed': 'Linear 댓글 설정을 불러오지 못했습니다.',
+ 'settings.integrations.hermes.title': 'Hermes',
+ 'settings.integrations.hermes.description': 'Agent-to-agent integration for activity streaming, session steering, and plan gates.',
+ 'settings.integrations.hermes.status.connected': 'Connected',
+ 'settings.integrations.hermes.status.unreachable': 'Unreachable',
+ 'settings.integrations.hermes.status.checking': 'Checking…',
+ 'settings.integrations.hermes.field.planGateDefault': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultAria': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultInfo': 'When enabled, new agent sessions wait for plan approval before making changes. Requires a server restart.',
+ 'settings.integrations.hermes.section.status': 'Integration status',
+ 'settings.integrations.hermes.field.activityStream': 'Activity stream',
+ 'settings.integrations.hermes.field.activityStreamDetail': 'Rate limited per session',
+ 'settings.integrations.hermes.field.steerChannel': 'Steer channel',
+ 'settings.integrations.hermes.field.planGate': 'Plan gate',
+ 'settings.integrations.hermes.field.uptime': 'Server uptime',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': '이슈 리뷰',
'settings.magicPrompts.page.group.linearIssueReview.title': '이슈 리뷰',
@@ -345,6 +429,20 @@ export const linearIntegrationI18n = {
'settings.integrations.linear.sessionComments.info': 'Dodaje komentarz do zgłoszenia, gdy sesja się zaczyna, kończy lub kończy błędem. Komentarze pojawiają się tylko wtedy, gdy ten serwer ma publiczny adres, żeby link otwierał sesję każdemu.',
'settings.integrations.linear.sessionComments.aria': 'Publikuj komentarze o stanie sesji w Linear',
'settings.integrations.linear.sessionComments.loadFailed': 'Nie udało się wczytać ustawień komentarzy Linear.',
+ 'settings.integrations.hermes.title': 'Hermes',
+ 'settings.integrations.hermes.description': 'Agent-to-agent integration for activity streaming, session steering, and plan gates.',
+ 'settings.integrations.hermes.status.connected': 'Connected',
+ 'settings.integrations.hermes.status.unreachable': 'Unreachable',
+ 'settings.integrations.hermes.status.checking': 'Checking…',
+ 'settings.integrations.hermes.field.planGateDefault': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultAria': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultInfo': 'When enabled, new agent sessions wait for plan approval before making changes. Requires a server restart.',
+ 'settings.integrations.hermes.section.status': 'Integration status',
+ 'settings.integrations.hermes.field.activityStream': 'Activity stream',
+ 'settings.integrations.hermes.field.activityStreamDetail': 'Rate limited per session',
+ 'settings.integrations.hermes.field.steerChannel': 'Steer channel',
+ 'settings.integrations.hermes.field.planGate': 'Plan gate',
+ 'settings.integrations.hermes.field.uptime': 'Server uptime',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Przegląd zgłoszenia',
'settings.magicPrompts.page.group.linearIssueReview.title': 'Przegląd zgłoszenia',
@@ -395,6 +493,20 @@ export const linearIntegrationI18n = {
'settings.integrations.linear.sessionComments.info': 'Comenta na issue quando uma sessão começa, termina ou falha. Os comentários só são publicados se este servidor tiver um endereço público, para que o link abra a sessão para todos.',
'settings.integrations.linear.sessionComments.aria': 'Publicar comentários de status de sessão no Linear',
'settings.integrations.linear.sessionComments.loadFailed': 'Não foi possível carregar as configurações de comentários do Linear.',
+ 'settings.integrations.hermes.title': 'Hermes',
+ 'settings.integrations.hermes.description': 'Agent-to-agent integration for activity streaming, session steering, and plan gates.',
+ 'settings.integrations.hermes.status.connected': 'Connected',
+ 'settings.integrations.hermes.status.unreachable': 'Unreachable',
+ 'settings.integrations.hermes.status.checking': 'Checking…',
+ 'settings.integrations.hermes.field.planGateDefault': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultAria': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultInfo': 'When enabled, new agent sessions wait for plan approval before making changes. Requires a server restart.',
+ 'settings.integrations.hermes.section.status': 'Integration status',
+ 'settings.integrations.hermes.field.activityStream': 'Activity stream',
+ 'settings.integrations.hermes.field.activityStreamDetail': 'Rate limited per session',
+ 'settings.integrations.hermes.field.steerChannel': 'Steer channel',
+ 'settings.integrations.hermes.field.planGate': 'Plan gate',
+ 'settings.integrations.hermes.field.uptime': 'Server uptime',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Revisão de issue',
'settings.magicPrompts.page.group.linearIssueReview.title': 'Revisão de issue',
@@ -445,6 +557,20 @@ export const linearIntegrationI18n = {
'settings.integrations.linear.sessionComments.info': 'Додає коментар до тікета, коли сесія починається, завершується або падає. Коментарі публікуються, лише якщо цей сервер має публічну адресу, щоб посилання відкривало сесію для всіх.',
'settings.integrations.linear.sessionComments.aria': 'Публікувати коментарі про стан сесії в Linear',
'settings.integrations.linear.sessionComments.loadFailed': 'Не вдалося завантажити налаштування коментарів Linear.',
+ 'settings.integrations.hermes.title': 'Hermes',
+ 'settings.integrations.hermes.description': 'Agent-to-agent integration for activity streaming, session steering, and plan gates.',
+ 'settings.integrations.hermes.status.connected': 'Connected',
+ 'settings.integrations.hermes.status.unreachable': 'Unreachable',
+ 'settings.integrations.hermes.status.checking': 'Checking…',
+ 'settings.integrations.hermes.field.planGateDefault': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultAria': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultInfo': 'When enabled, new agent sessions wait for plan approval before making changes. Requires a server restart.',
+ 'settings.integrations.hermes.section.status': 'Integration status',
+ 'settings.integrations.hermes.field.activityStream': 'Activity stream',
+ 'settings.integrations.hermes.field.activityStreamDetail': 'Rate limited per session',
+ 'settings.integrations.hermes.field.steerChannel': 'Steer channel',
+ 'settings.integrations.hermes.field.planGate': 'Plan gate',
+ 'settings.integrations.hermes.field.uptime': 'Server uptime',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Огляд issue',
'settings.magicPrompts.page.group.linearIssueReview.title': 'Огляд issue',
@@ -495,6 +621,20 @@ export const linearIntegrationI18n = {
'settings.integrations.linear.sessionComments.info': '会话开始、完成或失败时在议题下留言。仅当此服务器拥有公网地址时才发布,这样链接才能让所有人打开该会话。',
'settings.integrations.linear.sessionComments.aria': '将会话状态评论发布到 Linear',
'settings.integrations.linear.sessionComments.loadFailed': '无法加载 Linear 评论设置。',
+ 'settings.integrations.hermes.title': 'Hermes',
+ 'settings.integrations.hermes.description': 'Agent-to-agent integration for activity streaming, session steering, and plan gates.',
+ 'settings.integrations.hermes.status.connected': 'Connected',
+ 'settings.integrations.hermes.status.unreachable': 'Unreachable',
+ 'settings.integrations.hermes.status.checking': 'Checking…',
+ 'settings.integrations.hermes.field.planGateDefault': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultAria': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultInfo': 'When enabled, new agent sessions wait for plan approval before making changes. Requires a server restart.',
+ 'settings.integrations.hermes.section.status': 'Integration status',
+ 'settings.integrations.hermes.field.activityStream': 'Activity stream',
+ 'settings.integrations.hermes.field.activityStreamDetail': 'Rate limited per session',
+ 'settings.integrations.hermes.field.steerChannel': 'Steer channel',
+ 'settings.integrations.hermes.field.planGate': 'Plan gate',
+ 'settings.integrations.hermes.field.uptime': 'Server uptime',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue 审查',
'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue 审查',
@@ -545,6 +685,20 @@ export const linearIntegrationI18n = {
'settings.integrations.linear.sessionComments.info': '工作階段開始、完成或失敗時在議題留言。僅在這台伺服器有公開位址時才發布,這樣連結才能讓所有人開啟該工作階段。',
'settings.integrations.linear.sessionComments.aria': '將工作階段狀態留言發布到 Linear',
'settings.integrations.linear.sessionComments.loadFailed': '無法載入 Linear 留言設定。',
+ 'settings.integrations.hermes.title': 'Hermes',
+ 'settings.integrations.hermes.description': 'Agent-to-agent integration for activity streaming, session steering, and plan gates.',
+ 'settings.integrations.hermes.status.connected': 'Connected',
+ 'settings.integrations.hermes.status.unreachable': 'Unreachable',
+ 'settings.integrations.hermes.status.checking': 'Checking…',
+ 'settings.integrations.hermes.field.planGateDefault': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultAria': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultInfo': 'When enabled, new agent sessions wait for plan approval before making changes. Requires a server restart.',
+ 'settings.integrations.hermes.section.status': 'Integration status',
+ 'settings.integrations.hermes.field.activityStream': 'Activity stream',
+ 'settings.integrations.hermes.field.activityStreamDetail': 'Rate limited per session',
+ 'settings.integrations.hermes.field.steerChannel': 'Steer channel',
+ 'settings.integrations.hermes.field.planGate': 'Plan gate',
+ 'settings.integrations.hermes.field.uptime': 'Server uptime',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue 審查',
'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue 審查',
@@ -595,6 +749,20 @@ export const linearIntegrationI18n = {
'settings.integrations.linear.sessionComments.info': 'Bir oturum başladığında, bittiğinde veya başarısız olduğunda göreve yorum ekler. Bağlantının herkeste açılabilmesi için yorumlar yalnızca bu sunucunun genel bir adresi varsa gönderilir.',
'settings.integrations.linear.sessionComments.aria': 'Oturum durumu yorumlarını Linear’a gönder',
'settings.integrations.linear.sessionComments.loadFailed': 'Linear yorum ayarları yüklenemedi.',
+ 'settings.integrations.hermes.title': 'Hermes',
+ 'settings.integrations.hermes.description': 'Agent-to-agent integration for activity streaming, session steering, and plan gates.',
+ 'settings.integrations.hermes.status.connected': 'Connected',
+ 'settings.integrations.hermes.status.unreachable': 'Unreachable',
+ 'settings.integrations.hermes.status.checking': 'Checking…',
+ 'settings.integrations.hermes.field.planGateDefault': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultAria': 'Require plan approval by default',
+ 'settings.integrations.hermes.field.planGateDefaultInfo': 'When enabled, new agent sessions wait for plan approval before making changes. Requires a server restart.',
+ 'settings.integrations.hermes.section.status': 'Integration status',
+ 'settings.integrations.hermes.field.activityStream': 'Activity stream',
+ 'settings.integrations.hermes.field.activityStreamDetail': 'Rate limited per session',
+ 'settings.integrations.hermes.field.steerChannel': 'Steer channel',
+ 'settings.integrations.hermes.field.planGate': 'Plan gate',
+ 'settings.integrations.hermes.field.uptime': 'Server uptime',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue incelemesi',
'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue incelemesi',
diff --git a/packages/ui/src/lib/settings/registry.ts b/packages/ui/src/lib/settings/registry.ts
index f9465cc9..f888a360 100644
--- a/packages/ui/src/lib/settings/registry.ts
+++ b/packages/ui/src/lib/settings/registry.ts
@@ -256,6 +256,7 @@ export const SETTINGS_REGISTRY = {
agentControlToolEnabled: field({ scope: 'instance', parse: parseBoolean, ui: uiStore('agentControlToolEnabled', (v) => useUIStore.getState().setAgentControlToolEnabled(v)) }),
agentWebToolEnabled: field({ scope: 'instance', parse: parseBoolean, ui: uiStore('agentWebToolEnabled', (v) => useUIStore.getState().setAgentWebToolEnabled(v)) }),
agentMemoryToolEnabled: field({ scope: 'instance', parse: parseBoolean, ui: uiStore('agentMemoryToolEnabled', (v) => useUIStore.getState().setAgentMemoryToolEnabled(v)) }),
+ hermesPlanGateDefault: field({ scope: 'instance', parse: parseBoolean, ui: uiStore('hermesPlanGateDefault', (v) => useUIStore.getState().setHermesPlanGateDefault(v)) }),
// Server-owned: it says whether this build has the feature at all.
agentMemoryFeatureAvailable: field({
scope: 'instance',
diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts
index ad10fcbb..2ba7adaa 100644
--- a/packages/ui/src/lib/settings/search.ts
+++ b/packages/ui/src/lib/settings/search.ts
@@ -1086,6 +1086,14 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
keywords: ['linear', 'project', 'team', 'map', 'workspace', 'directory'],
isAvailable: (ctx) => !ctx.isVSCode,
},
+ {
+ id: 'integrations.hermes',
+ page: 'integrations',
+ titleKey: 'settings.integrations.hermes.title',
+ descriptionKey: 'settings.integrations.hermes.description',
+ keywords: ['hermes', 'agent', 'integration', 'plan gate', 'steer', 'activity'],
+ isAvailable: (ctx) => !ctx.isVSCode,
+ },
] as const;
interface BuildSettingsSearchResultsOptions {
diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts
index cbf9de35..08cf24df 100644
--- a/packages/ui/src/stores/useUIStore.ts
+++ b/packages/ui/src/stores/useUIStore.ts
@@ -937,6 +937,7 @@ interface UIStore {
agentControlToolEnabled: boolean;
agentWebToolEnabled: boolean;
agentMemoryToolEnabled: boolean;
+ hermesPlanGateDefault: boolean;
/**
* Whether this build has agent memory at all. Server-owned and not
* persisted: an unreleased feature must not come back from a stale cache.
@@ -1135,6 +1136,7 @@ interface UIStore {
setAgentControlToolEnabled: (value: boolean) => void;
setAgentWebToolEnabled: (value: boolean) => void;
setAgentMemoryToolEnabled: (value: boolean) => void;
+ setHermesPlanGateDefault: (value: boolean) => void;
setAgentMemoryFeatureAvailable: (value: boolean) => void;
markAgentMemoryViewed: (key: string, viewedAt: number) => void;
setProjectContextSidebarWidth: (width: number) => void;
@@ -1308,6 +1310,7 @@ export const useUIStore = create()(
agentControlToolEnabled: true,
agentWebToolEnabled: true,
agentMemoryToolEnabled: false,
+ hermesPlanGateDefault: false,
agentMemoryFeatureAvailable: false,
agentMemoryViewedAt: {},
projectContextSidebarWidth: 168,
@@ -2575,6 +2578,9 @@ export const useUIStore = create()(
setAgentMemoryToolEnabled: (value) => {
set({ agentMemoryToolEnabled: value });
},
+ setHermesPlanGateDefault: (value) => {
+ set({ hermesPlanGateDefault: value });
+ },
setAgentMemoryFeatureAvailable: (value) => {
set({ agentMemoryFeatureAvailable: value });
},
diff --git a/packages/web/server/index.js b/packages/web/server/index.js
index d5b97bb7..fb2f9f4f 100644
--- a/packages/web/server/index.js
+++ b/packages/web/server/index.js
@@ -95,6 +95,7 @@ import { createMessageQueueRuntime } from './lib/message-queue/runtime.js';
import { createAgentActivityRuntime, registerAgentActivityRoutes } from './lib/agent-activity/runtime.js';
import { createSessionSteerRuntime, registerSessionSteerRoutes } from './lib/session-steer/runtime.js';
import { createPlanGateRuntime, registerPlanGateRoutes } from './lib/plan-gate/runtime.js';
+import { createHermesIntegrationRuntime } from './lib/hermes-integration/runtime.js';
import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.js';
import { createProjectConfigRuntime } from './lib/projects/project-config.js';
import { migrateLegacyUserDirs } from './lib/data-dir-migration.js';
@@ -951,6 +952,14 @@ const planGateRuntime = createPlanGateRuntime({
});
planGateRuntime.start();
+const hermesIntegrationRuntime = createHermesIntegrationRuntime({
+ agentActivityRuntime,
+ sessionSteerRuntime,
+ planGateRuntime,
+ readSettingsFromDiskMigrated,
+ startedAt: () => Date.now() - (Date.now() - process.uptime() * 1000),
+});
+
const openCodeWatcherRuntime = createOpenCodeWatcherRuntime({
waitForOpenCodePort: (...args) => waitForOpenCodePort(...args),
buildOpenCodeUrl,
@@ -2013,6 +2022,7 @@ async function main(options = {}) {
agentActivityRuntime,
sessionSteerRuntime,
planGateRuntime,
+ hermesIntegrationRuntime,
});
const startupPipelineResult = await startupPipelineRuntime.run({
diff --git a/packages/web/server/lib/hermes-integration/routes.js b/packages/web/server/lib/hermes-integration/routes.js
new file mode 100644
index 00000000..d91a8afa
--- /dev/null
+++ b/packages/web/server/lib/hermes-integration/routes.js
@@ -0,0 +1,16 @@
+import express from 'express';
+
+export function registerHermesIntegrationRoutes(app, hermesIntegrationRuntime) {
+ app.get('/api/openchamber/hermes/status', async (req, res) => {
+ try {
+ const status = await hermesIntegrationRuntime.getStatus();
+ return res.json(status);
+ } catch (error) {
+ const statusCode = Number.isFinite(error?.status) ? error.status : 500;
+ return res.status(statusCode).json({
+ ok: false,
+ error: error?.message ?? 'Failed to get Hermes integration status',
+ });
+ }
+ });
+}
diff --git a/packages/web/server/lib/hermes-integration/routes.test.js b/packages/web/server/lib/hermes-integration/routes.test.js
new file mode 100644
index 00000000..86f6cd85
--- /dev/null
+++ b/packages/web/server/lib/hermes-integration/routes.test.js
@@ -0,0 +1,49 @@
+import express from 'express';
+import request from 'supertest';
+import { describe, expect, it, vi } from 'vitest';
+
+import { registerHermesIntegrationRoutes } from './routes.js';
+
+const createApp = (getStatus) => {
+ const app = express();
+ registerHermesIntegrationRoutes(app, { getStatus });
+ return app;
+};
+
+describe('Hermes integration route', () => {
+ it('returns status JSON from the runtime', async () => {
+ const getStatus = vi.fn(async () => ({
+ ok: true,
+ server: { version: '1.23.0', uptimeMs: 5000 },
+ integrations: {
+ agentActivity: { available: true },
+ steer: { available: true },
+ planGate: { available: true },
+ },
+ config: {
+ planGateDefault: false,
+ activityRateLimitMs: 2000,
+ },
+ }));
+ const response = await request(createApp(getStatus))
+ .get('/api/openchamber/hermes/status')
+ .expect(200);
+
+ expect(response.body.ok).toBe(true);
+ expect(response.body.server.version).toBe('1.23.0');
+ expect(response.body.integrations.agentActivity.available).toBe(true);
+ expect(getStatus).toHaveBeenCalledOnce();
+ });
+
+ it('returns 500 on runtime failure', async () => {
+ const getStatus = vi.fn(async () => {
+ throw new Error('runtime exploded');
+ });
+ const response = await request(createApp(getStatus))
+ .get('/api/openchamber/hermes/status')
+ .expect(500);
+
+ expect(response.body.ok).toBe(false);
+ expect(response.body.error).toBe('runtime exploded');
+ });
+});
diff --git a/packages/web/server/lib/hermes-integration/runtime.js b/packages/web/server/lib/hermes-integration/runtime.js
new file mode 100644
index 00000000..4b41efbe
--- /dev/null
+++ b/packages/web/server/lib/hermes-integration/runtime.js
@@ -0,0 +1,63 @@
+// Hermes integration status: reports the availability and configuration of the
+// three agent-to-agent runtimes (activity stream, steer channel, plan gate) that
+// power the Hermes ↔ OpenChamber link. Pure read-only — does not modify any
+// existing runtime.
+
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const PACKAGE_VERSION = (() => {
+ try {
+ const packagePath = path.resolve(__dirname, '..', '..', 'package.json');
+ const raw = fs.readFileSync(packagePath, 'utf8');
+ const pkg = JSON.parse(raw);
+ if (pkg && typeof pkg.version === 'string' && pkg.version.trim().length > 0) {
+ return pkg.version.trim();
+ }
+ } catch {
+ // fall through
+ }
+ return 'unknown';
+})();
+
+export function createHermesIntegrationRuntime({
+ agentActivityRuntime,
+ sessionSteerRuntime,
+ planGateRuntime,
+ readSettingsFromDiskMigrated,
+ startedAt = Date.now,
+}) {
+ const getStatus = async () => {
+ const settings = await readSettingsFromDiskMigrated().catch(() => null);
+ const uptimeMs = Date.now() - startedAt();
+
+ return {
+ ok: true,
+ server: {
+ version: PACKAGE_VERSION,
+ uptimeMs,
+ },
+ integrations: {
+ agentActivity: {
+ available: agentActivityRuntime != null,
+ },
+ steer: {
+ available: sessionSteerRuntime != null,
+ },
+ planGate: {
+ available: planGateRuntime != null,
+ },
+ },
+ config: {
+ planGateDefault: Boolean(settings?.hermesPlanGateDefault),
+ activityRateLimitMs: 2000,
+ },
+ };
+ };
+
+ return { getStatus };
+}
diff --git a/packages/web/server/lib/hermes-integration/runtime.test.js b/packages/web/server/lib/hermes-integration/runtime.test.js
new file mode 100644
index 00000000..dfbcf21d
--- /dev/null
+++ b/packages/web/server/lib/hermes-integration/runtime.test.js
@@ -0,0 +1,73 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { createHermesIntegrationRuntime } from './runtime.js';
+
+const createRuntime = (overrides = {}) => {
+ return createHermesIntegrationRuntime({
+ agentActivityRuntime: { processPayload: vi.fn() },
+ sessionSteerRuntime: { steer: vi.fn() },
+ planGateRuntime: { approvePlan: vi.fn() },
+ readSettingsFromDiskMigrated: vi.fn(async () => ({
+ hermesPlanGateDefault: false,
+ })),
+ startedAt: () => Date.now() - 10_000,
+ ...overrides,
+ });
+};
+
+describe('Hermes integration runtime', () => {
+ it('reports all three integrations as available', async () => {
+ const runtime = createRuntime();
+ const status = await runtime.getStatus();
+
+ expect(status.ok).toBe(true);
+ expect(status.server.version).toBeDefined();
+ expect(status.integrations.agentActivity.available).toBe(true);
+ expect(status.integrations.steer.available).toBe(true);
+ expect(status.integrations.planGate.available).toBe(true);
+ });
+
+ it('reports availability as false when runtimes are null', async () => {
+ const runtime = createRuntime({
+ agentActivityRuntime: null,
+ sessionSteerRuntime: null,
+ planGateRuntime: null,
+ });
+ const status = await runtime.getStatus();
+
+ expect(status.integrations.agentActivity.available).toBe(false);
+ expect(status.integrations.steer.available).toBe(false);
+ expect(status.integrations.planGate.available).toBe(false);
+ });
+
+ it('includes uptime and config', async () => {
+ const runtime = createRuntime();
+ const status = await runtime.getStatus();
+
+ expect(typeof status.server.uptimeMs).toBe('number');
+ expect(status.server.uptimeMs).toBeGreaterThanOrEqual(0);
+ expect(status.config.planGateDefault).toBe(false);
+ expect(status.config.activityRateLimitMs).toBe(2000);
+ });
+
+ it('reads planGateDefault from settings', async () => {
+ const readSettingsFromDiskMigrated = vi.fn(async () => ({
+ hermesPlanGateDefault: true,
+ }));
+ const runtime = createRuntime({ readSettingsFromDiskMigrated });
+ const status = await runtime.getStatus();
+
+ expect(status.config.planGateDefault).toBe(true);
+ });
+
+ it('handles settings read failure gracefully', async () => {
+ const readSettingsFromDiskMigrated = vi.fn(async () => {
+ throw new Error('settings read failed');
+ });
+ const runtime = createRuntime({ readSettingsFromDiskMigrated });
+ const status = await runtime.getStatus();
+
+ expect(status.ok).toBe(true);
+ expect(status.config.planGateDefault).toBe(false);
+ });
+});
diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js
index 5bd210b6..c258317b 100644
--- a/packages/web/server/lib/opencode/feature-routes-runtime.js
+++ b/packages/web/server/lib/opencode/feature-routes-runtime.js
@@ -28,6 +28,7 @@ import { registerMarkdownImageGrantRoutes } from '../markdown-image-grants/route
import { registerAgentActivityRoutes } from '../agent-activity/runtime.js';
import { registerSessionSteerRoutes } from '../session-steer/runtime.js';
import { registerPlanGateRoutes } from '../plan-gate/runtime.js';
+import { registerHermesIntegrationRoutes } from '../hermes-integration/routes.js';
import { registerSkillRoutes } from './skill-routes.js';
import { registerPluginRoutes } from './plugin-routes.js';
import { getNpmInfo, clearCache as clearNpmCache } from './npm-registry.js';
@@ -145,6 +146,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
agentActivityRuntime,
sessionSteerRuntime,
planGateRuntime,
+ hermesIntegrationRuntime,
} = routeDependencies;
registerSettingsUtilityRoutes(app, {
@@ -216,6 +218,10 @@ export const createFeatureRoutesRuntime = (dependencies) => {
registerSessionSteerRoutes(app, sessionSteerRuntime);
registerPlanGateRoutes(app, planGateRuntime);
+ if (hermesIntegrationRuntime) {
+ registerHermesIntegrationRoutes(app, hermesIntegrationRuntime);
+ }
+
registerMarkdownImageGrantRoutes(app, {
fsPromises,
path,