feat: add Hermes integration panel to Settings → Integrations

Server-side:
- New hermes-integration module (runtime.js + routes.js + tests)
  exposing GET /api/openchamber/hermes/status
- Reports availability of agent-activity, session-steer, and plan-gate
  runtimes plus plan-gate default config
- Wired into feature-routes-runtime.js alongside existing integrations

UI:
- New HermesIntegration.tsx collapsible panel in Integrations page
- Connection status indicator (green/red dot) with 30s polling
- Plan gate default toggle (persists via updateDesktopSettings +
  recordDeferredOpenCodeRestart)
- Read-only status rows for activity stream, steer channel, plan gate
- Server version and uptime display

Settings:
- hermesPlanGateDefault field in settings registry and useUIStore
- Search index entry with keywords: hermes, agent, integration, etc.
- i18n keys for all 12 locales (English text as fallback)

Verification:
- Server tests: 7/7 passed (runtime + routes)
- UI tests: 4/4 passed (HermesIntegration.test.tsx)
- Typecheck: clean (no new errors)
- oxlint: clean on all new/modified files
This commit is contained in:
2026-09-10 11:25:51 +00:00
parent d37c58bae3
commit be159dac19
14 changed files with 669 additions and 1 deletions
+10
View File
@@ -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({
@@ -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',
});
}
});
}
@@ -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');
});
});
@@ -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 };
}
@@ -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);
});
});
@@ -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,