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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user