Skip to content

feat(server): add Hermes HookProvider adapter - #390

Open
ky-os wants to merge 1 commit into
pixel-agents-hq:mainfrom
ky-os:hermes-provider
Open

feat(server): add Hermes HookProvider adapter#390
ky-os wants to merge 1 commit into
pixel-agents-hq:mainfrom
ky-os:hermes-provider

Conversation

@ky-os

@ky-os ky-os commented Aug 15, 2026

Copy link
Copy Markdown

Hermes HookProvider adapter

Adds a second HookProvider so Hermes roster bots render as characters in the Pixel Agents office, mirroring the reference Claude provider. Hermes needs no code changes: its config-driven outbound webhooks (agent/outbound_webhooks.py) already POST Claude-style payloads (hook_event_name, tool_name, tool_input, session_id, cwd), so the adapter is purely a normalization boundary on this side.

What's included

server/src/providers/hook/hermes/hermes.tsHookProvider impl:

  • normalizeHookEvent mapping table:

    Hermes event AgentEvent
    pre_tool_call toolStart (synthetic hook-* toolId)
    post_tool_call toolEnd (sentinel current toolId, correlated by the handler's currentHookToolId)
    on_turn_complete turnEnd with awaitingInput: true (Hermes is idle waiting on the user)
    on_session_start sessionStart (source from extra.platform, cwd top-level)
    on_session_end sessionEnd (reason derived from extra.completed/extra.interrupted)
    subagent_start subagentStart (toolName = extra.child_role)
    subagent_stop subagentEnd

    gateway_platform_event and on_session_finalize are intentionally dropped (informational / duplicate of sessionEnd cleanup).

  • formatToolStatus for Hermes snake_case tools: terminal, read_file, write_file, patch (single + V4A multi-file path arrays), search_files, web_search, web_extract, execute_code, browser_exec, computer_use, skill_view, delegate_task.

  • subagentToolNames = {delegate_task}, readingTools = read-family set, permissionExemptTools = {delegate_task}.

  • contextWindowForModel — best-effort heuristic (small-window regex → 128k, else 200k, undefined for unknown/synthetic). Runtime widens on overrun, so a wrong guess self-corrects.

  • No TeamProvider and no file-fallback surface: Hermes sessions live in SQLite, not JSONL, and subagents are plain delegate_task runs without team semantics.

hermesHookInstaller.ts — appends one target to hooks.outbound in Hermes config.yaml ($HERMES_CONFIG or ~/.hermes/config.yaml):

  • Edits the YAML directly with js-yaml (atomic tmp + rename), because hermes config set corrupts list-valued keys (known Hermes loader issue). Never uses it.
  • Idempotent; preserves unrelated config keys and any other outbound webhook targets (marker: name: pixel-agents).
  • Writes secret: <server token> so Hermes signs every delivery with X-Hermes-Signature-256: sha256=<hex> (GitHub-webhook style). secret_env is noted in-code as the recommended alternative for non-local deployments.
  • Events installed: pre_tool_call, post_tool_call, on_session_start, on_session_end, subagent_start, subagent_stop (all valid VALID_HOOKS plugin events). See the caveat below about on_turn_complete.

httpServer.ts — the hook route now accepts either the existing Authorization: Bearer <token> or Hermes' X-Hermes-Signature-256: sha256=<hex> HMAC verified over the exact raw body bytes (preserved via a parseAs: 'buffer' JSON content-type parser). Constant-time comparisons on both paths.

providers/index.ts — exports hermesProvider.

Known gaps (v2)

  • No permissionRequest event exists in Hermes. Approvals are an interactive CLI prompt, never a hook. A future permission surface should map to the permissionRequest AgentEvent.
  • on_turn_complete is not a plugin-hook event today. It's a context-engine observation method (agent/context_engine.py), so an outbound-webhook entry for it would be rejected at registration. normalizeHookEvent still handles the name defensively in case a future Hermes delivers it, but the installer's events list excludes it.

Follow-ups (not in this PR)

  • Provider selection wiring (which provider the standalone/VS Code server uses) — the provider is exported for adapters; the server currently hard-wires Claude.
  • Installing gitleaks for the pre-commit hook on Windows dev machines (hook requires it).

Verification

  • npm run check-types — green (both tsc --noEmit passes).
  • npm run lint — 0 errors (one pre-existing warning in webview-ui/src/App.tsx).
  • npm test:
    • webview: 52/52 pass
    • package-contract: 7/7 pass
    • server: 425 pass, 10 fail — all 10 pre-existing on main (verified by stashing this branch and running the same 3 files: identical failures). They are unrelated Windows test-infra issues:
      • configPersistence.test.ts / clientMessageHandler.test.ts (6): tests set process.env.HOME but configPersistence.ts resolves paths via os.homedir(), which on Windows ignores HOME — the tests read/write the real ~/.pixel-agents/config.json instead of the temp dir.
      • mockClaudeRunner.test.ts (4): fs.watch-based tests time out (10s) on Windows.
    • New hermes tests: 58/58 pass (hermes.test.ts normalize/format tables, hermesHookInstaller.test.ts install/idempotence/uninstall/preserve-keys, hermesHookAuth.test.ts HMAC-vs-Bearer auth).

Dependency change

server/package.json gains js-yaml (+ @types/js-yaml dev) for YAML config editing. Lockfile diff is minimal (js-yaml was already in the tree as a transitive dep; its dev flag is flipped off since the server now depends on it at runtime).

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.
@ky-os

ky-os commented Aug 15, 2026

Copy link
Copy Markdown
Author

QA Review — APPROVED (board verdict: kanban t_2ab0a6a9)

Independent review by @qa-reviewer. Artifact-lens round: cold-read the diff, then verified every claim against the actual Hermes source and by running the gates. (Formal approve not possible via API — PR authored by same account.)

Verified against Hermes source (C:/.../hermes-agent)

  • Wire format matches agent/outbound_webhooks.py exactly: hook_event_name, tool_name, tool_input, session_id, cwd, extra. Adapter reads only those fields.
  • X-Hermes-Signature-256: sha256=<hex> HMAC over raw body using hooks.outbound[].secret — confirmed in _build_delivery. Server seam verifies the same way (raw bytes preserved via parseAs:'buffer', constant-time compare). Bearer path unchanged.
  • All 6 installed events are in VALID_HOOKS (hermes_cli/plugins.py). on_turn_complete is confirmed NOT a valid plugin hook — it is a ContextEngine observation method — so excluding it from the installer while handling it defensively in normalizeHookEvent is correct.
  • extra.child_role (subagent_start) and extra.platform (on_session_start) land in extra because neither is a top-level payload key — mapping is correct.

Gates run (reproduced, not taken on faith)

  • npm run check-types — green (both tsc passes)
  • eslint on all changed server files — 0 errors
  • 3 new hermes test files — 58/58 pass
  • Full-suite failures: reproduced identical failures on pristine upstream/main (0f823e2): 3 configPersistence + 3 clientMessageHandler + 4 mockClaudeRunner, same Windows test-infra root causes. The branch run passed one extra flaky configPersistence test (9 vs 10). Zero failures introduced by this change.
  • Lockfile diff minimal and correct (js-yaml dev flag flip + @types/js-yaml). No secrets in diff. CI check passes.

Non-blocking observations (follow-ups, not required for merge)

  1. Subagent child label renders "Subtask: unknown": hookEventHandler.handleSubagentStart derives the name from provider.team?.extractTeammateNameFromEvent(...) ?? 'unknown', and this provider intentionally has no TeamProvider (per task spec). The adapter puts the real child_role in event.toolName; the handler just doesn't consume it in the no-team path. A v2 team surface (or handler fallback to event.toolName) would fix the label.
  2. End-to-end selection wiring: cli.ts still constructs AgentRuntime(store, claudeProvider) and hookEventHandler.handleEvent(_providerId, ...) ignores providerId, so the standalone server would normalize Hermes events with Claude's provider (→ null) until selection wiring lands. Correctly documented as a follow-up in the PR body.

No implementation files were edited by the reviewer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant