Skip to content

Commit 57b3ac4

Browse files
committed
feat(server): add Hermes HookProvider adapter
Add a second HookProvider so Hermes roster bots render as characters in the Pixel Agents office, mirroring the reference Claude provider. - providers/hook/hermes/hermes.ts: HookProvider impl. normalizeHookEvent maps Hermes outbound-webhook payloads (pre_tool_call -> toolStart, post_tool_call -> toolEnd, on_turn_complete -> turnEnd awaitingInput, on_session_start -> sessionStart, on_session_end -> sessionEnd, subagent_start -> subagentStart, subagent_stop -> subagentEnd); formatToolStatus for Hermes snake_case tool names; readingTools / subagentToolNames={delegate_task}; best-effort contextWindowForModel. No TeamProvider / file-fallback: Hermes sessions are SQLite, not JSONL. - providers/hook/hermes/hermesHookInstaller.ts: appends a target to hooks.outbound in Hermes config.yaml ($HERMES_CONFIG or ~/.hermes/config.yaml). Edits YAML directly with js-yaml (atomic tmp + rename) -- never 'hermes config set' for list keys (corrupts the loader). Idempotent, preserves unrelated config keys and other outbound targets. - providers/hook/hermes/constants.ts: Hermes hook events, provider id, context-window tables. - providers/index.ts: export hermesProvider. - httpServer.ts: hook route auth now accepts either the existing Bearer token or Hermes' X-Hermes-Signature-256 HMAC (GitHub-webhook style) verified over the raw body. Raw body preserved via a parseAs:'buffer' JSON content-type parser. - Tests mirroring the Claude provider suite: normalize table + formatToolStatus (hermes.test.ts), installer install/idempotence/ uninstall/preserve-keys (hermesHookInstaller.test.ts), and hook-route HMAC auth (hermesHookAuth.test.ts). 58 new tests, all green. - server/package.json: add js-yaml (+ @types/js-yaml) for YAML config editing. Known Hermes gaps (noted for v2): no permissionRequest event exists, and on_turn_complete is a context-engine observation rather than a plugin hook today, so it is handled defensively but not installed as an event.
1 parent 0f823e2 commit 57b3ac4

10 files changed

Lines changed: 1134 additions & 10 deletions

File tree

package-lock.json

Lines changed: 10 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/__tests__/hermes.test.ts

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { hermesProvider } from '../src/providers/hook/hermes/hermes.js';
4+
5+
describe('hermesProvider', () => {
6+
describe('identity', () => {
7+
it('has kind "hook"', () => {
8+
expect(hermesProvider.kind).toBe('hook');
9+
});
10+
it('has id "hermes"', () => {
11+
expect(hermesProvider.id).toBe('hermes');
12+
});
13+
it('has a displayName', () => {
14+
expect(hermesProvider.displayName).toBe('Hermes');
15+
});
16+
it('has delegate_task in subagentToolNames', () => {
17+
expect(hermesProvider.subagentToolNames.has('delegate_task')).toBe(true);
18+
});
19+
it('has reading tools read_file/search_files/web_search/web_extract', () => {
20+
for (const tool of ['read_file', 'search_files', 'web_search', 'web_extract']) {
21+
expect(hermesProvider.readingTools.has(tool)).toBe(true);
22+
}
23+
expect(hermesProvider.readingTools.has('terminal')).toBe(false);
24+
expect(hermesProvider.readingTools.has('patch')).toBe(false);
25+
});
26+
it('has protocolVersion 1', () => {
27+
expect(hermesProvider.protocolVersion).toBe(1);
28+
});
29+
it('has a terminalNamePrefix', () => {
30+
expect(hermesProvider.terminalNamePrefix).toBe('Hermes');
31+
});
32+
it('has no TeamProvider (Hermes subagents are plain delegate_task runs)', () => {
33+
expect(hermesProvider.team).toBeUndefined();
34+
});
35+
it('has no file-fallback surface (Hermes sessions are SQLite, not JSONL)', () => {
36+
expect(hermesProvider.getSessionDirs).toBeUndefined();
37+
expect(hermesProvider.getAllSessionRoots).toBeUndefined();
38+
expect(hermesProvider.sessionFilePattern).toBeUndefined();
39+
});
40+
});
41+
42+
describe('normalizeHookEvent', () => {
43+
it('returns null when hook_event_name is missing', () => {
44+
expect(hermesProvider.normalizeHookEvent({ session_id: 'x' })).toBeNull();
45+
});
46+
it('returns null when session_id is missing', () => {
47+
expect(hermesProvider.normalizeHookEvent({ hook_event_name: 'pre_tool_call' })).toBeNull();
48+
});
49+
it('returns null for unknown hook event names', () => {
50+
expect(
51+
hermesProvider.normalizeHookEvent({
52+
hook_event_name: 'SomethingWeird',
53+
session_id: 'x',
54+
}),
55+
).toBeNull();
56+
});
57+
58+
it('normalizes pre_tool_call with tool_name + tool_input', () => {
59+
const result = hermesProvider.normalizeHookEvent({
60+
hook_event_name: 'pre_tool_call',
61+
session_id: 'sess-1',
62+
tool_name: 'read_file',
63+
tool_input: { path: '/foo.ts' },
64+
cwd: '/home/user/project',
65+
});
66+
expect(result?.sessionId).toBe('sess-1');
67+
expect(result?.event.kind).toBe('toolStart');
68+
if (result?.event.kind === 'toolStart') {
69+
expect(result.event.toolName).toBe('read_file');
70+
expect(result.event.toolId.startsWith('hook-')).toBe(true);
71+
expect(result.event.input).toEqual({ path: '/foo.ts' });
72+
}
73+
});
74+
75+
it('pre_tool_call tolerates missing tool_input', () => {
76+
const result = hermesProvider.normalizeHookEvent({
77+
hook_event_name: 'pre_tool_call',
78+
session_id: 'sess-1',
79+
tool_name: 'terminal',
80+
});
81+
expect(result?.event.kind).toBe('toolStart');
82+
if (result?.event.kind === 'toolStart') {
83+
expect(result.event.input).toEqual({});
84+
}
85+
});
86+
87+
it('normalizes post_tool_call to toolEnd with sentinel toolId', () => {
88+
const result = hermesProvider.normalizeHookEvent({
89+
hook_event_name: 'post_tool_call',
90+
session_id: 'sess-1',
91+
tool_name: 'terminal',
92+
extra: { status: 'ok', duration_ms: 42 },
93+
});
94+
expect(result?.event.kind).toBe('toolEnd');
95+
});
96+
97+
it('normalizes on_turn_complete to turnEnd with awaitingInput=true', () => {
98+
const result = hermesProvider.normalizeHookEvent({
99+
hook_event_name: 'on_turn_complete',
100+
session_id: 'sess-1',
101+
});
102+
expect(result?.event.kind).toBe('turnEnd');
103+
if (result?.event.kind === 'turnEnd') {
104+
expect(result.event.awaitingInput).toBe(true);
105+
}
106+
});
107+
108+
it('normalizes on_session_start with cwd and platform source', () => {
109+
const result = hermesProvider.normalizeHookEvent({
110+
hook_event_name: 'on_session_start',
111+
session_id: 'sess-1',
112+
cwd: '/Users/x/work',
113+
extra: { model: 'deepseek-v4-flash', platform: 'cli' },
114+
});
115+
expect(result?.event.kind).toBe('sessionStart');
116+
if (result?.event.kind === 'sessionStart') {
117+
expect(result.event.source).toBe('cli');
118+
expect(result.event.cwd).toBe('/Users/x/work');
119+
expect(result.event.transcriptPath).toBeUndefined();
120+
}
121+
});
122+
123+
it('normalizes on_session_end with reason=completed from extra', () => {
124+
const result = hermesProvider.normalizeHookEvent({
125+
hook_event_name: 'on_session_end',
126+
session_id: 'sess-1',
127+
extra: { completed: true, interrupted: false, model: 'deepseek-v4-flash' },
128+
});
129+
expect(result?.event.kind).toBe('sessionEnd');
130+
if (result?.event.kind === 'sessionEnd') {
131+
expect(result.event.reason).toBe('completed');
132+
}
133+
});
134+
135+
it('normalizes on_session_end with reason=interrupted when interrupted', () => {
136+
const result = hermesProvider.normalizeHookEvent({
137+
hook_event_name: 'on_session_end',
138+
session_id: 'sess-1',
139+
extra: { completed: false, interrupted: true },
140+
});
141+
expect(result?.event.kind).toBe('sessionEnd');
142+
if (result?.event.kind === 'sessionEnd') {
143+
expect(result.event.reason).toBe('interrupted');
144+
}
145+
});
146+
147+
it('normalizes subagent_start with child_role as toolName', () => {
148+
const result = hermesProvider.normalizeHookEvent({
149+
hook_event_name: 'subagent_start',
150+
session_id: 'sess-1',
151+
extra: {
152+
child_role: 'leaf',
153+
child_session_id: 'sess-child',
154+
child_goal: 'Review the diff',
155+
},
156+
});
157+
expect(result?.event.kind).toBe('subagentStart');
158+
if (result?.event.kind === 'subagentStart') {
159+
expect(result.event.toolName).toBe('leaf');
160+
expect(result.event.parentToolId).toBe('current');
161+
expect(result.event.toolId.startsWith('hook-sub-leaf-')).toBe(true);
162+
}
163+
});
164+
165+
it('normalizes subagent_stop to subagentEnd', () => {
166+
const result = hermesProvider.normalizeHookEvent({
167+
hook_event_name: 'subagent_stop',
168+
session_id: 'sess-1',
169+
extra: { child_role: 'leaf', child_status: 'success' },
170+
});
171+
expect(result?.event.kind).toBe('subagentEnd');
172+
});
173+
174+
it('drops gateway_platform_event (no AgentEvent shape fits)', () => {
175+
expect(
176+
hermesProvider.normalizeHookEvent({
177+
hook_event_name: 'gateway_platform_event',
178+
session_id: 'sess-1',
179+
extra: { platform: 'telegram', event_type: 'reaction' },
180+
}),
181+
).toBeNull();
182+
});
183+
184+
it('drops on_session_finalize (sessionEnd already removes the agent)', () => {
185+
expect(
186+
hermesProvider.normalizeHookEvent({
187+
hook_event_name: 'on_session_finalize',
188+
session_id: 'sess-1',
189+
}),
190+
).toBeNull();
191+
});
192+
});
193+
194+
describe('formatToolStatus', () => {
195+
it('formats terminal with command', () => {
196+
expect(hermesProvider.formatToolStatus('terminal', { command: 'npm test' })).toBe(
197+
'Running: npm test',
198+
);
199+
});
200+
it('formats read_file', () => {
201+
expect(hermesProvider.formatToolStatus('read_file', { path: '/a/b.ts' })).toBe(
202+
'Reading b.ts',
203+
);
204+
});
205+
it('formats write_file', () => {
206+
expect(hermesProvider.formatToolStatus('write_file', { path: '/a/c.ts' })).toBe(
207+
'Writing c.ts',
208+
);
209+
});
210+
it('formats patch with a single path', () => {
211+
expect(hermesProvider.formatToolStatus('patch', { path: '/a/d.ts' })).toBe('Editing d.ts');
212+
});
213+
it('formats patch with a multi-file V4A path array', () => {
214+
expect(hermesProvider.formatToolStatus('patch', { path: ['/a/one.ts', '/a/two.ts'] })).toBe(
215+
'Editing one.ts, two.ts',
216+
);
217+
});
218+
it('formats search_files / web_search / web_extract', () => {
219+
expect(hermesProvider.formatToolStatus('search_files')).toBe('Searching files');
220+
expect(hermesProvider.formatToolStatus('web_search')).toBe('Searching the web');
221+
expect(hermesProvider.formatToolStatus('web_extract')).toBe('Extracting web content');
222+
});
223+
it('formats execute_code / browser_exec / computer_use', () => {
224+
expect(hermesProvider.formatToolStatus('execute_code')).toBe('Running code');
225+
expect(hermesProvider.formatToolStatus('browser_exec')).toBe('Browsing the web');
226+
expect(hermesProvider.formatToolStatus('computer_use')).toBe('Controlling the computer');
227+
});
228+
it('formats delegate_task with goal', () => {
229+
expect(hermesProvider.formatToolStatus('delegate_task', { goal: 'Research X' })).toBe(
230+
'Subtask: Research X',
231+
);
232+
expect(hermesProvider.formatToolStatus('delegate_task')).toBe('Running subtask');
233+
});
234+
it('formats skill_view with name', () => {
235+
expect(hermesProvider.formatToolStatus('skill_view', { name: 'github-pr-workflow' })).toBe(
236+
'Loading skill: github-pr-workflow',
237+
);
238+
});
239+
it('falls back to "Using X" for unknown tools', () => {
240+
expect(hermesProvider.formatToolStatus('FancyTool', {})).toBe('Using FancyTool');
241+
});
242+
it('handles undefined input', () => {
243+
expect(hermesProvider.formatToolStatus('read_file', undefined)).toBe('Reading ');
244+
});
245+
});
246+
247+
describe('contextWindowForModel', () => {
248+
it('returns small window for flash/mini models', () => {
249+
expect(hermesProvider.contextWindowForModel?.('deepseek-v4-flash')).toBe(128_000);
250+
expect(hermesProvider.contextWindowForModel?.('gpt-4o-mini')).toBe(128_000);
251+
});
252+
it('returns large window for recognized large models', () => {
253+
expect(hermesProvider.contextWindowForModel?.('claude-sonnet-5')).toBe(200_000);
254+
});
255+
it('returns undefined for unknown/synthetic models', () => {
256+
expect(hermesProvider.contextWindowForModel?.('<synthetic>')).toBeUndefined();
257+
expect(hermesProvider.contextWindowForModel?.(undefined)).toBeUndefined();
258+
});
259+
});
260+
});

0 commit comments

Comments
 (0)