diff --git a/dashboard/web/.gitignore b/dashboard/web/.gitignore
index 98e05a0..4b003ac 100644
--- a/dashboard/web/.gitignore
+++ b/dashboard/web/.gitignore
@@ -7,6 +7,8 @@
# testing
/coverage
+/test-results
+/playwright-report
# next.js
/.next/
diff --git a/dashboard/web/e2e/hermes.spec.ts b/dashboard/web/e2e/hermes.spec.ts
index c646476..1f13b9a 100644
--- a/dashboard/web/e2e/hermes.spec.ts
+++ b/dashboard/web/e2e/hermes.spec.ts
@@ -100,4 +100,22 @@ test.describe('Hermes Mission Control', () => {
await expect(page.getByRole('heading', { name: 'Hermes learning' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Timeline' })).toBeVisible();
});
+
+ test('exposes a global instance switcher with All / Vijay / Bheem', async ({ page }) => {
+ await page.goto('/hermes');
+ const switcher = page.getByRole('radiogroup', { name: 'Hermes instance filter' });
+ await expect(switcher).toBeVisible();
+
+ // Default selection persists across navigation; "All" should be the
+ // active option on first visit (no localStorage entry yet).
+ await expect(switcher.getByRole('radio', { name: 'All' })).toHaveAttribute('aria-checked', 'true');
+
+ // Per-instance roll-up section should always be visible regardless of filter.
+ await expect(page.getByRole('heading', { name: 'Per-instance roll-up' })).toBeVisible();
+
+ // Switching to Bheem should still keep the page rendering correctly.
+ await switcher.getByRole('radio', { name: 'Bheem (uma)' }).click();
+ await expect(switcher.getByRole('radio', { name: 'Bheem (uma)' })).toHaveAttribute('aria-checked', 'true');
+ await expect(page.getByRole('heading', { name: 'Hermes Mission Control' })).toBeVisible();
+ });
});
diff --git a/dashboard/web/src/app/hermes/agents/page.tsx b/dashboard/web/src/app/hermes/agents/page.tsx
index 53b808c..e332062 100644
--- a/dashboard/web/src/app/hermes/agents/page.tsx
+++ b/dashboard/web/src/app/hermes/agents/page.tsx
@@ -3,11 +3,15 @@
import Link from 'next/link';
import { ArrowLeft, Gauge, ShieldAlert, ServerCog } from 'lucide-react';
import { Badge, Button } from '@/components/ui/Primitives';
+import { useMemo } from 'react';
import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell';
+import { HermesInstanceBadge } from '@/components/hermes-instance-switcher';
+import { useHermesInstance } from '@/lib/hermes-instance-context';
import { getHermesAgents } from '@/lib/hermes';
export default function HermesAgentsPage() {
- const agents = getHermesAgents();
+ const { selectedInstance } = useHermesInstance();
+ const agents = useMemo(() => getHermesAgents(selectedInstance), [selectedInstance]);
const healthy = agents.filter((agent) => agent.status === 'healthy').length;
const degraded = agents.filter((agent) => agent.status === 'degraded').length;
const offline = agents.filter((agent) => agent.status === 'offline').length;
@@ -33,7 +37,10 @@ export default function HermesAgentsPage() {
{agent.name}
{agent.type} · {agent.callsToday} calls today
- {agent.status}
+
+ {agent.status}
+
+
Last success: {agent.lastSuccessAt ? new Date(agent.lastSuccessAt).toLocaleString() : '—'}
diff --git a/dashboard/web/src/app/hermes/history/page.tsx b/dashboard/web/src/app/hermes/history/page.tsx
index 9184322..2a78d0d 100644
--- a/dashboard/web/src/app/hermes/history/page.tsx
+++ b/dashboard/web/src/app/hermes/history/page.tsx
@@ -3,20 +3,28 @@
import Link from 'next/link';
import { ArrowLeft, Clock3, Flame, TrendingDown, TrendingUp } from 'lucide-react';
import { Badge, Button } from '@/components/ui/Primitives';
+import { useMemo } from 'react';
import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell';
+import { useHermesInstance } from '@/lib/hermes-instance-context';
import { getHermesHistory, hermesTasks } from '@/lib/hermes';
export default function HermesHistoryPage() {
- const history = getHermesHistory();
+ const { selectedInstance } = useHermesInstance();
+ const history = useMemo(() => getHermesHistory(selectedInstance), [selectedInstance]);
+ const filteredTasks = useMemo(
+ () => (selectedInstance === 'all' ? hermesTasks : hermesTasks.filter((task) => task.instanceId === selectedInstance)),
+ [selectedInstance],
+ );
const completedTrend = history.map((point) => point.completed);
const failedTrend = history.map((point) => point.failed);
const maxValue = Math.max(...history.flatMap((point) => [point.completed, point.failed, point.blocked, point.active]), 1);
const weeklyCompleted = completedTrend.reduce((sum, value) => sum + value, 0);
const weeklyFailed = failedTrend.reduce((sum, value) => sum + value, 0);
const blocked = history.reduce((sum, point) => sum + point.blocked, 0);
+ const tasksWithDuration = filteredTasks.filter((task) => task.durationMs);
const avgDuration = Math.round(
- hermesTasks.filter((task) => task.durationMs).reduce((sum, task) => sum + (task.durationMs ?? 0), 0) /
- Math.max(1, hermesTasks.filter((task) => task.durationMs).length) / 60000,
+ tasksWithDuration.reduce((sum, task) => sum + (task.durationMs ?? 0), 0) /
+ Math.max(1, tasksWithDuration.length) / 60000,
);
const failureReasons = [
diff --git a/dashboard/web/src/app/hermes/layout.tsx b/dashboard/web/src/app/hermes/layout.tsx
index f6cd96b..2c0b63d 100644
--- a/dashboard/web/src/app/hermes/layout.tsx
+++ b/dashboard/web/src/app/hermes/layout.tsx
@@ -1,14 +1,26 @@
'use client';
import { SidebarNav } from '@/components/sidebar-nav';
+import { HermesInstanceSwitcher } from '@/components/hermes-instance-switcher';
+import { HermesInstanceProvider } from '@/lib/hermes-instance-context';
export default function HermesLayout({ children }: { children: React.ReactNode }) {
return (
-
+
+
+
+
+
+ {/* Global instance switcher — every Mission Control pane reads
+ from the same `useHermesInstance()` hook, so this filter
+ propagates everywhere. */}
+
+
+
+ {children}
+
+
+
+
);
}
diff --git a/dashboard/web/src/app/hermes/page.tsx b/dashboard/web/src/app/hermes/page.tsx
index cb5c2af..0756fd1 100644
--- a/dashboard/web/src/app/hermes/page.tsx
+++ b/dashboard/web/src/app/hermes/page.tsx
@@ -1,10 +1,13 @@
'use client';
+import { useMemo } from 'react';
import Link from 'next/link';
import { ArrowRight, BadgeCheck, Bot, CheckCircle2, Clock3, LayoutDashboard, OctagonAlert, Rocket, ShieldAlert, Sparkles, TriangleAlert } from 'lucide-react';
import { Badge, Button } from '@/components/ui/Primitives';
import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell';
+import { HermesInstanceBadge } from '@/components/hermes-instance-switcher';
import { HermesOpsPanel } from '@/components/hermes-ops-panel';
+import { useHermesInstance } from '@/lib/hermes-instance-context';
import {
getHermesAgents,
getHermesOverview,
@@ -12,6 +15,7 @@ import {
getHermesTasks,
hermesProducts,
hermesTasks,
+ HERMES_INSTANCES,
type HermesProduct,
type HermesTask,
} from '@/lib/hermes';
@@ -51,7 +55,10 @@ function ProductMiniCard({ product }: { product: HermesProduct }) {
{product.name}
{product.category} · {product.priority}
- {product.needsAttention ? 'Attention' : 'Healthy'}
+
+
+ {product.needsAttention ? 'Attention' : 'Healthy'}
+
@@ -72,19 +79,51 @@ function ProductMiniCard({ product }: { product: HermesProduct }) {
}
export default function HermesMissionControlPage() {
- const overview = getHermesOverview();
- const activeTasks = getHermesTasks({ status: 'running' }).concat(getHermesTasks({ status: 'blocked' }), getHermesTasks({ status: 'queued' })).slice(0, 8);
- const attentionTasks = getHermesTasks({ status: 'blocked' }).concat(getHermesTasks({ status: 'failed' })).slice(0, 8);
- const recentProducts = hermesProducts
+ const { selectedInstance } = useHermesInstance();
+ const overview = useMemo(() => getHermesOverview(selectedInstance), [selectedInstance]);
+ // Per-instance roll-up cards always show both Vijay and Bheem regardless of
+ // the active filter — they're the "comparison" view that sits next to the
+ // filtered overview metrics. This satisfies the roadmap's "per-instance
+ // cards AND a combined roll-up" requirement.
+ const perInstance = useMemo(
+ () => HERMES_INSTANCES.map((inst) => ({ ...inst, overview: getHermesOverview(inst.id) })),
+ [],
+ );
+
+ const activeTasks = useMemo(
+ () => getHermesTasks({ status: 'running', instance: selectedInstance })
+ .concat(getHermesTasks({ status: 'blocked', instance: selectedInstance }))
+ .concat(getHermesTasks({ status: 'queued', instance: selectedInstance }))
+ .slice(0, 8),
+ [selectedInstance],
+ );
+ const attentionTasks = useMemo(
+ () => getHermesTasks({ status: 'blocked', instance: selectedInstance })
+ .concat(getHermesTasks({ status: 'failed', instance: selectedInstance }))
+ .slice(0, 8),
+ [selectedInstance],
+ );
+ const filteredProducts = useMemo(
+ () => (selectedInstance === 'all' ? hermesProducts : hermesProducts.filter((p) => p.instanceId === selectedInstance)),
+ [selectedInstance],
+ );
+ const filteredTasks = useMemo(
+ () => (selectedInstance === 'all' ? hermesTasks : hermesTasks.filter((t) => t.instanceId === selectedInstance)),
+ [selectedInstance],
+ );
+ const recentProducts = filteredProducts
.filter((product) => product.lastHermesActivityAt)
.sort((a, b) => new Date(b.lastHermesActivityAt!).getTime() - new Date(a.lastHermesActivityAt!).getTime())
.slice(0, 8);
- const completedToday = hermesTasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() > Date.now() - 86_400_000);
- const completedThisWeek = hermesTasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() > Date.now() - 7 * 86_400_000);
- const failedTasks = hermesTasks.filter((task) => task.status === 'failed');
- const repeatedFailures = getHermesProducts('repeated-failures').slice(0, 5);
- const actionableProducts = hermesProducts.filter((product) => product.needsAttention).slice(0, 6);
- const agentStatuses = getHermesAgents();
+ const completedToday = filteredTasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() > Date.now() - 86_400_000);
+ const completedThisWeek = filteredTasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() > Date.now() - 7 * 86_400_000);
+ const failedTasks = filteredTasks.filter((task) => task.status === 'failed');
+ const repeatedFailures = useMemo(
+ () => getHermesProducts('repeated-failures', selectedInstance).slice(0, 5),
+ [selectedInstance],
+ );
+ const actionableProducts = filteredProducts.filter((product) => product.needsAttention).slice(0, 6);
+ const agentStatuses = useMemo(() => getHermesAgents(selectedInstance), [selectedInstance]);
const autoActions = [
'Continue the queued execution lane for high-priority product updates.',
'Publish a weekly digest from completed and failed work.',
@@ -118,6 +157,32 @@ export default function HermesMissionControlPage() {
} helpText={`${overview.productsTouchedRecently} products touched in the last 14 days`} />
+
Always cross-instance}
+ >
+
+ {perInstance.map(({ id, label, description, overview: ov }) => (
+
+
+
+
{label}
+
{description}
+
+
{ov.status}
+
+
+
Active {ov.activeTasks}
+
Blocked {ov.blockedTasks}
+
Failed {ov.failedTasks}
+
Success % {ov.successRate}%
+
+
+ ))}
+
+
+
@@ -133,6 +198,7 @@ export default function HermesMissionControlPage() {
{task.title}
{taskStatusLabel(task)}
{task.priority}
+
{product?.name ?? 'Unknown product'} · {task.assignedAgent} · {task.type}
diff --git a/dashboard/web/src/app/hermes/products/page.tsx b/dashboard/web/src/app/hermes/products/page.tsx
index 781a4ac..608838e 100644
--- a/dashboard/web/src/app/hermes/products/page.tsx
+++ b/dashboard/web/src/app/hermes/products/page.tsx
@@ -5,6 +5,8 @@ import Link from 'next/link';
import { Filter, Orbit, Rocket, ShieldAlert, Sparkles, TrendingUp } from 'lucide-react';
import { Badge, Button, Input } from '@/components/ui/Primitives';
import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell';
+import { HermesInstanceBadge } from '@/components/hermes-instance-switcher';
+import { useHermesInstance } from '@/lib/hermes-instance-context';
import { getHermesProducts, getHermesTasks, hermesProducts, type HermesProduct } from '@/lib/hermes';
const views = [
@@ -33,7 +35,10 @@ function ProductCard({ product }: { product: HermesProduct }) {
{product.name}
{product.category} · {product.owner}
- {product.status}
+
+
+ {product.status}
+
{product.description}
@@ -54,12 +59,13 @@ export default function HermesProductsPage() {
const [query, setQuery] = useState('');
const [view, setView] = useState<(typeof views)[number]['key']>('all');
+ const { selectedInstance } = useHermesInstance();
const products = useMemo(() => {
- return getHermesProducts(view).filter((product) => {
+ return getHermesProducts(view, selectedInstance).filter((product) => {
const haystack = [product.name, product.slug, product.category, product.owner, product.description, ...product.tags].join(' ').toLowerCase();
return haystack.includes(query.toLowerCase().trim());
});
- }, [query, view]);
+ }, [query, view, selectedInstance]);
const attentionCount = hermesProducts.filter((product) => product.needsAttention).length;
const highPriorityCount = hermesProducts.filter((product) => product.priority === 'P0' || product.priority === 'P1').length;
diff --git a/dashboard/web/src/app/hermes/tasks/page.tsx b/dashboard/web/src/app/hermes/tasks/page.tsx
index 42ca226..f913369 100644
--- a/dashboard/web/src/app/hermes/tasks/page.tsx
+++ b/dashboard/web/src/app/hermes/tasks/page.tsx
@@ -5,6 +5,8 @@ import Link from 'next/link';
import { Download, Filter, Search, ChevronDown, ChevronUp, ArrowLeftRight } from 'lucide-react';
import { Badge, Button, Input } from '@/components/ui/Primitives';
import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell';
+import { HermesInstanceBadge } from '@/components/hermes-instance-switcher';
+import { useHermesInstance } from '@/lib/hermes-instance-context';
import {
getHermesProductById,
getHermesTasks,
@@ -49,7 +51,11 @@ export default function HermesTaskLedgerPage() {
const [page, setPage] = useState(1);
const [expandedTaskId, setExpandedTaskId] = useState
(null);
- const tasks = useMemo(() => getHermesTasks({ query, status, productId, priority, type, source, updatedWithinDays, sort }), [query, status, productId, priority, type, source, updatedWithinDays, sort]);
+ const { selectedInstance } = useHermesInstance();
+ const tasks = useMemo(
+ () => getHermesTasks({ query, status, productId, priority, type, source, instance: selectedInstance, updatedWithinDays, sort }),
+ [query, status, productId, priority, type, source, selectedInstance, updatedWithinDays, sort],
+ );
const totalPages = Math.max(1, Math.ceil(tasks.length / pageSize));
const pagedTasks = tasks.slice((page - 1) * pageSize, page * pageSize);
@@ -145,7 +151,12 @@ export default function HermesTaskLedgerPage() {
{task.description}
- {product?.name ?? 'Unknown'}
+
+
+ {product?.name ?? 'Unknown'}
+
+
+
{task.status}
{task.priority}
{task.type}
diff --git a/dashboard/web/src/components/hermes-instance-switcher.tsx b/dashboard/web/src/components/hermes-instance-switcher.tsx
new file mode 100644
index 0000000..bf782a0
--- /dev/null
+++ b/dashboard/web/src/components/hermes-instance-switcher.tsx
@@ -0,0 +1,67 @@
+'use client';
+
+import { Badge } from '@/components/ui/Primitives';
+import { cn } from '@/lib/utils';
+import { HERMES_INSTANCES, type HermesInstanceFilter, type HermesInstanceId } from '@/lib/hermes';
+import { useHermesInstance } from '@/lib/hermes-instance-context';
+
+const OPTIONS: Array<{ id: HermesInstanceFilter; label: string }> = [
+ { id: 'all', label: 'All' },
+ ...HERMES_INSTANCES.map((inst) => ({ id: inst.id, label: inst.label })),
+];
+
+interface HermesInstanceSwitcherProps {
+ className?: string;
+}
+
+// Segmented control mounted in the Hermes layout header. Persists its
+// selection via `useHermesInstance` (localStorage-backed); every Mission
+// Control pane reads from the same hook.
+export function HermesInstanceSwitcher({ className }: HermesInstanceSwitcherProps) {
+ const { selectedInstance, setSelectedInstance } = useHermesInstance();
+
+ return (
+
+ {OPTIONS.map((option) => {
+ const active = option.id === selectedInstance;
+ return (
+ setSelectedInstance(option.id)}
+ className={cn(
+ 'rounded-full px-3 py-1 transition-colors',
+ active
+ ? 'bg-[var(--bl-accent)] text-[var(--bl-text-on-accent,white)]'
+ : 'text-[var(--bl-text-secondary)] hover:bg-[var(--bl-surface-muted)]',
+ )}
+ >
+ {option.label}
+
+ );
+ })}
+
+ );
+}
+
+// Small per-row badge so table cells can show which instance owns the entity.
+// Use `'all'` for entities scoped to both instances (e.g. cross-cutting agents
+// like Hermes Core, GitHub link).
+const INSTANCE_LABEL: Record = {
+ vijay: 'Vijay',
+ bheem: 'Bheem',
+ all: 'Both',
+};
+
+export function HermesInstanceBadge({ instanceId }: { instanceId: HermesInstanceId | 'all' }) {
+ return {INSTANCE_LABEL[instanceId]} ;
+}
diff --git a/dashboard/web/src/lib/hermes-instance-context.tsx b/dashboard/web/src/lib/hermes-instance-context.tsx
new file mode 100644
index 0000000..abfde18
--- /dev/null
+++ b/dashboard/web/src/lib/hermes-instance-context.tsx
@@ -0,0 +1,70 @@
+'use client';
+
+import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
+import type { HermesInstanceFilter } from './hermes';
+
+// Persisted across navigation so the switcher choice sticks. Bumping the key
+// resets the stored value (use this if we ever change the allowed filter
+// values incompatibly).
+const STORAGE_KEY = 'hermes.instanceFilter.v1';
+const DEFAULT_FILTER: HermesInstanceFilter = 'all';
+
+const VALID_FILTERS: HermesInstanceFilter[] = ['all', 'vijay', 'bheem'];
+
+function readPersisted(): HermesInstanceFilter {
+ if (typeof window === 'undefined') return DEFAULT_FILTER;
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ if (raw && (VALID_FILTERS as string[]).includes(raw)) {
+ return raw as HermesInstanceFilter;
+ }
+ } catch {
+ // localStorage may be unavailable (private mode / SSR). Fall through to default.
+ }
+ return DEFAULT_FILTER;
+}
+
+interface HermesInstanceContextValue {
+ selectedInstance: HermesInstanceFilter;
+ setSelectedInstance: (next: HermesInstanceFilter) => void;
+}
+
+const HermesInstanceContext = createContext(null);
+
+export function HermesInstanceProvider({ children }: { children: ReactNode }) {
+ // Always start with the default on the server so the SSR + first client render
+ // agree (avoids hydration mismatch). After mount we read the persisted value.
+ const [selectedInstance, setSelectedInstanceState] = useState(DEFAULT_FILTER);
+
+ useEffect(() => {
+ setSelectedInstanceState(readPersisted());
+ }, []);
+
+ const setSelectedInstance = useCallback((next: HermesInstanceFilter) => {
+ setSelectedInstanceState(next);
+ if (typeof window !== 'undefined') {
+ try {
+ window.localStorage.setItem(STORAGE_KEY, next);
+ } catch {
+ // Best-effort persistence; ignore failures.
+ }
+ }
+ }, []);
+
+ const value = useMemo(
+ () => ({ selectedInstance, setSelectedInstance }),
+ [selectedInstance, setSelectedInstance],
+ );
+
+ return {children} ;
+}
+
+export function useHermesInstance(): HermesInstanceContextValue {
+ const ctx = useContext(HermesInstanceContext);
+ if (!ctx) {
+ // Allow components to render outside the provider (e.g. ad-hoc usage in
+ // storybook/tests) by falling back to the default — never crash the tree.
+ return { selectedInstance: DEFAULT_FILTER, setSelectedInstance: () => {} };
+ }
+ return ctx;
+}
diff --git a/dashboard/web/src/lib/hermes.test.ts b/dashboard/web/src/lib/hermes.test.ts
index e66718f..3c855e2 100644
--- a/dashboard/web/src/lib/hermes.test.ts
+++ b/dashboard/web/src/lib/hermes.test.ts
@@ -47,4 +47,38 @@ describe('hermes mock service', () => {
expect(getHermesSettings().registry.length).toBeGreaterThan(0);
expect(getHermesProductById(hermesProducts[0].id)).toBeDefined();
});
+
+ it('tags every product/task with a Hermes instance', () => {
+ expect(hermesProducts.every((p) => p.instanceId === 'vijay' || p.instanceId === 'bheem')).toBe(true);
+ expect(hermesTasks.every((t) => t.instanceId === 'vijay' || t.instanceId === 'bheem')).toBe(true);
+ // Both instances should be represented in the seed data so the switcher
+ // doesn't bottom out on either one.
+ expect(hermesProducts.some((p) => p.instanceId === 'vijay')).toBe(true);
+ expect(hermesProducts.some((p) => p.instanceId === 'bheem')).toBe(true);
+ });
+
+ it('filters tasks/products/agents/overview by instance', () => {
+ const vijayTasks = getHermesTasks({ instance: 'vijay' });
+ const bheemTasks = getHermesTasks({ instance: 'bheem' });
+ expect(vijayTasks.every((t) => t.instanceId === 'vijay')).toBe(true);
+ expect(bheemTasks.every((t) => t.instanceId === 'bheem')).toBe(true);
+ expect(vijayTasks.length + bheemTasks.length).toBe(hermesTasks.length);
+
+ const vijayProducts = getHermesProducts('all', 'vijay');
+ expect(vijayProducts.every((p) => p.instanceId === 'vijay')).toBe(true);
+
+ // Cross-cutting agents (scope `'all'`) appear in every per-instance view;
+ // strict per-instance agents only appear when their instance matches.
+ const vijayAgents = getHermesAgents('vijay');
+ const bheemAgents = getHermesAgents('bheem');
+ expect(vijayAgents.every((a) => a.instanceId === 'vijay' || a.instanceId === 'all')).toBe(true);
+ expect(bheemAgents.every((a) => a.instanceId === 'bheem' || a.instanceId === 'all')).toBe(true);
+ expect(vijayAgents.some((a) => a.id === 'cli-runner')).toBe(true);
+ expect(bheemAgents.some((a) => a.id === 'local-vm-runner')).toBe(true);
+ expect(vijayAgents.some((a) => a.id === 'local-vm-runner')).toBe(false);
+
+ const overviewAll = getHermesOverview('all');
+ const overviewVijay = getHermesOverview('vijay');
+ expect(overviewVijay.activeTasks).toBeLessThanOrEqual(overviewAll.activeTasks);
+ });
});
diff --git a/dashboard/web/src/lib/hermes.ts b/dashboard/web/src/lib/hermes.ts
index 8e72fe9..2564c7a 100644
--- a/dashboard/web/src/lib/hermes.ts
+++ b/dashboard/web/src/lib/hermes.ts
@@ -1,3 +1,20 @@
+// Hermes runs as two co-located instances ("Vijay" on the root host, "Bheem"
+// under the `uma` user). Every entity that flows through Mission Control —
+// tasks, products, events, runs, agents — is tagged with the instance that
+// owns it so panes can filter or roll up across instances. The literal `'all'`
+// is a UI-only filter value (never stored on entities).
+export type HermesInstanceId = 'vijay' | 'bheem';
+export type HermesInstanceFilter = 'all' | HermesInstanceId;
+
+export const HERMES_INSTANCES: ReadonlyArray<{
+ id: HermesInstanceId;
+ label: string;
+ description: string;
+}> = [
+ { id: 'vijay', label: 'Vijay (root)', description: 'Primary host instance' },
+ { id: 'bheem', label: 'Bheem (uma)', description: 'Secondary user instance' },
+];
+
export type HermesStatus = 'running' | 'idle' | 'degraded' | 'error';
export type HermesTaskStatus =
@@ -39,6 +56,7 @@ export type HermesTaskSource =
export interface HermesProduct {
id: string;
+ instanceId: HermesInstanceId;
name: string;
slug: string;
description: string;
@@ -61,6 +79,7 @@ export interface HermesProduct {
export interface HermesTask {
id: string;
+ instanceId: HermesInstanceId;
title: string;
description: string;
productId: string;
@@ -87,6 +106,7 @@ export interface HermesTask {
export interface HermesEvent {
id: string;
+ instanceId: HermesInstanceId;
taskId: string;
timestamp: string;
level: 'debug' | 'info' | 'warn' | 'error' | 'success';
@@ -114,6 +134,7 @@ export interface HermesEvent {
export interface HermesRun {
id: string;
+ instanceId: HermesInstanceId;
taskId: string;
startedAt: string;
endedAt?: string;
@@ -128,6 +149,11 @@ export interface HermesRun {
export interface HermesAgentStatus {
id: string;
+ // An agent's "scope" — which Hermes instance(s) it serves. Some integrations
+ // (Hermes Core, GitHub link) span both; we model that with a literal `'all'`
+ // rather than duplicating rows. Filtering treats `'all'` as a match for any
+ // selected instance.
+ instanceId: HermesInstanceId | 'all';
name: string;
type: 'agent' | 'tool' | 'integration' | 'runner';
status: 'healthy' | 'degraded' | 'offline' | 'unknown';
@@ -250,8 +276,14 @@ export const hermesProducts: HermesProduct[] = Array.from({ length: 50 }, (_, in
),
);
+ // Deterministic split: every other product to Bheem (uma), the rest to
+ // Vijay (root). Gives a roughly 50/50 mix so the switcher is exercisable
+ // out of the box without any single instance going empty.
+ const instanceId: HermesInstanceId = index % 2 === 0 ? 'vijay' : 'bheem';
+
return {
id: `product-${ordinal}`,
+ instanceId,
name: `${seed.name} ${ordinal}`,
slug: `${seed.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${ordinal}`,
description: `${seed.category} product managed by Hermes for ${seed.owner.toLowerCase()} workflows.`,
@@ -321,6 +353,7 @@ export const hermesTasks: HermesTask[] = Array.from({ length: 36 }, (_, index) =
return {
id: `task-${index + 1}`,
+ instanceId: product.instanceId,
title,
description: `Hermes is working on ${taskTemplates[index % taskTemplates.length]} for ${product.name}.`,
productId: product.id,
@@ -354,6 +387,7 @@ const eventBlueprints = new Map(
const events: HermesEvent[] = [
{
id: `${task.id}-event-created`,
+ instanceId: task.instanceId,
taskId: task.id,
timestamp: created,
level: 'info',
@@ -363,6 +397,7 @@ const eventBlueprints = new Map(
},
{
id: `${task.id}-event-planned`,
+ instanceId: task.instanceId,
taskId: task.id,
timestamp: started,
level: 'info',
@@ -374,6 +409,7 @@ const eventBlueprints = new Map(
if (task.status === 'running' || task.status === 'completed' || task.status === 'blocked' || task.status === 'failed') {
events.push({
id: `${task.id}-event-started`,
+ instanceId: task.instanceId,
taskId: task.id,
timestamp: started,
level: 'success',
@@ -382,6 +418,7 @@ const eventBlueprints = new Map(
});
events.push({
id: `${task.id}-event-command`,
+ instanceId: task.instanceId,
taskId: task.id,
timestamp: isoMinutesAgo(index * 7 + 25),
level: 'debug',
@@ -395,6 +432,7 @@ const eventBlueprints = new Map(
if (task.status === 'blocked') {
events.push({
id: `${task.id}-event-blocked`,
+ instanceId: task.instanceId,
taskId: task.id,
timestamp: isoMinutesAgo(index * 7 + 10),
level: 'warn',
@@ -407,6 +445,7 @@ const eventBlueprints = new Map(
if (task.status === 'failed') {
events.push({
id: `${task.id}-event-error`,
+ instanceId: task.instanceId,
taskId: task.id,
timestamp: isoMinutesAgo(index * 7 + 8),
level: 'error',
@@ -416,6 +455,7 @@ const eventBlueprints = new Map(
});
events.push({
id: `${task.id}-event-retry`,
+ instanceId: task.instanceId,
taskId: task.id,
timestamp: isoMinutesAgo(index * 7 + 5),
level: 'warn',
@@ -427,6 +467,7 @@ const eventBlueprints = new Map(
if (task.status === 'completed' || task.status === 'skipped') {
events.push({
id: `${task.id}-event-completed`,
+ instanceId: task.instanceId,
taskId: task.id,
timestamp: completed,
level: 'success',
@@ -437,6 +478,7 @@ const eventBlueprints = new Map(
if (task.type === 'deploy') {
events.push({
id: `${task.id}-event-deployment`,
+ instanceId: task.instanceId,
taskId: task.id,
timestamp: completed,
level: 'success',
@@ -449,6 +491,7 @@ const eventBlueprints = new Map(
if (task.priority === 'P0') {
events.push({
id: `${task.id}-event-memory`,
+ instanceId: task.instanceId,
taskId: task.id,
timestamp: isoMinutesAgo(index * 7 + 2),
level: 'info',
@@ -464,6 +507,7 @@ const eventBlueprints = new Map(
export const hermesAgentStatuses: HermesAgentStatus[] = [
{
id: 'hermes-core',
+ instanceId: 'all',
name: 'Hermes Core',
type: 'agent',
status: 'healthy',
@@ -474,6 +518,7 @@ export const hermesAgentStatuses: HermesAgentStatus[] = [
},
{
id: 'openclaw-integration',
+ instanceId: 'all',
name: 'OpenClaw integration',
type: 'integration',
status: 'degraded',
@@ -486,6 +531,7 @@ export const hermesAgentStatuses: HermesAgentStatus[] = [
},
{
id: 'github-link',
+ instanceId: 'all',
name: 'GitHub integration',
type: 'integration',
status: 'healthy',
@@ -496,6 +542,7 @@ export const hermesAgentStatuses: HermesAgentStatus[] = [
},
{
id: 'local-vm-runner',
+ instanceId: 'bheem',
name: 'Local VM runner',
type: 'runner',
status: 'healthy',
@@ -506,6 +553,7 @@ export const hermesAgentStatuses: HermesAgentStatus[] = [
},
{
id: 'cli-runner',
+ instanceId: 'vijay',
name: 'CLI runner',
type: 'runner',
status: 'healthy',
@@ -516,6 +564,7 @@ export const hermesAgentStatuses: HermesAgentStatus[] = [
},
{
id: 'scheduler-cron',
+ instanceId: 'vijay',
name: 'Scheduler / cron',
type: 'tool',
status: 'healthy',
@@ -526,6 +575,7 @@ export const hermesAgentStatuses: HermesAgentStatus[] = [
},
{
id: 'deployment-tools',
+ instanceId: 'all',
name: 'Deployment tools',
type: 'tool',
status: 'degraded',
@@ -538,6 +588,7 @@ export const hermesAgentStatuses: HermesAgentStatus[] = [
},
{
id: 'notifications',
+ instanceId: 'all',
name: 'Notification tools',
type: 'tool',
status: 'offline',
@@ -608,26 +659,46 @@ export interface HermesTaskFilters {
priority?: HermesPriority | 'all';
type?: HermesTaskType | 'all';
source?: HermesTaskSource | 'all';
+ // Restrict to a single Hermes instance, or roll up across both with `'all'`.
+ instance?: HermesInstanceFilter;
updatedWithinDays?: number | 'all';
sort?: 'newest' | 'oldest' | 'priority' | 'status';
}
-export function getHermesOverview(): HermesOverview {
- const activeTasks = hermesTasks.filter((task) => task.status === 'running' || task.status === 'queued' || task.status === 'blocked').length;
- const completedToday = hermesTasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() >= now - 86_400_000).length;
- const completedThisWeek = hermesTasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() >= now - 7 * 86_400_000).length;
- const failedTasks = hermesTasks.filter((task) => task.status === 'failed').length;
- const blockedTasks = hermesTasks.filter((task) => task.status === 'blocked').length;
- const completedWithDuration = hermesTasks.filter((task) => typeof task.durationMs === 'number' && task.status === 'completed');
+// Helper used by every list-fetcher in this module so the filter semantics
+// stay consistent: an entity matches when the requested filter is `'all'` OR
+// equals the entity's own instance. For agents whose own scope is `'all'`,
+// they always match (they live on both instances).
+function instanceMatches(
+ entityScope: HermesInstanceId | 'all',
+ filter: HermesInstanceFilter,
+): boolean {
+ if (filter === 'all') return true;
+ if (entityScope === 'all') return true;
+ return entityScope === filter;
+}
+
+export function getHermesOverview(instance: HermesInstanceFilter = 'all'): HermesOverview {
+ const tasks = hermesTasks.filter((task) => instanceMatches(task.instanceId, instance));
+ const products = hermesProducts.filter((product) => instanceMatches(product.instanceId, instance));
+
+ const activeTasks = tasks.filter((task) => task.status === 'running' || task.status === 'queued' || task.status === 'blocked').length;
+ const completedToday = tasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() >= now - 86_400_000).length;
+ const completedThisWeek = tasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() >= now - 7 * 86_400_000).length;
+ const failedTasks = tasks.filter((task) => task.status === 'failed').length;
+ const blockedTasks = tasks.filter((task) => task.status === 'blocked').length;
+ const completedWithDuration = tasks.filter((task) => typeof task.durationMs === 'number' && task.status === 'completed');
const averageDurationMs = completedWithDuration.length
? Math.round(completedWithDuration.reduce((sum, task) => sum + (task.durationMs ?? 0), 0) / completedWithDuration.length)
: 0;
- const successRate = Math.round((hermesTasks.filter((task) => task.status === 'completed' || task.status === 'skipped').length / hermesTasks.length) * 100);
- const productsTouchedRecently = hermesProducts.filter((product) => product.lastHermesActivityAt && new Date(product.lastHermesActivityAt).getTime() >= now - 14 * 86_400_000).length;
- const founderAttentionCount = hermesTasks.filter((task) => task.status === 'blocked' || task.status === 'failed').length + hermesProducts.filter((product) => product.needsAttention).length;
- const upcomingJobs = hermesTasks.filter((task) => task.status === 'queued').length;
- const lastAction = hermesEventsSorted()[0]?.message ?? 'Hermes has not recorded an action yet.';
- const nextRecommendedAction = computeNextRecommendedAction();
+ const successRate = tasks.length === 0
+ ? 0
+ : Math.round((tasks.filter((task) => task.status === 'completed' || task.status === 'skipped').length / tasks.length) * 100);
+ const productsTouchedRecently = products.filter((product) => product.lastHermesActivityAt && new Date(product.lastHermesActivityAt).getTime() >= now - 14 * 86_400_000).length;
+ const founderAttentionCount = tasks.filter((task) => task.status === 'blocked' || task.status === 'failed').length + products.filter((product) => product.needsAttention).length;
+ const upcomingJobs = tasks.filter((task) => task.status === 'queued').length;
+ const lastAction = hermesEventsSorted(instance)[0]?.message ?? 'Hermes has not recorded an action yet.';
+ const nextRecommendedAction = computeNextRecommendedAction(instance);
return {
status: failedTasks > 6 ? 'error' : blockedTasks > 4 ? 'degraded' : activeTasks > 0 ? 'running' : 'idle',
@@ -654,12 +725,14 @@ export function getHermesTasks(filters: HermesTaskFilters = {}): HermesTask[] {
priority = 'all',
type = 'all',
source = 'all',
+ instance = 'all',
updatedWithinDays = 'all',
sort = 'newest',
} = filters;
const normalizedQuery = query?.trim().toLowerCase();
const filtered = hermesTasks.filter((task) => {
+ if (!instanceMatches(task.instanceId, instance)) return false;
const product = hermesProducts.find((item) => item.id === task.productId);
const matchesQuery = !normalizedQuery || [
task.title,
@@ -718,10 +791,14 @@ export function getHermesProductById(id: string): HermesProduct | undefined {
return hermesProducts.find((product) => product.id === id);
}
-export function getHermesProducts(view: 'all' | 'high-priority' | 'needs-attention' | 'no-recent-activity' | 'repeated-failures' | 'recently-shipped' = 'all'): HermesProduct[] {
+export function getHermesProducts(
+ view: 'all' | 'high-priority' | 'needs-attention' | 'no-recent-activity' | 'repeated-failures' | 'recently-shipped' = 'all',
+ instance: HermesInstanceFilter = 'all',
+): HermesProduct[] {
const recentCutoff = now - 14 * 86_400_000;
const shippedCutoff = now - 7 * 86_400_000;
return hermesProducts.filter((product) => {
+ if (!instanceMatches(product.instanceId, instance)) return false;
const recentFailedTasks = hermesTasks.filter((task) => task.productId === product.id && task.status === 'failed').length;
const recentCompletedTasks = hermesTasks.filter((task) => task.productId === product.id && task.status === 'completed').length;
switch (view) {
@@ -742,37 +819,51 @@ export function getHermesProducts(view: 'all' | 'high-priority' | 'needs-attenti
});
}
-export function getHermesHistory() {
- return hermesHistory;
+export function getHermesHistory(instance: HermesInstanceFilter = 'all'): HermesHistoryPoint[] {
+ // History is hand-tuned aggregate seed data. For the per-instance filter we
+ // approximate by halving each bar (instances were seeded ~50/50). This is
+ // mock data — Phase 3 replaces it with real per-instance time series.
+ if (instance === 'all') return hermesHistory;
+ return hermesHistory.map((point) => ({
+ label: point.label,
+ completed: Math.round(point.completed / 2),
+ failed: Math.round(point.failed / 2),
+ blocked: Math.round(point.blocked / 2),
+ active: Math.round(point.active / 2),
+ }));
}
-export function getHermesAgents() {
- return hermesAgentStatuses;
+export function getHermesAgents(instance: HermesInstanceFilter = 'all'): HermesAgentStatus[] {
+ return hermesAgentStatuses.filter((agent) => instanceMatches(agent.instanceId, instance));
}
export function getHermesSettings() {
return hermesSettings;
}
-function hermesEventsSorted() {
+function hermesEventsSorted(instance: HermesInstanceFilter = 'all') {
return Array.from(eventBlueprints.values())
.flat()
+ .filter((event) => instanceMatches(event.instanceId, instance))
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
}
-function computeNextRecommendedAction() {
- const blocked = hermesTasks.filter((task) => task.status === 'blocked');
+function computeNextRecommendedAction(instance: HermesInstanceFilter = 'all') {
+ const tasks = hermesTasks.filter((task) => instanceMatches(task.instanceId, instance));
+ const products = hermesProducts.filter((product) => instanceMatches(product.instanceId, instance));
+
+ const blocked = tasks.filter((task) => task.status === 'blocked');
if (blocked.length > 0) {
const next = blocked[0];
return `Unblock ${next.title} for ${getHermesProductById(next.productId)?.name ?? 'an active product'}.`;
}
- const failed = hermesTasks.find((task) => task.status === 'failed');
+ const failed = tasks.find((task) => task.status === 'failed');
if (failed) {
return `Inspect and retry ${failed.title}.`;
}
- const staleProduct = hermesProducts.find((product) => product.needsAttention);
+ const staleProduct = products.find((product) => product.needsAttention);
if (staleProduct) {
return `Review ${staleProduct.name} because it needs attention.`;
}
diff --git a/docs/hermes_dashboard_v2_roadmap.md b/docs/hermes_dashboard_v2_roadmap.md
index b2b1d21..cdd7688 100644
--- a/docs/hermes_dashboard_v2_roadmap.md
+++ b/docs/hermes_dashboard_v2_roadmap.md
@@ -87,10 +87,10 @@ The `hermes-ops` snapshot becomes the single source of truth for live status. Be
## Phase 2 — Instance dimension across Mission Control (G2)
-- [ ] Add `instanceId: 'vijay' | 'bheem'` to the core types in `web/src/lib/hermes` (`HermesTask`, `HermesProduct`, `HermesEvent`, `HermesRun`, agent/overview models) and to the backend contracts.
-- [ ] Add a global **instance switcher** in `HermesShell` (`All` / `Vijay (root)` / `Bheem (uma)`) with persisted selection; thread it through every pane.
-- [ ] Overview: show per-instance cards **and** a combined roll-up (extend the existing "Healthy instances 2/2" pattern from the ops panel to the whole overview).
-- [ ] Ledger / Products / History / Agents: filter and badge by instance.
+- [x] Add `instanceId: 'vijay' | 'bheem'` to the core types in `web/src/lib/hermes` (`HermesTask`, `HermesProduct`, `HermesEvent`, `HermesRun`, agent/overview models) and to the backend contracts. *(Web: `instanceId` now on `HermesProduct`, `HermesTask`, `HermesEvent`, `HermesRun`, `HermesAgentStatus` (with a `'all'` literal for cross-cutting agents like Hermes Core / GitHub link). Seed data deterministically split ~50/50 across instances. Backend ops contract already carried per-instance shape under `HermesOpsSnapshot.instances` from Phase 1; no separate backend change needed for this slice.)*
+- [x] Add a global **instance switcher** in `HermesShell` (`All` / `Vijay (root)` / `Bheem (uma)`) with persisted selection; thread it through every pane. *(New `HermesInstanceProvider` (React context, localStorage-backed under key `hermes.instanceFilter.v1`, with SSR-safe default to avoid hydration mismatch) mounted in `app/hermes/layout.tsx`. New `HermesInstanceSwitcher` segmented control rendered in the layout header above every pane. Every pane reads `useHermesInstance()` and threads the value into the data-fetcher.)*
+- [x] Overview: show per-instance cards **and** a combined roll-up. *(New "Per-instance roll-up" section on `/hermes` always shows Vijay and Bheem side-by-side with active/blocked/failed/success-rate cells regardless of the switcher state — that's the "always cross-instance" comparison view, while the eight metric cards above it are filtered by the switcher.)*
+- [x] Ledger / Products / History / Agents: filter and badge by instance. *(`HermesInstanceBadge` component shipped; tasks (Active Missions + Task Ledger), product cards (overview minicards + portfolio cards), and agent rows now show their instance. Filter helpers `getHermesTasks({instance})`, `getHermesProducts(view, instance)`, `getHermesAgents(instance)`, `getHermesHistory(instance)`, `getHermesOverview(instance)` all accept the filter and short-circuit `'all'`. New unit tests in `lib/hermes.test.ts` cover the filter semantics. New E2E test asserts the switcher's radiogroup, default selection, and persistence-friendly state change. 7/7 E2E + 13/13 web unit tests green.)*
## Phase 3 — Real per-instance telemetry, replacing mock pane by pane (G1, G4)
@@ -186,7 +186,7 @@ Update only with evidence (source review, tests, build output, or browser/VM ver
- [ ] Phase 0 — Guardrails reconfirmed
- [x] Phase 1 — `hermes-ops` hardened + tested
-- [ ] Phase 2 — Instance dimension + switcher
+- [x] Phase 2 — Instance dimension + switcher
- [ ] Phase 3 — Real telemetry ingestion + panes converted
- [ ] Phase 4 — Bheem/Uma parity (backup, watchdog, restore drill)
- [x] Phase 5 — App/CI hardening (P0/P1/P2 done; P2 follow-ups in DEPLOYMENT.md mitigation roadmap remain)