]`**, reverse only Honeycomb's footprint for one harness, or for every detected harness when no target is given.
-The connector registry (`src/cli/connector-runner.ts`, `createConnectorRegistry`) builds each connector over the real `node:fs`-backed `ConnectorFs` and the user's home. Claude Code is wired by registering its marketplace plugin via the real `claude plugin` CLI (rather than writing top-level `settings.json` hooks); Codex and Cursor are wired by the config-patch path. A new harness is a subclass added to the registry, never a fork of install logic.
+The connector registry (`src/cli/connector-runner.ts`, `createConnectorRegistry`) builds each connector over the real `node:fs`-backed `ConnectorFs` and the user's home. Claude Code is wired by registering its marketplace plugin via the real `claude plugin` CLI (rather than writing top-level `settings.json` hooks); Codex, Cursor, and Hermes are wired by their native config-patch paths. A new harness is a subclass added to the registry, never a fork of install logic.
## The support matrix
@@ -82,21 +83,15 @@ Each harness wires the same logical lifecycle events through its own mechanism;
| Claude Code | Supported | Marketplace plugin + hooks + MCP | Reference connector and reference hook set; model-only context, `legacy` runtime path |
| Codex | Supported | `~/.codex/hooks.json` + hooks + MCP | Nested matcher-block config shape; user-visible context; Bash-only VFS intercept |
| Cursor | Supported | `~/.cursor/hooks.json` + extension + MCP | Flat per-event config shape; first-party editor extension; `Shell`-tool VFS intercept; see [`../frontend/cursor-extension-architecture.md`](../frontend/cursor-extension-architecture.md) |
-| Hermes | In progress | Planned hook + MCP path | Not wired as a production connector path yet |
+| Hermes | Supported | `$HERMES_HOME/config.yaml` shell hooks + MCP | Native 0.19 lifecycle; model-only `pre_llm_call` recall; explicit first-use hook consent |
| pi | In progress | Planned extension + `AGENTS.md` path | Not wired as a production connector path yet |
| OpenClaw | In progress | Planned native-extension path | Not wired as a production connector path yet |
-The differences are real but shallow: native event names and payload fields vary, and the context channel is model-only on some harnesses (Claude Code, Cursor, OpenClaw) and user-visible on others (Codex, Hermes, pi), so each shim normalizes before handing off and renders the context block through its harness's channel.
+The differences are real but shallow: native event names and payload fields vary, and the context channel is model-only on some harnesses (Claude Code, Cursor, Hermes, OpenClaw) and user-visible on others (Codex, pi), so each shim normalizes before handing off and renders the context block through its harness's channel.
## MCP-server-via-install
-For harnesses that speak the Model Context Protocol, the Honeycomb MCP server is registered during install so its `honeycomb_*` tools appear in the harness's native tool list. The server bundle is built by esbuild to `mcp/bundle/server.js` and ships with the package. Hermes, for example, registers it through its `.mcp.json`:
-
-```json
-{ "mcpServers": { "honeycomb": { "command": "node", "args": ["mcp/bundle/server.js"] } } }
-```
-
-and the Hermes shim appends a user-visible mention so the agent knows the tools exist: `(Honeycomb MCP tools available: honeycomb_search, honeycomb_read, honeycomb_index.)`. The same `node mcp/bundle/server.js` stdio entry registers into the other MCP-speaking harnesses during their connect step. The tool surface, the read/resolve and search/mine clusters, and the registration mechanics are documented in [`mcp-and-sdk.md`](mcp-and-sdk.md).
+For harnesses that speak the Model Context Protocol, the Honeycomb MCP server is registered during install so its `honeycomb_*` tools appear in the harness's native tool list. The server bundle is built by esbuild to `mcp/bundle/server.js` and ships with the package. The Hermes connector copies it to `$HERMES_HOME/honeycomb/mcp/server.mjs` and writes a foreign-safe `mcp_servers.honeycomb` stdio entry in `config.yaml`; no repository-local `.mcp.json` is involved. The tool surface, the read/resolve and search/mine clusters, and the registration mechanics are documented in [`mcp-and-sdk.md`](mcp-and-sdk.md).
## The Claude Code plugin: packaging and delivery
diff --git a/library/knowledge/private/integrations/hook-lifecycle.md b/library/knowledge/private/integrations/hook-lifecycle.md
index 2455765e..3ed3df83 100644
--- a/library/knowledge/private/integrations/hook-lifecycle.md
+++ b/library/knowledge/private/integrations/hook-lifecycle.md
@@ -29,12 +29,12 @@ Each harness has its own event vocabulary. The table maps the logical Honeycomb
| Logical event | Claude Code | Codex | Cursor | Hermes | pi | OpenClaw |
|---|---|---|---|---|---|---|
-| Session start / recall inject | `SessionStart` | `SessionStart` | `sessionStart` | `on_session_start` | AGENTS.md static block | `before_agent_start` + `before_prompt_build` |
-| Prompt capture | `UserPromptSubmit` | `UserPromptSubmit` | `beforeSubmitPrompt` | `on_user_message` | (batched) | `agent_end` (batch) |
-| Pre-tool intercept (VFS recall) | `PreToolUse` | `PreToolUse` (Bash) | `beforeShellExecution` (Shell) | `on_tool_use` (terminal only) | N/A | N/A |
-| Tool-call capture | `PostToolUse` | `PostToolUse` | `postToolUse` | `on_tool_use` (terminal only) | N/A | `agent_end` (batch) |
-| Assistant response capture | `Stop` / `SubagentStop` | `Stop` | `afterAgentResponse` / `stop` | N/A | N/A | `agent_end` (batch) |
-| Session end / summary spawn | `SessionEnd` | N/A (periodic only) | `sessionEnd` | `on_session_end` | `agent_end` / `session_shutdown` | `agent_end` (with summary slice) |
+| Session start / recall inject | `SessionStart` | `SessionStart` | `sessionStart` | `on_session_start` + `pre_llm_call` | AGENTS.md static block | `before_agent_start` + `before_prompt_build` |
+| Prompt capture | `UserPromptSubmit` | `UserPromptSubmit` | `beforeSubmitPrompt` | `pre_llm_call` (`extra.user_message`) | (batched) | `agent_end` (batch) |
+| Pre-tool intercept (VFS recall) | `PreToolUse` | `PreToolUse` (Bash) | `beforeShellExecution` (Shell) | N/A | N/A | N/A |
+| Tool-call capture | `PostToolUse` | `PostToolUse` | `postToolUse` | `post_tool_call` (`extra.result`) | N/A | `agent_end` (batch) |
+| Assistant response capture | `Stop` / `SubagentStop` | `Stop` | `afterAgentResponse` / `stop` | `post_llm_call` (`extra.assistant_response`) | N/A | `agent_end` (batch) |
+| Session end / summary spawn | `SessionEnd` | N/A (periodic only) | `sessionEnd` | `on_session_finalize` | `agent_end` / `session_shutdown` | `agent_end` (with summary slice) |
A blank cell means that native event is not available on that harness. The lifecycle is still functionally complete: OpenClaw batches capture across the full conversation in `agent_end` rather than per-event, producing the same rows the daemon would have written incrementally, just grouped into one flush; pi reads its session-start context from the static `AGENTS.md` block rather than a live event.
@@ -45,7 +45,7 @@ Each harness also carries a context channel and a host CLI, both single-sourced
| Claude Code | model-only (`additionalContext`) | `legacy` | `claude -p` |
| Codex | user-visible | `legacy` | `codex exec --dangerously-bypass-approvals-and-sandbox` |
| Cursor | model-only (`additional_context`) | `plugin` | `cursor-agent` → `claude` fallback |
-| Hermes | user-visible (`{ context }` + MCP mention) | `legacy` | `hermes --non-interactive` |
+| Hermes | model-only (`{ context }`) | `legacy` | `hermes chat -Q -q` |
| pi | user-visible | `plugin` | `pi --print --provider --model ` |
| OpenClaw | model-only | `plugin` | native extension slice (no host CLI) |
@@ -134,7 +134,7 @@ The pre-tool-use core is the VFS intercept. It runs before tool execution and lo
- `grep` / `Glob` becomes a hybrid lexical-plus-semantic search through the daemon's grep-direct path.
- `ls` becomes a path-prefix listing; `find` becomes a path-pattern query.
-Write and Edit on a memory path are denied with guidance to use the CLI instead. Commands the VFS cannot model (interpreters, pipes, command substitution) are rewritten to a harmless `echo`. The harnesses differ on coverage: Claude Code and Codex intercept Bash; Cursor normalizes its `Shell` tool to the canonical `Bash` shape so the same intercept applies; Hermes intercepts terminal tools only; pi and OpenClaw have no pre-tool intercept.
+Write and Edit on a memory path are denied with guidance to use the CLI instead. Commands the VFS cannot model (interpreters, pipes, command substitution) are rewritten to a harmless `echo`. The harnesses differ on coverage: Claude Code and Codex intercept Bash; Cursor normalizes its `Shell` tool to the canonical `Bash` shape so the same intercept applies; Hermes, pi, and OpenClaw have no pre-tool intercept.
**This path went live in PRD-075.** It was previously scaffolded but dormant. 075a wires the real daemon-backed `VfsIntercept` and propagates a `PreToolDecision` back out of the shared core, and 075b renders that decision into the Claude Code `PreToolUse` contract as a **block-and-inject**: `permissionDecision: "deny"` plus `hookSpecificOutput.additionalContext` carrying the recalled content, so the model's tool call is intercepted and the memory is handed back in one response. The rendered shape is pinned to the real Claude Code contract by conformance tests (`references/claude-code/pretool-response-schema.ts`) so a harness contract change cannot silently break the inject. This is the **model-commanded recall arm**: the model reaches for a memory path and the hook answers, complementary to the always-on prompt-time floor above.
diff --git a/library/knowledge/private/overview.md b/library/knowledge/private/overview.md
index 31b69808..8cc3ac88 100644
--- a/library/knowledge/private/overview.md
+++ b/library/knowledge/private/overview.md
@@ -22,7 +22,7 @@ Honeycomb is the merger of two systems. Hivemind contributed the broad product:
The result is one daemon that captures everything a harness does, distills it into structured, source-backed memory, and serves it back, all on a DeepLake substrate that a team can share.
-Honeycomb is production ready and live-tested end to end: the capture-to-recall path runs green against live Deeplake (`npm run smoke:golden-path` with credentials), and three harnesses (Claude Code, Cursor, Codex) ship in production today, with Hermes, pi, and OpenClaw in progress. Embeddings, the distillation pipeline, and cross-device sharing are deliberate opt-in and by-design choices (covered below and in the linked operations docs), not gaps.
+Honeycomb is production-ready and live-tested end-to-end: the capture-to-recall path runs green against live Deeplake (`npm run smoke:golden-path` with credentials), and four harnesses (Claude Code, Cursor, Codex, Hermes) ship in production today, with pi and OpenClaw in progress. Embeddings, the distillation pipeline, and cross-device sharing are deliberate opt-in and by-design choices (covered below and in the linked operations docs), not gaps.
## The shape
diff --git a/library/knowledge/public/faqs/faq.md b/library/knowledge/public/faqs/faq.md
index 03c9cf8b..791b395c 100644
--- a/library/knowledge/public/faqs/faq.md
+++ b/library/knowledge/public/faqs/faq.md
@@ -20,7 +20,7 @@ A shared, lasting memory for your AI coding assistants, so what one of them lear
No. You install with one command, click a button, and use plain commands like `remember` and `recall`. The technical machinery is hidden behind a friendly dashboard.
**Which AI coding assistants work with it?**
-Three are supported today: Claude Code, Cursor, and Codex. Three more, Hermes, pi, and OpenClaw, are in progress. Honeycomb plugs underneath whichever supported ones you have installed, and a memory written from one is recalled by the others.
+Four are supported today: Claude Code, Cursor, Codex, and Hermes. Two more, pi and OpenClaw, are in progress. Honeycomb plugs underneath whichever supported ones you have installed, and a memory written from one is recalled by the others.
**Who makes Honeycomb?**
It is a collaboration between Legion Code and Activeloop. Activeloop provides [Deep Lake](https://deeplake.ai) (the database for AI it stores memory in) and [Hivemind](https://github.com/activeloopai/hivemind) (the open-source project it builds on). Legion Code adds the multi-tier memory, skill sharing, the self-tidying loop, and the local helper that ties it together.
diff --git a/library/knowledge/public/overview/glossary.md b/library/knowledge/public/overview/glossary.md
index a1024d76..71005136 100644
--- a/library/knowledge/public/overview/glossary.md
+++ b/library/knowledge/public/overview/glossary.md
@@ -13,7 +13,7 @@ Plain-language definitions of the words you will see around Honeycomb. Each entr
**Honeycomb**: A shared, lasting memory for your AI coding assistants. It remembers what you and your assistants do so the knowledge is there next time, in any tool, on any device.
-**Agent / assistant / harness**: All three words point at the same thing: the AI coding tool you actually use (for example Claude Code, Cursor, or Codex). "Harness" is just the technical word for "the tool Honeycomb plugs underneath." Honeycomb supports three today (Claude Code, Cursor, Codex), with three more (Hermes, pi, OpenClaw) in progress.
+**Agent / assistant / harness**: All three words point at the same thing: the AI coding tool you actually use (for example Claude Code, Cursor, Codex, or Hermes). "Harness" is just the technical word for "the tool Honeycomb plugs underneath." Honeycomb supports four today (Claude Code, Cursor, Codex, Hermes), with two more (pi, OpenClaw) in progress.
**Daemon**: The small helper program that runs quietly in the background on your machine. It is the only part of Honeycomb that touches your memory store, which keeps everything in one safe, consistent place. You rarely interact with it directly; it starts itself when needed.
diff --git a/library/knowledge/public/overview/what-is-honeycomb.md b/library/knowledge/public/overview/what-is-honeycomb.md
index 96f2b852..70d7739e 100644
--- a/library/knowledge/public/overview/what-is-honeycomb.md
+++ b/library/knowledge/public/overview/what-is-honeycomb.md
@@ -24,7 +24,7 @@ Think of it as a shared brain your assistants read from and write to on every tu
## What you actually get
- **Memory that survives.** What you figured out yesterday is waiting for you today, already summarized.
-- **Memory that travels across tools.** A note written while using one assistant is recalled by another. Honeycomb plugs underneath the coding assistants you already use (Claude Code, Cursor, and Codex today, with three more in progress).
+- **Memory that travels across tools.** A note written while using one assistant is recalled by another. Honeycomb plugs underneath the coding assistants you already use (Claude Code, Cursor, Codex, and Hermes today, with two more in progress).
- **Skills that spread.** When you (or a teammate) solve something reusable, Honeycomb can turn it into a shareable "skill" that shows up automatically for everyone, no copy-paste.
- **A memory that gets sharper, not noisier.** Honeycomb periodically tidies its own notes: merging duplicates, dropping junk, and keeping the current version of a fact instead of letting stale ones pile up.
- **A friendly dashboard.** A simple local web page shows what has been remembered, how your tools are wired, and the health of everything. No database knowledge required.
diff --git a/package.json b/package.json
index 604d39cd..5b20036e 100644
--- a/package.json
+++ b/package.json
@@ -78,7 +78,8 @@
"mutation:state": "stryker run stryker/state.json",
"pack:check": "node scripts/pack-check.mjs",
"pack:prepare": "node scripts/prepare-packed-artifact.mjs",
- "test:packed-cli": "node scripts/packed-cli-conformance.mjs",
+ "test:packed-cli": "node scripts/packed-cli-conformance.mjs && node scripts/packed-hermes-conformance.mjs",
+ "test:packed-hermes": "node scripts/packed-hermes-conformance.mjs",
"rebuild:native": "node scripts/ensure-tree-sitter.mjs",
"ensure:embed-deps": "node scripts/ensure-embed-deps.mjs",
"ci": "npm run typecheck && npm run dup && npm run test && npm run audit:sql",
diff --git a/references/hermes/hooks-schema.ts b/references/hermes/hooks-schema.ts
new file mode 100644
index 00000000..9fbb89f9
--- /dev/null
+++ b/references/hermes/hooks-schema.ts
@@ -0,0 +1,95 @@
+/*
+ * Honeycomb - a cross-harness AI memory system.
+ * Copyright (C) 2026 Legion Code Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version. See the LICENSE file for details.
+ */
+
+/**
+ * Independent Hermes Agent shell-hook/MCP config oracle.
+ *
+ * Grounded in Hermes Agent 0.19's current hook registry and shell-hook parser:
+ * `hermes_cli/plugins.py`, `agent/shell_hooks.py`, and the official Hooks docs.
+ * This module deliberately does not import Honeycomb connector constants.
+ */
+
+import { z } from "zod";
+
+export const HERMES_HOOK_EVENT_NAMES = [
+ "pre_tool_call",
+ "post_tool_call",
+ "transform_terminal_output",
+ "transform_tool_result",
+ "transform_llm_output",
+ "pre_llm_call",
+ "post_llm_call",
+ "pre_verify",
+ "pre_api_request",
+ "post_api_request",
+ "api_request_error",
+ "on_session_start",
+ "on_session_end",
+ "on_session_finalize",
+ "on_session_reset",
+ "subagent_start",
+ "subagent_stop",
+ "pre_gateway_dispatch",
+ "pre_approval_request",
+ "post_approval_response",
+ "kanban_task_claimed",
+ "kanban_task_completed",
+ "kanban_task_blocked",
+] as const;
+
+const HERMES_EVENTS = new Set(HERMES_HOOK_EVENT_NAMES);
+
+export function isHermesHookEvent(value: string): value is (typeof HERMES_HOOK_EVENT_NAMES)[number] {
+ return HERMES_EVENTS.has(value);
+}
+
+export const hermesHookEntry = z
+ .object({
+ command: z.string().min(1),
+ matcher: z.string().min(1).optional(),
+ timeout: z.number().int().min(1).max(300).optional(),
+ _honeycomb: z.boolean().optional(),
+ })
+ .passthrough();
+
+export const hermesMcpServer = z
+ .object({
+ command: z.string().min(1).optional(),
+ args: z.array(z.string()).optional(),
+ url: z.string().min(1).optional(),
+ enabled: z.boolean().optional(),
+ _honeycomb: z.boolean().optional(),
+ })
+ .passthrough()
+ .refine((server) => server.command !== undefined || server.url !== undefined, {
+ message: "Hermes MCP server requires command or url",
+ });
+
+export const hermesConfig = z
+ .object({
+ hooks: z.record(z.string(), z.array(hermesHookEntry)).optional(),
+ mcp_servers: z.record(z.string(), hermesMcpServer).optional(),
+ })
+ .passthrough()
+ .superRefine((config, ctx) => {
+ for (const event of Object.keys(config.hooks ?? {})) {
+ if (!isHermesHookEvent(event)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["hooks", event],
+ message: `"${event}" is not a Hermes hook event name`,
+ });
+ }
+ }
+ });
+
+export function assertHermesConfigConforms(value: unknown): void {
+ hermesConfig.parse(value);
+}
diff --git a/scripts/pack-check.mjs b/scripts/pack-check.mjs
index c3cf84f4..792a2ccd 100644
--- a/scripts/pack-check.mjs
+++ b/scripts/pack-check.mjs
@@ -65,7 +65,7 @@ const hits = entries.filter((p) => FORBIDDEN.some((rx) => rx.test(p)));
if (hits.length) {
console.error("Refusing to publish — forbidden filenames in tarball:");
- for (const h of hits) console.error(" " + h);
+ for (const h of hits) console.error(` ${h}`);
process.exit(1);
}
@@ -80,6 +80,10 @@ const REQUIRED = [
/(^|\/)bundle\/cli\.js$/, // the `honeycomb` bin
/(^|\/)daemon\/index\.js$/, // the daemon entry the CLI spawns
/(^|\/)harnesses\/claude-code\/mcp\/bundle\/server\.js$/, // Claude Code plugin-internal MCP server path
+ /(^|\/)harnesses\/hermes\/bundle\/session-start\.mjs$/, // Hermes lifecycle hook alias
+ /(^|\/)harnesses\/hermes\/bundle\/capture\.mjs$/, // Hermes capture + recall hook alias
+ /(^|\/)harnesses\/hermes\/bundle\/session-end\.mjs$/, // Hermes finalization hook alias
+ /(^|\/)mcp\/bundle\/server\.js$/, // copied into $HERMES_HOME/honeycomb/mcp on connect
/(^|\/)assets\/styles\.css$/, // resolveAssetsDir() locator
/(^|\/)assets\/tokens\/base\.css$/, // the DS token CSS the dashboard serves
/(^|\/)assets\/logos\/honeycomb-memory-cluster\.svg$/, // the brand mark the header renders
@@ -88,7 +92,7 @@ const REQUIRED = [
const missing = REQUIRED.filter((rx) => !entries.some((p) => rx.test(p)));
if (missing.length) {
console.error("Refusing to publish — required runtime files missing from tarball:");
- for (const m of missing) console.error(" " + String(m));
+ for (const m of missing) console.error(` ${String(m)}`);
console.error(" (widen package.json's `files` allowlist — the install would be broken)");
process.exit(1);
}
diff --git a/scripts/packed-cli-conformance.mjs b/scripts/packed-cli-conformance.mjs
index 9f014c29..cc85e3ed 100644
--- a/scripts/packed-cli-conformance.mjs
+++ b/scripts/packed-cli-conformance.mjs
@@ -59,6 +59,7 @@ const priorCommands = [
"telemetry",
"update",
"uninstall",
+ "connect",
];
function npmCliPath() {
@@ -168,7 +169,7 @@ try {
const inventoryResult = runFixture("inventory", []);
assertResult(inventoryResult, 0, "packed inventory");
const inventory = JSON.parse(inventoryResult.stdout);
- if (inventory.length !== 42) throw new Error(`packed command inventory expected 42, got ${inventory.length}`);
+ if (inventory.length !== 43) throw new Error(`packed command inventory expected 43, got ${inventory.length}`);
for (const command of priorCommands) {
if (!inventory.includes(command)) throw new Error(`packed command inventory regressed ${command}`);
}
diff --git a/scripts/packed-hermes-conformance.mjs b/scripts/packed-hermes-conformance.mjs
new file mode 100644
index 00000000..87c35213
--- /dev/null
+++ b/scripts/packed-hermes-conformance.mjs
@@ -0,0 +1,235 @@
+#!/usr/bin/env node
+/*
+ * Honeycomb - a cross-harness AI memory system.
+ * Copyright (C) 2026 Legion Code Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version. See the LICENSE file for details.
+ */
+
+import { execFileSync, spawnSync } from "node:child_process";
+import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
+import { mkdir, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
+
+const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
+const work = mkdtempSync(join(tmpdir(), "honeycomb-packed-hermes-"));
+const runtimeNode = process.env.HONEYCOMB_CONFORMANCE_NODE ?? process.execPath;
+if (!isAbsolute(runtimeNode) || !existsSync(runtimeNode)) {
+ throw new Error("HONEYCOMB_CONFORMANCE_NODE must name an existing absolute Node executable");
+}
+
+function npmCliPath() {
+ const fromEnv = process.env.npm_execpath;
+ if (fromEnv && existsSync(fromEnv)) return fromEnv;
+ const bin = dirname(process.execPath);
+ for (const candidate of [
+ join(bin, "node_modules", "npm", "bin", "npm-cli.js"),
+ resolve(bin, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
+ ]) {
+ if (existsSync(candidate)) return candidate;
+ }
+ throw new Error("could not locate npm-cli.js");
+}
+
+function assert(result, label) {
+ if (result.status !== 0)
+ throw new Error(`${label}: exit=${result.status}\nstdout=${result.stdout}\nstderr=${result.stderr}`);
+}
+
+let tarball;
+let ownsTarball = false;
+try {
+ const npmCli = npmCliPath();
+ const prebuilt = process.env.HONEYCOMB_PACKED_TARBALL;
+ if (prebuilt !== undefined) {
+ tarball = resolve(prebuilt);
+ const fromWorkspace = relative(resolve("."), tarball);
+ if (
+ isAbsolute(fromWorkspace) ||
+ fromWorkspace.startsWith("..") ||
+ basename(tarball) !== `legioncodeinc-honeycomb-${pkg.version}.tgz`
+ )
+ throw new Error("HONEYCOMB_PACKED_TARBALL must be the current workspace package tarball");
+ if (!existsSync(tarball)) throw new Error("HONEYCOMB_PACKED_TARBALL does not exist");
+ } else {
+ const packed = JSON.parse(execFileSync(process.execPath, [npmCli, "pack", "--json"], { encoding: "utf8" }));
+ tarball = resolve(packed[0].filename);
+ ownsTarball = true;
+ }
+ const install = join(work, "install");
+ execFileSync(process.execPath, [npmCli, "install", "--prefix", install, "--ignore-scripts", tarball], {
+ stdio: "ignore",
+ });
+
+ const packageRoot = join(install, "node_modules", "@legioncodeinc", "honeycomb");
+ const cli =
+ process.platform === "win32"
+ ? join(packageRoot, "bundle", "cli.js")
+ : join(install, "node_modules", ".bin", "honeycomb");
+ const hermesHome = join(work, "hermes");
+ const env = { ...process.env, HOME: join(work, "home"), HERMES_HOME: hermesHome, NO_COLOR: "1" };
+ const configPath = join(hermesHome, "config.yaml");
+ await mkdir(hermesHome, { recursive: true });
+ await writeFile(
+ configPath,
+ [
+ "# foreign comment",
+ "hooks:",
+ " post_tool_call:",
+ " - command: /opt/foreign-audit # preserve",
+ " timeout: 9",
+ "foreign_setting: keep-me",
+ "",
+ ].join("\n"),
+ );
+ const run = (args) => spawnSync(runtimeNode, [cli, ...args], { encoding: "utf8", env });
+
+ assert(run(["--help"]), "packed help");
+ assert(run(["connect", "hermes", "--no-color"]), "packed Hermes connect");
+ const installed = readFileSync(configPath, "utf8");
+ for (const token of [
+ "# foreign comment",
+ "/opt/foreign-audit # preserve",
+ "foreign_setting: keep-me",
+ "on_session_finalize:",
+ "mcp_servers:",
+ "_honeycomb: true",
+ ]) {
+ if (!installed.includes(token)) throw new Error(`packed Hermes install omitted ${token}`);
+ }
+ for (const file of [
+ "honeycomb/manifest.json",
+ "honeycomb/bundle/session-start.mjs",
+ "honeycomb/bundle/capture.mjs",
+ "honeycomb/bundle/session-end.mjs",
+ "honeycomb/mcp/server.mjs",
+ ]) {
+ if (!existsSync(join(hermesHome, file))) throw new Error(`packed Hermes install omitted ${file}`);
+ }
+
+ const sessionId = "packed-hermes-native-protocol";
+ const baseEvent = { session_id: sessionId, cwd: work, transcript_path: join(work, "transcript.jsonl") };
+ const hookCases = [
+ {
+ label: "session start",
+ file: "session-start.mjs",
+ args: [],
+ payload: { ...baseEvent, hook_event_name: "on_session_start", extra: { source: "packed-conformance" } },
+ },
+ {
+ label: "user capture",
+ file: "capture.mjs",
+ args: [],
+ payload: { ...baseEvent, hook_event_name: "pre_llm_call", extra: { user_message: "verify package" } },
+ },
+ {
+ label: "user recall",
+ file: "capture.mjs",
+ args: ["--honeycomb-recall"],
+ payload: { ...baseEvent, hook_event_name: "pre_llm_call", extra: { user_message: "verify package" } },
+ },
+ {
+ label: "tool capture",
+ file: "capture.mjs",
+ args: [],
+ payload: {
+ ...baseEvent,
+ hook_event_name: "post_tool_call",
+ tool_name: "terminal",
+ tool_input: { command: "pwd" },
+ extra: { result: "ok" },
+ },
+ },
+ {
+ label: "assistant capture",
+ file: "capture.mjs",
+ args: [],
+ payload: { ...baseEvent, hook_event_name: "post_llm_call", extra: { assistant_response: "verified" } },
+ },
+ {
+ label: "session finalize",
+ file: "session-end.mjs",
+ args: [],
+ payload: { ...baseEvent, hook_event_name: "on_session_finalize", extra: { reason: "complete" } },
+ },
+ ];
+ for (const hookCase of hookCases) {
+ const result = spawnSync(runtimeNode, [join(hermesHome, "honeycomb", "bundle", hookCase.file), ...hookCase.args], {
+ encoding: "utf8",
+ env,
+ input: JSON.stringify(hookCase.payload),
+ });
+ assert(result, `packed Hermes native ${hookCase.label}`);
+ }
+
+ assert(run(["uninstall", "hermes", "--yes", "--no-color"]), "packed Hermes uninstall");
+ const removed = readFileSync(configPath, "utf8");
+ for (const token of ["# foreign comment", "/opt/foreign-audit # preserve", "foreign_setting: keep-me"]) {
+ if (!removed.includes(token)) throw new Error(`packed Hermes uninstall removed foreign content: ${token}`);
+ }
+ if (removed.includes("_honeycomb: true") || existsSync(join(hermesHome, "honeycomb")))
+ throw new Error("packed Hermes uninstall left empty Honeycomb-owned state behind");
+
+ // A user may have placed their own file below the Honeycomb root. A second uninstall
+ // must preserve it rather than recursively removing the directory we originally created.
+ assert(run(["connect", "hermes", "--no-color"]), "packed Hermes reconnect");
+ const foreignOwnedRootFile = join(hermesHome, "honeycomb", "foreign.keep");
+ await writeFile(foreignOwnedRootFile, "foreign content");
+ assert(run(["uninstall", "hermes", "--yes", "--no-color"]), "packed Hermes foreign-root uninstall");
+ if (!existsSync(foreignOwnedRootFile))
+ throw new Error("packed Hermes uninstall removed a foreign Honeycomb-root file");
+
+ // Managed artifacts are removed only while their content still matches the atomic
+ // ownership manifest. User modifications must survive uninstall.
+ const modifiedHome = join(work, "modified-artifact-hermes");
+ await mkdir(modifiedHome, { recursive: true });
+ const modifiedEnv = { ...env, HERMES_HOME: modifiedHome };
+ const modifiedRun = (args) => spawnSync(runtimeNode, [cli, ...args], { encoding: "utf8", env: modifiedEnv });
+ assert(modifiedRun(["connect", "hermes", "--no-color"]), "packed Hermes modified-artifact connect");
+ const modifiedCapture = join(modifiedHome, "honeycomb", "bundle", "capture.mjs");
+ await writeFile(modifiedCapture, "// user modified\n");
+ assert(modifiedRun(["uninstall", "hermes", "--yes", "--no-color"]), "packed Hermes modified-artifact uninstall");
+ if (readFileSync(modifiedCapture, "utf8") !== "// user modified\n")
+ throw new Error("packed Hermes uninstall removed or changed a modified managed artifact");
+ if (!existsSync(join(modifiedHome, "honeycomb", "manifest.json")))
+ throw new Error("packed Hermes uninstall removed ownership evidence for a modified managed artifact");
+
+ // A pre-existing managed target with no Honeycomb ownership manifest must fail
+ // closed before config or any other artifact is changed.
+ const foreignArtifactHome = join(work, "foreign-artifact-hermes");
+ const foreignCapture = join(foreignArtifactHome, "honeycomb", "bundle", "capture.mjs");
+ await mkdir(dirname(foreignCapture), { recursive: true });
+ await writeFile(foreignCapture, "// foreign capture\n");
+ const foreignArtifact = spawnSync(runtimeNode, [cli, "connect", "hermes", "--no-color"], {
+ encoding: "utf8",
+ env: { ...env, HERMES_HOME: foreignArtifactHome },
+ });
+ if (foreignArtifact.status === 0) throw new Error("packed Hermes connect overwrote an unowned managed artifact");
+ if (readFileSync(foreignCapture, "utf8") !== "// foreign capture\n")
+ throw new Error("packed Hermes foreign-artifact refusal changed the existing artifact");
+ if (existsSync(join(foreignArtifactHome, "config.yaml")))
+ throw new Error("packed Hermes foreign-artifact refusal left a partial config behind");
+
+ // A conflicting foreign MCP key must fail closed before config or artifacts are written.
+ const conflictHome = join(work, "foreign-mcp-hermes");
+ await mkdir(conflictHome, { recursive: true });
+ const conflictConfigPath = join(conflictHome, "config.yaml");
+ const conflictConfig = "mcp_servers:\n honeycomb:\n command: /opt/acme/not-ours\n args: []\n";
+ await writeFile(conflictConfigPath, conflictConfig);
+ const conflict = spawnSync(runtimeNode, [cli, "connect", "hermes", "--no-color"], {
+ encoding: "utf8",
+ env: { ...env, HERMES_HOME: conflictHome },
+ });
+ if (conflict.status === 0) throw new Error("packed Hermes connect accepted a foreign honeycomb MCP server");
+ if (readFileSync(conflictConfigPath, "utf8") !== conflictConfig || existsSync(join(conflictHome, "honeycomb")))
+ throw new Error("packed Hermes foreign MCP refusal left partial state behind");
+
+ console.log(`packed-hermes-conformance OK - ${pkg.name}@${pkg.version} install/uninstall is isolated and reversible`);
+} finally {
+ if (ownsTarball && tarball) rmSync(tarball, { force: true });
+ rmSync(work, { recursive: true, force: true });
+}
diff --git a/src/cli/connector-runner.ts b/src/cli/connector-runner.ts
index 03623a48..69182fd1 100644
--- a/src/cli/connector-runner.ts
+++ b/src/cli/connector-runner.ts
@@ -5,7 +5,7 @@
* 020a's `runConnectorVerb` routes the CLI verb onto the 019a `connectorMain` through the
* {@link ConnectorRunner} seam but left the seam UNBOUND (the deferred-assembly stub). 021b binds it:
* this module builds the real {@link ConnectorRegistry} over a `node:fs`-backed {@link ConnectorFs}
- * and the claude-code + cursor connectors, then adapts `connectorMain`'s result into the
+ * and the supported connectors, then adapts `connectorMain`'s result into the
* `{ exitCode, harnesses }` shape `runConnectorVerb` reports. No install logic is re-implemented —
* every merge / foreign-preserve / idempotency / reversibility rule is the 019a engine's (D-4).
*
@@ -24,11 +24,12 @@ import {
CodexConnector,
type ConnectorFs,
type ConnectorRegistry,
- createClaudePluginRunner,
CursorConnector,
connectorMain,
+ createClaudePluginRunner,
createNodeConnectorFs,
type HarnessConnector,
+ HermesConnector,
} from "../connectors/index.js";
/** Resolve the package root so the connector finds the bundled `harnesses//bundle/` sources. */
@@ -43,14 +44,20 @@ function packageRoot(): string {
return resolve(here, "..");
}
+/** Normalize Hermes' optional profile home exactly as Hermes treats the environment value. */
+export function normalizeHermesHome(value: string | undefined): string | undefined {
+ const trimmed = value?.trim();
+ return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed;
+}
+
/** The bundled hook-handler source dir for a harness slug (`harnesses//bundle`). */
function bundleSourceFor(slug: string): string {
return join(packageRoot(), "harnesses", slug, "bundle");
}
/**
- * The real connector registry (D-4): the two supported hook-protocol connectors (claude-code as the
- * reference, cursor as the sibling), each built over the supplied `node:fs` {@link ConnectorFs} and
+ * The real connector registry (D-4): each supported harness connector is built over the supplied
+ * `node:fs` {@link ConnectorFs} and
* pointed at the bundled handler sources + the user's home. A new harness is a SUBCLASS added here —
* never a fork of install logic (019a a-AC-5).
*/
@@ -59,6 +66,7 @@ export function createConnectorRegistry(home: string = homedir()): ConnectorRegi
// (not by writing top-level settings.json hooks). The runner shells to `claude`; `packageRoot()`
// resolves the dir holding `.claude-plugin/marketplace.json` (the same dir that holds `harnesses/`).
const claudePluginRunner = createClaudePluginRunner();
+ const hermesHome = normalizeHermesHome(process.env.HERMES_HOME);
const builders: Readonly HarnessConnector>> = {
"claude-code": (fs) =>
new ClaudeCodeConnector(fs, {
@@ -70,6 +78,14 @@ export function createConnectorRegistry(home: string = homedir()): ConnectorRegi
}),
codex: (fs) => new CodexConnector(fs, { home, bundleSource: bundleSourceFor("codex") }),
cursor: (fs) => new CursorConnector(fs, { home, bundleSource: bundleSourceFor("cursor") }),
+ hermes: (fs) =>
+ new HermesConnector(fs, {
+ home,
+ ...(hermesHome !== undefined ? { hermesHome } : {}),
+ bundleSource: bundleSourceFor("hermes"),
+ mcpServerPath: join(packageRoot(), "mcp", "bundle", "server.js"),
+ notify: (line) => console.log(line),
+ }),
};
return {
build(harness: string, fs: ConnectorFs): HarnessConnector | undefined {
diff --git a/src/commands/contracts.ts b/src/commands/contracts.ts
index 82fc7ddb..2651006d 100644
--- a/src/commands/contracts.ts
+++ b/src/commands/contracts.ts
@@ -203,6 +203,7 @@ export const VERB_TABLE: readonly VerbSpec[] = Object.freeze([
},
// Setup & system — install/onboard, daemon lifecycle, dashboard, hooks, telemetry, update.
{ verb: "setup", cls: "local", group: "system", summary: "detect assistants, wire hooks, bring up the daemon" },
+ { verb: "connect", cls: "local", group: "system", summary: "wire one supported assistant (`connect `)" },
{
verb: "install",
cls: "local",
diff --git a/src/connectors/contracts.ts b/src/connectors/contracts.ts
index 6bc42df1..3484e7b2 100644
--- a/src/connectors/contracts.ts
+++ b/src/connectors/contracts.ts
@@ -70,12 +70,16 @@ export interface ConnectorFs {
readFile(path: string): Promise;
/** Write a UTF-8 file, creating parent dirs as needed. */
writeFile(path: string, contents: string): Promise;
+ /** Atomically replace a UTF-8 file from a same-directory temporary file when supported. */
+ writeFileAtomic?(path: string, contents: string): Promise;
/** Remove a file. No-op when absent (idempotent uninstall). */
removeFile(path: string): Promise;
/** True when a path exists (file, dir, or symlink). */
exists(path: string): Promise;
/** Ensure a directory exists (mkdir -p). */
ensureDir(path: string): Promise;
+ /** Remove a directory only when empty when supported; never removes foreign contents. */
+ removeEmptyDir?(path: string): Promise;
/** Create a symlink `linkPath` → `target`, never clobbering a foreign entry (FR-4 / a-AC-6). */
symlink(target: string, linkPath: string): Promise;
/** Read a symlink's target, or `undefined` when `linkPath` is not a symlink. */
@@ -121,6 +125,10 @@ export function createFakeFs(seed?: { files?: Record; links?: Re
files.set(path, contents);
writes.push(path);
},
+ async writeFileAtomic(path: string, contents: string): Promise {
+ files.set(path, contents);
+ writes.push(path);
+ },
async removeFile(path: string): Promise {
files.delete(path);
},
@@ -130,6 +138,9 @@ export function createFakeFs(seed?: { files?: Record; links?: Re
async ensureDir(): Promise {
/* in-memory: dirs are implicit */
},
+ async removeEmptyDir(): Promise {
+ /* in-memory: dirs are implicit */
+ },
async symlink(target: string, linkPath: string): Promise {
links.set(linkPath, target);
},
@@ -162,6 +173,12 @@ export interface HookHandlerEntry {
readonly async?: boolean;
}
+/** An additional install-time artifact copied alongside hook handlers (for example an MCP server bundle). */
+export interface InstallFileEntry {
+ readonly sourcePath: string;
+ readonly targetPath: string;
+}
+
/**
* One hook entry as it lands inside a harness config event block (FR-2 / FR-3). The Claude
* Code shape (`{ type, command, timeout, async }`) is the lingua franca every hooks-based
@@ -255,6 +272,10 @@ export abstract class HarnessConnector {
protected abstract skillLinkTargets(): readonly SkillLinkTarget[];
/** SEAM 4 — the native event-name map (FR-1). */
protected abstract eventNameMap(): Readonly>;
+ /** Optional non-hook files this connector installs and owns. */
+ protected additionalFiles(): readonly InstallFileEntry[] {
+ return [];
+ }
/**
* SEAM 3.5 (optional) — the config root that PROVES this harness is installed
@@ -356,12 +377,18 @@ export abstract class HarnessConnector {
await this.fs.writeFile(handler.handlerPath, body);
written.push(handler.handlerPath);
}
+ for (const file of this.additionalFiles()) {
+ const body = await this.fs.readFile(file.sourcePath);
+ if (body === undefined) continue;
+ await this.fs.ensureDir(dirOf(file.targetPath));
+ await this.fs.writeFile(file.targetPath, body);
+ written.push(file.targetPath);
+ }
// 2. Patch the config, foreign-preserving + idempotent.
const path = this.configPath();
- const config = parseConfig(await this.fs.readFile(path));
- const patched = this.patchConfig(config, handlers);
- const wroteConfig = await this.writeJsonIfChanged(path, serializeConfig(patched));
+ const patchedText = this.patchConfigText(await this.fs.readFile(path), handlers);
+ const wroteConfig = await this.writeJsonIfChanged(path, patchedText);
// 3. Symlink skills, preserving foreign entries (FR-4 / a-AC-6).
const skillLinks = await this.linkSkills();
@@ -379,18 +406,17 @@ export abstract class HarnessConnector {
*/
async uninstall(): Promise {
const path = this.configPath();
- const config = parseConfig(await this.fs.readFile(path));
- const stripped = this.stripHoneycomb(config);
+ const stripped = this.stripConfigText(await this.fs.readFile(path));
let wroteConfig = false;
- if (this.isConfigEmpty(stripped)) {
+ if (stripped.empty) {
// FR-6: an emptied config is cleanly UNLINKED, not left as `{}`.
if (await this.fs.exists(path)) {
await this.fs.removeFile(path);
wroteConfig = true;
}
} else {
- wroteConfig = await this.writeJsonIfChanged(path, serializeConfig(stripped));
+ wroteConfig = await this.writeJsonIfChanged(path, stripped.text);
}
// Remove the written handler files.
@@ -401,6 +427,12 @@ export abstract class HarnessConnector {
removedHandlers.push(handler.handlerPath);
}
}
+ for (const file of this.additionalFiles()) {
+ if (await this.fs.exists(file.targetPath)) {
+ await this.fs.removeFile(file.targetPath);
+ removedHandlers.push(file.targetPath);
+ }
+ }
// Unlink ONLY Honeycomb's skill symlinks (a foreign entry is never touched).
const removedLinks = await this.unlinkSkills();
@@ -410,6 +442,24 @@ export abstract class HarnessConnector {
// ── Internal patch/link helpers (shared by every connector) ───────────────
+ /**
+ * Parse, patch, and serialize this harness's config text. JSON is the shared
+ * default. A non-JSON harness (Hermes YAML) overrides this text seam while
+ * retaining the base install/uninstall filesystem mechanics.
+ */
+ protected patchConfigText(text: string | undefined, handlers: readonly HookHandlerEntry[]): string {
+ return serializeConfig(this.patchConfig(parseConfig(text), handlers));
+ }
+
+ /**
+ * Strip Honeycomb-owned entries and serialize the remaining config. The
+ * `empty` bit controls whether the base removes the config file entirely.
+ */
+ protected stripConfigText(text: string | undefined): { readonly empty: boolean; readonly text: string } {
+ const stripped = this.stripHoneycomb(parseConfig(text));
+ return { empty: this.isConfigEmpty(stripped), text: serializeConfig(stripped) };
+ }
+
/**
* Append fresh Honeycomb hook entries to the config, foreign-preserving (FR-2 / a-AC-1).
* For each native event the handlers register under: filter out any prior Honeycomb
@@ -560,7 +610,7 @@ function isConfigEmpty(config: HarnessConfig): boolean {
}
/** The directory portion of a `/`-or-`\`-separated path. */
-function dirOf(path: string): string {
+export function dirOf(path: string): string {
const norm = path.replace(/\\/g, "/");
const idx = norm.lastIndexOf("/");
return idx <= 0 ? "" : norm.slice(0, idx);
diff --git a/src/connectors/hermes.ts b/src/connectors/hermes.ts
new file mode 100644
index 00000000..0129ef00
--- /dev/null
+++ b/src/connectors/hermes.ts
@@ -0,0 +1,470 @@
+/*
+ * Honeycomb - a cross-harness AI memory system.
+ * Copyright (C) 2026 Legion Code Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version. See the LICENSE file for details.
+ */
+
+/**
+ * Hermes Agent connector — wires Honeycomb through Hermes' native shell hooks
+ * and stdio MCP client configuration in `$HERMES_HOME/config.yaml`.
+ *
+ * Hermes uses YAML and a flat `hooks.[]` shape, so this connector
+ * overrides the base class's config-text seams while inheriting all handler,
+ * idempotency, detection, and skill-link filesystem mechanics.
+ */
+
+import { createHash } from "node:crypto";
+import { isAbsolute } from "node:path";
+
+import { isMap, isSeq, parseDocument, YAMLMap, YAMLSeq } from "yaml";
+
+import {
+ type ConnectorFs,
+ type ConnectorRunResult,
+ dirOf,
+ HarnessConnector,
+ HONEYCOMB_ENTRY_KEY,
+ HONEYCOMB_MARKER,
+ type HookHandlerEntry,
+ type InstallFileEntry,
+ type SkillLinkTarget,
+} from "./contracts.js";
+
+export interface HermesConnectorOptions {
+ readonly home: string;
+ /** Active profile root (`$HERMES_HOME`); defaults to `/.hermes`. */
+ readonly hermesHome?: string;
+ readonly pluginRoot?: string;
+ readonly bundleSource: string;
+ readonly mcpServerPath: string;
+ readonly nodeExecutable?: string;
+ readonly skillSources?: readonly string[];
+ readonly notify?: (line: string) => void;
+}
+
+/** Canonical Honeycomb-owned MCP server key in Hermes config. */
+export const HERMES_MCP_SERVER_NAME = "honeycomb" as const;
+
+/** Hermes shell-hook event names, grounded in Hermes' hooks reference. */
+const HERMES_EVENT_MAP: Readonly> = {
+ "session-start": "on_session_start",
+ user_message: "pre_llm_call",
+ user_prompt_recall: "pre_llm_call",
+ post_tool: "post_tool_call",
+ assistant_message: "post_llm_call",
+ "session-end": "on_session_finalize",
+};
+
+const HERMES_HANDLERS: ReadonlyArray<{
+ logical: string;
+ file: string;
+ timeout: number;
+ recall?: boolean;
+}> = [
+ { logical: "session-start", file: "session-start.mjs", timeout: 30 },
+ { logical: "user_prompt_recall", file: "capture.mjs", timeout: 10, recall: true },
+ { logical: "user_message", file: "capture.mjs", timeout: 10 },
+ { logical: "post_tool", file: "capture.mjs", timeout: 15 },
+ { logical: "assistant_message", file: "capture.mjs", timeout: 30 },
+ { logical: "session-end", file: "session-end.mjs", timeout: 60 },
+];
+
+type UnknownRecord = Record;
+
+function asRecord(value: unknown): UnknownRecord {
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as UnknownRecord) : {};
+}
+
+const OWNERSHIP_MANIFEST_VERSION = 1 as const;
+
+interface HermesOwnershipManifest {
+ readonly _honeycomb: true;
+ readonly version: typeof OWNERSHIP_MANIFEST_VERSION;
+ readonly files: Readonly>;
+}
+
+function sha256(contents: string): string {
+ return createHash("sha256").update(contents).digest("hex");
+}
+
+function parseOwnershipManifest(text: string, path: string): HermesOwnershipManifest {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(text);
+ } catch {
+ throw new Error(`HermesConnector: invalid ownership manifest at ${path}; refusing to modify artifacts`);
+ }
+ const record = asRecord(parsed);
+ const rawFiles = record.files;
+ const files = asRecord(rawFiles);
+ if (
+ record._honeycomb !== true ||
+ record.version !== OWNERSHIP_MANIFEST_VERSION ||
+ rawFiles === null ||
+ typeof rawFiles !== "object" ||
+ Array.isArray(rawFiles) ||
+ Object.values(files).some((value) => typeof value !== "string")
+ ) {
+ throw new Error(`HermesConnector: foreign ownership manifest at ${path}; refusing to modify artifacts`);
+ }
+ return {
+ _honeycomb: true,
+ version: OWNERSHIP_MANIFEST_VERSION,
+ files: files as Record,
+ };
+}
+
+function parseHermesDocument(text: string | undefined) {
+ const document = parseDocument(text ?? "{}\n");
+ if (document.errors.length > 0) {
+ throw new Error(`HermesConnector: invalid YAML in config.yaml: ${document.errors[0]?.message ?? "parse error"}`);
+ }
+ const root = document.toJS();
+ if (root !== null && (typeof root !== "object" || Array.isArray(root))) {
+ throw new Error("HermesConnector: config.yaml root must be a mapping");
+ }
+ return document;
+}
+
+function yamlText(document: ReturnType): string {
+ const text = document.toString();
+ return text.endsWith("\n") ? text : `${text}\n`;
+}
+
+function yamlMapAt(
+ document: ReturnType,
+ key: string,
+ create: boolean,
+): YAMLMap | undefined {
+ const current = document.get(key, true);
+ if (current === undefined) {
+ if (!create) return undefined;
+ const map = new YAMLMap(document.schema);
+ document.set(key, map);
+ return map;
+ }
+ if (!isMap(current)) throw new Error(`HermesConnector: ${key} must be a mapping`);
+ return current;
+}
+
+function yamlSeqAt(map: YAMLMap, key: string, create: boolean): YAMLSeq | undefined {
+ const current = map.get(key, true);
+ if (current === undefined) {
+ if (!create) return undefined;
+ const seq = new YAMLSeq(map.schema);
+ map.set(key, seq);
+ return seq;
+ }
+ if (!isSeq(current)) throw new Error(`HermesConnector: hooks.${key} must be a sequence`);
+ return current;
+}
+
+function isOwnedYamlEntry(value: unknown): boolean {
+ return isMap(value) && value.get(HONEYCOMB_ENTRY_KEY) === true;
+}
+
+/** Hermes Agent's native YAML connector. */
+export class HermesConnector extends HarnessConnector {
+ readonly harness = "hermes";
+
+ private readonly opts: {
+ readonly hermesHome: string;
+ readonly pluginRoot: string;
+ readonly bundleSource: string;
+ readonly mcpServerPath: string;
+ readonly nodeExecutable: string;
+ readonly skillSources: readonly string[];
+ readonly notify: ((line: string) => void) | undefined;
+ };
+
+ constructor(fs: ConnectorFs, opts: HermesConnectorOptions) {
+ super(fs);
+ const hermesHome = opts.hermesHome ?? `${opts.home}/.hermes`;
+ if (hermesHome.length === 0 || hermesHome.includes("\0") || !isAbsolute(hermesHome)) {
+ throw new Error("HermesConnector: HERMES_HOME must be a non-empty absolute path without NUL bytes");
+ }
+ const pluginRoot = opts.pluginRoot ?? `${hermesHome}/${HONEYCOMB_MARKER}`;
+ if (pluginRoot.length === 0 || pluginRoot.includes("\0") || !isAbsolute(pluginRoot)) {
+ throw new Error("HermesConnector: plugin root must be a non-empty absolute path without NUL bytes");
+ }
+ this.opts = {
+ hermesHome,
+ pluginRoot,
+ bundleSource: opts.bundleSource,
+ mcpServerPath: opts.mcpServerPath,
+ nodeExecutable: opts.nodeExecutable ?? process.execPath,
+ skillSources: opts.skillSources ?? [],
+ notify: opts.notify,
+ };
+ }
+
+ private ownershipManifestPath(): string {
+ return `${this.opts.pluginRoot}/manifest.json`;
+ }
+
+ private managedFiles(): readonly InstallFileEntry[] {
+ const files = new Map();
+ for (const handler of this.hookHandlers()) {
+ files.set(handler.handlerPath, { sourcePath: handler.sourcePath, targetPath: handler.handlerPath });
+ }
+ for (const file of this.additionalFiles()) files.set(file.targetPath, file);
+ return [...files.values()];
+ }
+
+ private async assertInstallTreeIsNotSymlinked(): Promise {
+ const paths = new Set([
+ this.opts.hermesHome,
+ this.configPath(),
+ this.opts.pluginRoot,
+ `${this.opts.pluginRoot}/bundle`,
+ `${this.opts.pluginRoot}/mcp`,
+ this.ownershipManifestPath(),
+ ...this.managedFiles().map((file) => file.targetPath),
+ ]);
+ for (const path of paths) {
+ if ((await this.fs.readlink(path)) !== undefined) {
+ throw new Error(`HermesConnector: refusing to use symlinked owned path: ${path}`);
+ }
+ }
+ }
+
+ private async readManagedSourceBodies(managedFiles: readonly InstallFileEntry[]): Promise