From bd0afc76e96658008b6b31c1295a3be70bce17fe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 8 Jun 2026 17:12:29 +0000 Subject: [PATCH 1/8] Add Hermes plugin sharing the host-agnostic stop-hook core Hermes (NousResearch/hermes-agent) is config-driven rather than manifest-based: MCP servers and hooks live in ~/.hermes/config.yaml, so the Mainframe plugin for Hermes is a generated .hermes-plugin/config.yaml fragment that wires the hosted Mainframe MCP server (reusing the URL from ./.mcp.json) and a pre_llm_call shell hook. The stop nudge reuses hooks/core's CLI plumbing (runStopHookCli) and work/share detection (accumulateClassifiedRows, hasMainframeVideoUrl), but not the AFK time gate: Hermes hook payloads carry no per-message timestamps and no post-turn re-prompt, and pre_llm_call (returning {"context": ...}) is the only hook event whose output steers the agent. So the Hermes hook injects the share-video nudge at the start of a turn when the previous turn did real work and no Mainframe video has been shared yet. The share-video call-to-action copy is extracted into hooks/core so both the AFK gate and the Hermes nudge stay consistent. Generated config, package bin (mainframe-hook-hermes), package surface, and tooling tests follow the existing Cursor/Codex/Claude patterns. Docs cover loading the skill via skills.external_dirs and merging the config fragment. --- .hermes-plugin/config.yaml | 9 +++ AGENTS.md | 30 ++++--- README.md | 45 ++++++++++- bun.lock | 5 +- dist/hooks/core/afk-gate.js | 6 +- dist/hooks/hermes/stop-evaluator.js | 39 +++++++++ dist/hooks/hermes/stop.js | 4 + dist/hooks/hermes/transcript.js | 40 ++++++++++ hooks/core/afk-gate.ts | 8 +- hooks/hermes/fixtures/stop.json | 30 +++++++ hooks/hermes/stop-evaluator.ts | 58 ++++++++++++++ hooks/hermes/stop.test.ts | 119 ++++++++++++++++++++++++++++ hooks/hermes/stop.ts | 5 ++ hooks/hermes/transcript.test.ts | 87 ++++++++++++++++++++ hooks/hermes/transcript.ts | 53 +++++++++++++ package.json | 11 ++- tooling/generate.ts | 45 ++++++++++- tooling/generated-drift-check.ts | 1 + tooling/manifest.test.ts | 34 ++++++++ tooling/mark-executable.ts | 1 + tooling/package-surface.test.ts | 33 ++++++++ tooling/package-surface.ts | 5 ++ 22 files changed, 646 insertions(+), 22 deletions(-) create mode 100644 .hermes-plugin/config.yaml create mode 100644 dist/hooks/hermes/stop-evaluator.js create mode 100755 dist/hooks/hermes/stop.js create mode 100644 dist/hooks/hermes/transcript.js create mode 100644 hooks/hermes/fixtures/stop.json create mode 100644 hooks/hermes/stop-evaluator.ts create mode 100644 hooks/hermes/stop.test.ts create mode 100644 hooks/hermes/stop.ts create mode 100644 hooks/hermes/transcript.test.ts create mode 100644 hooks/hermes/transcript.ts diff --git a/.hermes-plugin/config.yaml b/.hermes-plugin/config.yaml new file mode 100644 index 0000000..448a9ac --- /dev/null +++ b/.hermes-plugin/config.yaml @@ -0,0 +1,9 @@ +# Mainframe Hermes plugin wiring — generated by tooling/generate.ts; do not edit by hand. +# Merge into ~/.hermes/config.yaml. See README.md for the share-video skill setup. +mcp_servers: + mainframe: + url: https://mcp.mainframe.app/mcp +hooks: + pre_llm_call: + - command: mainframe-hook-hermes + timeout: 30 diff --git a/AGENTS.md b/AGENTS.md index e1ba6cb..6a83984 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,25 +1,29 @@ # Agent notes -This repository packages the Mainframe Cursor, Codex, and Claude Code plugins. Keep it focused on -the Cursor, Codex, and Claude Code manifests, hosted MCP wiring, the `share-video` skill, and the -stop hooks. +This repository packages the Mainframe Cursor, Codex, Claude Code, and Hermes plugins. Keep it +focused on the Cursor, Codex, Claude Code, and Hermes manifests, hosted MCP wiring, the +`share-video` skill, and the stop hooks. ## Repository boundaries - User-visible copy should say "Mainframe", not legacy product names. - Do not add secrets, customer data, private URLs, or private business context. -- Cursor, Codex, and Claude Code are the supported hosts. All three plugins share the repo root, - the `share-video` skill, the `./.mcp.json` wiring, and the `hooks/core` runtime. Codex and Claude - Code share the same Stop hook contract, so they both use `hooks/core/stop-hook.ts`; only the - transcript parser differs per host. Do not add other host surfaces unless the product task - explicitly asks for them. +- Cursor, Codex, Claude Code, and Hermes are the supported hosts. All four plugins share the repo + root, the `share-video` skill, the `./.mcp.json` wiring, and the `hooks/core` runtime. Codex and + Claude Code share the same Stop hook contract, so they both use `hooks/core/stop-hook.ts`; only + the transcript parser differs per host. Hermes is config-driven: its MCP server and stop hook + live in `~/.hermes/config.yaml`, so the plugin is a generated `.hermes-plugin/config.yaml` + fragment. The Hermes stop nudge is a `pre_llm_call` shell hook that reuses the `hooks/core` CLI + plumbing and share detection, but not the AFK time gate — Hermes hook payloads carry no + timestamps, so it nudges on unshared work instead of elapsed time. Do not add other host + surfaces unless the product task explicitly asks for them. - Run `bun run verify` before considering changes ready. -- Generated Cursor, Codex, and Claude Code manifest and marketplace files come from - `tooling/generate.ts`; edit the config there, then run `bun run generate`. +- Generated Cursor, Codex, Claude Code, and Hermes manifest, marketplace, and config files come + from `tooling/generate.ts`; edit the config there, then run `bun run generate`. - The canonical skill is `skills/share-video/SKILL.md`. -- Keep generated metadata in `.cursor-plugin/`, `.codex-plugin/`, `.agents/plugins/`, and - `.claude-plugin/`. Do not add package-local docs or extra top-level markdown unless the user - asks; improve `README.md` or this file instead. +- Keep generated metadata in `.cursor-plugin/`, `.codex-plugin/`, `.agents/plugins/`, + `.claude-plugin/`, and `.hermes-plugin/`. Do not add package-local docs or extra top-level + markdown unless the user asks; improve `README.md` or this file instead. ## Start here diff --git a/README.md b/README.md index c99e9ac..76abd4e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Mainframe plugins Mainframe is the video sharing platform for agents. This repository ships the `share-video` skill -and Mainframe plugins for Cursor, Codex, and Claude Code. +and Mainframe plugins for Cursor, Codex, Claude Code, and Hermes. ## Install @@ -47,6 +47,49 @@ claude The Claude Code plugin gives Claude the same `share-video` skill and Mainframe tools as the Cursor and Codex plugins. +### Hermes + +[Hermes](https://hermes-agent.nousresearch.com) is config-driven rather than manifest-based, so the +Mainframe plugin for Hermes is wired through `~/.hermes/config.yaml` plus the shared skill. The +generated fragment to merge in lives at `.hermes-plugin/config.yaml`. + +1. Build this repository so the stop-hook runtime is available, and expose its `mainframe-hook-hermes` + command on your `PATH`: + + ```sh + bun install && bun run build && bun link + ``` + + You can instead point the hook at the built script directly with + `node /absolute/path/to/dist/hooks/hermes/stop.js`. + +2. Merge the MCP server and the stop hook into `~/.hermes/config.yaml`: + + ```yaml + mcp_servers: + mainframe: + url: https://mcp.mainframe.app/mcp + hooks: + pre_llm_call: + - command: mainframe-hook-hermes + timeout: 30 + ``` + + Hermes asks for consent the first time it runs a shell hook (or set `hooks_auto_accept: true`). + +3. Load the `share-video` skill by pointing Hermes at this repository's `skills` directory: + + ```yaml + skills: + external_dirs: + - /absolute/path/to/mainframe-plugins/skills + ``` + +The Hermes stop hook runs on `pre_llm_call`, the one hook event whose output Hermes feeds back to +the agent. Hermes hook payloads carry no timestamps, so there is no "away for N hours" timer like +the other hosts; instead the hook nudges the agent toward the `share-video` skill at the start of a +turn when the previous turn did real work and no Mainframe video has been shared yet. + ## Included skill - `share-video` — share a short video that explains what the agent did, useful for demos, diff --git a/bun.lock b/bun.lock index 8e1c213..30d7432 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "oxlint-tsgolint": "^0.22.1", "typescript": "^5.9.3", "vitest": "^4.1.5", + "yaml": "^2.9.0", "zod": "^4.1.13", }, }, @@ -319,10 +320,12 @@ "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], - "yaml": ["yaml@2.8.4", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "lint-staged/yaml": ["yaml@2.8.4", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog=="], + "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], "log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], diff --git a/dist/hooks/core/afk-gate.js b/dist/hooks/core/afk-gate.js index abafaf7..1762318 100644 --- a/dist/hooks/core/afk-gate.js +++ b/dist/hooks/core/afk-gate.js @@ -1,5 +1,9 @@ export const MS_PER_HOUR = 3_600_000; export const DEFAULT_AFK_THRESHOLD_MS = MS_PER_HOUR; +// Shared share-video call to action. Hosts frame it differently (the AFK gate +// prepends an elapsed-hours clause; Hermes prepends an unshared-work clause), +// so the product copy lives here once to keep every host's nudge consistent. +export const SHARE_VIDEO_SKILL_SUGGESTION = "using the share-video skill to leave a short Mainframe video summarizing what you did"; export function evaluateAfkGate(input) { const elapsedMs = input.stopTimeMs - input.lastUserTimeMs; if (elapsedMs < DEFAULT_AFK_THRESHOLD_MS) { @@ -14,6 +18,6 @@ export function evaluateAfkGate(input) { const elapsedHours = (elapsedMs / MS_PER_HOUR).toFixed(1); return { fire: true, - reason: `The user has been away for about ${elapsedHours} hours while you worked. Consider using the share-video skill to leave a short Mainframe video summarizing what you did, then stop.`, + reason: `The user has been away for about ${elapsedHours} hours while you worked. Consider ${SHARE_VIDEO_SKILL_SUGGESTION}, then stop.`, }; } diff --git a/dist/hooks/hermes/stop-evaluator.js b/dist/hooks/hermes/stop-evaluator.js new file mode 100644 index 0000000..28797ad --- /dev/null +++ b/dist/hooks/hermes/stop-evaluator.js @@ -0,0 +1,39 @@ +import { SHARE_VIDEO_SKILL_SUGGESTION } from "../core/afk-gate.js"; +import { isJsonRecord, parseJsonRecord } from "../core/json.js"; +import { summarizeHermesConversation } from "./transcript.js"; +// Injected at the start of the next turn. Mirrors the other hosts' stop nudge +// but drops the "away for N hours" clause: Hermes hook payloads carry no +// timestamps, so there is no AFK timer to report. The "is this a good moment?" +// judgment is delegated to the agent and the share-video skill, which already +// encodes when not to record (active iteration, unfinished work, secrets). +const SHARE_VIDEO_NUDGE = `You did work in your previous turn without sharing a Mainframe video. ` + + `If you are at a good stopping point, consider ${SHARE_VIDEO_SKILL_SUGGESTION}.`; +export function evaluateHermesStopHook(input) { + const conversationHistory = parsePreLlmCallConversation(input.stdin); + if (conversationHistory === null) { + return {}; + } + const summary = summarizeHermesConversation(conversationHistory); + if (summary === "unreadable" || !summary.workHappened || summary.alreadyShared) { + return {}; + } + return { context: SHARE_VIDEO_NUDGE }; +} +// Read the prior conversation from a Hermes `pre_llm_call` shell-hook payload. +// Fails closed (returns null, so the hook stays silent) on any other event or a +// payload shape that does not carry an inline conversation array. +function parsePreLlmCallConversation(stdin) { + const input = parseJsonRecord(stdin); + if (input === null || input.hook_event_name !== "pre_llm_call") { + return null; + } + const extra = input.extra; + if (!isJsonRecord(extra)) { + return null; + } + const conversationHistory = extra.conversation_history; + if (!Array.isArray(conversationHistory)) { + return null; + } + return conversationHistory; +} diff --git a/dist/hooks/hermes/stop.js b/dist/hooks/hermes/stop.js new file mode 100755 index 0000000..ed4ff87 --- /dev/null +++ b/dist/hooks/hermes/stop.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { runStopHookCli } from "../core/run-stop-hook.js"; +import { evaluateHermesStopHook } from "./stop-evaluator.js"; +runStopHookCli(evaluateHermesStopHook); diff --git a/dist/hooks/hermes/transcript.js b/dist/hooks/hermes/transcript.js new file mode 100644 index 0000000..338f32a --- /dev/null +++ b/dist/hooks/hermes/transcript.js @@ -0,0 +1,40 @@ +import { isJsonRecord } from "../core/json.js"; +import { accumulateClassifiedRows, } from "../core/transcript.js"; +// Hermes fires the share-video nudge from a `pre_llm_call` shell hook whose +// stdin payload carries the prior conversation inline as OpenAI-format message +// records under `extra.conversation_history` — there is no transcript file and +// no per-message timestamps (see hooks/hermes/stop-evaluator.ts). So unlike the +// file-backed hosts, Hermes classifies the in-payload messages directly and +// uses only the timeless work/share signals the shared accumulator derives; the +// non-decreasing user-time cursor stays inert because these rows carry no +// `timestamp`, leaving `lastUserTimeMs` null. +export function summarizeHermesConversation(conversationHistory) { + const records = []; + for (const entry of conversationHistory) { + if (!isJsonRecord(entry)) { + return "unreadable"; + } + records.push(entry); + } + return accumulateClassifiedRows(records, classifyHermesMessage); +} +// A real user turn is a `role: "user"` message; it resets the accumulator's +// per-turn work and share flags. Agent work is an assistant message that issued +// tool calls, or a `role: "tool"` result row. Assistant prose and any other +// role (system, developer) are ignored. +function classifyHermesMessage(record) { + const role = record.role; + if (role === "user") { + return "user"; + } + if (role === "assistant" && hasToolCalls(record.tool_calls)) { + return "work"; + } + if (role === "tool") { + return "work"; + } + return "ignore"; +} +function hasToolCalls(toolCalls) { + return Array.isArray(toolCalls) && toolCalls.length > 0; +} diff --git a/hooks/core/afk-gate.ts b/hooks/core/afk-gate.ts index 7eb2c76..7f82b00 100644 --- a/hooks/core/afk-gate.ts +++ b/hooks/core/afk-gate.ts @@ -1,6 +1,12 @@ export const MS_PER_HOUR = 3_600_000; export const DEFAULT_AFK_THRESHOLD_MS = MS_PER_HOUR; +// Shared share-video call to action. Hosts frame it differently (the AFK gate +// prepends an elapsed-hours clause; Hermes prepends an unshared-work clause), +// so the product copy lives here once to keep every host's nudge consistent. +export const SHARE_VIDEO_SKILL_SUGGESTION = + "using the share-video skill to leave a short Mainframe video summarizing what you did"; + export type AfkGateInput = { stopTimeMs: number; lastUserTimeMs: number; @@ -25,6 +31,6 @@ export function evaluateAfkGate(input: AfkGateInput): AfkGateResult { const elapsedHours = (elapsedMs / MS_PER_HOUR).toFixed(1); return { fire: true, - reason: `The user has been away for about ${elapsedHours} hours while you worked. Consider using the share-video skill to leave a short Mainframe video summarizing what you did, then stop.`, + reason: `The user has been away for about ${elapsedHours} hours while you worked. Consider ${SHARE_VIDEO_SKILL_SUGGESTION}, then stop.`, }; } diff --git a/hooks/hermes/fixtures/stop.json b/hooks/hermes/fixtures/stop.json new file mode 100644 index 0000000..ffa8346 --- /dev/null +++ b/hooks/hermes/fixtures/stop.json @@ -0,0 +1,30 @@ +{ + "hook_event_name": "pre_llm_call", + "tool_name": null, + "tool_input": null, + "session_id": "session-123", + "cwd": "/workspace/mainframe-plugins", + "extra": { + "user_message": "Anything else before you wrap up?", + "is_first_turn": false, + "model": "anthropic/claude-sonnet-4.6", + "platform": "cli", + "telemetry_schema_version": 1, + "conversation_history": [ + { "role": "user", "content": "Please implement this. SECRET_NEVER_LEAK" }, + { + "role": "assistant", + "content": "On it.", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { "name": "terminal", "arguments": "{\"command\":\"bun run build\"}" } + } + ] + }, + { "role": "tool", "tool_call_id": "call_1", "content": "build succeeded" }, + { "role": "assistant", "content": "Build finished." } + ] + } +} diff --git a/hooks/hermes/stop-evaluator.ts b/hooks/hermes/stop-evaluator.ts new file mode 100644 index 0000000..1252514 --- /dev/null +++ b/hooks/hermes/stop-evaluator.ts @@ -0,0 +1,58 @@ +import { SHARE_VIDEO_SKILL_SUGGESTION } from "../core/afk-gate.js"; +import { isJsonRecord, parseJsonRecord } from "../core/json.js"; +import { summarizeHermesConversation } from "./transcript.js"; + +export type HermesStopEvaluationInput = { + stdin: string; +}; + +// Hermes shell hooks read `{"context": "..."}` from stdout only for the +// `pre_llm_call` event and prepend it to the current turn's user message; an +// empty object is a silent no-op. That injection is the one Hermes hook channel +// that can steer the agent, so the share-video nudge rides on it. +export type HermesStopHookOutput = { context?: string }; + +// Injected at the start of the next turn. Mirrors the other hosts' stop nudge +// but drops the "away for N hours" clause: Hermes hook payloads carry no +// timestamps, so there is no AFK timer to report. The "is this a good moment?" +// judgment is delegated to the agent and the share-video skill, which already +// encodes when not to record (active iteration, unfinished work, secrets). +const SHARE_VIDEO_NUDGE = + `You did work in your previous turn without sharing a Mainframe video. ` + + `If you are at a good stopping point, consider ${SHARE_VIDEO_SKILL_SUGGESTION}.`; + +export function evaluateHermesStopHook(input: HermesStopEvaluationInput): HermesStopHookOutput { + const conversationHistory = parsePreLlmCallConversation(input.stdin); + if (conversationHistory === null) { + return {}; + } + + const summary = summarizeHermesConversation(conversationHistory); + if (summary === "unreadable" || !summary.workHappened || summary.alreadyShared) { + return {}; + } + + return { context: SHARE_VIDEO_NUDGE }; +} + +// Read the prior conversation from a Hermes `pre_llm_call` shell-hook payload. +// Fails closed (returns null, so the hook stays silent) on any other event or a +// payload shape that does not carry an inline conversation array. +function parsePreLlmCallConversation(stdin: string): readonly unknown[] | null { + const input = parseJsonRecord(stdin); + if (input === null || input.hook_event_name !== "pre_llm_call") { + return null; + } + + const extra = input.extra; + if (!isJsonRecord(extra)) { + return null; + } + + const conversationHistory = extra.conversation_history; + if (!Array.isArray(conversationHistory)) { + return null; + } + + return conversationHistory; +} diff --git a/hooks/hermes/stop.test.ts b/hooks/hermes/stop.test.ts new file mode 100644 index 0000000..4ec41d4 --- /dev/null +++ b/hooks/hermes/stop.test.ts @@ -0,0 +1,119 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { evaluateHermesStopHook } from "./stop-evaluator.js"; + +const fixtureDir = dirname(fileURLToPath(import.meta.url)); +const stopPath = join(fixtureDir, "fixtures", "stop.json"); +const SHARED_VIDEO_URL = "https://mainframe.app/v/37507089004e8f3700deb918a48b2556"; + +describe("Hermes stop hook", () => { + it("injects a share-video nudge after unshared work", () => { + const output = evaluateHermesStopHook({ stdin: readFileSync(stopPath, "utf8") }); + + expect(output.context).toContain("share-video"); + expect(output.context).not.toContain("SECRET_NEVER_LEAK"); + }); + + it("does not fire for non-pre_llm_call events", () => { + for (const hook_event_name of ["post_llm_call", "pre_tool_call", "on_session_end", "Stop"]) { + expect( + evaluateHermesStopHook({ + stdin: preLlmCall([userMessage("please work on this"), toolCallTurn()], { + hook_event_name, + }), + }), + ).toEqual({}); + } + }); + + it("does not fire when no work happened since the last user message", () => { + expect( + evaluateHermesStopHook({ + stdin: preLlmCall([ + userMessage("please answer this"), + { role: "assistant", content: "Here is my answer." }, + ]), + }), + ).toEqual({}); + }); + + it("does not fire after a Mainframe video was shared", () => { + expect( + evaluateHermesStopHook({ + stdin: preLlmCall([ + userMessage("please work on this"), + toolCallTurn(), + { role: "tool", tool_call_id: "call_1", content: `Shared: ${SHARED_VIDEO_URL}` }, + ]), + }), + ).toEqual({}); + }); + + it("does not treat a video URL in a user message as an existing share", () => { + const output = evaluateHermesStopHook({ + stdin: preLlmCall([userMessage(`please look at ${SHARED_VIDEO_URL}`), toolCallTurn()]), + }); + + expect(output.context).toContain("share-video"); + }); + + it("does not fire when the conversation history is missing, empty, or not a list", () => { + for (const conversation_history of [undefined, [], "not-a-list"]) { + expect( + evaluateHermesStopHook({ + stdin: JSON.stringify({ + hook_event_name: "pre_llm_call", + extra: { conversation_history }, + }), + }), + ).toEqual({}); + } + }); + + it("does not fire when the payload has no extra object", () => { + expect( + evaluateHermesStopHook({ stdin: JSON.stringify({ hook_event_name: "pre_llm_call" }) }), + ).toEqual({}); + }); + + it("does not fire on corrupt JSON input", () => { + expect(evaluateHermesStopHook({ stdin: "{not-json" })).toEqual({}); + }); +}); + +function preLlmCall( + conversationHistory: Array>, + overrides: Record = {}, +): string { + return JSON.stringify({ + hook_event_name: "pre_llm_call", + tool_name: null, + tool_input: null, + session_id: "session-123", + cwd: "/workspace", + extra: { conversation_history: conversationHistory }, + ...overrides, + }); +} + +function userMessage(content: string): Record { + return { role: "user", content }; +} + +function toolCallTurn(): Record { + return { + role: "assistant", + content: "Working on it.", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "terminal", arguments: '{"command":"bun run build"}' }, + }, + ], + }; +} diff --git a/hooks/hermes/stop.ts b/hooks/hermes/stop.ts new file mode 100644 index 0000000..1f6e69d --- /dev/null +++ b/hooks/hermes/stop.ts @@ -0,0 +1,5 @@ +#!/usr/bin/env node +import { runStopHookCli } from "../core/run-stop-hook.js"; +import { evaluateHermesStopHook } from "./stop-evaluator.js"; + +runStopHookCli(evaluateHermesStopHook); diff --git a/hooks/hermes/transcript.test.ts b/hooks/hermes/transcript.test.ts new file mode 100644 index 0000000..7067885 --- /dev/null +++ b/hooks/hermes/transcript.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; + +import { summarizeHermesConversation } from "./transcript.js"; + +const SHARED_VIDEO_URL = "https://mainframe.app/v/37507089004e8f3700deb918a48b2556"; + +describe("summarizeHermesConversation", () => { + it("reports tool-call work after the last user message", () => { + const summary = summarizeHermesConversation([ + userMessage("earlier request"), + userMessage("final request"), + toolCallTurn(), + { role: "tool", tool_call_id: "call_1", content: "done" }, + ]); + + expect(summary).toMatchObject({ workHappened: true, alreadyShared: false }); + }); + + it("counts a bare tool result row as work", () => { + const summary = summarizeHermesConversation([ + userMessage("please work on this"), + { role: "tool", tool_call_id: "call_1", content: "done" }, + ]); + + expect(summary).toMatchObject({ workHappened: true }); + }); + + it("does not count assistant prose without tool calls as work", () => { + const summary = summarizeHermesConversation([ + userMessage("please answer this"), + { role: "assistant", content: "Here is the answer." }, + ]); + + expect(summary).toMatchObject({ workHappened: false }); + }); + + it("resets work state on each user turn", () => { + const summary = summarizeHermesConversation([ + userMessage("first request"), + toolCallTurn(), + userMessage("second request"), + ]); + + expect(summary).toMatchObject({ workHappened: false }); + }); + + it("detects an existing Mainframe share from a tool result", () => { + const summary = summarizeHermesConversation([ + userMessage("please work on this"), + toolCallTurn(), + { role: "tool", tool_call_id: "call_1", content: `Shared: ${SHARED_VIDEO_URL}` }, + ]); + + expect(summary).toMatchObject({ workHappened: true, alreadyShared: true }); + }); + + it("does not treat a video URL in a user message as an existing share", () => { + const summary = summarizeHermesConversation([ + userMessage(`please look at ${SHARED_VIDEO_URL}`), + toolCallTurn(), + ]); + + expect(summary).toMatchObject({ workHappened: true, alreadyShared: false }); + }); + + it("treats a non-object message row as unreadable", () => { + expect(summarizeHermesConversation([userMessage("hi"), "not-a-message"])).toBe("unreadable"); + }); +}); + +function userMessage(content: string): Record { + return { role: "user", content }; +} + +function toolCallTurn(): Record { + return { + role: "assistant", + content: "Working on it.", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "terminal", arguments: '{"command":"bun run build"}' }, + }, + ], + }; +} diff --git a/hooks/hermes/transcript.ts b/hooks/hermes/transcript.ts new file mode 100644 index 0000000..ef5ed73 --- /dev/null +++ b/hooks/hermes/transcript.ts @@ -0,0 +1,53 @@ +import { isJsonRecord, type JsonRecord } from "../core/json.js"; +import { + accumulateClassifiedRows, + type ClassifiedRowKind, + type ParsedTranscript, +} from "../core/transcript.js"; + +// Hermes fires the share-video nudge from a `pre_llm_call` shell hook whose +// stdin payload carries the prior conversation inline as OpenAI-format message +// records under `extra.conversation_history` — there is no transcript file and +// no per-message timestamps (see hooks/hermes/stop-evaluator.ts). So unlike the +// file-backed hosts, Hermes classifies the in-payload messages directly and +// uses only the timeless work/share signals the shared accumulator derives; the +// non-decreasing user-time cursor stays inert because these rows carry no +// `timestamp`, leaving `lastUserTimeMs` null. +export function summarizeHermesConversation( + conversationHistory: readonly unknown[], +): ParsedTranscript | "unreadable" { + const records: JsonRecord[] = []; + for (const entry of conversationHistory) { + if (!isJsonRecord(entry)) { + return "unreadable"; + } + records.push(entry); + } + + return accumulateClassifiedRows(records, classifyHermesMessage); +} + +// A real user turn is a `role: "user"` message; it resets the accumulator's +// per-turn work and share flags. Agent work is an assistant message that issued +// tool calls, or a `role: "tool"` result row. Assistant prose and any other +// role (system, developer) are ignored. +function classifyHermesMessage(record: JsonRecord): ClassifiedRowKind { + const role = record.role; + if (role === "user") { + return "user"; + } + + if (role === "assistant" && hasToolCalls(record.tool_calls)) { + return "work"; + } + + if (role === "tool") { + return "work"; + } + + return "ignore"; +} + +function hasToolCalls(toolCalls: unknown): boolean { + return Array.isArray(toolCalls) && toolCalls.length > 0; +} diff --git a/package.json b/package.json index 9e84996..8afdf10 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@mainframe/plugins", "version": "0.1.0", "private": true, - "description": "Mainframe Cursor, Codex, and Claude Code plugin manifests, skill, MCP wiring, and stop hooks.", + "description": "Mainframe Cursor, Codex, Claude Code, and Hermes plugin manifests, skill, MCP wiring, and stop hooks.", "keywords": [ "agent-skills", "hooks", @@ -19,13 +19,15 @@ "bin": { "mainframe-hook-claude": "./dist/hooks/claude/stop.js", "mainframe-hook-codex": "./dist/hooks/codex/stop.js", - "mainframe-hook-cursor": "./dist/hooks/cursor/stop.js" + "mainframe-hook-cursor": "./dist/hooks/cursor/stop.js", + "mainframe-hook-hermes": "./dist/hooks/hermes/stop.js" }, "files": [ ".agents", ".claude-plugin", ".codex-plugin", ".cursor-plugin", + ".hermes-plugin", ".mcp.json", "LICENSE", "README.md", @@ -41,9 +43,9 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "build": "rm -rf dist && tsc -p tsconfig.build.json && bun tooling/mark-executable.ts", "build:check": "bun run build && git diff --exit-code -- dist", - "generate": "bun tooling/generate.ts && oxfmt --write package.json .cursor-plugin .codex-plugin .agents .claude-plugin", + "generate": "bun tooling/generate.ts && oxfmt --write package.json .cursor-plugin .codex-plugin .agents .claude-plugin .hermes-plugin", "check:generated": "bun tooling/generated-drift-check.ts", - "archive:release": "bun run generate && bun run build && git diff --exit-code -- package.json .cursor-plugin .codex-plugin .agents .claude-plugin dist && bun tooling/release-archive.ts", + "archive:release": "bun run generate && bun run build && git diff --exit-code -- package.json .cursor-plugin .codex-plugin .agents .claude-plugin .hermes-plugin dist && bun tooling/release-archive.ts", "pack:release": "bun run verify && bun run archive:release", "lint": "oxlint . --deny-warnings --report-unused-disable-directives", "lint:fix": "oxlint . --fix", @@ -63,6 +65,7 @@ "oxlint-tsgolint": "^0.22.1", "typescript": "^5.9.3", "vitest": "^4.1.5", + "yaml": "^2.9.0", "zod": "^4.1.13" }, "lint-staged": { diff --git a/tooling/generate.ts b/tooling/generate.ts index 5af20ba..dbe0fcb 100644 --- a/tooling/generate.ts +++ b/tooling/generate.ts @@ -1,11 +1,20 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; +import { stringify as stringifyYaml } from "yaml"; import { z } from "zod"; import { PACKAGE_FILES } from "./package-surface.js"; const JsonRecordSchema = z.record(z.string(), z.unknown()); +// `.mcp.json` is the single source of truth for the hosted MCP wiring. Cursor, +// Codex, and Claude Code reference the file directly; Hermes is config-driven +// and needs the same servers re-expressed as YAML, so we read them from here +// instead of restating the URL. +const McpConfigSchema = z.object({ + mcpServers: z.record(z.string(), JsonRecordSchema), +}); + const MetadataSchema = z.object({ name: z.string().min(1), displayName: z.string().min(1), @@ -31,6 +40,22 @@ const CURSOR_HOOKS = "./hooks/cursor/hooks.json"; const CODEX_HOOKS = "./hooks/codex/hooks.json"; const CLAUDE_HOOKS = "./hooks/claude/hooks.json"; +// Hermes has no plugin manifest that bundles MCP, skills, and hooks the way the +// other hosts do: MCP servers and shell hooks are declared in the user's +// `~/.hermes/config.yaml`. So the Hermes "plugin" is a generated config +// fragment users merge in. The stop nudge runs as a `pre_llm_call` shell hook — +// Hermes invokes the command, pipes the turn payload to stdin, and prepends any +// returned `{"context": ...}` to the user message (see hooks/hermes/stop.ts). +const HERMES_CONFIG = ".hermes-plugin/config.yaml"; +const HERMES_HOOK_BIN = "mainframe-hook-hermes"; +const HERMES_HOOK_EVENT = "pre_llm_call"; +const HERMES_HOOK_TIMEOUT_SECONDS = 30; +const HERMES_CONFIG_HEADER = [ + "# Mainframe Hermes plugin wiring — generated by tooling/generate.ts; do not edit by hand.", + "# Merge into ~/.hermes/config.yaml. See README.md for the share-video skill setup.", + "", +].join("\n"); + const metadata = MetadataSchema.parse({ name: "mainframe", displayName: "Mainframe", @@ -81,9 +106,21 @@ function main(): void { writeJson(".claude-plugin/plugin.json", claudeManifest()); writeJson(".claude-plugin/marketplace.json", claudeMarketplace()); + writeYaml(HERMES_CONFIG, HERMES_CONFIG_HEADER, hermesConfig()); + updatePackageJson(); } +function hermesConfig() { + const mcp = McpConfigSchema.parse(JSON.parse(readFileSync(".mcp.json", "utf8"))); + return { + mcp_servers: mcp.mcpServers, + hooks: { + [HERMES_HOOK_EVENT]: [{ command: HERMES_HOOK_BIN, timeout: HERMES_HOOK_TIMEOUT_SECONDS }], + }, + }; +} + function cursorMarketplace() { return { name: metadata.name, @@ -179,7 +216,7 @@ function updatePackageJson(): void { packageJson.name = metadata.packageName; packageJson.version = metadata.version; packageJson.description = - "Mainframe Cursor, Codex, and Claude Code plugin manifests, skill, MCP wiring, and stop hooks."; + "Mainframe Cursor, Codex, Claude Code, and Hermes plugin manifests, skill, MCP wiring, and stop hooks."; packageJson.private = true; packageJson.license = metadata.license; packageJson.homepage = metadata.homepage; @@ -192,6 +229,7 @@ function updatePackageJson(): void { "mainframe-hook-cursor": "./dist/hooks/cursor/stop.js", "mainframe-hook-codex": "./dist/hooks/codex/stop.js", "mainframe-hook-claude": "./dist/hooks/claude/stop.js", + "mainframe-hook-hermes": "./dist/hooks/hermes/stop.js", }; packageJson.files = PACKAGE_FILES; @@ -203,4 +241,9 @@ function writeJson(path: string, value: unknown): void { writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); } +function writeYaml(path: string, header: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${header}${stringifyYaml(value)}`); +} + main(); diff --git a/tooling/generated-drift-check.ts b/tooling/generated-drift-check.ts index b97d339..259b1f7 100644 --- a/tooling/generated-drift-check.ts +++ b/tooling/generated-drift-check.ts @@ -8,6 +8,7 @@ const generatedPaths = [ ".agents/plugins/marketplace.json", ".claude-plugin/plugin.json", ".claude-plugin/marketplace.json", + ".hermes-plugin/config.yaml", "package.json", ]; diff --git a/tooling/manifest.test.ts b/tooling/manifest.test.ts index b5eccdf..d6f9ab6 100644 --- a/tooling/manifest.test.ts +++ b/tooling/manifest.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; +import { parse as parseYaml } from "yaml"; import { z } from "zod"; const AuthorSchema = z @@ -170,3 +171,36 @@ describe("generated plugin manifests", () => { marketplaceSchema.parse(JSON.parse(readFileSync(".claude-plugin/marketplace.json", "utf8"))); }); }); + +describe("generated Hermes plugin config", () => { + it(".hermes-plugin/config.yaml wires the Mainframe MCP server and the pre_llm_call shell hook", () => { + const configSchema = z + .object({ + mcp_servers: z + .object({ + mainframe: z.object({ url: z.literal("https://mcp.mainframe.app/mcp") }).strict(), + }) + .strict(), + hooks: z + .object({ + pre_llm_call: z.tuple([ + z + .object({ + command: z.literal("mainframe-hook-hermes"), + timeout: z.literal(30), + }) + .strict(), + ]), + }) + .strict(), + }) + .strict(); + + const config = configSchema.parse( + parseYaml(readFileSync(".hermes-plugin/config.yaml", "utf8")), + ); + + expect(config.mcp_servers.mainframe.url).toBe("https://mcp.mainframe.app/mcp"); + expect(config.hooks.pre_llm_call[0].command).toBe("mainframe-hook-hermes"); + }); +}); diff --git a/tooling/mark-executable.ts b/tooling/mark-executable.ts index d8f0a33..24d2149 100644 --- a/tooling/mark-executable.ts +++ b/tooling/mark-executable.ts @@ -4,6 +4,7 @@ const files = [ "dist/hooks/cursor/stop.js", "dist/hooks/codex/stop.js", "dist/hooks/claude/stop.js", + "dist/hooks/hermes/stop.js", ]; for (const file of files) { diff --git a/tooling/package-surface.test.ts b/tooling/package-surface.test.ts index 2e440b7..7662360 100644 --- a/tooling/package-surface.test.ts +++ b/tooling/package-surface.test.ts @@ -2,6 +2,7 @@ import { lstatSync, readFileSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { parse as parseYaml } from "yaml"; import { z } from "zod"; import { PACKAGE_FILES, SHIPPED_FILES, readPackageFiles } from "./package-surface.js"; @@ -47,6 +48,23 @@ const NestedStopHooksSchema = z }) .passthrough(); +// Hermes shell hooks invoke a bare command resolved from PATH rather than a +// `node "$PLUGIN_ROOT/..."` path, so the runtime is referenced by its package +// bin name and resolved through `package.json`'s `bin` map. +const HermesConfigSchema = z + .object({ + hooks: z.object({ + pre_llm_call: z.tuple([ + z + .object({ + command: z.string(), + }) + .passthrough(), + ]), + }), + }) + .passthrough(); + describe("package runtime surface", () => { it("ships only the allowlisted plugin package surface", () => { const packageJson = PackageSchema.parse(readJson("package.json")); @@ -107,6 +125,21 @@ describe("package runtime surface", () => { expect(statSync(hookTarget).mode & 0o111).not.toBe(0); }); + it("ships the executable Hermes hook runtime referenced by the config bin name", () => { + const packageJson = PackageSchema.parse(readJson("package.json")); + const config = HermesConfigSchema.parse( + parseYaml(readFileSync(".hermes-plugin/config.yaml", "utf8")), + ); + + const command = config.hooks.pre_llm_call[0].command; + const hookTarget = (packageJson.bin[command] ?? "").replace(/^\.\//, ""); + + expect(command).toBe("mainframe-hook-hermes"); + expect(hookTarget).toBe("dist/hooks/hermes/stop.js"); + expect(isPackaged(hookTarget, packageJson.files)).toBe(true); + expect(statSync(hookTarget).mode & 0o111).not.toBe(0); + }); + it("does not include symlinks in package files", () => { const packageJson = PackageSchema.parse(readJson("package.json")); diff --git a/tooling/package-surface.ts b/tooling/package-surface.ts index e18c3f2..05416b3 100644 --- a/tooling/package-surface.ts +++ b/tooling/package-surface.ts @@ -6,6 +6,7 @@ export const PACKAGE_FILES = [ ".claude-plugin", ".codex-plugin", ".cursor-plugin", + ".hermes-plugin", ".mcp.json", "LICENSE", "README.md", @@ -24,6 +25,7 @@ export const SHIPPED_FILES = [ ".codex-plugin/plugin.json", ".cursor-plugin/marketplace.json", ".cursor-plugin/plugin.json", + ".hermes-plugin/config.yaml", ".mcp.json", "LICENSE", "README.md", @@ -43,6 +45,9 @@ export const SHIPPED_FILES = [ "dist/hooks/cursor/stop-evaluator.js", "dist/hooks/cursor/stop.js", "dist/hooks/cursor/transcript.js", + "dist/hooks/hermes/stop-evaluator.js", + "dist/hooks/hermes/stop.js", + "dist/hooks/hermes/transcript.js", "hooks/claude/hooks.json", "hooks/codex/hooks.json", "hooks/cursor/hooks.json", From ccbe885500e1916bd062ab4d7a32c3bd2b2c603e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 8 Jun 2026 18:44:16 +0000 Subject: [PATCH 2/8] Drop the Hermes stop hook; ship the plugin as MCP + skill only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes has no hook event that fires when the agent stops and can re-engage it. The previous pre_llm_call hook was therefore a turn-start reminder, not a stop hook, and firing it every turn (whenever prior work was unshared) was noisy and semantically backwards — it nudges "leave a video, the user is away" exactly when the user has just sent a message. So the Hermes plugin is now the shared share-video skill plus the Mainframe MCP server, which map cleanly onto Hermes. Removed the hooks/hermes runtime, the mainframe-hook-hermes bin, the pre_llm_call shell-hook config, and the related tests; .hermes-plugin/config.yaml now carries only the mcp_servers wiring (still generated from ./.mcp.json). Reverted the hooks/core share-video copy extraction since nothing else uses it. README/AGENTS explain that Hermes ships no stop hook and the agent reaches for the skill on its own. --- .hermes-plugin/config.yaml | 4 - AGENTS.md | 19 ++--- README.md | 33 +++----- dist/hooks/core/afk-gate.js | 6 +- dist/hooks/hermes/stop-evaluator.js | 39 --------- dist/hooks/hermes/stop.js | 4 - dist/hooks/hermes/transcript.js | 40 ---------- hooks/core/afk-gate.ts | 8 +- hooks/hermes/fixtures/stop.json | 30 ------- hooks/hermes/stop-evaluator.ts | 58 -------------- hooks/hermes/stop.test.ts | 119 ---------------------------- hooks/hermes/stop.ts | 5 -- hooks/hermes/transcript.test.ts | 87 -------------------- hooks/hermes/transcript.ts | 53 ------------- package.json | 3 +- tooling/generate.ts | 25 +++--- tooling/manifest.test.ts | 15 +--- tooling/mark-executable.ts | 1 - tooling/package-surface.test.ts | 33 -------- tooling/package-surface.ts | 3 - 20 files changed, 32 insertions(+), 553 deletions(-) delete mode 100644 dist/hooks/hermes/stop-evaluator.js delete mode 100755 dist/hooks/hermes/stop.js delete mode 100644 dist/hooks/hermes/transcript.js delete mode 100644 hooks/hermes/fixtures/stop.json delete mode 100644 hooks/hermes/stop-evaluator.ts delete mode 100644 hooks/hermes/stop.test.ts delete mode 100644 hooks/hermes/stop.ts delete mode 100644 hooks/hermes/transcript.test.ts delete mode 100644 hooks/hermes/transcript.ts diff --git a/.hermes-plugin/config.yaml b/.hermes-plugin/config.yaml index 448a9ac..d4b8a32 100644 --- a/.hermes-plugin/config.yaml +++ b/.hermes-plugin/config.yaml @@ -3,7 +3,3 @@ mcp_servers: mainframe: url: https://mcp.mainframe.app/mcp -hooks: - pre_llm_call: - - command: mainframe-hook-hermes - timeout: 30 diff --git a/AGENTS.md b/AGENTS.md index 6a83984..9ad8903 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,15 +8,16 @@ focused on the Cursor, Codex, Claude Code, and Hermes manifests, hosted MCP wiri - User-visible copy should say "Mainframe", not legacy product names. - Do not add secrets, customer data, private URLs, or private business context. -- Cursor, Codex, Claude Code, and Hermes are the supported hosts. All four plugins share the repo - root, the `share-video` skill, the `./.mcp.json` wiring, and the `hooks/core` runtime. Codex and - Claude Code share the same Stop hook contract, so they both use `hooks/core/stop-hook.ts`; only - the transcript parser differs per host. Hermes is config-driven: its MCP server and stop hook - live in `~/.hermes/config.yaml`, so the plugin is a generated `.hermes-plugin/config.yaml` - fragment. The Hermes stop nudge is a `pre_llm_call` shell hook that reuses the `hooks/core` CLI - plumbing and share detection, but not the AFK time gate — Hermes hook payloads carry no - timestamps, so it nudges on unshared work instead of elapsed time. Do not add other host - surfaces unless the product task explicitly asks for them. +- Cursor, Codex, Claude Code, and Hermes are the supported hosts. Cursor, Codex, and Claude Code + share the repo root, the `share-video` skill, the `./.mcp.json` wiring, and the `hooks/core` + stop-hook runtime. Codex and Claude Code share the same Stop hook contract, so they both use + `hooks/core/stop-hook.ts`; only the transcript parser differs per host. Hermes is config-driven + and ships only the `share-video` skill plus the MCP server, generated as a + `.hermes-plugin/config.yaml` fragment users merge into `~/.hermes/config.yaml`. Hermes ships no + stop hook: it has no hook event that fires when the agent stops and can re-engage it, so there is + nothing for the `hooks/core` runtime to drive (its only agent-facing hooks are `pre_tool_call` + and a turn-start `pre_llm_call`, neither of which is a stop). Do not add other host surfaces + unless the product task explicitly asks for them. - Run `bun run verify` before considering changes ready. - Generated Cursor, Codex, Claude Code, and Hermes manifest, marketplace, and config files come from `tooling/generate.ts`; edit the config there, then run `bun run generate`. diff --git a/README.md b/README.md index 76abd4e..45e0a47 100644 --- a/README.md +++ b/README.md @@ -50,34 +50,19 @@ and Codex plugins. ### Hermes [Hermes](https://hermes-agent.nousresearch.com) is config-driven rather than manifest-based, so the -Mainframe plugin for Hermes is wired through `~/.hermes/config.yaml` plus the shared skill. The -generated fragment to merge in lives at `.hermes-plugin/config.yaml`. +Mainframe plugin for Hermes is the shared `share-video` skill plus the Mainframe MCP server wired +through `~/.hermes/config.yaml`. The generated MCP fragment to merge in lives at +`.hermes-plugin/config.yaml`. -1. Build this repository so the stop-hook runtime is available, and expose its `mainframe-hook-hermes` - command on your `PATH`: - - ```sh - bun install && bun run build && bun link - ``` - - You can instead point the hook at the built script directly with - `node /absolute/path/to/dist/hooks/hermes/stop.js`. - -2. Merge the MCP server and the stop hook into `~/.hermes/config.yaml`: +1. Add the Mainframe MCP server to `~/.hermes/config.yaml`: ```yaml mcp_servers: mainframe: url: https://mcp.mainframe.app/mcp - hooks: - pre_llm_call: - - command: mainframe-hook-hermes - timeout: 30 ``` - Hermes asks for consent the first time it runs a shell hook (or set `hooks_auto_accept: true`). - -3. Load the `share-video` skill by pointing Hermes at this repository's `skills` directory: +2. Load the `share-video` skill by pointing Hermes at this repository's `skills` directory: ```yaml skills: @@ -85,10 +70,10 @@ generated fragment to merge in lives at `.hermes-plugin/config.yaml`. - /absolute/path/to/mainframe-plugins/skills ``` -The Hermes stop hook runs on `pre_llm_call`, the one hook event whose output Hermes feeds back to -the agent. Hermes hook payloads carry no timestamps, so there is no "away for N hours" timer like -the other hosts; instead the hook nudges the agent toward the `share-video` skill at the start of a -turn when the previous turn did real work and no Mainframe video has been shared yet. +This gives Hermes the same `share-video` skill and Mainframe tools as the other hosts. Hermes does +not ship a stop hook: it has no hook event that fires when the agent stops and can re-engage it (the +other hosts' nudge relies on that), so on Hermes the agent reaches for the `share-video` skill on +its own, guided by the skill's own "use when" criteria. ## Included skill diff --git a/dist/hooks/core/afk-gate.js b/dist/hooks/core/afk-gate.js index 1762318..abafaf7 100644 --- a/dist/hooks/core/afk-gate.js +++ b/dist/hooks/core/afk-gate.js @@ -1,9 +1,5 @@ export const MS_PER_HOUR = 3_600_000; export const DEFAULT_AFK_THRESHOLD_MS = MS_PER_HOUR; -// Shared share-video call to action. Hosts frame it differently (the AFK gate -// prepends an elapsed-hours clause; Hermes prepends an unshared-work clause), -// so the product copy lives here once to keep every host's nudge consistent. -export const SHARE_VIDEO_SKILL_SUGGESTION = "using the share-video skill to leave a short Mainframe video summarizing what you did"; export function evaluateAfkGate(input) { const elapsedMs = input.stopTimeMs - input.lastUserTimeMs; if (elapsedMs < DEFAULT_AFK_THRESHOLD_MS) { @@ -18,6 +14,6 @@ export function evaluateAfkGate(input) { const elapsedHours = (elapsedMs / MS_PER_HOUR).toFixed(1); return { fire: true, - reason: `The user has been away for about ${elapsedHours} hours while you worked. Consider ${SHARE_VIDEO_SKILL_SUGGESTION}, then stop.`, + reason: `The user has been away for about ${elapsedHours} hours while you worked. Consider using the share-video skill to leave a short Mainframe video summarizing what you did, then stop.`, }; } diff --git a/dist/hooks/hermes/stop-evaluator.js b/dist/hooks/hermes/stop-evaluator.js deleted file mode 100644 index 28797ad..0000000 --- a/dist/hooks/hermes/stop-evaluator.js +++ /dev/null @@ -1,39 +0,0 @@ -import { SHARE_VIDEO_SKILL_SUGGESTION } from "../core/afk-gate.js"; -import { isJsonRecord, parseJsonRecord } from "../core/json.js"; -import { summarizeHermesConversation } from "./transcript.js"; -// Injected at the start of the next turn. Mirrors the other hosts' stop nudge -// but drops the "away for N hours" clause: Hermes hook payloads carry no -// timestamps, so there is no AFK timer to report. The "is this a good moment?" -// judgment is delegated to the agent and the share-video skill, which already -// encodes when not to record (active iteration, unfinished work, secrets). -const SHARE_VIDEO_NUDGE = `You did work in your previous turn without sharing a Mainframe video. ` + - `If you are at a good stopping point, consider ${SHARE_VIDEO_SKILL_SUGGESTION}.`; -export function evaluateHermesStopHook(input) { - const conversationHistory = parsePreLlmCallConversation(input.stdin); - if (conversationHistory === null) { - return {}; - } - const summary = summarizeHermesConversation(conversationHistory); - if (summary === "unreadable" || !summary.workHappened || summary.alreadyShared) { - return {}; - } - return { context: SHARE_VIDEO_NUDGE }; -} -// Read the prior conversation from a Hermes `pre_llm_call` shell-hook payload. -// Fails closed (returns null, so the hook stays silent) on any other event or a -// payload shape that does not carry an inline conversation array. -function parsePreLlmCallConversation(stdin) { - const input = parseJsonRecord(stdin); - if (input === null || input.hook_event_name !== "pre_llm_call") { - return null; - } - const extra = input.extra; - if (!isJsonRecord(extra)) { - return null; - } - const conversationHistory = extra.conversation_history; - if (!Array.isArray(conversationHistory)) { - return null; - } - return conversationHistory; -} diff --git a/dist/hooks/hermes/stop.js b/dist/hooks/hermes/stop.js deleted file mode 100755 index ed4ff87..0000000 --- a/dist/hooks/hermes/stop.js +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env node -import { runStopHookCli } from "../core/run-stop-hook.js"; -import { evaluateHermesStopHook } from "./stop-evaluator.js"; -runStopHookCli(evaluateHermesStopHook); diff --git a/dist/hooks/hermes/transcript.js b/dist/hooks/hermes/transcript.js deleted file mode 100644 index 338f32a..0000000 --- a/dist/hooks/hermes/transcript.js +++ /dev/null @@ -1,40 +0,0 @@ -import { isJsonRecord } from "../core/json.js"; -import { accumulateClassifiedRows, } from "../core/transcript.js"; -// Hermes fires the share-video nudge from a `pre_llm_call` shell hook whose -// stdin payload carries the prior conversation inline as OpenAI-format message -// records under `extra.conversation_history` — there is no transcript file and -// no per-message timestamps (see hooks/hermes/stop-evaluator.ts). So unlike the -// file-backed hosts, Hermes classifies the in-payload messages directly and -// uses only the timeless work/share signals the shared accumulator derives; the -// non-decreasing user-time cursor stays inert because these rows carry no -// `timestamp`, leaving `lastUserTimeMs` null. -export function summarizeHermesConversation(conversationHistory) { - const records = []; - for (const entry of conversationHistory) { - if (!isJsonRecord(entry)) { - return "unreadable"; - } - records.push(entry); - } - return accumulateClassifiedRows(records, classifyHermesMessage); -} -// A real user turn is a `role: "user"` message; it resets the accumulator's -// per-turn work and share flags. Agent work is an assistant message that issued -// tool calls, or a `role: "tool"` result row. Assistant prose and any other -// role (system, developer) are ignored. -function classifyHermesMessage(record) { - const role = record.role; - if (role === "user") { - return "user"; - } - if (role === "assistant" && hasToolCalls(record.tool_calls)) { - return "work"; - } - if (role === "tool") { - return "work"; - } - return "ignore"; -} -function hasToolCalls(toolCalls) { - return Array.isArray(toolCalls) && toolCalls.length > 0; -} diff --git a/hooks/core/afk-gate.ts b/hooks/core/afk-gate.ts index 7f82b00..7eb2c76 100644 --- a/hooks/core/afk-gate.ts +++ b/hooks/core/afk-gate.ts @@ -1,12 +1,6 @@ export const MS_PER_HOUR = 3_600_000; export const DEFAULT_AFK_THRESHOLD_MS = MS_PER_HOUR; -// Shared share-video call to action. Hosts frame it differently (the AFK gate -// prepends an elapsed-hours clause; Hermes prepends an unshared-work clause), -// so the product copy lives here once to keep every host's nudge consistent. -export const SHARE_VIDEO_SKILL_SUGGESTION = - "using the share-video skill to leave a short Mainframe video summarizing what you did"; - export type AfkGateInput = { stopTimeMs: number; lastUserTimeMs: number; @@ -31,6 +25,6 @@ export function evaluateAfkGate(input: AfkGateInput): AfkGateResult { const elapsedHours = (elapsedMs / MS_PER_HOUR).toFixed(1); return { fire: true, - reason: `The user has been away for about ${elapsedHours} hours while you worked. Consider ${SHARE_VIDEO_SKILL_SUGGESTION}, then stop.`, + reason: `The user has been away for about ${elapsedHours} hours while you worked. Consider using the share-video skill to leave a short Mainframe video summarizing what you did, then stop.`, }; } diff --git a/hooks/hermes/fixtures/stop.json b/hooks/hermes/fixtures/stop.json deleted file mode 100644 index ffa8346..0000000 --- a/hooks/hermes/fixtures/stop.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "hook_event_name": "pre_llm_call", - "tool_name": null, - "tool_input": null, - "session_id": "session-123", - "cwd": "/workspace/mainframe-plugins", - "extra": { - "user_message": "Anything else before you wrap up?", - "is_first_turn": false, - "model": "anthropic/claude-sonnet-4.6", - "platform": "cli", - "telemetry_schema_version": 1, - "conversation_history": [ - { "role": "user", "content": "Please implement this. SECRET_NEVER_LEAK" }, - { - "role": "assistant", - "content": "On it.", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { "name": "terminal", "arguments": "{\"command\":\"bun run build\"}" } - } - ] - }, - { "role": "tool", "tool_call_id": "call_1", "content": "build succeeded" }, - { "role": "assistant", "content": "Build finished." } - ] - } -} diff --git a/hooks/hermes/stop-evaluator.ts b/hooks/hermes/stop-evaluator.ts deleted file mode 100644 index 1252514..0000000 --- a/hooks/hermes/stop-evaluator.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { SHARE_VIDEO_SKILL_SUGGESTION } from "../core/afk-gate.js"; -import { isJsonRecord, parseJsonRecord } from "../core/json.js"; -import { summarizeHermesConversation } from "./transcript.js"; - -export type HermesStopEvaluationInput = { - stdin: string; -}; - -// Hermes shell hooks read `{"context": "..."}` from stdout only for the -// `pre_llm_call` event and prepend it to the current turn's user message; an -// empty object is a silent no-op. That injection is the one Hermes hook channel -// that can steer the agent, so the share-video nudge rides on it. -export type HermesStopHookOutput = { context?: string }; - -// Injected at the start of the next turn. Mirrors the other hosts' stop nudge -// but drops the "away for N hours" clause: Hermes hook payloads carry no -// timestamps, so there is no AFK timer to report. The "is this a good moment?" -// judgment is delegated to the agent and the share-video skill, which already -// encodes when not to record (active iteration, unfinished work, secrets). -const SHARE_VIDEO_NUDGE = - `You did work in your previous turn without sharing a Mainframe video. ` + - `If you are at a good stopping point, consider ${SHARE_VIDEO_SKILL_SUGGESTION}.`; - -export function evaluateHermesStopHook(input: HermesStopEvaluationInput): HermesStopHookOutput { - const conversationHistory = parsePreLlmCallConversation(input.stdin); - if (conversationHistory === null) { - return {}; - } - - const summary = summarizeHermesConversation(conversationHistory); - if (summary === "unreadable" || !summary.workHappened || summary.alreadyShared) { - return {}; - } - - return { context: SHARE_VIDEO_NUDGE }; -} - -// Read the prior conversation from a Hermes `pre_llm_call` shell-hook payload. -// Fails closed (returns null, so the hook stays silent) on any other event or a -// payload shape that does not carry an inline conversation array. -function parsePreLlmCallConversation(stdin: string): readonly unknown[] | null { - const input = parseJsonRecord(stdin); - if (input === null || input.hook_event_name !== "pre_llm_call") { - return null; - } - - const extra = input.extra; - if (!isJsonRecord(extra)) { - return null; - } - - const conversationHistory = extra.conversation_history; - if (!Array.isArray(conversationHistory)) { - return null; - } - - return conversationHistory; -} diff --git a/hooks/hermes/stop.test.ts b/hooks/hermes/stop.test.ts deleted file mode 100644 index 4ec41d4..0000000 --- a/hooks/hermes/stop.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { describe, expect, it } from "vitest"; - -import { evaluateHermesStopHook } from "./stop-evaluator.js"; - -const fixtureDir = dirname(fileURLToPath(import.meta.url)); -const stopPath = join(fixtureDir, "fixtures", "stop.json"); -const SHARED_VIDEO_URL = "https://mainframe.app/v/37507089004e8f3700deb918a48b2556"; - -describe("Hermes stop hook", () => { - it("injects a share-video nudge after unshared work", () => { - const output = evaluateHermesStopHook({ stdin: readFileSync(stopPath, "utf8") }); - - expect(output.context).toContain("share-video"); - expect(output.context).not.toContain("SECRET_NEVER_LEAK"); - }); - - it("does not fire for non-pre_llm_call events", () => { - for (const hook_event_name of ["post_llm_call", "pre_tool_call", "on_session_end", "Stop"]) { - expect( - evaluateHermesStopHook({ - stdin: preLlmCall([userMessage("please work on this"), toolCallTurn()], { - hook_event_name, - }), - }), - ).toEqual({}); - } - }); - - it("does not fire when no work happened since the last user message", () => { - expect( - evaluateHermesStopHook({ - stdin: preLlmCall([ - userMessage("please answer this"), - { role: "assistant", content: "Here is my answer." }, - ]), - }), - ).toEqual({}); - }); - - it("does not fire after a Mainframe video was shared", () => { - expect( - evaluateHermesStopHook({ - stdin: preLlmCall([ - userMessage("please work on this"), - toolCallTurn(), - { role: "tool", tool_call_id: "call_1", content: `Shared: ${SHARED_VIDEO_URL}` }, - ]), - }), - ).toEqual({}); - }); - - it("does not treat a video URL in a user message as an existing share", () => { - const output = evaluateHermesStopHook({ - stdin: preLlmCall([userMessage(`please look at ${SHARED_VIDEO_URL}`), toolCallTurn()]), - }); - - expect(output.context).toContain("share-video"); - }); - - it("does not fire when the conversation history is missing, empty, or not a list", () => { - for (const conversation_history of [undefined, [], "not-a-list"]) { - expect( - evaluateHermesStopHook({ - stdin: JSON.stringify({ - hook_event_name: "pre_llm_call", - extra: { conversation_history }, - }), - }), - ).toEqual({}); - } - }); - - it("does not fire when the payload has no extra object", () => { - expect( - evaluateHermesStopHook({ stdin: JSON.stringify({ hook_event_name: "pre_llm_call" }) }), - ).toEqual({}); - }); - - it("does not fire on corrupt JSON input", () => { - expect(evaluateHermesStopHook({ stdin: "{not-json" })).toEqual({}); - }); -}); - -function preLlmCall( - conversationHistory: Array>, - overrides: Record = {}, -): string { - return JSON.stringify({ - hook_event_name: "pre_llm_call", - tool_name: null, - tool_input: null, - session_id: "session-123", - cwd: "/workspace", - extra: { conversation_history: conversationHistory }, - ...overrides, - }); -} - -function userMessage(content: string): Record { - return { role: "user", content }; -} - -function toolCallTurn(): Record { - return { - role: "assistant", - content: "Working on it.", - tool_calls: [ - { - id: "call_1", - type: "function", - function: { name: "terminal", arguments: '{"command":"bun run build"}' }, - }, - ], - }; -} diff --git a/hooks/hermes/stop.ts b/hooks/hermes/stop.ts deleted file mode 100644 index 1f6e69d..0000000 --- a/hooks/hermes/stop.ts +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env node -import { runStopHookCli } from "../core/run-stop-hook.js"; -import { evaluateHermesStopHook } from "./stop-evaluator.js"; - -runStopHookCli(evaluateHermesStopHook); diff --git a/hooks/hermes/transcript.test.ts b/hooks/hermes/transcript.test.ts deleted file mode 100644 index 7067885..0000000 --- a/hooks/hermes/transcript.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { summarizeHermesConversation } from "./transcript.js"; - -const SHARED_VIDEO_URL = "https://mainframe.app/v/37507089004e8f3700deb918a48b2556"; - -describe("summarizeHermesConversation", () => { - it("reports tool-call work after the last user message", () => { - const summary = summarizeHermesConversation([ - userMessage("earlier request"), - userMessage("final request"), - toolCallTurn(), - { role: "tool", tool_call_id: "call_1", content: "done" }, - ]); - - expect(summary).toMatchObject({ workHappened: true, alreadyShared: false }); - }); - - it("counts a bare tool result row as work", () => { - const summary = summarizeHermesConversation([ - userMessage("please work on this"), - { role: "tool", tool_call_id: "call_1", content: "done" }, - ]); - - expect(summary).toMatchObject({ workHappened: true }); - }); - - it("does not count assistant prose without tool calls as work", () => { - const summary = summarizeHermesConversation([ - userMessage("please answer this"), - { role: "assistant", content: "Here is the answer." }, - ]); - - expect(summary).toMatchObject({ workHappened: false }); - }); - - it("resets work state on each user turn", () => { - const summary = summarizeHermesConversation([ - userMessage("first request"), - toolCallTurn(), - userMessage("second request"), - ]); - - expect(summary).toMatchObject({ workHappened: false }); - }); - - it("detects an existing Mainframe share from a tool result", () => { - const summary = summarizeHermesConversation([ - userMessage("please work on this"), - toolCallTurn(), - { role: "tool", tool_call_id: "call_1", content: `Shared: ${SHARED_VIDEO_URL}` }, - ]); - - expect(summary).toMatchObject({ workHappened: true, alreadyShared: true }); - }); - - it("does not treat a video URL in a user message as an existing share", () => { - const summary = summarizeHermesConversation([ - userMessage(`please look at ${SHARED_VIDEO_URL}`), - toolCallTurn(), - ]); - - expect(summary).toMatchObject({ workHappened: true, alreadyShared: false }); - }); - - it("treats a non-object message row as unreadable", () => { - expect(summarizeHermesConversation([userMessage("hi"), "not-a-message"])).toBe("unreadable"); - }); -}); - -function userMessage(content: string): Record { - return { role: "user", content }; -} - -function toolCallTurn(): Record { - return { - role: "assistant", - content: "Working on it.", - tool_calls: [ - { - id: "call_1", - type: "function", - function: { name: "terminal", arguments: '{"command":"bun run build"}' }, - }, - ], - }; -} diff --git a/hooks/hermes/transcript.ts b/hooks/hermes/transcript.ts deleted file mode 100644 index ef5ed73..0000000 --- a/hooks/hermes/transcript.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { isJsonRecord, type JsonRecord } from "../core/json.js"; -import { - accumulateClassifiedRows, - type ClassifiedRowKind, - type ParsedTranscript, -} from "../core/transcript.js"; - -// Hermes fires the share-video nudge from a `pre_llm_call` shell hook whose -// stdin payload carries the prior conversation inline as OpenAI-format message -// records under `extra.conversation_history` — there is no transcript file and -// no per-message timestamps (see hooks/hermes/stop-evaluator.ts). So unlike the -// file-backed hosts, Hermes classifies the in-payload messages directly and -// uses only the timeless work/share signals the shared accumulator derives; the -// non-decreasing user-time cursor stays inert because these rows carry no -// `timestamp`, leaving `lastUserTimeMs` null. -export function summarizeHermesConversation( - conversationHistory: readonly unknown[], -): ParsedTranscript | "unreadable" { - const records: JsonRecord[] = []; - for (const entry of conversationHistory) { - if (!isJsonRecord(entry)) { - return "unreadable"; - } - records.push(entry); - } - - return accumulateClassifiedRows(records, classifyHermesMessage); -} - -// A real user turn is a `role: "user"` message; it resets the accumulator's -// per-turn work and share flags. Agent work is an assistant message that issued -// tool calls, or a `role: "tool"` result row. Assistant prose and any other -// role (system, developer) are ignored. -function classifyHermesMessage(record: JsonRecord): ClassifiedRowKind { - const role = record.role; - if (role === "user") { - return "user"; - } - - if (role === "assistant" && hasToolCalls(record.tool_calls)) { - return "work"; - } - - if (role === "tool") { - return "work"; - } - - return "ignore"; -} - -function hasToolCalls(toolCalls: unknown): boolean { - return Array.isArray(toolCalls) && toolCalls.length > 0; -} diff --git a/package.json b/package.json index 8afdf10..4b944eb 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,7 @@ "bin": { "mainframe-hook-claude": "./dist/hooks/claude/stop.js", "mainframe-hook-codex": "./dist/hooks/codex/stop.js", - "mainframe-hook-cursor": "./dist/hooks/cursor/stop.js", - "mainframe-hook-hermes": "./dist/hooks/hermes/stop.js" + "mainframe-hook-cursor": "./dist/hooks/cursor/stop.js" }, "files": [ ".agents", diff --git a/tooling/generate.ts b/tooling/generate.ts index dbe0fcb..15abb86 100644 --- a/tooling/generate.ts +++ b/tooling/generate.ts @@ -40,16 +40,15 @@ const CURSOR_HOOKS = "./hooks/cursor/hooks.json"; const CODEX_HOOKS = "./hooks/codex/hooks.json"; const CLAUDE_HOOKS = "./hooks/claude/hooks.json"; -// Hermes has no plugin manifest that bundles MCP, skills, and hooks the way the -// other hosts do: MCP servers and shell hooks are declared in the user's -// `~/.hermes/config.yaml`. So the Hermes "plugin" is a generated config -// fragment users merge in. The stop nudge runs as a `pre_llm_call` shell hook — -// Hermes invokes the command, pipes the turn payload to stdin, and prepends any -// returned `{"context": ...}` to the user message (see hooks/hermes/stop.ts). +// Hermes has no plugin manifest that bundles MCP and skills the way the other +// hosts do: MCP servers are declared in the user's `~/.hermes/config.yaml`, and +// skills load from a directory. So the Hermes "plugin" is a generated MCP config +// fragment users merge in, plus the shared `share-video` skill (loaded via +// `skills.external_dirs` — see README.md). Hermes ships no stop hook: it has no +// hook event that fires when the agent stops and can re-engage it, so unlike +// Cursor/Codex/Claude there is nothing for the host-agnostic stop runtime to +// drive. const HERMES_CONFIG = ".hermes-plugin/config.yaml"; -const HERMES_HOOK_BIN = "mainframe-hook-hermes"; -const HERMES_HOOK_EVENT = "pre_llm_call"; -const HERMES_HOOK_TIMEOUT_SECONDS = 30; const HERMES_CONFIG_HEADER = [ "# Mainframe Hermes plugin wiring — generated by tooling/generate.ts; do not edit by hand.", "# Merge into ~/.hermes/config.yaml. See README.md for the share-video skill setup.", @@ -113,12 +112,7 @@ function main(): void { function hermesConfig() { const mcp = McpConfigSchema.parse(JSON.parse(readFileSync(".mcp.json", "utf8"))); - return { - mcp_servers: mcp.mcpServers, - hooks: { - [HERMES_HOOK_EVENT]: [{ command: HERMES_HOOK_BIN, timeout: HERMES_HOOK_TIMEOUT_SECONDS }], - }, - }; + return { mcp_servers: mcp.mcpServers }; } function cursorMarketplace() { @@ -229,7 +223,6 @@ function updatePackageJson(): void { "mainframe-hook-cursor": "./dist/hooks/cursor/stop.js", "mainframe-hook-codex": "./dist/hooks/codex/stop.js", "mainframe-hook-claude": "./dist/hooks/claude/stop.js", - "mainframe-hook-hermes": "./dist/hooks/hermes/stop.js", }; packageJson.files = PACKAGE_FILES; diff --git a/tooling/manifest.test.ts b/tooling/manifest.test.ts index d6f9ab6..5a59c26 100644 --- a/tooling/manifest.test.ts +++ b/tooling/manifest.test.ts @@ -173,7 +173,7 @@ describe("generated plugin manifests", () => { }); describe("generated Hermes plugin config", () => { - it(".hermes-plugin/config.yaml wires the Mainframe MCP server and the pre_llm_call shell hook", () => { + it(".hermes-plugin/config.yaml wires the Mainframe MCP server", () => { const configSchema = z .object({ mcp_servers: z @@ -181,18 +181,6 @@ describe("generated Hermes plugin config", () => { mainframe: z.object({ url: z.literal("https://mcp.mainframe.app/mcp") }).strict(), }) .strict(), - hooks: z - .object({ - pre_llm_call: z.tuple([ - z - .object({ - command: z.literal("mainframe-hook-hermes"), - timeout: z.literal(30), - }) - .strict(), - ]), - }) - .strict(), }) .strict(); @@ -201,6 +189,5 @@ describe("generated Hermes plugin config", () => { ); expect(config.mcp_servers.mainframe.url).toBe("https://mcp.mainframe.app/mcp"); - expect(config.hooks.pre_llm_call[0].command).toBe("mainframe-hook-hermes"); }); }); diff --git a/tooling/mark-executable.ts b/tooling/mark-executable.ts index 24d2149..d8f0a33 100644 --- a/tooling/mark-executable.ts +++ b/tooling/mark-executable.ts @@ -4,7 +4,6 @@ const files = [ "dist/hooks/cursor/stop.js", "dist/hooks/codex/stop.js", "dist/hooks/claude/stop.js", - "dist/hooks/hermes/stop.js", ]; for (const file of files) { diff --git a/tooling/package-surface.test.ts b/tooling/package-surface.test.ts index 7662360..2e440b7 100644 --- a/tooling/package-surface.test.ts +++ b/tooling/package-surface.test.ts @@ -2,7 +2,6 @@ import { lstatSync, readFileSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { parse as parseYaml } from "yaml"; import { z } from "zod"; import { PACKAGE_FILES, SHIPPED_FILES, readPackageFiles } from "./package-surface.js"; @@ -48,23 +47,6 @@ const NestedStopHooksSchema = z }) .passthrough(); -// Hermes shell hooks invoke a bare command resolved from PATH rather than a -// `node "$PLUGIN_ROOT/..."` path, so the runtime is referenced by its package -// bin name and resolved through `package.json`'s `bin` map. -const HermesConfigSchema = z - .object({ - hooks: z.object({ - pre_llm_call: z.tuple([ - z - .object({ - command: z.string(), - }) - .passthrough(), - ]), - }), - }) - .passthrough(); - describe("package runtime surface", () => { it("ships only the allowlisted plugin package surface", () => { const packageJson = PackageSchema.parse(readJson("package.json")); @@ -125,21 +107,6 @@ describe("package runtime surface", () => { expect(statSync(hookTarget).mode & 0o111).not.toBe(0); }); - it("ships the executable Hermes hook runtime referenced by the config bin name", () => { - const packageJson = PackageSchema.parse(readJson("package.json")); - const config = HermesConfigSchema.parse( - parseYaml(readFileSync(".hermes-plugin/config.yaml", "utf8")), - ); - - const command = config.hooks.pre_llm_call[0].command; - const hookTarget = (packageJson.bin[command] ?? "").replace(/^\.\//, ""); - - expect(command).toBe("mainframe-hook-hermes"); - expect(hookTarget).toBe("dist/hooks/hermes/stop.js"); - expect(isPackaged(hookTarget, packageJson.files)).toBe(true); - expect(statSync(hookTarget).mode & 0o111).not.toBe(0); - }); - it("does not include symlinks in package files", () => { const packageJson = PackageSchema.parse(readJson("package.json")); diff --git a/tooling/package-surface.ts b/tooling/package-surface.ts index 05416b3..a05cd7a 100644 --- a/tooling/package-surface.ts +++ b/tooling/package-surface.ts @@ -45,9 +45,6 @@ export const SHIPPED_FILES = [ "dist/hooks/cursor/stop-evaluator.js", "dist/hooks/cursor/stop.js", "dist/hooks/cursor/transcript.js", - "dist/hooks/hermes/stop-evaluator.js", - "dist/hooks/hermes/stop.js", - "dist/hooks/hermes/transcript.js", "hooks/claude/hooks.json", "hooks/codex/hooks.json", "hooks/cursor/hooks.json", From c0d419bb52c00249123e25cf0f42e2bc8705b75f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 8 Jun 2026 19:35:46 +0000 Subject: [PATCH 3/8] Lead Hermes docs with the native skill install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per a council review of the plugin's final form (verdict: keep MCP fragment + skill, no Python plugin, no hook), foreground Hermes' native one-command skill install — `hermes skills install mainframecomputer/mainframe-plugins/skills/share-video` (identifier format owner/repo/path verified in tools/skills_hub.py) — and demote the absolute-path skills.external_dirs to a local-dev fallback. The generated .hermes-plugin/config.yaml stays a copy-paste MCP convenience, not a "plugin install". --- README.md | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 45e0a47..dd910ed 100644 --- a/README.md +++ b/README.md @@ -49,12 +49,20 @@ and Codex plugins. ### Hermes -[Hermes](https://hermes-agent.nousresearch.com) is config-driven rather than manifest-based, so the -Mainframe plugin for Hermes is the shared `share-video` skill plus the Mainframe MCP server wired -through `~/.hermes/config.yaml`. The generated MCP fragment to merge in lives at -`.hermes-plugin/config.yaml`. +[Hermes](https://hermes-agent.nousresearch.com) is config-driven rather than manifest-based, so +Mainframe for Hermes is the shared `share-video` skill plus the Mainframe MCP server. Both install +through Hermes' own native flows — there is no plugin manifest to generate. -1. Add the Mainframe MCP server to `~/.hermes/config.yaml`: +1. Install the `share-video` skill straight from this repository: + + ```sh + hermes skills install mainframecomputer/mainframe-plugins/skills/share-video + ``` + + For local development you can instead point `skills.external_dirs` in `~/.hermes/config.yaml` at + this repository's `skills` directory. + +2. Add the Mainframe MCP server to `~/.hermes/config.yaml`: ```yaml mcp_servers: @@ -62,18 +70,12 @@ through `~/.hermes/config.yaml`. The generated MCP fragment to merge in lives at url: https://mcp.mainframe.app/mcp ``` -2. Load the `share-video` skill by pointing Hermes at this repository's `skills` directory: - - ```yaml - skills: - external_dirs: - - /absolute/path/to/mainframe-plugins/skills - ``` + The same `mcp_servers` block is generated to `.hermes-plugin/config.yaml` for easy copy-paste. -This gives Hermes the same `share-video` skill and Mainframe tools as the other hosts. Hermes does -not ship a stop hook: it has no hook event that fires when the agent stops and can re-engage it (the -other hosts' nudge relies on that), so on Hermes the agent reaches for the `share-video` skill on -its own, guided by the skill's own "use when" criteria. +This gives Hermes the same `share-video` skill and Mainframe tools as the other hosts. Hermes ships +no stop hook: it has no hook event that fires when the agent stops and can re-engage it (the other +hosts' nudge relies on that), so on Hermes the agent reaches for the `share-video` skill on its own, +guided by the skill's own "use when" criteria. ## Included skill From 452299eed57929273b15193daed44d8662e4f483 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 8 Jun 2026 21:43:06 +0000 Subject: [PATCH 4/8] Tighten Hermes docs per fresh code-quality review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A from-scratch thermo-nuclear review (re-verifying the skill + MCP setup against the live Hermes source) approved the change and confirmed the setup is correct, flagging two precision/DRY nits: - DRY: the README hand-restated the MCP URL in an inline block that nothing drift-checks. Point at the generated .hermes-plugin/config.yaml (derived from the single-source .mcp.json) instead of duplicating the literal. - Accuracy: "no hook event that fires when the agent stops and can re-engage it" was marginally overstated (a CLI-only post_llm_call plugin using ctx.inject_message() technically could). Reword to the precise claim — no stop event's return value re-engages the agent, and shell hooks (the hooks/core analog) only act on pre_tool_call/pre_llm_call — in README, AGENTS.md, and the generate.ts comment. Docs/comment only; generated output unchanged (drift check passes); verify green. --- AGENTS.md | 8 ++++---- README.md | 18 ++++++------------ tooling/generate.ts | 9 +++++---- 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9ad8903..9446ba9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,10 +14,10 @@ focused on the Cursor, Codex, Claude Code, and Hermes manifests, hosted MCP wiri `hooks/core/stop-hook.ts`; only the transcript parser differs per host. Hermes is config-driven and ships only the `share-video` skill plus the MCP server, generated as a `.hermes-plugin/config.yaml` fragment users merge into `~/.hermes/config.yaml`. Hermes ships no - stop hook: it has no hook event that fires when the agent stops and can re-engage it, so there is - nothing for the `hooks/core` runtime to drive (its only agent-facing hooks are `pre_tool_call` - and a turn-start `pre_llm_call`, neither of which is a stop). Do not add other host surfaces - unless the product task explicitly asks for them. + stop hook: no Hermes stop event's return value can re-engage the agent (`post_llm_call`, + `on_session_end`, and friends are observers; shell hooks — the `hooks/core` analog — only act on + `pre_tool_call` and a turn-start `pre_llm_call`), so there is nothing for the `hooks/core` runtime + to drive. Do not add other host surfaces unless the product task explicitly asks for them. - Run `bun run verify` before considering changes ready. - Generated Cursor, Codex, Claude Code, and Hermes manifest, marketplace, and config files come from `tooling/generate.ts`; edit the config there, then run `bun run generate`. diff --git a/README.md b/README.md index dd910ed..c1753c6 100644 --- a/README.md +++ b/README.md @@ -62,20 +62,14 @@ through Hermes' own native flows — there is no plugin manifest to generate. For local development you can instead point `skills.external_dirs` in `~/.hermes/config.yaml` at this repository's `skills` directory. -2. Add the Mainframe MCP server to `~/.hermes/config.yaml`: - - ```yaml - mcp_servers: - mainframe: - url: https://mcp.mainframe.app/mcp - ``` - - The same `mcp_servers` block is generated to `.hermes-plugin/config.yaml` for easy copy-paste. +2. Add the Mainframe MCP server to `~/.hermes/config.yaml` by merging in the generated + `mcp_servers` block from `.hermes-plugin/config.yaml`. That fragment is generated from this + repo's `.mcp.json`, so it always carries the current Mainframe MCP endpoint. This gives Hermes the same `share-video` skill and Mainframe tools as the other hosts. Hermes ships -no stop hook: it has no hook event that fires when the agent stops and can re-engage it (the other -hosts' nudge relies on that), so on Hermes the agent reaches for the `share-video` skill on its own, -guided by the skill's own "use when" criteria. +no stop hook: no Hermes stop event can re-engage the agent the way the other hosts' nudge does, so on +Hermes the agent reaches for the `share-video` skill on its own, guided by the skill's own "use when" +criteria. ## Included skill diff --git a/tooling/generate.ts b/tooling/generate.ts index 15abb86..cb40f3e 100644 --- a/tooling/generate.ts +++ b/tooling/generate.ts @@ -44,10 +44,11 @@ const CLAUDE_HOOKS = "./hooks/claude/hooks.json"; // hosts do: MCP servers are declared in the user's `~/.hermes/config.yaml`, and // skills load from a directory. So the Hermes "plugin" is a generated MCP config // fragment users merge in, plus the shared `share-video` skill (loaded via -// `skills.external_dirs` — see README.md). Hermes ships no stop hook: it has no -// hook event that fires when the agent stops and can re-engage it, so unlike -// Cursor/Codex/Claude there is nothing for the host-agnostic stop runtime to -// drive. +// `hermes skills install` or `skills.external_dirs` — see README.md). Hermes +// ships no stop hook: no Hermes stop event's return value re-engages the agent +// (stop-time hooks are observers; shell hooks, the hooks/core analog, only act +// on pre_tool_call/pre_llm_call), so unlike Cursor/Codex/Claude there is nothing +// for the host-agnostic stop runtime to drive. const HERMES_CONFIG = ".hermes-plugin/config.yaml"; const HERMES_CONFIG_HEADER = [ "# Mainframe Hermes plugin wiring — generated by tooling/generate.ts; do not edit by hand.", From c9fb9da13f4ff7eee41598ce0b297678ab97a4af Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 8 Jun 2026 21:46:33 +0000 Subject: [PATCH 5/8] Make the README Hermes section user-facing only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README is end-user install docs, so drop the maintainer rationale from the Hermes section: the "no stop hook / stop event / re-engage" explanation, the internal `.hermes-plugin/config.yaml` and `.mcp.json` plumbing references, the "config-driven vs manifest" framing, and the local-dev `external_dirs` aside. Show the `mcp_servers` block inline so users can paste it directly. The design rationale still lives where maintainers look — AGENTS.md and the generate.ts comment. --- README.md | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c1753c6..9e628f3 100644 --- a/README.md +++ b/README.md @@ -49,27 +49,24 @@ and Codex plugins. ### Hermes -[Hermes](https://hermes-agent.nousresearch.com) is config-driven rather than manifest-based, so -Mainframe for Hermes is the shared `share-video` skill plus the Mainframe MCP server. Both install -through Hermes' own native flows — there is no plugin manifest to generate. +Hermes installs Mainframe through its skill and config flows: install the `share-video` skill, then +add the Mainframe MCP server. -1. Install the `share-video` skill straight from this repository: +1. Install the `share-video` skill: ```sh hermes skills install mainframecomputer/mainframe-plugins/skills/share-video ``` - For local development you can instead point `skills.external_dirs` in `~/.hermes/config.yaml` at - this repository's `skills` directory. +2. Add the Mainframe MCP server to `~/.hermes/config.yaml`: -2. Add the Mainframe MCP server to `~/.hermes/config.yaml` by merging in the generated - `mcp_servers` block from `.hermes-plugin/config.yaml`. That fragment is generated from this - repo's `.mcp.json`, so it always carries the current Mainframe MCP endpoint. + ```yaml + mcp_servers: + mainframe: + url: https://mcp.mainframe.app/mcp + ``` -This gives Hermes the same `share-video` skill and Mainframe tools as the other hosts. Hermes ships -no stop hook: no Hermes stop event can re-engage the agent the way the other hosts' nudge does, so on -Hermes the agent reaches for the `share-video` skill on its own, guided by the skill's own "use when" -criteria. +This gives Hermes the same `share-video` skill and Mainframe tools as the other hosts. ## Included skill From 20c0ad0da03548d826a4c1c840c6518cc366c845 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 8 Jun 2026 22:09:41 +0000 Subject: [PATCH 6/8] Fix Hermes MCP wiring: opt into OAuth (auth: oauth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh thermo-nuclear review caught a blocker: https://mcp.mainframe.app/mcp is OAuth-protected (verified live: HTTP 401 + `WWW-Authenticate: Bearer realm="OAuth"`), and Hermes only runs the OAuth flow when an MCP entry opts in with `auth: oauth` — it does not auto-negotiate from a bare URL the way the Cursor/Claude marketplace hosts do (tools/mcp_tool.py builds the OAuth provider only when auth == "oauth"). As shipped, the Hermes server would 401 and register zero Mainframe tools, breaking the whole integration. The auth server advertises dynamic client registration + PKCE S256, so a bare `auth: oauth` self-registers and runs the browser flow — no pre-registered client needed. - generate.ts: hermesConfig() now adds `auth: oauth` to each server when re-expressing ./.mcp.json (URL still single-sourced); McpServerSchema requires a `url` so an unsupported server shape fails closed. - README: the user-facing config block gains `auth: oauth` plus a note that Hermes opens the browser to authorize on first use. - manifest.test.ts: replace the near-tautological assertion with a cross-check that every .mcp.json server is re-expressed with its URL and `auth: oauth`. - AGENTS.md: document why the entry opts into OAuth. --- .hermes-plugin/config.yaml | 1 + AGENTS.md | 5 ++++- README.md | 3 +++ tooling/generate.ts | 13 +++++++++++-- tooling/manifest.test.ts | 32 ++++++++++++++++++-------------- 5 files changed, 37 insertions(+), 17 deletions(-) diff --git a/.hermes-plugin/config.yaml b/.hermes-plugin/config.yaml index d4b8a32..ba8b4c3 100644 --- a/.hermes-plugin/config.yaml +++ b/.hermes-plugin/config.yaml @@ -3,3 +3,4 @@ mcp_servers: mainframe: url: https://mcp.mainframe.app/mcp + auth: oauth diff --git a/AGENTS.md b/AGENTS.md index 9446ba9..253a4a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,10 @@ focused on the Cursor, Codex, Claude Code, and Hermes manifests, hosted MCP wiri stop-hook runtime. Codex and Claude Code share the same Stop hook contract, so they both use `hooks/core/stop-hook.ts`; only the transcript parser differs per host. Hermes is config-driven and ships only the `share-video` skill plus the MCP server, generated as a - `.hermes-plugin/config.yaml` fragment users merge into `~/.hermes/config.yaml`. Hermes ships no + `.hermes-plugin/config.yaml` fragment users merge into `~/.hermes/config.yaml`. The generated + `mcp_servers` entry sets `auth: oauth` because Hermes only runs the OAuth flow for a hosted MCP + server when the entry opts in (it does not auto-negotiate from a bare URL like the marketplace + hosts), so `tooling/generate.ts` adds it when re-expressing `./.mcp.json`. Hermes ships no stop hook: no Hermes stop event's return value can re-engage the agent (`post_llm_call`, `on_session_end`, and friends are observers; shell hooks — the `hooks/core` analog — only act on `pre_tool_call` and a turn-start `pre_llm_call`), so there is nothing for the `hooks/core` runtime diff --git a/README.md b/README.md index 9e628f3..10ed2bf 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,11 @@ add the Mainframe MCP server. mcp_servers: mainframe: url: https://mcp.mainframe.app/mcp + auth: oauth ``` + On first use, Hermes opens your browser to authorize Mainframe. + This gives Hermes the same `share-video` skill and Mainframe tools as the other hosts. ## Included skill diff --git a/tooling/generate.ts b/tooling/generate.ts index cb40f3e..b072640 100644 --- a/tooling/generate.ts +++ b/tooling/generate.ts @@ -11,8 +11,9 @@ const JsonRecordSchema = z.record(z.string(), z.unknown()); // Codex, and Claude Code reference the file directly; Hermes is config-driven // and needs the same servers re-expressed as YAML, so we read them from here // instead of restating the URL. +const McpServerSchema = z.object({ url: z.string().url() }).passthrough(); const McpConfigSchema = z.object({ - mcpServers: z.record(z.string(), JsonRecordSchema), + mcpServers: z.record(z.string(), McpServerSchema), }); const MetadataSchema = z.object({ @@ -113,7 +114,15 @@ function main(): void { function hermesConfig() { const mcp = McpConfigSchema.parse(JSON.parse(readFileSync(".mcp.json", "utf8"))); - return { mcp_servers: mcp.mcpServers }; + // Hermes only runs the OAuth flow for a hosted MCP server when its entry opts + // in with `auth: oauth`; it does not auto-negotiate from a bare URL the way the + // marketplace hosts do. The Mainframe MCP is OAuth-protected, so add the opt-in + // to each server. McpServerSchema's `url` requirement fails closed if .mcp.json + // ever adds a server shape Hermes would need to handle differently. + const servers = Object.fromEntries( + Object.entries(mcp.mcpServers).map(([name, server]) => [name, { ...server, auth: "oauth" }]), + ); + return { mcp_servers: servers }; } function cursorMarketplace() { diff --git a/tooling/manifest.test.ts b/tooling/manifest.test.ts index 5a59c26..a43e965 100644 --- a/tooling/manifest.test.ts +++ b/tooling/manifest.test.ts @@ -173,21 +173,25 @@ describe("generated plugin manifests", () => { }); describe("generated Hermes plugin config", () => { - it(".hermes-plugin/config.yaml wires the Mainframe MCP server", () => { - const configSchema = z + it("re-expresses every .mcp.json server for Hermes with the OAuth opt-in", () => { + const { mcpServers } = z + .object({ mcpServers: z.record(z.string(), z.object({ url: z.string() }).passthrough()) }) + .parse(JSON.parse(readFileSync(".mcp.json", "utf8"))); + + const { mcp_servers } = z .object({ - mcp_servers: z - .object({ - mainframe: z.object({ url: z.literal("https://mcp.mainframe.app/mcp") }).strict(), - }) - .strict(), + mcp_servers: z.record( + z.string(), + z.object({ url: z.string(), auth: z.literal("oauth") }).passthrough(), + ), }) - .strict(); - - const config = configSchema.parse( - parseYaml(readFileSync(".hermes-plugin/config.yaml", "utf8")), - ); - - expect(config.mcp_servers.mainframe.url).toBe("https://mcp.mainframe.app/mcp"); + .strict() + .parse(parseYaml(readFileSync(".hermes-plugin/config.yaml", "utf8"))); + + expect(Object.keys(mcp_servers)).toEqual(Object.keys(mcpServers)); + for (const [name, server] of Object.entries(mcpServers)) { + expect(mcp_servers[name].url).toBe(server.url); + expect(mcp_servers[name].auth).toBe("oauth"); + } }); }); From f93be28cd9d0a4e6241cbad464ecc047f2a289e0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 8 Jun 2026 22:25:29 +0000 Subject: [PATCH 7/8] Drop the orphan Hermes generated config; keep README + skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh review confirmed the generated `.hermes-plugin/config.yaml` has no machine consumer: Hermes reads `~/.hermes/config.yaml`, never this repo file (unlike the Cursor/Codex/Claude manifests, which the host reads on install). It only duplicated the user-facing README block, and its test/drift-check guarded that orphan copy rather than what users actually use. The OAuth fix also broke its "verbatim .mcp.json passthrough" premise (Hermes needs a host-specific `auth: oauth`). So delete the layer rather than polish it: remove `.hermes-plugin/config.yaml`, the `yaml` dependency, and the `hermesConfig`/`writeYaml`/`McpServerSchema` generator scaffolding, plus the package-surface, drift-check, manifest-test, and package.json wiring. Hermes support now lives entirely in `README.md` (install the shared `share-video` skill + add the `mcp_servers` block with `auth: oauth`) and the shared skill — the only mechanisms Hermes actually consumes. AGENTS.md updated to match. Net change vs main is now Hermes setup docs only. --- .hermes-plugin/config.yaml | 6 ---- AGENTS.md | 35 +++++++++++------------ bun.lock | 5 +--- package.json | 8 ++---- tooling/generate.ts | 48 +------------------------------- tooling/generated-drift-check.ts | 1 - tooling/manifest.test.ts | 25 ----------------- tooling/package-surface.ts | 2 -- 8 files changed, 22 insertions(+), 108 deletions(-) delete mode 100644 .hermes-plugin/config.yaml diff --git a/.hermes-plugin/config.yaml b/.hermes-plugin/config.yaml deleted file mode 100644 index ba8b4c3..0000000 --- a/.hermes-plugin/config.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Mainframe Hermes plugin wiring — generated by tooling/generate.ts; do not edit by hand. -# Merge into ~/.hermes/config.yaml. See README.md for the share-video skill setup. -mcp_servers: - mainframe: - url: https://mcp.mainframe.app/mcp - auth: oauth diff --git a/AGENTS.md b/AGENTS.md index 253a4a4..9fcb0ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,8 @@ # Agent notes -This repository packages the Mainframe Cursor, Codex, Claude Code, and Hermes plugins. Keep it -focused on the Cursor, Codex, Claude Code, and Hermes manifests, hosted MCP wiring, the -`share-video` skill, and the stop hooks. +This repository packages the Mainframe Cursor, Codex, and Claude Code plugins and documents Hermes +setup. Keep it focused on the Cursor, Codex, and Claude Code manifests, hosted MCP wiring, the +`share-video` skill, the stop hooks, and the Hermes setup docs in `README.md`. ## Repository boundaries @@ -11,23 +11,22 @@ focused on the Cursor, Codex, Claude Code, and Hermes manifests, hosted MCP wiri - Cursor, Codex, Claude Code, and Hermes are the supported hosts. Cursor, Codex, and Claude Code share the repo root, the `share-video` skill, the `./.mcp.json` wiring, and the `hooks/core` stop-hook runtime. Codex and Claude Code share the same Stop hook contract, so they both use - `hooks/core/stop-hook.ts`; only the transcript parser differs per host. Hermes is config-driven - and ships only the `share-video` skill plus the MCP server, generated as a - `.hermes-plugin/config.yaml` fragment users merge into `~/.hermes/config.yaml`. The generated - `mcp_servers` entry sets `auth: oauth` because Hermes only runs the OAuth flow for a hosted MCP - server when the entry opts in (it does not auto-negotiate from a bare URL like the marketplace - hosts), so `tooling/generate.ts` adds it when re-expressing `./.mcp.json`. Hermes ships no - stop hook: no Hermes stop event's return value can re-engage the agent (`post_llm_call`, - `on_session_end`, and friends are observers; shell hooks — the `hooks/core` analog — only act on - `pre_tool_call` and a turn-start `pre_llm_call`), so there is nothing for the `hooks/core` runtime - to drive. Do not add other host surfaces unless the product task explicitly asks for them. + `hooks/core/stop-hook.ts`; only the transcript parser differs per host. Hermes is config-driven and + ships no generated manifest; its support lives in `README.md` — install the shared `share-video` + skill (`hermes skills install …`) and add the Mainframe MCP server to `~/.hermes/config.yaml` with + `auth: oauth` (Hermes only runs the OAuth flow for a hosted MCP server when the entry opts in; it + does not auto-negotiate from a bare URL like the marketplace hosts). Hermes ships no stop hook: no + Hermes stop event's return value can re-engage the agent (`post_llm_call`, `on_session_end`, and + friends are observers; shell hooks — the `hooks/core` analog — only act on `pre_tool_call` and a + turn-start `pre_llm_call`), so there is nothing for the `hooks/core` runtime to drive. Do not add + other host surfaces unless the product task explicitly asks for them. - Run `bun run verify` before considering changes ready. -- Generated Cursor, Codex, Claude Code, and Hermes manifest, marketplace, and config files come - from `tooling/generate.ts`; edit the config there, then run `bun run generate`. +- Generated Cursor, Codex, and Claude Code manifest and marketplace files come from + `tooling/generate.ts`; edit the config there, then run `bun run generate`. - The canonical skill is `skills/share-video/SKILL.md`. -- Keep generated metadata in `.cursor-plugin/`, `.codex-plugin/`, `.agents/plugins/`, - `.claude-plugin/`, and `.hermes-plugin/`. Do not add package-local docs or extra top-level - markdown unless the user asks; improve `README.md` or this file instead. +- Keep generated metadata in `.cursor-plugin/`, `.codex-plugin/`, `.agents/plugins/`, and + `.claude-plugin/`. Do not add package-local docs or extra top-level markdown unless the user + asks; improve `README.md` or this file instead. ## Start here diff --git a/bun.lock b/bun.lock index 30d7432..8e1c213 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,6 @@ "oxlint-tsgolint": "^0.22.1", "typescript": "^5.9.3", "vitest": "^4.1.5", - "yaml": "^2.9.0", "zod": "^4.1.13", }, }, @@ -320,12 +319,10 @@ "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], - "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "yaml": ["yaml@2.8.4", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog=="], "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "lint-staged/yaml": ["yaml@2.8.4", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog=="], - "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], "log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], diff --git a/package.json b/package.json index 4b944eb..9e84996 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@mainframe/plugins", "version": "0.1.0", "private": true, - "description": "Mainframe Cursor, Codex, Claude Code, and Hermes plugin manifests, skill, MCP wiring, and stop hooks.", + "description": "Mainframe Cursor, Codex, and Claude Code plugin manifests, skill, MCP wiring, and stop hooks.", "keywords": [ "agent-skills", "hooks", @@ -26,7 +26,6 @@ ".claude-plugin", ".codex-plugin", ".cursor-plugin", - ".hermes-plugin", ".mcp.json", "LICENSE", "README.md", @@ -42,9 +41,9 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "build": "rm -rf dist && tsc -p tsconfig.build.json && bun tooling/mark-executable.ts", "build:check": "bun run build && git diff --exit-code -- dist", - "generate": "bun tooling/generate.ts && oxfmt --write package.json .cursor-plugin .codex-plugin .agents .claude-plugin .hermes-plugin", + "generate": "bun tooling/generate.ts && oxfmt --write package.json .cursor-plugin .codex-plugin .agents .claude-plugin", "check:generated": "bun tooling/generated-drift-check.ts", - "archive:release": "bun run generate && bun run build && git diff --exit-code -- package.json .cursor-plugin .codex-plugin .agents .claude-plugin .hermes-plugin dist && bun tooling/release-archive.ts", + "archive:release": "bun run generate && bun run build && git diff --exit-code -- package.json .cursor-plugin .codex-plugin .agents .claude-plugin dist && bun tooling/release-archive.ts", "pack:release": "bun run verify && bun run archive:release", "lint": "oxlint . --deny-warnings --report-unused-disable-directives", "lint:fix": "oxlint . --fix", @@ -64,7 +63,6 @@ "oxlint-tsgolint": "^0.22.1", "typescript": "^5.9.3", "vitest": "^4.1.5", - "yaml": "^2.9.0", "zod": "^4.1.13" }, "lint-staged": { diff --git a/tooling/generate.ts b/tooling/generate.ts index b072640..5af20ba 100644 --- a/tooling/generate.ts +++ b/tooling/generate.ts @@ -1,21 +1,11 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; -import { stringify as stringifyYaml } from "yaml"; import { z } from "zod"; import { PACKAGE_FILES } from "./package-surface.js"; const JsonRecordSchema = z.record(z.string(), z.unknown()); -// `.mcp.json` is the single source of truth for the hosted MCP wiring. Cursor, -// Codex, and Claude Code reference the file directly; Hermes is config-driven -// and needs the same servers re-expressed as YAML, so we read them from here -// instead of restating the URL. -const McpServerSchema = z.object({ url: z.string().url() }).passthrough(); -const McpConfigSchema = z.object({ - mcpServers: z.record(z.string(), McpServerSchema), -}); - const MetadataSchema = z.object({ name: z.string().min(1), displayName: z.string().min(1), @@ -41,22 +31,6 @@ const CURSOR_HOOKS = "./hooks/cursor/hooks.json"; const CODEX_HOOKS = "./hooks/codex/hooks.json"; const CLAUDE_HOOKS = "./hooks/claude/hooks.json"; -// Hermes has no plugin manifest that bundles MCP and skills the way the other -// hosts do: MCP servers are declared in the user's `~/.hermes/config.yaml`, and -// skills load from a directory. So the Hermes "plugin" is a generated MCP config -// fragment users merge in, plus the shared `share-video` skill (loaded via -// `hermes skills install` or `skills.external_dirs` — see README.md). Hermes -// ships no stop hook: no Hermes stop event's return value re-engages the agent -// (stop-time hooks are observers; shell hooks, the hooks/core analog, only act -// on pre_tool_call/pre_llm_call), so unlike Cursor/Codex/Claude there is nothing -// for the host-agnostic stop runtime to drive. -const HERMES_CONFIG = ".hermes-plugin/config.yaml"; -const HERMES_CONFIG_HEADER = [ - "# Mainframe Hermes plugin wiring — generated by tooling/generate.ts; do not edit by hand.", - "# Merge into ~/.hermes/config.yaml. See README.md for the share-video skill setup.", - "", -].join("\n"); - const metadata = MetadataSchema.parse({ name: "mainframe", displayName: "Mainframe", @@ -107,24 +81,9 @@ function main(): void { writeJson(".claude-plugin/plugin.json", claudeManifest()); writeJson(".claude-plugin/marketplace.json", claudeMarketplace()); - writeYaml(HERMES_CONFIG, HERMES_CONFIG_HEADER, hermesConfig()); - updatePackageJson(); } -function hermesConfig() { - const mcp = McpConfigSchema.parse(JSON.parse(readFileSync(".mcp.json", "utf8"))); - // Hermes only runs the OAuth flow for a hosted MCP server when its entry opts - // in with `auth: oauth`; it does not auto-negotiate from a bare URL the way the - // marketplace hosts do. The Mainframe MCP is OAuth-protected, so add the opt-in - // to each server. McpServerSchema's `url` requirement fails closed if .mcp.json - // ever adds a server shape Hermes would need to handle differently. - const servers = Object.fromEntries( - Object.entries(mcp.mcpServers).map(([name, server]) => [name, { ...server, auth: "oauth" }]), - ); - return { mcp_servers: servers }; -} - function cursorMarketplace() { return { name: metadata.name, @@ -220,7 +179,7 @@ function updatePackageJson(): void { packageJson.name = metadata.packageName; packageJson.version = metadata.version; packageJson.description = - "Mainframe Cursor, Codex, Claude Code, and Hermes plugin manifests, skill, MCP wiring, and stop hooks."; + "Mainframe Cursor, Codex, and Claude Code plugin manifests, skill, MCP wiring, and stop hooks."; packageJson.private = true; packageJson.license = metadata.license; packageJson.homepage = metadata.homepage; @@ -244,9 +203,4 @@ function writeJson(path: string, value: unknown): void { writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); } -function writeYaml(path: string, header: string, value: unknown): void { - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, `${header}${stringifyYaml(value)}`); -} - main(); diff --git a/tooling/generated-drift-check.ts b/tooling/generated-drift-check.ts index 259b1f7..b97d339 100644 --- a/tooling/generated-drift-check.ts +++ b/tooling/generated-drift-check.ts @@ -8,7 +8,6 @@ const generatedPaths = [ ".agents/plugins/marketplace.json", ".claude-plugin/plugin.json", ".claude-plugin/marketplace.json", - ".hermes-plugin/config.yaml", "package.json", ]; diff --git a/tooling/manifest.test.ts b/tooling/manifest.test.ts index a43e965..b5eccdf 100644 --- a/tooling/manifest.test.ts +++ b/tooling/manifest.test.ts @@ -1,7 +1,6 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { parse as parseYaml } from "yaml"; import { z } from "zod"; const AuthorSchema = z @@ -171,27 +170,3 @@ describe("generated plugin manifests", () => { marketplaceSchema.parse(JSON.parse(readFileSync(".claude-plugin/marketplace.json", "utf8"))); }); }); - -describe("generated Hermes plugin config", () => { - it("re-expresses every .mcp.json server for Hermes with the OAuth opt-in", () => { - const { mcpServers } = z - .object({ mcpServers: z.record(z.string(), z.object({ url: z.string() }).passthrough()) }) - .parse(JSON.parse(readFileSync(".mcp.json", "utf8"))); - - const { mcp_servers } = z - .object({ - mcp_servers: z.record( - z.string(), - z.object({ url: z.string(), auth: z.literal("oauth") }).passthrough(), - ), - }) - .strict() - .parse(parseYaml(readFileSync(".hermes-plugin/config.yaml", "utf8"))); - - expect(Object.keys(mcp_servers)).toEqual(Object.keys(mcpServers)); - for (const [name, server] of Object.entries(mcpServers)) { - expect(mcp_servers[name].url).toBe(server.url); - expect(mcp_servers[name].auth).toBe("oauth"); - } - }); -}); diff --git a/tooling/package-surface.ts b/tooling/package-surface.ts index a05cd7a..e18c3f2 100644 --- a/tooling/package-surface.ts +++ b/tooling/package-surface.ts @@ -6,7 +6,6 @@ export const PACKAGE_FILES = [ ".claude-plugin", ".codex-plugin", ".cursor-plugin", - ".hermes-plugin", ".mcp.json", "LICENSE", "README.md", @@ -25,7 +24,6 @@ export const SHIPPED_FILES = [ ".codex-plugin/plugin.json", ".cursor-plugin/marketplace.json", ".cursor-plugin/plugin.json", - ".hermes-plugin/config.yaml", ".mcp.json", "LICENSE", "README.md", From 558b6437a463e6d71202e99ed263709de965a0d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 8 Jun 2026 22:40:29 +0000 Subject: [PATCH 8/8] Clarify Hermes browser-auth note for headless hosts A fresh thermo-nuclear review approved the docs-only Hermes setup (skill + MCP verified correct against Hermes source + live endpoint). Its one user-facing nit: the README said Hermes "opens your browser" unconditionally, but on headless/SSH hosts it prints an authorize URL instead. Reword to cover both. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 10ed2bf..f398a68 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ add the Mainframe MCP server. auth: oauth ``` - On first use, Hermes opens your browser to authorize Mainframe. + On first use, Hermes opens your browser (or prints an authorize URL) to authorize Mainframe. This gives Hermes the same `share-video` skill and Mainframe tools as the other hosts.