@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.
Files changed (251) hide show
  1. package/README.md +211 -8
  2. package/bin/halofy-agent.mjs +83 -8
  3. package/node_modules/yaml/LICENSE +13 -0
  4. package/node_modules/yaml/README.md +172 -0
  5. package/node_modules/yaml/bin.mjs +11 -0
  6. package/node_modules/yaml/browser/dist/compose/compose-collection.js +88 -0
  7. package/node_modules/yaml/browser/dist/compose/compose-doc.js +43 -0
  8. package/node_modules/yaml/browser/dist/compose/compose-node.js +109 -0
  9. package/node_modules/yaml/browser/dist/compose/compose-scalar.js +86 -0
  10. package/node_modules/yaml/browser/dist/compose/composer.js +219 -0
  11. package/node_modules/yaml/browser/dist/compose/resolve-block-map.js +115 -0
  12. package/node_modules/yaml/browser/dist/compose/resolve-block-scalar.js +198 -0
  13. package/node_modules/yaml/browser/dist/compose/resolve-block-seq.js +49 -0
  14. package/node_modules/yaml/browser/dist/compose/resolve-end.js +37 -0
  15. package/node_modules/yaml/browser/dist/compose/resolve-flow-collection.js +207 -0
  16. package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +226 -0
  17. package/node_modules/yaml/browser/dist/compose/resolve-props.js +146 -0
  18. package/node_modules/yaml/browser/dist/compose/util-contains-newline.js +34 -0
  19. package/node_modules/yaml/browser/dist/compose/util-empty-scalar-position.js +26 -0
  20. package/node_modules/yaml/browser/dist/compose/util-flow-indent-check.js +15 -0
  21. package/node_modules/yaml/browser/dist/compose/util-map-includes.js +13 -0
  22. package/node_modules/yaml/browser/dist/doc/Document.js +335 -0
  23. package/node_modules/yaml/browser/dist/doc/anchors.js +71 -0
  24. package/node_modules/yaml/browser/dist/doc/applyReviver.js +55 -0
  25. package/node_modules/yaml/browser/dist/doc/createNode.js +88 -0
  26. package/node_modules/yaml/browser/dist/doc/directives.js +176 -0
  27. package/node_modules/yaml/browser/dist/errors.js +57 -0
  28. package/node_modules/yaml/browser/dist/index.js +17 -0
  29. package/node_modules/yaml/browser/dist/log.js +11 -0
  30. package/node_modules/yaml/browser/dist/nodes/Alias.js +118 -0
  31. package/node_modules/yaml/browser/dist/nodes/Collection.js +147 -0
  32. package/node_modules/yaml/browser/dist/nodes/Node.js +38 -0
  33. package/node_modules/yaml/browser/dist/nodes/Pair.js +36 -0
  34. package/node_modules/yaml/browser/dist/nodes/Scalar.js +24 -0
  35. package/node_modules/yaml/browser/dist/nodes/YAMLMap.js +144 -0
  36. package/node_modules/yaml/browser/dist/nodes/YAMLSeq.js +113 -0
  37. package/node_modules/yaml/browser/dist/nodes/addPairToJSMap.js +63 -0
  38. package/node_modules/yaml/browser/dist/nodes/identity.js +36 -0
  39. package/node_modules/yaml/browser/dist/nodes/toJS.js +37 -0
  40. package/node_modules/yaml/browser/dist/parse/cst-scalar.js +214 -0
  41. package/node_modules/yaml/browser/dist/parse/cst-stringify.js +61 -0
  42. package/node_modules/yaml/browser/dist/parse/cst-visit.js +97 -0
  43. package/node_modules/yaml/browser/dist/parse/cst.js +98 -0
  44. package/node_modules/yaml/browser/dist/parse/lexer.js +721 -0
  45. package/node_modules/yaml/browser/dist/parse/line-counter.js +39 -0
  46. package/node_modules/yaml/browser/dist/parse/parser.js +975 -0
  47. package/node_modules/yaml/browser/dist/public-api.js +102 -0
  48. package/node_modules/yaml/browser/dist/schema/Schema.js +37 -0
  49. package/node_modules/yaml/browser/dist/schema/common/map.js +17 -0
  50. package/node_modules/yaml/browser/dist/schema/common/null.js +15 -0
  51. package/node_modules/yaml/browser/dist/schema/common/seq.js +17 -0
  52. package/node_modules/yaml/browser/dist/schema/common/string.js +14 -0
  53. package/node_modules/yaml/browser/dist/schema/core/bool.js +19 -0
  54. package/node_modules/yaml/browser/dist/schema/core/float.js +43 -0
  55. package/node_modules/yaml/browser/dist/schema/core/int.js +38 -0
  56. package/node_modules/yaml/browser/dist/schema/core/schema.js +23 -0
  57. package/node_modules/yaml/browser/dist/schema/json/schema.js +62 -0
  58. package/node_modules/yaml/browser/dist/schema/tags.js +96 -0
  59. package/node_modules/yaml/browser/dist/schema/yaml-1.1/binary.js +58 -0
  60. package/node_modules/yaml/browser/dist/schema/yaml-1.1/bool.js +26 -0
  61. package/node_modules/yaml/browser/dist/schema/yaml-1.1/float.js +46 -0
  62. package/node_modules/yaml/browser/dist/schema/yaml-1.1/int.js +71 -0
  63. package/node_modules/yaml/browser/dist/schema/yaml-1.1/merge.js +67 -0
  64. package/node_modules/yaml/browser/dist/schema/yaml-1.1/omap.js +74 -0
  65. package/node_modules/yaml/browser/dist/schema/yaml-1.1/pairs.js +78 -0
  66. package/node_modules/yaml/browser/dist/schema/yaml-1.1/schema.js +39 -0
  67. package/node_modules/yaml/browser/dist/schema/yaml-1.1/set.js +93 -0
  68. package/node_modules/yaml/browser/dist/schema/yaml-1.1/timestamp.js +101 -0
  69. package/node_modules/yaml/browser/dist/stringify/foldFlowLines.js +146 -0
  70. package/node_modules/yaml/browser/dist/stringify/stringify.js +129 -0
  71. package/node_modules/yaml/browser/dist/stringify/stringifyCollection.js +153 -0
  72. package/node_modules/yaml/browser/dist/stringify/stringifyComment.js +20 -0
  73. package/node_modules/yaml/browser/dist/stringify/stringifyDocument.js +85 -0
  74. package/node_modules/yaml/browser/dist/stringify/stringifyNumber.js +25 -0
  75. package/node_modules/yaml/browser/dist/stringify/stringifyPair.js +150 -0
  76. package/node_modules/yaml/browser/dist/stringify/stringifyString.js +336 -0
  77. package/node_modules/yaml/browser/dist/util.js +11 -0
  78. package/node_modules/yaml/browser/dist/visit.js +233 -0
  79. package/node_modules/yaml/browser/index.js +5 -0
  80. package/node_modules/yaml/browser/package.json +3 -0
  81. package/node_modules/yaml/dist/cli.d.ts +8 -0
  82. package/node_modules/yaml/dist/cli.mjs +201 -0
  83. package/node_modules/yaml/dist/compose/compose-collection.d.ts +11 -0
  84. package/node_modules/yaml/dist/compose/compose-collection.js +90 -0
  85. package/node_modules/yaml/dist/compose/compose-doc.d.ts +7 -0
  86. package/node_modules/yaml/dist/compose/compose-doc.js +45 -0
  87. package/node_modules/yaml/dist/compose/compose-node.d.ts +29 -0
  88. package/node_modules/yaml/dist/compose/compose-node.js +112 -0
  89. package/node_modules/yaml/dist/compose/compose-scalar.d.ts +5 -0
  90. package/node_modules/yaml/dist/compose/compose-scalar.js +88 -0
  91. package/node_modules/yaml/dist/compose/composer.d.ts +63 -0
  92. package/node_modules/yaml/dist/compose/composer.js +224 -0
  93. package/node_modules/yaml/dist/compose/resolve-block-map.d.ts +6 -0
  94. package/node_modules/yaml/dist/compose/resolve-block-map.js +117 -0
  95. package/node_modules/yaml/dist/compose/resolve-block-scalar.d.ts +11 -0
  96. package/node_modules/yaml/dist/compose/resolve-block-scalar.js +200 -0
  97. package/node_modules/yaml/dist/compose/resolve-block-seq.d.ts +6 -0
  98. package/node_modules/yaml/dist/compose/resolve-block-seq.js +51 -0
  99. package/node_modules/yaml/dist/compose/resolve-end.d.ts +6 -0
  100. package/node_modules/yaml/dist/compose/resolve-end.js +39 -0
  101. package/node_modules/yaml/dist/compose/resolve-flow-collection.d.ts +7 -0
  102. package/node_modules/yaml/dist/compose/resolve-flow-collection.js +209 -0
  103. package/node_modules/yaml/dist/compose/resolve-flow-scalar.d.ts +10 -0
  104. package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +228 -0
  105. package/node_modules/yaml/dist/compose/resolve-props.d.ts +23 -0
  106. package/node_modules/yaml/dist/compose/resolve-props.js +148 -0
  107. package/node_modules/yaml/dist/compose/util-contains-newline.d.ts +2 -0
  108. package/node_modules/yaml/dist/compose/util-contains-newline.js +36 -0
  109. package/node_modules/yaml/dist/compose/util-empty-scalar-position.d.ts +2 -0
  110. package/node_modules/yaml/dist/compose/util-empty-scalar-position.js +28 -0
  111. package/node_modules/yaml/dist/compose/util-flow-indent-check.d.ts +3 -0
  112. package/node_modules/yaml/dist/compose/util-flow-indent-check.js +17 -0
  113. package/node_modules/yaml/dist/compose/util-map-includes.d.ts +4 -0
  114. package/node_modules/yaml/dist/compose/util-map-includes.js +15 -0
  115. package/node_modules/yaml/dist/doc/Document.d.ts +141 -0
  116. package/node_modules/yaml/dist/doc/Document.js +337 -0
  117. package/node_modules/yaml/dist/doc/anchors.d.ts +24 -0
  118. package/node_modules/yaml/dist/doc/anchors.js +76 -0
  119. package/node_modules/yaml/dist/doc/applyReviver.d.ts +9 -0
  120. package/node_modules/yaml/dist/doc/applyReviver.js +57 -0
  121. package/node_modules/yaml/dist/doc/createNode.d.ts +17 -0
  122. package/node_modules/yaml/dist/doc/createNode.js +90 -0
  123. package/node_modules/yaml/dist/doc/directives.d.ts +49 -0
  124. package/node_modules/yaml/dist/doc/directives.js +178 -0
  125. package/node_modules/yaml/dist/errors.d.ts +21 -0
  126. package/node_modules/yaml/dist/errors.js +62 -0
  127. package/node_modules/yaml/dist/index.d.ts +25 -0
  128. package/node_modules/yaml/dist/index.js +50 -0
  129. package/node_modules/yaml/dist/log.d.ts +3 -0
  130. package/node_modules/yaml/dist/log.js +19 -0
  131. package/node_modules/yaml/dist/nodes/Alias.d.ts +29 -0
  132. package/node_modules/yaml/dist/nodes/Alias.js +120 -0
  133. package/node_modules/yaml/dist/nodes/Collection.d.ts +73 -0
  134. package/node_modules/yaml/dist/nodes/Collection.js +151 -0
  135. package/node_modules/yaml/dist/nodes/Node.d.ts +53 -0
  136. package/node_modules/yaml/dist/nodes/Node.js +40 -0
  137. package/node_modules/yaml/dist/nodes/Pair.d.ts +22 -0
  138. package/node_modules/yaml/dist/nodes/Pair.js +39 -0
  139. package/node_modules/yaml/dist/nodes/Scalar.d.ts +47 -0
  140. package/node_modules/yaml/dist/nodes/Scalar.js +27 -0
  141. package/node_modules/yaml/dist/nodes/YAMLMap.d.ts +53 -0
  142. package/node_modules/yaml/dist/nodes/YAMLMap.js +147 -0
  143. package/node_modules/yaml/dist/nodes/YAMLSeq.d.ts +60 -0
  144. package/node_modules/yaml/dist/nodes/YAMLSeq.js +115 -0
  145. package/node_modules/yaml/dist/nodes/addPairToJSMap.d.ts +4 -0
  146. package/node_modules/yaml/dist/nodes/addPairToJSMap.js +65 -0
  147. package/node_modules/yaml/dist/nodes/identity.d.ts +23 -0
  148. package/node_modules/yaml/dist/nodes/identity.js +53 -0
  149. package/node_modules/yaml/dist/nodes/toJS.d.ts +29 -0
  150. package/node_modules/yaml/dist/nodes/toJS.js +39 -0
  151. package/node_modules/yaml/dist/options.d.ts +350 -0
  152. package/node_modules/yaml/dist/parse/cst-scalar.d.ts +64 -0
  153. package/node_modules/yaml/dist/parse/cst-scalar.js +218 -0
  154. package/node_modules/yaml/dist/parse/cst-stringify.d.ts +8 -0
  155. package/node_modules/yaml/dist/parse/cst-stringify.js +63 -0
  156. package/node_modules/yaml/dist/parse/cst-visit.d.ts +39 -0
  157. package/node_modules/yaml/dist/parse/cst-visit.js +99 -0
  158. package/node_modules/yaml/dist/parse/cst.d.ts +109 -0
  159. package/node_modules/yaml/dist/parse/cst.js +112 -0
  160. package/node_modules/yaml/dist/parse/lexer.d.ts +87 -0
  161. package/node_modules/yaml/dist/parse/lexer.js +723 -0
  162. package/node_modules/yaml/dist/parse/line-counter.d.ts +22 -0
  163. package/node_modules/yaml/dist/parse/line-counter.js +41 -0
  164. package/node_modules/yaml/dist/parse/parser.d.ts +84 -0
  165. package/node_modules/yaml/dist/parse/parser.js +980 -0
  166. package/node_modules/yaml/dist/public-api.d.ts +44 -0
  167. package/node_modules/yaml/dist/public-api.js +107 -0
  168. package/node_modules/yaml/dist/schema/Schema.d.ts +17 -0
  169. package/node_modules/yaml/dist/schema/Schema.js +39 -0
  170. package/node_modules/yaml/dist/schema/common/map.d.ts +2 -0
  171. package/node_modules/yaml/dist/schema/common/map.js +19 -0
  172. package/node_modules/yaml/dist/schema/common/null.d.ts +4 -0
  173. package/node_modules/yaml/dist/schema/common/null.js +17 -0
  174. package/node_modules/yaml/dist/schema/common/seq.d.ts +2 -0
  175. package/node_modules/yaml/dist/schema/common/seq.js +19 -0
  176. package/node_modules/yaml/dist/schema/common/string.d.ts +2 -0
  177. package/node_modules/yaml/dist/schema/common/string.js +16 -0
  178. package/node_modules/yaml/dist/schema/core/bool.d.ts +4 -0
  179. package/node_modules/yaml/dist/schema/core/bool.js +21 -0
  180. package/node_modules/yaml/dist/schema/core/float.d.ts +4 -0
  181. package/node_modules/yaml/dist/schema/core/float.js +47 -0
  182. package/node_modules/yaml/dist/schema/core/int.d.ts +4 -0
  183. package/node_modules/yaml/dist/schema/core/int.js +42 -0
  184. package/node_modules/yaml/dist/schema/core/schema.d.ts +1 -0
  185. package/node_modules/yaml/dist/schema/core/schema.js +25 -0
  186. package/node_modules/yaml/dist/schema/json/schema.d.ts +2 -0
  187. package/node_modules/yaml/dist/schema/json/schema.js +64 -0
  188. package/node_modules/yaml/dist/schema/json-schema.d.ts +69 -0
  189. package/node_modules/yaml/dist/schema/tags.d.ts +48 -0
  190. package/node_modules/yaml/dist/schema/tags.js +99 -0
  191. package/node_modules/yaml/dist/schema/types.d.ts +92 -0
  192. package/node_modules/yaml/dist/schema/yaml-1.1/binary.d.ts +2 -0
  193. package/node_modules/yaml/dist/schema/yaml-1.1/binary.js +70 -0
  194. package/node_modules/yaml/dist/schema/yaml-1.1/bool.d.ts +7 -0
  195. package/node_modules/yaml/dist/schema/yaml-1.1/bool.js +29 -0
  196. package/node_modules/yaml/dist/schema/yaml-1.1/float.d.ts +4 -0
  197. package/node_modules/yaml/dist/schema/yaml-1.1/float.js +50 -0
  198. package/node_modules/yaml/dist/schema/yaml-1.1/int.d.ts +5 -0
  199. package/node_modules/yaml/dist/schema/yaml-1.1/int.js +76 -0
  200. package/node_modules/yaml/dist/schema/yaml-1.1/merge.d.ts +9 -0
  201. package/node_modules/yaml/dist/schema/yaml-1.1/merge.js +71 -0
  202. package/node_modules/yaml/dist/schema/yaml-1.1/omap.d.ts +22 -0
  203. package/node_modules/yaml/dist/schema/yaml-1.1/omap.js +77 -0
  204. package/node_modules/yaml/dist/schema/yaml-1.1/pairs.d.ts +10 -0
  205. package/node_modules/yaml/dist/schema/yaml-1.1/pairs.js +82 -0
  206. package/node_modules/yaml/dist/schema/yaml-1.1/schema.d.ts +1 -0
  207. package/node_modules/yaml/dist/schema/yaml-1.1/schema.js +41 -0
  208. package/node_modules/yaml/dist/schema/yaml-1.1/set.d.ts +28 -0
  209. package/node_modules/yaml/dist/schema/yaml-1.1/set.js +96 -0
  210. package/node_modules/yaml/dist/schema/yaml-1.1/timestamp.d.ts +6 -0
  211. package/node_modules/yaml/dist/schema/yaml-1.1/timestamp.js +105 -0
  212. package/node_modules/yaml/dist/stringify/foldFlowLines.d.ts +34 -0
  213. package/node_modules/yaml/dist/stringify/foldFlowLines.js +151 -0
  214. package/node_modules/yaml/dist/stringify/stringify.d.ts +21 -0
  215. package/node_modules/yaml/dist/stringify/stringify.js +132 -0
  216. package/node_modules/yaml/dist/stringify/stringifyCollection.d.ts +17 -0
  217. package/node_modules/yaml/dist/stringify/stringifyCollection.js +155 -0
  218. package/node_modules/yaml/dist/stringify/stringifyComment.d.ts +10 -0
  219. package/node_modules/yaml/dist/stringify/stringifyComment.js +24 -0
  220. package/node_modules/yaml/dist/stringify/stringifyDocument.d.ts +4 -0
  221. package/node_modules/yaml/dist/stringify/stringifyDocument.js +87 -0
  222. package/node_modules/yaml/dist/stringify/stringifyNumber.d.ts +2 -0
  223. package/node_modules/yaml/dist/stringify/stringifyNumber.js +27 -0
  224. package/node_modules/yaml/dist/stringify/stringifyPair.d.ts +3 -0
  225. package/node_modules/yaml/dist/stringify/stringifyPair.js +152 -0
  226. package/node_modules/yaml/dist/stringify/stringifyString.d.ts +9 -0
  227. package/node_modules/yaml/dist/stringify/stringifyString.js +338 -0
  228. package/node_modules/yaml/dist/test-events.d.ts +4 -0
  229. package/node_modules/yaml/dist/test-events.js +134 -0
  230. package/node_modules/yaml/dist/util.d.ts +16 -0
  231. package/node_modules/yaml/dist/util.js +28 -0
  232. package/node_modules/yaml/dist/visit.d.ts +102 -0
  233. package/node_modules/yaml/dist/visit.js +236 -0
  234. package/node_modules/yaml/package.json +97 -0
  235. package/node_modules/yaml/util.js +2 -0
  236. package/package.json +8 -2
  237. package/src/claude-config.mjs +60 -2
  238. package/src/claude-hook.mjs +8 -1
  239. package/src/client-registry.mjs +13 -0
  240. package/src/delivery-sync.mjs +14 -0
  241. package/src/health.mjs +15 -0
  242. package/src/hermes-config.mjs +283 -0
  243. package/src/hermes-health.mjs +45 -0
  244. package/src/hermes-hook.mjs +86 -0
  245. package/src/hermes-plugin.py +67 -0
  246. package/src/install.mjs +8 -0
  247. package/src/installer-cli.mjs +25 -3
  248. package/src/instructions.mjs +5 -1
  249. package/src/skill-guard.mjs +171 -0
  250. package/src/skills-sync.mjs +32 -16
  251. 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") {
@@ -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 kept current at each session start; withdrawn skills are moved aside, never deleted.`
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 kept current at each session start; withdrawn skills are moved aside, never deleted. " +
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({