@halofy/agent-connect 0.12.0 → 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 (253) hide show
  1. package/README.md +228 -12
  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 +20 -7
  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/host-hook.mjs +4 -7
  247. package/src/install.mjs +10 -2
  248. package/src/installer-cli.mjs +25 -3
  249. package/src/instructions.mjs +34 -15
  250. package/src/runtime.mjs +1 -1
  251. package/src/skill-guard.mjs +171 -0
  252. package/src/skills-sync.mjs +32 -16
  253. 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
@@ -9,7 +9,7 @@ import {
9
9
  rankedRecallBlocks,
10
10
  } from "./session.mjs";
11
11
  import { defaultRuntimeDirectory } from "./storage.mjs";
12
- import { readHookInput, syncSkillsAtSessionStart } from "./claude-hook.mjs";
12
+ import { readHookInput, syncConfigurationAtSessionStart } from "./claude-hook.mjs";
13
13
  import { syncManagedDelivery } from "./delivery-sync.mjs";
14
14
  import { transcriptDriverFor } from "./transcript-drivers/index.mjs";
15
15
 
@@ -161,9 +161,8 @@ export async function runHostLifecycleHook(connection, eventName, {
161
161
  const runtime = runtimeFactory(connection, { root, captureGeneration: capture.generation });
162
162
  if (capture.paused) {
163
163
  if (START_EVENTS.has(eventName)) {
164
- await syncSkillsAtSessionStart(runtime, connection, root, stderr, { home, env });
165
- // Capture pause does not suspend governed instruction delivery.
166
- await runtime.syncInstructions?.().catch(() => {});
164
+ // Capture pause does not suspend instruction or reference delivery.
165
+ await syncConfigurationAtSessionStart(runtime, connection, root, stderr, { home, env });
167
166
  await runtime.heartbeat(connection.capabilities || {});
168
167
  } else if (USER_EVENTS.has(eventName)) {
169
168
  const delivery = await syncManagedDelivery({ connection, transport: runtime.transport, root, home, env, stderr, phase: "turn" });
@@ -175,9 +174,7 @@ export async function runHostLifecycleHook(connection, eventName, {
175
174
 
176
175
  if (START_EVENTS.has(eventName)) {
177
176
  // Refresh/withdraw local context even when replay or heartbeat fails.
178
- await syncSkillsAtSessionStart(runtime, connection, root, stderr, { home, env });
179
- // Instructions have their own brownout boundary, independent of recall.
180
- await runtime.syncInstructions?.().catch(() => {});
177
+ await syncConfigurationAtSessionStart(runtime, connection, root, stderr, { home, env });
181
178
  await runtime.replay();
182
179
  await runtime.heartbeat(connection.capabilities || {});
183
180
  if (RECALL_INJECTION_ENABLED) {
package/src/install.mjs CHANGED
@@ -4,11 +4,12 @@ import { chmod, cp, rename, rm, unlink } from "node:fs/promises";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { generateInstallationKeyPair } from "./crypto.mjs";
6
6
  import { ConnectionStore, defaultRuntimeDirectory, ensurePrivateDirectory, readJson, writePrivateFile } from "./storage.mjs";
7
- import { syncAgentInstructions } from "./instructions.mjs";
7
+ import { INSTALL_INSTRUCTION_SYNC_TIMEOUT_MS, syncAgentInstructions } from "./instructions.mjs";
8
8
  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,
@@ -232,7 +237,7 @@ export async function installLocalConnection({
232
237
  }
233
238
  }
234
239
  const instructions = instructionProfile
235
- ? await syncAgentInstructions(connection, { root, transport: new SignedRuntimeTransport(connection, { fetchImpl, timeoutMs: 2_000 }) })
240
+ ? await syncAgentInstructions(connection, { root, fetchImpl, timeoutMs: INSTALL_INSTRUCTION_SYNC_TIMEOUT_MS })
236
241
  : undefined;
237
242
  return {
238
243
  installationId,
@@ -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") {