@halofy/agent-connect 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +211 -8
- package/bin/halofy-agent.mjs +83 -8
- package/node_modules/yaml/LICENSE +13 -0
- package/node_modules/yaml/README.md +172 -0
- package/node_modules/yaml/bin.mjs +11 -0
- package/node_modules/yaml/browser/dist/compose/compose-collection.js +88 -0
- package/node_modules/yaml/browser/dist/compose/compose-doc.js +43 -0
- package/node_modules/yaml/browser/dist/compose/compose-node.js +109 -0
- package/node_modules/yaml/browser/dist/compose/compose-scalar.js +86 -0
- package/node_modules/yaml/browser/dist/compose/composer.js +219 -0
- package/node_modules/yaml/browser/dist/compose/resolve-block-map.js +115 -0
- package/node_modules/yaml/browser/dist/compose/resolve-block-scalar.js +198 -0
- package/node_modules/yaml/browser/dist/compose/resolve-block-seq.js +49 -0
- package/node_modules/yaml/browser/dist/compose/resolve-end.js +37 -0
- package/node_modules/yaml/browser/dist/compose/resolve-flow-collection.js +207 -0
- package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +226 -0
- package/node_modules/yaml/browser/dist/compose/resolve-props.js +146 -0
- package/node_modules/yaml/browser/dist/compose/util-contains-newline.js +34 -0
- package/node_modules/yaml/browser/dist/compose/util-empty-scalar-position.js +26 -0
- package/node_modules/yaml/browser/dist/compose/util-flow-indent-check.js +15 -0
- package/node_modules/yaml/browser/dist/compose/util-map-includes.js +13 -0
- package/node_modules/yaml/browser/dist/doc/Document.js +335 -0
- package/node_modules/yaml/browser/dist/doc/anchors.js +71 -0
- package/node_modules/yaml/browser/dist/doc/applyReviver.js +55 -0
- package/node_modules/yaml/browser/dist/doc/createNode.js +88 -0
- package/node_modules/yaml/browser/dist/doc/directives.js +176 -0
- package/node_modules/yaml/browser/dist/errors.js +57 -0
- package/node_modules/yaml/browser/dist/index.js +17 -0
- package/node_modules/yaml/browser/dist/log.js +11 -0
- package/node_modules/yaml/browser/dist/nodes/Alias.js +118 -0
- package/node_modules/yaml/browser/dist/nodes/Collection.js +147 -0
- package/node_modules/yaml/browser/dist/nodes/Node.js +38 -0
- package/node_modules/yaml/browser/dist/nodes/Pair.js +36 -0
- package/node_modules/yaml/browser/dist/nodes/Scalar.js +24 -0
- package/node_modules/yaml/browser/dist/nodes/YAMLMap.js +144 -0
- package/node_modules/yaml/browser/dist/nodes/YAMLSeq.js +113 -0
- package/node_modules/yaml/browser/dist/nodes/addPairToJSMap.js +63 -0
- package/node_modules/yaml/browser/dist/nodes/identity.js +36 -0
- package/node_modules/yaml/browser/dist/nodes/toJS.js +37 -0
- package/node_modules/yaml/browser/dist/parse/cst-scalar.js +214 -0
- package/node_modules/yaml/browser/dist/parse/cst-stringify.js +61 -0
- package/node_modules/yaml/browser/dist/parse/cst-visit.js +97 -0
- package/node_modules/yaml/browser/dist/parse/cst.js +98 -0
- package/node_modules/yaml/browser/dist/parse/lexer.js +721 -0
- package/node_modules/yaml/browser/dist/parse/line-counter.js +39 -0
- package/node_modules/yaml/browser/dist/parse/parser.js +975 -0
- package/node_modules/yaml/browser/dist/public-api.js +102 -0
- package/node_modules/yaml/browser/dist/schema/Schema.js +37 -0
- package/node_modules/yaml/browser/dist/schema/common/map.js +17 -0
- package/node_modules/yaml/browser/dist/schema/common/null.js +15 -0
- package/node_modules/yaml/browser/dist/schema/common/seq.js +17 -0
- package/node_modules/yaml/browser/dist/schema/common/string.js +14 -0
- package/node_modules/yaml/browser/dist/schema/core/bool.js +19 -0
- package/node_modules/yaml/browser/dist/schema/core/float.js +43 -0
- package/node_modules/yaml/browser/dist/schema/core/int.js +38 -0
- package/node_modules/yaml/browser/dist/schema/core/schema.js +23 -0
- package/node_modules/yaml/browser/dist/schema/json/schema.js +62 -0
- package/node_modules/yaml/browser/dist/schema/tags.js +96 -0
- package/node_modules/yaml/browser/dist/schema/yaml-1.1/binary.js +58 -0
- package/node_modules/yaml/browser/dist/schema/yaml-1.1/bool.js +26 -0
- package/node_modules/yaml/browser/dist/schema/yaml-1.1/float.js +46 -0
- package/node_modules/yaml/browser/dist/schema/yaml-1.1/int.js +71 -0
- package/node_modules/yaml/browser/dist/schema/yaml-1.1/merge.js +67 -0
- package/node_modules/yaml/browser/dist/schema/yaml-1.1/omap.js +74 -0
- package/node_modules/yaml/browser/dist/schema/yaml-1.1/pairs.js +78 -0
- package/node_modules/yaml/browser/dist/schema/yaml-1.1/schema.js +39 -0
- package/node_modules/yaml/browser/dist/schema/yaml-1.1/set.js +93 -0
- package/node_modules/yaml/browser/dist/schema/yaml-1.1/timestamp.js +101 -0
- package/node_modules/yaml/browser/dist/stringify/foldFlowLines.js +146 -0
- package/node_modules/yaml/browser/dist/stringify/stringify.js +129 -0
- package/node_modules/yaml/browser/dist/stringify/stringifyCollection.js +153 -0
- package/node_modules/yaml/browser/dist/stringify/stringifyComment.js +20 -0
- package/node_modules/yaml/browser/dist/stringify/stringifyDocument.js +85 -0
- package/node_modules/yaml/browser/dist/stringify/stringifyNumber.js +25 -0
- package/node_modules/yaml/browser/dist/stringify/stringifyPair.js +150 -0
- package/node_modules/yaml/browser/dist/stringify/stringifyString.js +336 -0
- package/node_modules/yaml/browser/dist/util.js +11 -0
- package/node_modules/yaml/browser/dist/visit.js +233 -0
- package/node_modules/yaml/browser/index.js +5 -0
- package/node_modules/yaml/browser/package.json +3 -0
- package/node_modules/yaml/dist/cli.d.ts +8 -0
- package/node_modules/yaml/dist/cli.mjs +201 -0
- package/node_modules/yaml/dist/compose/compose-collection.d.ts +11 -0
- package/node_modules/yaml/dist/compose/compose-collection.js +90 -0
- package/node_modules/yaml/dist/compose/compose-doc.d.ts +7 -0
- package/node_modules/yaml/dist/compose/compose-doc.js +45 -0
- package/node_modules/yaml/dist/compose/compose-node.d.ts +29 -0
- package/node_modules/yaml/dist/compose/compose-node.js +112 -0
- package/node_modules/yaml/dist/compose/compose-scalar.d.ts +5 -0
- package/node_modules/yaml/dist/compose/compose-scalar.js +88 -0
- package/node_modules/yaml/dist/compose/composer.d.ts +63 -0
- package/node_modules/yaml/dist/compose/composer.js +224 -0
- package/node_modules/yaml/dist/compose/resolve-block-map.d.ts +6 -0
- package/node_modules/yaml/dist/compose/resolve-block-map.js +117 -0
- package/node_modules/yaml/dist/compose/resolve-block-scalar.d.ts +11 -0
- package/node_modules/yaml/dist/compose/resolve-block-scalar.js +200 -0
- package/node_modules/yaml/dist/compose/resolve-block-seq.d.ts +6 -0
- package/node_modules/yaml/dist/compose/resolve-block-seq.js +51 -0
- package/node_modules/yaml/dist/compose/resolve-end.d.ts +6 -0
- package/node_modules/yaml/dist/compose/resolve-end.js +39 -0
- package/node_modules/yaml/dist/compose/resolve-flow-collection.d.ts +7 -0
- package/node_modules/yaml/dist/compose/resolve-flow-collection.js +209 -0
- package/node_modules/yaml/dist/compose/resolve-flow-scalar.d.ts +10 -0
- package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +228 -0
- package/node_modules/yaml/dist/compose/resolve-props.d.ts +23 -0
- package/node_modules/yaml/dist/compose/resolve-props.js +148 -0
- package/node_modules/yaml/dist/compose/util-contains-newline.d.ts +2 -0
- package/node_modules/yaml/dist/compose/util-contains-newline.js +36 -0
- package/node_modules/yaml/dist/compose/util-empty-scalar-position.d.ts +2 -0
- package/node_modules/yaml/dist/compose/util-empty-scalar-position.js +28 -0
- package/node_modules/yaml/dist/compose/util-flow-indent-check.d.ts +3 -0
- package/node_modules/yaml/dist/compose/util-flow-indent-check.js +17 -0
- package/node_modules/yaml/dist/compose/util-map-includes.d.ts +4 -0
- package/node_modules/yaml/dist/compose/util-map-includes.js +15 -0
- package/node_modules/yaml/dist/doc/Document.d.ts +141 -0
- package/node_modules/yaml/dist/doc/Document.js +337 -0
- package/node_modules/yaml/dist/doc/anchors.d.ts +24 -0
- package/node_modules/yaml/dist/doc/anchors.js +76 -0
- package/node_modules/yaml/dist/doc/applyReviver.d.ts +9 -0
- package/node_modules/yaml/dist/doc/applyReviver.js +57 -0
- package/node_modules/yaml/dist/doc/createNode.d.ts +17 -0
- package/node_modules/yaml/dist/doc/createNode.js +90 -0
- package/node_modules/yaml/dist/doc/directives.d.ts +49 -0
- package/node_modules/yaml/dist/doc/directives.js +178 -0
- package/node_modules/yaml/dist/errors.d.ts +21 -0
- package/node_modules/yaml/dist/errors.js +62 -0
- package/node_modules/yaml/dist/index.d.ts +25 -0
- package/node_modules/yaml/dist/index.js +50 -0
- package/node_modules/yaml/dist/log.d.ts +3 -0
- package/node_modules/yaml/dist/log.js +19 -0
- package/node_modules/yaml/dist/nodes/Alias.d.ts +29 -0
- package/node_modules/yaml/dist/nodes/Alias.js +120 -0
- package/node_modules/yaml/dist/nodes/Collection.d.ts +73 -0
- package/node_modules/yaml/dist/nodes/Collection.js +151 -0
- package/node_modules/yaml/dist/nodes/Node.d.ts +53 -0
- package/node_modules/yaml/dist/nodes/Node.js +40 -0
- package/node_modules/yaml/dist/nodes/Pair.d.ts +22 -0
- package/node_modules/yaml/dist/nodes/Pair.js +39 -0
- package/node_modules/yaml/dist/nodes/Scalar.d.ts +47 -0
- package/node_modules/yaml/dist/nodes/Scalar.js +27 -0
- package/node_modules/yaml/dist/nodes/YAMLMap.d.ts +53 -0
- package/node_modules/yaml/dist/nodes/YAMLMap.js +147 -0
- package/node_modules/yaml/dist/nodes/YAMLSeq.d.ts +60 -0
- package/node_modules/yaml/dist/nodes/YAMLSeq.js +115 -0
- package/node_modules/yaml/dist/nodes/addPairToJSMap.d.ts +4 -0
- package/node_modules/yaml/dist/nodes/addPairToJSMap.js +65 -0
- package/node_modules/yaml/dist/nodes/identity.d.ts +23 -0
- package/node_modules/yaml/dist/nodes/identity.js +53 -0
- package/node_modules/yaml/dist/nodes/toJS.d.ts +29 -0
- package/node_modules/yaml/dist/nodes/toJS.js +39 -0
- package/node_modules/yaml/dist/options.d.ts +350 -0
- package/node_modules/yaml/dist/parse/cst-scalar.d.ts +64 -0
- package/node_modules/yaml/dist/parse/cst-scalar.js +218 -0
- package/node_modules/yaml/dist/parse/cst-stringify.d.ts +8 -0
- package/node_modules/yaml/dist/parse/cst-stringify.js +63 -0
- package/node_modules/yaml/dist/parse/cst-visit.d.ts +39 -0
- package/node_modules/yaml/dist/parse/cst-visit.js +99 -0
- package/node_modules/yaml/dist/parse/cst.d.ts +109 -0
- package/node_modules/yaml/dist/parse/cst.js +112 -0
- package/node_modules/yaml/dist/parse/lexer.d.ts +87 -0
- package/node_modules/yaml/dist/parse/lexer.js +723 -0
- package/node_modules/yaml/dist/parse/line-counter.d.ts +22 -0
- package/node_modules/yaml/dist/parse/line-counter.js +41 -0
- package/node_modules/yaml/dist/parse/parser.d.ts +84 -0
- package/node_modules/yaml/dist/parse/parser.js +980 -0
- package/node_modules/yaml/dist/public-api.d.ts +44 -0
- package/node_modules/yaml/dist/public-api.js +107 -0
- package/node_modules/yaml/dist/schema/Schema.d.ts +17 -0
- package/node_modules/yaml/dist/schema/Schema.js +39 -0
- package/node_modules/yaml/dist/schema/common/map.d.ts +2 -0
- package/node_modules/yaml/dist/schema/common/map.js +19 -0
- package/node_modules/yaml/dist/schema/common/null.d.ts +4 -0
- package/node_modules/yaml/dist/schema/common/null.js +17 -0
- package/node_modules/yaml/dist/schema/common/seq.d.ts +2 -0
- package/node_modules/yaml/dist/schema/common/seq.js +19 -0
- package/node_modules/yaml/dist/schema/common/string.d.ts +2 -0
- package/node_modules/yaml/dist/schema/common/string.js +16 -0
- package/node_modules/yaml/dist/schema/core/bool.d.ts +4 -0
- package/node_modules/yaml/dist/schema/core/bool.js +21 -0
- package/node_modules/yaml/dist/schema/core/float.d.ts +4 -0
- package/node_modules/yaml/dist/schema/core/float.js +47 -0
- package/node_modules/yaml/dist/schema/core/int.d.ts +4 -0
- package/node_modules/yaml/dist/schema/core/int.js +42 -0
- package/node_modules/yaml/dist/schema/core/schema.d.ts +1 -0
- package/node_modules/yaml/dist/schema/core/schema.js +25 -0
- package/node_modules/yaml/dist/schema/json/schema.d.ts +2 -0
- package/node_modules/yaml/dist/schema/json/schema.js +64 -0
- package/node_modules/yaml/dist/schema/json-schema.d.ts +69 -0
- package/node_modules/yaml/dist/schema/tags.d.ts +48 -0
- package/node_modules/yaml/dist/schema/tags.js +99 -0
- package/node_modules/yaml/dist/schema/types.d.ts +92 -0
- package/node_modules/yaml/dist/schema/yaml-1.1/binary.d.ts +2 -0
- package/node_modules/yaml/dist/schema/yaml-1.1/binary.js +70 -0
- package/node_modules/yaml/dist/schema/yaml-1.1/bool.d.ts +7 -0
- package/node_modules/yaml/dist/schema/yaml-1.1/bool.js +29 -0
- package/node_modules/yaml/dist/schema/yaml-1.1/float.d.ts +4 -0
- package/node_modules/yaml/dist/schema/yaml-1.1/float.js +50 -0
- package/node_modules/yaml/dist/schema/yaml-1.1/int.d.ts +5 -0
- package/node_modules/yaml/dist/schema/yaml-1.1/int.js +76 -0
- package/node_modules/yaml/dist/schema/yaml-1.1/merge.d.ts +9 -0
- package/node_modules/yaml/dist/schema/yaml-1.1/merge.js +71 -0
- package/node_modules/yaml/dist/schema/yaml-1.1/omap.d.ts +22 -0
- package/node_modules/yaml/dist/schema/yaml-1.1/omap.js +77 -0
- package/node_modules/yaml/dist/schema/yaml-1.1/pairs.d.ts +10 -0
- package/node_modules/yaml/dist/schema/yaml-1.1/pairs.js +82 -0
- package/node_modules/yaml/dist/schema/yaml-1.1/schema.d.ts +1 -0
- package/node_modules/yaml/dist/schema/yaml-1.1/schema.js +41 -0
- package/node_modules/yaml/dist/schema/yaml-1.1/set.d.ts +28 -0
- package/node_modules/yaml/dist/schema/yaml-1.1/set.js +96 -0
- package/node_modules/yaml/dist/schema/yaml-1.1/timestamp.d.ts +6 -0
- package/node_modules/yaml/dist/schema/yaml-1.1/timestamp.js +105 -0
- package/node_modules/yaml/dist/stringify/foldFlowLines.d.ts +34 -0
- package/node_modules/yaml/dist/stringify/foldFlowLines.js +151 -0
- package/node_modules/yaml/dist/stringify/stringify.d.ts +21 -0
- package/node_modules/yaml/dist/stringify/stringify.js +132 -0
- package/node_modules/yaml/dist/stringify/stringifyCollection.d.ts +17 -0
- package/node_modules/yaml/dist/stringify/stringifyCollection.js +155 -0
- package/node_modules/yaml/dist/stringify/stringifyComment.d.ts +10 -0
- package/node_modules/yaml/dist/stringify/stringifyComment.js +24 -0
- package/node_modules/yaml/dist/stringify/stringifyDocument.d.ts +4 -0
- package/node_modules/yaml/dist/stringify/stringifyDocument.js +87 -0
- package/node_modules/yaml/dist/stringify/stringifyNumber.d.ts +2 -0
- package/node_modules/yaml/dist/stringify/stringifyNumber.js +27 -0
- package/node_modules/yaml/dist/stringify/stringifyPair.d.ts +3 -0
- package/node_modules/yaml/dist/stringify/stringifyPair.js +152 -0
- package/node_modules/yaml/dist/stringify/stringifyString.d.ts +9 -0
- package/node_modules/yaml/dist/stringify/stringifyString.js +338 -0
- package/node_modules/yaml/dist/test-events.d.ts +4 -0
- package/node_modules/yaml/dist/test-events.js +134 -0
- package/node_modules/yaml/dist/util.d.ts +16 -0
- package/node_modules/yaml/dist/util.js +28 -0
- package/node_modules/yaml/dist/visit.d.ts +102 -0
- package/node_modules/yaml/dist/visit.js +236 -0
- package/node_modules/yaml/package.json +97 -0
- package/node_modules/yaml/util.js +2 -0
- package/package.json +8 -2
- package/src/claude-config.mjs +60 -2
- package/src/claude-hook.mjs +8 -1
- package/src/client-registry.mjs +13 -0
- package/src/delivery-sync.mjs +14 -0
- package/src/health.mjs +15 -0
- package/src/hermes-config.mjs +283 -0
- package/src/hermes-health.mjs +45 -0
- package/src/hermes-hook.mjs +86 -0
- package/src/hermes-plugin.py +67 -0
- package/src/install.mjs +8 -0
- package/src/installer-cli.mjs +25 -3
- package/src/instructions.mjs +5 -1
- package/src/skill-guard.mjs +171 -0
- package/src/skills-sync.mjs +32 -16
- package/src/version.mjs +3 -3
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { lstat, mkdir, open, readFile, readdir, rename, rm, unlink } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { isMap, isScalar, isSeq, parseDocument, visit } from "yaml";
|
|
8
|
+
import { defaultRuntimeDirectory, safeDirectory, writeHostConfigFile } from "./storage.mjs";
|
|
9
|
+
|
|
10
|
+
export const HERMES_REVIEWED_VERSION = "0.21.3";
|
|
11
|
+
export const HERMES_HOOK_EVENTS = Object.freeze([
|
|
12
|
+
"on_session_start", "pre_llm_call", "post_llm_call", "post_tool_call",
|
|
13
|
+
"on_session_end", "on_session_finalize",
|
|
14
|
+
]);
|
|
15
|
+
const PLUGIN = "halofy-lifecycle";
|
|
16
|
+
export const HERMES_PLUGIN_KEYS = Object.freeze([`${PLUGIN}/capture`, `${PLUGIN}/memory`]);
|
|
17
|
+
const MARKER = ".halofy-managed.json";
|
|
18
|
+
const FILES = ["capture/plugin.yaml", "capture/__init__.py", "capture/connection.json", "memory/plugin.json", "memory/mcp.json"];
|
|
19
|
+
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
|
20
|
+
// Pinned Hermes _portable_skill_namespace: the discovery key, not the manifest name.
|
|
21
|
+
const MCP_SERVER_NAME = `agent-plugin-halofy-lifecycle-memory-${sha256(HERMES_PLUGIN_KEYS[1]).slice(0, 8)}__halofy`;
|
|
22
|
+
|
|
23
|
+
/** Probe the executable, not an installation directory or a guessed host version. */
|
|
24
|
+
export function detectHermes({ command = "hermes", execute = spawnSync, env = process.env } = {}) {
|
|
25
|
+
const result = execute(command, ["--version"], {
|
|
26
|
+
encoding: "utf8", timeout: 15_000, maxBuffer: 32_768, shell: false, env,
|
|
27
|
+
});
|
|
28
|
+
if (result.error || result.status !== 0) throw new Error("Hermes executable version probe failed");
|
|
29
|
+
const match = String(result.stdout || "").match(/^Hermes Agent v(\d+\.\d+\.\d+) \(2026\.9\.14\)\r?$/m);
|
|
30
|
+
if (match?.[1] !== HERMES_REVIEWED_VERSION) {
|
|
31
|
+
throw new Error(`Hermes lifecycle requires reviewed host ${HERMES_REVIEWED_VERSION} (2026.9.14)`);
|
|
32
|
+
}
|
|
33
|
+
return match[1];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function absolutePath(value, label) {
|
|
37
|
+
if (typeof value !== "string" || !value || /[\0\r\n]/.test(value) || !isAbsolute(value)) {
|
|
38
|
+
throw new Error(`${label} must be an absolute local path`);
|
|
39
|
+
}
|
|
40
|
+
return resolve(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function fileSnapshot(path) {
|
|
44
|
+
await safeDirectory(dirname(path));
|
|
45
|
+
let stat;
|
|
46
|
+
try { stat = await lstat(path); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
47
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1 || stat.size > 4_194_304) {
|
|
48
|
+
throw new Error("unsafe Hermes configuration file");
|
|
49
|
+
}
|
|
50
|
+
return { bytes: await readFile(path), mode: stat.mode & 0o777 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseConfig(source) {
|
|
54
|
+
const doc = parseDocument(source, { uniqueKeys: true, strict: true });
|
|
55
|
+
if (doc.errors.length || doc.warnings.length) throw new Error("Hermes config.yaml is malformed or unsupported");
|
|
56
|
+
visit(doc, {
|
|
57
|
+
Alias() { throw new Error("Hermes configuration aliases are not supported for safe installation"); },
|
|
58
|
+
Node(_key, node) {
|
|
59
|
+
if (node.anchor || (node.tag && !node.tag.startsWith("tag:yaml.org,2002:"))) {
|
|
60
|
+
throw new Error("Hermes configuration anchors or custom tags are not supported");
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
Pair(_key, pair) {
|
|
64
|
+
if (!isScalar(pair.key) || typeof pair.key.value !== "string" || pair.key.value === "<<") {
|
|
65
|
+
throw new Error("Hermes configuration requires unique string mapping keys");
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
if (!doc.contents && !source.trim().replace(/^#.*$/gm, "").trim()) doc.contents = doc.createNode({});
|
|
70
|
+
if (!isMap(doc.contents)) throw new Error("Hermes configuration must be a mapping");
|
|
71
|
+
for (const key of ["plugins", "mcp_servers"]) {
|
|
72
|
+
if (doc.has(key) && !isMap(doc.get(key, true))) throw new Error(`Hermes ${key} must be a mapping`);
|
|
73
|
+
}
|
|
74
|
+
for (const key of ["enabled", "disabled"]) {
|
|
75
|
+
const list = doc.getIn(["plugins", key], true);
|
|
76
|
+
if (list !== undefined && (!isSeq(list) || list.items.some((item) => !isScalar(item) || typeof item.value !== "string"))) {
|
|
77
|
+
throw new Error(`Hermes plugins.${key} must be a string list`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return doc;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function legacyMcp(entry, serverUrl) {
|
|
84
|
+
if (!serverUrl || !entry || typeof entry !== "object" || entry.command || !entry.url) return false;
|
|
85
|
+
try {
|
|
86
|
+
const base = new URL(serverUrl);
|
|
87
|
+
const target = new URL(entry.url);
|
|
88
|
+
if (!["http:", "https:"].includes(base.protocol) || target.username || target.password || target.search || target.hash) return false;
|
|
89
|
+
const expectedPath = `${base.pathname.replace(/\/$/, "")}/mcp`;
|
|
90
|
+
const bearer = Object.entries(entry.headers || {}).some(([key, value]) =>
|
|
91
|
+
key.toLowerCase() === "authorization" && typeof value === "string" && /^Bearer\s+\S+$/i.test(value));
|
|
92
|
+
return target.origin === base.origin && target.pathname === expectedPath && bearer;
|
|
93
|
+
} catch { return false; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function ownedPlugin(pluginPath) {
|
|
97
|
+
let exists;
|
|
98
|
+
try { exists = await lstat(pluginPath); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
99
|
+
if (!exists.isDirectory() || exists.isSymbolicLink()) throw new Error("unsafe Hermes plugin directory");
|
|
100
|
+
const markerFile = await fileSnapshot(join(pluginPath, MARKER));
|
|
101
|
+
let marker;
|
|
102
|
+
try { marker = JSON.parse(markerFile?.bytes.toString() || "null"); } catch { /* report without file contents */ }
|
|
103
|
+
if (marker?.owner !== "halofy-agent-runtime" || marker.schemaVersion !== 2 || !marker.files || !marker.mcp) {
|
|
104
|
+
throw new Error("unmanaged Hermes plugin directory; preserve it and resolve the conflict first");
|
|
105
|
+
}
|
|
106
|
+
for (const name of FILES) {
|
|
107
|
+
const file = await fileSnapshot(join(pluginPath, name));
|
|
108
|
+
if (!file || sha256(file.bytes) !== marker.files[name]) throw new Error("managed Hermes plugin file was edited; refusing overwrite");
|
|
109
|
+
}
|
|
110
|
+
async function inspectDirectory(relative = "") {
|
|
111
|
+
const directory = join(pluginPath, relative);
|
|
112
|
+
await safeDirectory(directory);
|
|
113
|
+
for (const name of await readdir(directory)) {
|
|
114
|
+
const entry = relative ? `${relative}/${name}` : name;
|
|
115
|
+
if ([MARKER, ...FILES].includes(entry)) continue;
|
|
116
|
+
if (entry === "capture" || entry === "memory") { await inspectDirectory(entry); continue; }
|
|
117
|
+
if (entry !== "capture/__pycache__") throw new Error("unmanaged file in Hermes plugin directory");
|
|
118
|
+
await safeDirectory(join(pluginPath, entry));
|
|
119
|
+
for (const cached of await readdir(join(pluginPath, entry))) {
|
|
120
|
+
if (!/^__init__\.cpython-\d+(?:\.opt-\d+)?\.pyc$/.test(cached)) throw new Error("unmanaged Hermes plugin cache");
|
|
121
|
+
await fileSnapshot(join(pluginPath, entry, cached));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
await inspectDirectory();
|
|
126
|
+
return marker;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Inspect every existing ancestor without creating a fresh host installation. */
|
|
130
|
+
async function existingDirectory(path) {
|
|
131
|
+
try { await safeDirectory(path); return true; }
|
|
132
|
+
catch (error) { if (error.code === "directory_missing") return false; throw error; }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Validate conflicts before the installer consumes an enrollment claim. No files are written. */
|
|
136
|
+
export async function preflightHermes(options) {
|
|
137
|
+
return configureHermesInternal({ ...options, installationId: options.installationId ?? "preflight" }, true);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Install only in the selected Hermes home, preserving user YAML and refusing ambiguous ownership. */
|
|
141
|
+
export async function configureHermes(options) {
|
|
142
|
+
return configureHermesInternal(options, false);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function configureHermesInternal({
|
|
146
|
+
installationId, runtimePath, root = defaultRuntimeDirectory(), nodePath = process.execPath,
|
|
147
|
+
home = homedir(), env = process.env, serverUrl,
|
|
148
|
+
pluginSourcePath = fileURLToPath(new URL("./hermes-plugin.py", import.meta.url)),
|
|
149
|
+
}, dryRun) {
|
|
150
|
+
if (typeof installationId !== "string" || !/^[A-Za-z0-9_-]{1,160}$/.test(installationId)) throw new Error("invalid installation id");
|
|
151
|
+
const hermesHome = absolutePath(env.HERMES_HOME || join(absolutePath(home, "home"), ".hermes"), "Hermes home");
|
|
152
|
+
const connection = {
|
|
153
|
+
nodePath: absolutePath(nodePath, "nodePath"), runtimePath: absolutePath(runtimePath, "runtimePath"),
|
|
154
|
+
installationId, root: absolutePath(root, "runtime root"), hermesHome,
|
|
155
|
+
};
|
|
156
|
+
let homeExists = true;
|
|
157
|
+
if (dryRun) homeExists = await existingDirectory(hermesHome);
|
|
158
|
+
else await safeDirectory(hermesHome, true);
|
|
159
|
+
const configPath = join(hermesHome, "config.yaml");
|
|
160
|
+
const pluginsPath = join(hermesHome, "plugins");
|
|
161
|
+
if (dryRun) await existingDirectory(pluginsPath);
|
|
162
|
+
else await safeDirectory(pluginsPath, true);
|
|
163
|
+
const pluginPath = join(pluginsPath, PLUGIN);
|
|
164
|
+
const lockPath = join(pluginsPath, ".halofy-lifecycle.install.lock");
|
|
165
|
+
let lock;
|
|
166
|
+
if (dryRun) {
|
|
167
|
+
let lockExists = false;
|
|
168
|
+
try { await lstat(lockPath); lockExists = true; } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
169
|
+
if (lockExists) throw new Error("Hermes installation is busy; inspect its installation lock before retrying");
|
|
170
|
+
} else {
|
|
171
|
+
try { lock = await open(lockPath, "wx", 0o600); } catch (error) {
|
|
172
|
+
if (error.code === "EEXIST") throw new Error("Hermes installation is busy; inspect its installation lock before retrying");
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const nonce = `${process.pid}.${randomBytes(8).toString("hex")}`;
|
|
177
|
+
const stage = join(pluginsPath, `.halofy-stage-${nonce}`);
|
|
178
|
+
const backup = join(pluginsPath, `.halofy-backup-${nonce}`);
|
|
179
|
+
let backedUp = false;
|
|
180
|
+
let installed = false;
|
|
181
|
+
let committed = false;
|
|
182
|
+
try {
|
|
183
|
+
const original = homeExists ? await fileSnapshot(configPath) : null;
|
|
184
|
+
const doc = parseConfig(original?.bytes.toString("utf8") || "");
|
|
185
|
+
const old = await ownedPlugin(pluginPath);
|
|
186
|
+
const enabled = doc.getIn(["plugins", "enabled"])?.toJSON() || [];
|
|
187
|
+
const disabled = doc.getIn(["plugins", "disabled"])?.toJSON() || [];
|
|
188
|
+
if ([PLUGIN, ...HERMES_PLUGIN_KEYS, "halofy-capture", "halofy-memory"].some((name) => disabled.includes(name))) {
|
|
189
|
+
throw new Error("Hermes lifecycle plugin was explicitly disabled; refusing to re-enable it");
|
|
190
|
+
}
|
|
191
|
+
const mcp = {
|
|
192
|
+
type: "stdio",
|
|
193
|
+
command: basename(connection.nodePath),
|
|
194
|
+
args: [connection.runtimePath, "hermes-mcp", "--hermes-home", hermesHome, "--connection", installationId],
|
|
195
|
+
// Portable plugins only interpolate reserved plugin variables; ${PATH}
|
|
196
|
+
// would remain literal. Capture the installer environment for the host
|
|
197
|
+
// version probe and prepend the reviewed Node executable's directory.
|
|
198
|
+
env: { HALOFY_AGENT_HOME: connection.root,
|
|
199
|
+
PATH: [dirname(connection.nodePath), env.PATH ?? process.env.PATH].filter(Boolean).join(delimiter) },
|
|
200
|
+
};
|
|
201
|
+
let replacedLegacyMcpEntries = 0;
|
|
202
|
+
const servers = doc.get("mcp_servers")?.toJSON() || {};
|
|
203
|
+
if (Object.hasOwn(servers, MCP_SERVER_NAME)) {
|
|
204
|
+
throw new Error("Hermes namespaced Halofy MCP entry conflicts with the portable memory plugin");
|
|
205
|
+
}
|
|
206
|
+
for (const [name, entry] of Object.entries(servers)) {
|
|
207
|
+
if (!["halofy", "halomem"].includes(name.toLowerCase())) continue;
|
|
208
|
+
const owned = name === "halofy" && old && sha256(JSON.stringify(entry)) === sha256(JSON.stringify(old.mcp));
|
|
209
|
+
if (!owned && !legacyMcp(entry, serverUrl)) {
|
|
210
|
+
throw new Error("unmanaged or edited Hermes Halofy MCP entry; refusing overwrite");
|
|
211
|
+
}
|
|
212
|
+
doc.deleteIn(["mcp_servers", name]);
|
|
213
|
+
replacedLegacyMcpEntries += 1;
|
|
214
|
+
}
|
|
215
|
+
for (const key of HERMES_PLUGIN_KEYS) {
|
|
216
|
+
if (!enabled.includes(key)) {
|
|
217
|
+
if (doc.hasIn(["plugins", "enabled"])) doc.addIn(["plugins", "enabled"], key);
|
|
218
|
+
else doc.setIn(["plugins", "enabled"], [key]);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const pluginSource = await readFile(pluginSourcePath);
|
|
222
|
+
if (!pluginSource.length) throw new Error("Hermes lifecycle plugin source is empty");
|
|
223
|
+
if (dryRun) return { ready: true, configPath, pluginPath, replacedLegacyMcpEntries };
|
|
224
|
+
const contents = {
|
|
225
|
+
"capture/plugin.yaml": `name: halofy-capture\nversion: "1.0.0"\ndescription: Halofy signed lifecycle capture\nrequires_hermes: "==${HERMES_REVIEWED_VERSION}"\ncapabilities: []\nhooks:\n${HERMES_HOOK_EVENTS.map((event) => ` - ${event}`).join("\n")}\n`,
|
|
226
|
+
"capture/__init__.py": pluginSource,
|
|
227
|
+
"capture/connection.json": `${JSON.stringify(connection, null, 2)}\n`,
|
|
228
|
+
"memory/plugin.json": `${JSON.stringify({
|
|
229
|
+
$schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
230
|
+
name: "halofy-memory", version: "1.0.0", description: "Halofy agent-invoked memory tools",
|
|
231
|
+
}, null, 2)}\n`,
|
|
232
|
+
"memory/mcp.json": `${JSON.stringify({
|
|
233
|
+
$schema: "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", mcpServers: { halofy: mcp },
|
|
234
|
+
}, null, 2)}\n`,
|
|
235
|
+
};
|
|
236
|
+
const files = Object.fromEntries(Object.entries(contents).map(([name, bytes]) => [name, sha256(bytes)]));
|
|
237
|
+
const marker = { owner: "halofy-agent-runtime", schemaVersion: 2, files, mcp };
|
|
238
|
+
await mkdir(stage, { mode: 0o700 });
|
|
239
|
+
await mkdir(join(stage, "capture"), { mode: 0o700 });
|
|
240
|
+
await mkdir(join(stage, "memory"), { mode: 0o700 });
|
|
241
|
+
for (const [name, bytes] of Object.entries({ ...contents, [MARKER]: `${JSON.stringify(marker, null, 2)}\n` })) {
|
|
242
|
+
const handle = await open(join(stage, name), "wx", 0o600);
|
|
243
|
+
try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); }
|
|
244
|
+
}
|
|
245
|
+
// Do not clobber an edit made while staging the managed plugin.
|
|
246
|
+
const current = await fileSnapshot(configPath);
|
|
247
|
+
if (!!original !== !!current || (original && (!original.bytes.equals(current.bytes) || original.mode !== current.mode))) {
|
|
248
|
+
throw new Error("Hermes configuration changed during installation; retry after reviewing the change");
|
|
249
|
+
}
|
|
250
|
+
await safeDirectory(pluginsPath);
|
|
251
|
+
if (old) {
|
|
252
|
+
await ownedPlugin(pluginPath);
|
|
253
|
+
await rename(pluginPath, backup);
|
|
254
|
+
backedUp = true;
|
|
255
|
+
} else {
|
|
256
|
+
try { await lstat(pluginPath); throw new Error("Hermes plugin appeared during installation"); }
|
|
257
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
258
|
+
}
|
|
259
|
+
await rename(stage, pluginPath);
|
|
260
|
+
installed = true;
|
|
261
|
+
const beforeWrite = await fileSnapshot(configPath);
|
|
262
|
+
if (!!original !== !!beforeWrite || (original && (!original.bytes.equals(beforeWrite.bytes) || original.mode !== beforeWrite.mode))) {
|
|
263
|
+
throw new Error("Hermes configuration changed during installation; retry after reviewing the change");
|
|
264
|
+
}
|
|
265
|
+
await writeHostConfigFile(configPath, doc.toString());
|
|
266
|
+
committed = true;
|
|
267
|
+
return {
|
|
268
|
+
configuredPaths: [configPath, ...FILES.map((name) => join(pluginPath, name))],
|
|
269
|
+
hookEvents: [...HERMES_HOOK_EVENTS], replacedLegacyMcpEntries,
|
|
270
|
+
hermes: { configPath, pluginPath, files, mcp, hostVersion: HERMES_REVIEWED_VERSION,
|
|
271
|
+
pluginKeys: [...HERMES_PLUGIN_KEYS], mcpServerName: MCP_SERVER_NAME },
|
|
272
|
+
};
|
|
273
|
+
} finally {
|
|
274
|
+
if (!committed && installed) await rm(pluginPath, { recursive: true, force: true });
|
|
275
|
+
if (!committed && backedUp) await rename(backup, pluginPath);
|
|
276
|
+
if (committed && backedUp) await rm(backup, { recursive: true, force: true });
|
|
277
|
+
if (!dryRun) {
|
|
278
|
+
await rm(stage, { recursive: true, force: true });
|
|
279
|
+
await lock.close();
|
|
280
|
+
await unlink(lockPath);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import { parseDocument } from "yaml";
|
|
6
|
+
import { safeDirectory } from "./storage.mjs";
|
|
7
|
+
|
|
8
|
+
async function regularFile(path) {
|
|
9
|
+
await safeDirectory(dirname(path));
|
|
10
|
+
const entry = await lstat(path);
|
|
11
|
+
if (!entry.isFile() || entry.isSymbolicLink() || entry.nlink !== 1 || entry.size > 4 * 1024 * 1024) {
|
|
12
|
+
throw new Error("unsafe Hermes configuration");
|
|
13
|
+
}
|
|
14
|
+
return readFile(path);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Local inspection is configuration evidence only, never proof of captured turns. */
|
|
18
|
+
export async function inspectHermesCaptureHealth(metadata) {
|
|
19
|
+
try {
|
|
20
|
+
if (!metadata?.configPath || !metadata?.pluginPath || !metadata?.files || !metadata?.mcp ||
|
|
21
|
+
!metadata?.mcpServerName || !Array.isArray(metadata.pluginKeys) || metadata.pluginKeys.length !== 2) {
|
|
22
|
+
return { captureState: "unknown" };
|
|
23
|
+
}
|
|
24
|
+
const document = parseDocument((await regularFile(metadata.configPath)).toString("utf8"), { uniqueKeys: true });
|
|
25
|
+
if (document.errors.length || document.warnings.length) return { captureState: "hooks_missing" };
|
|
26
|
+
const config = document.toJS({ maxAliasCount: 0 });
|
|
27
|
+
if (!Array.isArray(config?.plugins?.enabled) ||
|
|
28
|
+
!metadata.pluginKeys.every((key) => config.plugins.enabled.includes(key)) ||
|
|
29
|
+
["halofy-lifecycle", "halofy-capture", "halofy-memory", ...metadata.pluginKeys]
|
|
30
|
+
.some((key) => config.plugins.disabled?.includes(key)) ||
|
|
31
|
+
Object.keys(config.mcp_servers || {}).some((key) =>
|
|
32
|
+
["halofy", "halomem", metadata.mcpServerName.toLowerCase()].includes(key.toLowerCase()))) {
|
|
33
|
+
return { captureState: "hooks_missing" };
|
|
34
|
+
}
|
|
35
|
+
for (const name of ["capture/plugin.yaml", "capture/__init__.py", "capture/connection.json", "memory/plugin.json", "memory/mcp.json"]) {
|
|
36
|
+
const actual = createHash("sha256").update(await regularFile(join(metadata.pluginPath, name))).digest("hex");
|
|
37
|
+
if (actual !== metadata.files[name]) return { captureState: "hooks_missing" };
|
|
38
|
+
}
|
|
39
|
+
const portable = JSON.parse((await regularFile(join(metadata.pluginPath, "memory/mcp.json"))).toString("utf8"));
|
|
40
|
+
if (!isDeepStrictEqual(portable.mcpServers?.halofy, metadata.mcp)) return { captureState: "hooks_missing" };
|
|
41
|
+
return { captureState: "active" };
|
|
42
|
+
} catch {
|
|
43
|
+
return { captureState: "hooks_missing" };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { isActiveInstallation } from "./active.mjs";
|
|
3
|
+
import { readHookInput } from "./claude-hook.mjs";
|
|
4
|
+
import { captureStateSnapshot } from "./health.mjs";
|
|
5
|
+
import { LifecycleRuntime } from "./runtime.mjs";
|
|
6
|
+
import { normalizedEvent, normalizeClaudeHookEvent, normalizeHostMessageEvent } from "./session.mjs";
|
|
7
|
+
import { defaultRuntimeDirectory } from "./storage.mjs";
|
|
8
|
+
|
|
9
|
+
export const HERMES_HOOK_EVENTS = Object.freeze(new Set([
|
|
10
|
+
"on_session_start", "pre_llm_call", "post_llm_call", "post_tool_call",
|
|
11
|
+
"on_session_end", "on_session_finalize",
|
|
12
|
+
]));
|
|
13
|
+
|
|
14
|
+
function identity(value) {
|
|
15
|
+
return typeof value === "string" && value.length > 0 && value.length <= 512 &&
|
|
16
|
+
!/[\u0000-\u001f\u007f]/.test(value) ? value : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Only native observer callbacks from the reviewed Hermes release reach this seam. */
|
|
20
|
+
export async function runHermesLifecycleHook(connection, eventName, {
|
|
21
|
+
input, inputStream = process.stdin, root = defaultRuntimeDirectory(), stderr = process.stderr,
|
|
22
|
+
runtimeFactory = (active, options) => new LifecycleRuntime(active, options),
|
|
23
|
+
} = {}) {
|
|
24
|
+
try {
|
|
25
|
+
if (connection.clientKind !== "hermes-agents" || !HERMES_HOOK_EVENTS.has(eventName)) {
|
|
26
|
+
throw new Error("unsupported Hermes hook");
|
|
27
|
+
}
|
|
28
|
+
if (!await isActiveInstallation(connection, root)) return { handled: true, inactive: true };
|
|
29
|
+
// Bind admission to callback entry, before potentially delayed stdin. A
|
|
30
|
+
// pause/resume while input arrives cannot relabel prior content as new.
|
|
31
|
+
const capture = await captureStateSnapshot(root, connection.installationId);
|
|
32
|
+
if (capture.paused) return { handled: true, paused: true };
|
|
33
|
+
const body = input ?? await readHookInput(inputStream, 4 * 1024 * 1024);
|
|
34
|
+
if (!await isActiveInstallation(connection, root)) return { handled: true, inactive: true };
|
|
35
|
+
const session = identity(body.session_id);
|
|
36
|
+
if (!session) return { handled: true, unavailable: "missing_host_session" };
|
|
37
|
+
const runtime = runtimeFactory(connection, { root, captureGeneration: capture.generation });
|
|
38
|
+
const nativeId = eventName === "post_tool_call" ? identity(body.tool_call_id) : identity(body.turn_id);
|
|
39
|
+
const evidenceId = createHash("sha256").update(`${session}\0${eventName}\0${nativeId || "boundary"}`).digest("hex");
|
|
40
|
+
async function gap(reason) {
|
|
41
|
+
await runtime.enqueueSequencedEvents(session, ({ nextSequence }) => [{
|
|
42
|
+
...normalizedEvent({ eventKey: `hermes:gap:${evidenceId}`, type: "checkpoint",
|
|
43
|
+
occurredAt: new Date().toISOString(), payload: { captureStatus: "unavailable",
|
|
44
|
+
captureReasonCode: reason, contentFormat: "json", boundary: eventName } }),
|
|
45
|
+
sequence: nextSequence,
|
|
46
|
+
}]);
|
|
47
|
+
return { handled: true, unavailable: reason };
|
|
48
|
+
}
|
|
49
|
+
if (body.capture_gap) return await gap("hermes_hook_payload_too_large");
|
|
50
|
+
if (eventName === "on_session_start") {
|
|
51
|
+
await runtime.replay();
|
|
52
|
+
await runtime.resolveSession(session);
|
|
53
|
+
await runtime.heartbeat(connection.capabilities || {});
|
|
54
|
+
} else if (eventName === "pre_llm_call" || eventName === "post_llm_call") {
|
|
55
|
+
const role = eventName === "pre_llm_call" ? "user" : "assistant";
|
|
56
|
+
const text = role === "user" ? body.user_message : body.assistant_response;
|
|
57
|
+
if (!nativeId || typeof text !== "string") return await gap("missing_message_evidence");
|
|
58
|
+
await runtime.enqueueSequencedEvents(session, ({ nextSequence }) => [normalizeHostMessageEvent({
|
|
59
|
+
clientKind: "hermes-agents", role, text, eventId: evidenceId,
|
|
60
|
+
}, { sequence: nextSequence })]);
|
|
61
|
+
} else if (eventName === "post_tool_call") {
|
|
62
|
+
if (!nativeId || !identity(body.tool_name)) return await gap("missing_tool_evidence");
|
|
63
|
+
const common = { event_id: `hermes:${evidenceId}`, tool_name: body.tool_name,
|
|
64
|
+
tool_use_id: evidenceId, tool_input: body.args, tool_response: body.result,
|
|
65
|
+
tool_error: ["error", "blocked", "cancelled", "timeout"].includes(body.status) };
|
|
66
|
+
await runtime.enqueueSequencedEvents(session, ({ sessionHash, nextSequence }) => [
|
|
67
|
+
normalizeClaudeHookEvent("tool_call", common, { sessionHash, sequence: nextSequence }),
|
|
68
|
+
normalizeClaudeHookEvent("tool_result", common, { sessionHash, sequence: nextSequence + 1 }),
|
|
69
|
+
]);
|
|
70
|
+
} else if (eventName === "on_session_end") {
|
|
71
|
+
// Hermes fires this after each turn. It is NOT a conversation close.
|
|
72
|
+
if (body.interrupted === true || body.failed === true) {
|
|
73
|
+
await gap(body.interrupted === true ? "hermes_turn_interrupted" : "hermes_turn_failed");
|
|
74
|
+
}
|
|
75
|
+
await runtime.replay();
|
|
76
|
+
await runtime.commit(session, "checkpoint");
|
|
77
|
+
await runtime.heartbeat(connection.capabilities || {});
|
|
78
|
+
} else {
|
|
79
|
+
await runtime.close(session, "session_end");
|
|
80
|
+
}
|
|
81
|
+
return { handled: true };
|
|
82
|
+
} catch {
|
|
83
|
+
stderr.write("[halofy] Hermes lifecycle hook degraded: runtime_unavailable\n");
|
|
84
|
+
return { handled: true, degraded: true };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Halofy observer for Hermes 0.21.3 (v2026.9.14).
|
|
2
|
+
|
|
3
|
+
The plugin has no provider/storage credentials and never changes a hook result.
|
|
4
|
+
It forwards only reviewed event fields to the installed signed Node runtime.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
EVENTS = (
|
|
13
|
+
"on_session_start", "pre_llm_call", "post_llm_call", "post_tool_call",
|
|
14
|
+
"on_session_end", "on_session_finalize",
|
|
15
|
+
)
|
|
16
|
+
FIELDS = (
|
|
17
|
+
"session_id", "turn_id", "tool_call_id", "user_message",
|
|
18
|
+
"assistant_response", "tool_name", "args", "result", "status",
|
|
19
|
+
"completed", "failed", "interrupted", "turn_exit_reason",
|
|
20
|
+
)
|
|
21
|
+
MAX_INPUT_BYTES = 4 * 1024 * 1024
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _forward(event, **kwargs):
|
|
25
|
+
try:
|
|
26
|
+
config_path = Path(__file__).with_name("connection.json")
|
|
27
|
+
if config_path.is_symlink():
|
|
28
|
+
raise ValueError("unsafe configuration")
|
|
29
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
30
|
+
from hermes_cli.config import get_hermes_home
|
|
31
|
+
enrolled_home = Path(config["hermesHome"]).resolve(strict=True)
|
|
32
|
+
if (get_hermes_home().resolve() != enrolled_home or
|
|
33
|
+
Path(__file__).resolve().parent != enrolled_home / "plugins" / "halofy-lifecycle" / "capture"):
|
|
34
|
+
raise ValueError("profile is not enrolled")
|
|
35
|
+
payload = {key: kwargs[key] for key in FIELDS if key in kwargs}
|
|
36
|
+
encoded = json.dumps(payload, ensure_ascii=False, allow_nan=False).encode("utf-8")
|
|
37
|
+
if len(encoded) > MAX_INPUT_BYTES:
|
|
38
|
+
# Retain an explicit gap, never a truncated conversation body.
|
|
39
|
+
encoded = json.dumps({"session_id": kwargs.get("session_id"),
|
|
40
|
+
"turn_id": kwargs.get("turn_id"),
|
|
41
|
+
"tool_call_id": kwargs.get("tool_call_id"),
|
|
42
|
+
"capture_gap": "hermes_hook_payload_too_large"}).encode("utf-8")
|
|
43
|
+
environment = dict(os.environ, HALOFY_AGENT_HOME=config["root"])
|
|
44
|
+
completed = subprocess.run(
|
|
45
|
+
[config["nodePath"], config["runtimePath"], "hermes-hook", event,
|
|
46
|
+
"--connection", config["installationId"]],
|
|
47
|
+
input=encoded, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
48
|
+
env=environment, timeout=20, check=False,
|
|
49
|
+
)
|
|
50
|
+
if completed.returncode:
|
|
51
|
+
raise RuntimeError("runtime unavailable")
|
|
52
|
+
except Exception:
|
|
53
|
+
# Host operation continues; neither event contents nor credentials reach logs.
|
|
54
|
+
print("[halofy] Hermes capture unavailable; inspect connection diagnostics.", file=sys.stderr)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def register(ctx):
|
|
59
|
+
# Hermes's manifest constraint parser is permissive for unknown/prerelease
|
|
60
|
+
# versions; enforce the exact reviewed native ABI before registering hooks.
|
|
61
|
+
from hermes_cli import __version__, __release_date__
|
|
62
|
+
if __version__ != "0.21.3" or str(__release_date__) != "2026.9.14":
|
|
63
|
+
raise RuntimeError("Halofy lifecycle requires reviewed Hermes 0.21.3 (2026.9.14)")
|
|
64
|
+
for event in EVENTS:
|
|
65
|
+
def observer(_event=event, **kwargs):
|
|
66
|
+
return _forward(_event, **kwargs)
|
|
67
|
+
ctx.register_hook(event, observer)
|
package/src/install.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { SignedRuntimeTransport } from "./transport.mjs";
|
|
|
9
9
|
import { inspectCaptureHealth } from "./health.mjs";
|
|
10
10
|
import { INSTALLER_VERSION, RUNTIME_VERSION } from "./version.mjs";
|
|
11
11
|
import { CLIENT_KINDS, CLIENT_REGISTRY, lifecycleClient } from "./client-registry.mjs";
|
|
12
|
+
import { initializeSkillGuard } from "./skill-guard.mjs";
|
|
12
13
|
import { syncManagedSkills } from "./skills-sync.mjs";
|
|
13
14
|
import { syncManagedDelivery } from "./delivery-sync.mjs";
|
|
14
15
|
import { retirePredecessorContent } from "./predecessor-sync.mjs";
|
|
@@ -207,6 +208,10 @@ export async function installLocalConnection({
|
|
|
207
208
|
...(typeof consumed.namespace === "string" ? { namespace: consumed.namespace } : {}),
|
|
208
209
|
...(instructionProfile ? { instructionProfile } : {}),
|
|
209
210
|
};
|
|
211
|
+
if (clientKind === "claude-code") {
|
|
212
|
+
await initializeSkillGuard(root);
|
|
213
|
+
connection.skillGuardVersion = 1;
|
|
214
|
+
}
|
|
210
215
|
await store.save(connection);
|
|
211
216
|
await writePrivateFile(join(root, `active-${clientKind}.json`), `${JSON.stringify({
|
|
212
217
|
version: 1,
|
|
@@ -256,6 +261,9 @@ export async function installRuntimeBundle({
|
|
|
256
261
|
await ensurePrivateDirectory(staging);
|
|
257
262
|
await cp(join(sourceRoot, "src"), join(staging, "src"), { recursive: true, force: false });
|
|
258
263
|
await cp(join(sourceRoot, "bin"), join(staging, "bin"), { recursive: true, force: false });
|
|
264
|
+
// yaml is exact-pinned and bundled in the reviewed tarball, including its ISC
|
|
265
|
+
// license. The durable copy must remain usable after npx's cache is removed.
|
|
266
|
+
await cp(join(sourceRoot, "node_modules", "yaml"), join(staging, "node_modules", "yaml"), { recursive: true, force: false });
|
|
259
267
|
await rm(runtimeRoot, { recursive: true, force: true });
|
|
260
268
|
await rename(staging, runtimeRoot);
|
|
261
269
|
if (process.platform !== "win32") {
|
package/src/installer-cli.mjs
CHANGED
|
@@ -15,11 +15,12 @@ import {
|
|
|
15
15
|
syncInstalledDelivery,
|
|
16
16
|
} from "./install.mjs";
|
|
17
17
|
import { configureClaudeProject } from "./claude-config.mjs";
|
|
18
|
+
import { configureHermes, detectHermes, preflightHermes } from "./hermes-config.mjs";
|
|
18
19
|
import { configureCline, configureCodex, configureCursor, configureGemini, configureKimi, configureVscode } from "./host-config.mjs";
|
|
19
20
|
import { CLIENT_KINDS, lifecycleClient } from "./client-registry.mjs";
|
|
20
21
|
import { defaultRuntimeDirectory } from "./storage.mjs";
|
|
21
22
|
import { instructionProfile } from "./instructions.mjs";
|
|
22
|
-
import { DISCLOSURE_VERSION, INSTALLER_VERSION } from "./version.mjs";
|
|
23
|
+
import { DISCLOSURE_VERSION, INSTALLER_VERSION, RUNTIME_VERSION } from "./version.mjs";
|
|
23
24
|
import { managedSkillsDirectory } from "./skills-sync.mjs";
|
|
24
25
|
|
|
25
26
|
const CLAIM_PATTERN = /^hsc_[A-Za-z0-9_-]{43}$/;
|
|
@@ -118,6 +119,7 @@ export function detectCline(home = homedir()) {
|
|
|
118
119
|
|
|
119
120
|
export function detectClient(clientKind) {
|
|
120
121
|
const client = lifecycleClient(clientKind);
|
|
122
|
+
if (clientKind === "hermes-agents") return detectHermes();
|
|
121
123
|
if (clientKind === "cline") return detectCline();
|
|
122
124
|
if (!client.command) throw new Error(`${client.label} does not have a packaged detector`);
|
|
123
125
|
const result = spawnSync(client.command, ["--version"], {
|
|
@@ -188,9 +190,12 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
|
|
|
188
190
|
"- encrypted local retry queue and governed retained conversations,",
|
|
189
191
|
"- canonical learning when the selected badge permits writes, and",
|
|
190
192
|
skillsRoot
|
|
191
|
-
? `- approved company and team skills written to ${skillsRoot}/<skill>/SKILL.md and
|
|
193
|
+
? `- approved company and team skills written to ${skillsRoot}/<skill>/SKILL.md and refreshed at supported session starts; withdrawn skills are moved aside, never deleted.`
|
|
192
194
|
: "- no managed skills folder for this host (skills stay available through skill_invoke).",
|
|
193
195
|
skillsRoot ? "- authorized company and team policy/knowledge references copied locally; failed refreshes remove managed context from discovery." : "",
|
|
196
|
+
clientKind === "codex" ? "Codex exec/headless hooks are not verified. Automatic startup sync and capture must not be assumed; use the exact syncCommand executable and arguments printed after installation before headless work. Explicit sync does not enable capture." : "",
|
|
197
|
+
clientKind === "claude-code" ? "Claude receives a Read allow rule only for this installation's policy/knowledge context folder. Existing ask/deny rules and project trust still apply; assigned skills and MCP tools may require separate host permission." : "",
|
|
198
|
+
clientKind === "hermes-agents" ? "Hermes 0.21.3 only: installs an observer plugin and signed MCP in the selected HERMES_HOME profile. Captures text prompts, final uninterrupted replies, tool calls/results and finalization. Intermediate or interrupted replies, compaction, subagents, tokens, managed skills and organization instructions are unavailable. No tool-override permission or automatic recall injection is enabled." : "",
|
|
194
199
|
"",
|
|
195
200
|
`Supported capture categories: ${supported.length > 0 ? supported.join(", ") : "none"}.`,
|
|
196
201
|
`Unsupported capture categories: ${unsupported.length > 0 ? unsupported.join(", ") : "none"}.`,
|
|
@@ -231,6 +236,7 @@ export function allDisclosureText({ serverUrl, projectRoot, organization = null,
|
|
|
231
236
|
lines.push(
|
|
232
237
|
"",
|
|
233
238
|
`--- ${client.label} (${host.clientVersion}) ---`,
|
|
239
|
+
host.clientKind === "hermes-agents" ? "Hermes 0.21.3 only: selected profile observer and signed MCP; final uninterrupted text only. Interrupted/intermediate replies, compaction, subagents, token usage, managed instructions and skills are unavailable. No automatic recall injection or tool-override grant." : "",
|
|
234
240
|
`Supported capture categories: ${supported.length > 0 ? supported.join(", ") : "none"}.`,
|
|
235
241
|
`Unsupported capture categories: ${unsupported.length > 0 ? unsupported.join(", ") : "none"}.`,
|
|
236
242
|
client.coverage === "complete"
|
|
@@ -238,7 +244,7 @@ export function allDisclosureText({ serverUrl, projectRoot, organization = null,
|
|
|
238
244
|
: `This host has partial coverage (${client.reason}); missing categories remain explicit.`,
|
|
239
245
|
managedSkillsDirectory(host.clientKind)
|
|
240
246
|
? `Approved company and team skills are written to ${managedSkillsDirectory(host.clientKind)}/<skill>/SKILL.md ` +
|
|
241
|
-
"and
|
|
247
|
+
"and refreshed at supported session starts; withdrawn skills are moved aside, never deleted. " +
|
|
242
248
|
"Authorized company and team policy/knowledge references are copied locally; failed refreshes remove managed context from discovery."
|
|
243
249
|
: "No managed skills folder for this host; skills stay available through skill_invoke.",
|
|
244
250
|
);
|
|
@@ -252,6 +258,7 @@ export function allDisclosureText({ serverUrl, projectRoot, organization = null,
|
|
|
252
258
|
}
|
|
253
259
|
lines.push(
|
|
254
260
|
"",
|
|
261
|
+
"Codex exec/headless hooks are not verified: use the exact syncCommand executable and arguments printed after installation before work; this does not enable capture. Claude Read grants cover only the installation's policy/knowledge context, preserve ask/deny, and require project trust; skills and MCP tools may need separate permission.",
|
|
255
262
|
"Authorized organization managers may review retained conversations and summaries.",
|
|
256
263
|
"This reads and writes its managed skill folders and instruction sections; it does not scan historical files, other applications, clipboard, keystrokes, or other agents.",
|
|
257
264
|
"Organization instructions: supported adapters manage an additive global rule file or marked section across projects in the selected local agent profile. Existing personal instructions are preserved. Updates sync at installation and supported session starts; restart the host to load changes.",
|
|
@@ -275,6 +282,7 @@ export async function confirmDisclosure({ input = process.stdin, output = proces
|
|
|
275
282
|
}
|
|
276
283
|
|
|
277
284
|
async function configureHost(clientKind, common, { claudeConfigPath } = {}) {
|
|
285
|
+
if (clientKind === "hermes-agents") return configureHermes(common);
|
|
278
286
|
if (clientKind === "claude-code") {
|
|
279
287
|
return configureClaudeProject({ ...common, ...(claudeConfigPath ? { claudeConfigPath } : {}) });
|
|
280
288
|
}
|
|
@@ -351,6 +359,9 @@ async function runAllInstaller(input, {
|
|
|
351
359
|
const results = [];
|
|
352
360
|
for (const host of detected) {
|
|
353
361
|
try {
|
|
362
|
+
if (host.clientKind === "hermes-agents") await preflightHermes({ root,
|
|
363
|
+
runtimePath: bundle.runtimePath, serverUrl: input.serverUrl,
|
|
364
|
+
...(skillsHome ? { home: skillsHome } : {}) });
|
|
354
365
|
const installed = await installLocalConnection({
|
|
355
366
|
serverUrl: input.serverUrl,
|
|
356
367
|
claim: host.claim,
|
|
@@ -361,10 +372,12 @@ async function runAllInstaller(input, {
|
|
|
361
372
|
instructionProfile: instructionProfile(host.clientKind, skillsHome ? { home: skillsHome } : {}),
|
|
362
373
|
});
|
|
363
374
|
const configured = await configureHost(host.clientKind, {
|
|
375
|
+
root,
|
|
364
376
|
projectRoot: input.projectRoot,
|
|
365
377
|
installationId: installed.installationId,
|
|
366
378
|
serverUrl: input.serverUrl,
|
|
367
379
|
runtimePath: bundle.runtimePath,
|
|
380
|
+
...(skillsHome ? { home: skillsHome } : {}),
|
|
368
381
|
}, { claudeConfigPath });
|
|
369
382
|
await recordHealthTargets({ root, installationId: installed.installationId, configured, runtimePath: bundle.runtimePath });
|
|
370
383
|
let heartbeat = false;
|
|
@@ -390,6 +403,8 @@ async function runAllInstaller(input, {
|
|
|
390
403
|
: null,
|
|
391
404
|
proofStorage: installed.proofStorage,
|
|
392
405
|
instructions: installed.instructions,
|
|
406
|
+
syncCommand: { command: process.execPath, args: [bundle.runtimePath, "sync", "--connection", installed.installationId], environment: { HALOFY_AGENT_HOME: root } },
|
|
407
|
+
skillGuard: configured.skillGuard,
|
|
393
408
|
configuredPaths: configured.configuredPaths || [configured.mcpPath, configured.settingsPath],
|
|
394
409
|
replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries || 0,
|
|
395
410
|
nextStep: `Restart ${lifecycleClient(host.clientKind).label}, then check the connection in Halofy.`,
|
|
@@ -457,6 +472,9 @@ export async function runInstaller(argv, {
|
|
|
457
472
|
})}\n`);
|
|
458
473
|
await confirm();
|
|
459
474
|
|
|
475
|
+
if (input.clientKind === "hermes-agents") await preflightHermes({ root,
|
|
476
|
+
runtimePath: join(root, "runtime", RUNTIME_VERSION, "bin", "halofy-agent.mjs"), serverUrl: input.serverUrl,
|
|
477
|
+
...(skillsHome ? { home: skillsHome } : {}) });
|
|
460
478
|
const installed = await installLocalConnection({
|
|
461
479
|
serverUrl: input.serverUrl,
|
|
462
480
|
claim: input.claim,
|
|
@@ -468,10 +486,12 @@ export async function runInstaller(argv, {
|
|
|
468
486
|
});
|
|
469
487
|
const bundle = await installRuntimeBundle({ root, ...(sourceRoot ? { sourceRoot } : {}) });
|
|
470
488
|
const common = {
|
|
489
|
+
root,
|
|
471
490
|
projectRoot: input.projectRoot,
|
|
472
491
|
installationId: installed.installationId,
|
|
473
492
|
serverUrl: input.serverUrl,
|
|
474
493
|
runtimePath: bundle.runtimePath,
|
|
494
|
+
...(skillsHome ? { home: skillsHome } : {}),
|
|
475
495
|
};
|
|
476
496
|
const configured = await configureHost(input.clientKind, common, { claudeConfigPath });
|
|
477
497
|
await recordHealthTargets({ root, installationId: installed.installationId, configured, runtimePath: bundle.runtimePath });
|
|
@@ -496,6 +516,8 @@ export async function runInstaller(argv, {
|
|
|
496
516
|
publishedPackage: true,
|
|
497
517
|
projectConfigured: true,
|
|
498
518
|
instructions: installed.instructions,
|
|
519
|
+
syncCommand: { command: process.execPath, args: [bundle.runtimePath, "sync", "--connection", installed.installationId], environment: { HALOFY_AGENT_HOME: root } },
|
|
520
|
+
skillGuard: configured.skillGuard,
|
|
499
521
|
configuredPaths: configured.configuredPaths || [configured.mcpPath, configured.settingsPath],
|
|
500
522
|
replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries || 0,
|
|
501
523
|
mcpConfiguration: localMcpSnippet({
|