@halofy/agent-connect 0.12.1 → 0.13.1
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 +249 -10
- package/bin/halofy-agent.mjs +96 -11
- 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 +26 -4
- 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/host-hook.mjs +6 -8
- package/src/install.mjs +8 -0
- package/src/installer-cli.mjs +25 -3
- package/src/instructions.mjs +5 -1
- package/src/runtime.mjs +33 -22
- 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/host-hook.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { captureStateSnapshot } from "./health.mjs";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
3
2
|
import { basename } from "node:path";
|
|
4
|
-
import { LifecycleRuntime } from "./runtime.mjs";
|
|
3
|
+
import { LifecycleRuntime, newLocalEventId } from "./runtime.mjs";
|
|
5
4
|
import {
|
|
6
5
|
normalizeClaudeHookEvent,
|
|
7
6
|
normalizeHostMessageEvent,
|
|
@@ -29,10 +28,6 @@ export const HOST_HOOK_EVENTS = Object.freeze(new Set([
|
|
|
29
28
|
...SUBAGENT_START_EVENTS, ...SUBAGENT_STOP_EVENTS,
|
|
30
29
|
]));
|
|
31
30
|
|
|
32
|
-
function digest(value) {
|
|
33
|
-
return createHash("sha256").update(String(value)).digest("hex");
|
|
34
|
-
}
|
|
35
|
-
|
|
36
31
|
function hostSession(input) {
|
|
37
32
|
return String(input.session_id || input.conversation_id || input.taskId || input.task_id ||
|
|
38
33
|
input.sessionKey || input.session_key || input.extra?.session_id || "");
|
|
@@ -46,8 +41,11 @@ function childSession(input, parent) {
|
|
|
46
41
|
|
|
47
42
|
function eventEvidenceId(eventName, input) {
|
|
48
43
|
const native = input.event_id || input.generation_id || input.turn_id || input.tool_call_id ||
|
|
49
|
-
input.
|
|
50
|
-
|
|
44
|
+
input.extra?.turn_id || input.extra?.tool_call_id;
|
|
45
|
+
// A task id identifies a session, and identical text can be a new turn.
|
|
46
|
+
// Without native event identity, assign one id per hook invocation. The
|
|
47
|
+
// encrypted queue retains that id across all transport retries.
|
|
48
|
+
return native ? `${eventName}:${String(native)}` : `${eventName}:${newLocalEventId()}`;
|
|
51
49
|
}
|
|
52
50
|
|
|
53
51
|
function promptText(input) {
|
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") {
|