@sentropic/h2a-cli 0.74.0 → 0.78.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 (346) hide show
  1. package/dist/index.d.ts +1 -36
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +2 -40
  4. package/dist/index.js.map +1 -1
  5. package/package.json +4 -15
  6. package/dist/bin.d.ts +0 -3
  7. package/dist/bin.d.ts.map +0 -1
  8. package/dist/bin.js +0 -83
  9. package/dist/bin.js.map +0 -1
  10. package/dist/cli-contract.d.ts +0 -62
  11. package/dist/cli-contract.d.ts.map +0 -1
  12. package/dist/cli-contract.js +0 -729
  13. package/dist/cli-contract.js.map +0 -1
  14. package/dist/cli.d.ts +0 -254
  15. package/dist/cli.d.ts.map +0 -1
  16. package/dist/cli.js +0 -4530
  17. package/dist/cli.js.map +0 -1
  18. package/dist/hosts/agy.d.ts +0 -14
  19. package/dist/hosts/agy.d.ts.map +0 -1
  20. package/dist/hosts/agy.js +0 -47
  21. package/dist/hosts/agy.js.map +0 -1
  22. package/dist/hosts/claude.d.ts +0 -12
  23. package/dist/hosts/claude.d.ts.map +0 -1
  24. package/dist/hosts/claude.js +0 -46
  25. package/dist/hosts/claude.js.map +0 -1
  26. package/dist/hosts/codex.d.ts +0 -65
  27. package/dist/hosts/codex.d.ts.map +0 -1
  28. package/dist/hosts/codex.js +0 -45
  29. package/dist/hosts/codex.js.map +0 -1
  30. package/dist/hosts/gemini.d.ts +0 -12
  31. package/dist/hosts/gemini.d.ts.map +0 -1
  32. package/dist/hosts/gemini.js +0 -46
  33. package/dist/hosts/gemini.js.map +0 -1
  34. package/dist/hosts/plugin.d.ts +0 -151
  35. package/dist/hosts/plugin.d.ts.map +0 -1
  36. package/dist/hosts/plugin.js +0 -188
  37. package/dist/hosts/plugin.js.map +0 -1
  38. package/dist/mcp.d.ts +0 -2
  39. package/dist/mcp.d.ts.map +0 -1
  40. package/dist/mcp.js +0 -32
  41. package/dist/mcp.js.map +0 -1
  42. package/dist/runtime/blockage/index.d.ts +0 -3
  43. package/dist/runtime/blockage/index.d.ts.map +0 -1
  44. package/dist/runtime/blockage/index.js +0 -3
  45. package/dist/runtime/blockage/index.js.map +0 -1
  46. package/dist/runtime/blockage/notifiers.d.ts +0 -52
  47. package/dist/runtime/blockage/notifiers.d.ts.map +0 -1
  48. package/dist/runtime/blockage/notifiers.js +0 -74
  49. package/dist/runtime/blockage/notifiers.js.map +0 -1
  50. package/dist/runtime/blockage/registry.d.ts +0 -30
  51. package/dist/runtime/blockage/registry.d.ts.map +0 -1
  52. package/dist/runtime/blockage/registry.js +0 -86
  53. package/dist/runtime/blockage/registry.js.map +0 -1
  54. package/dist/runtime/deploy/k8s-sidecar.d.ts +0 -92
  55. package/dist/runtime/deploy/k8s-sidecar.d.ts.map +0 -1
  56. package/dist/runtime/deploy/k8s-sidecar.js +0 -166
  57. package/dist/runtime/deploy/k8s-sidecar.js.map +0 -1
  58. package/dist/runtime/deploy/k8s-tenant.d.ts +0 -86
  59. package/dist/runtime/deploy/k8s-tenant.d.ts.map +0 -1
  60. package/dist/runtime/deploy/k8s-tenant.js +0 -255
  61. package/dist/runtime/deploy/k8s-tenant.js.map +0 -1
  62. package/dist/runtime/drive/inbox-wake.d.ts +0 -36
  63. package/dist/runtime/drive/inbox-wake.d.ts.map +0 -1
  64. package/dist/runtime/drive/inbox-wake.js +0 -50
  65. package/dist/runtime/drive/inbox-wake.js.map +0 -1
  66. package/dist/runtime/drive/index.d.ts +0 -173
  67. package/dist/runtime/drive/index.d.ts.map +0 -1
  68. package/dist/runtime/drive/index.js +0 -503
  69. package/dist/runtime/drive/index.js.map +0 -1
  70. package/dist/runtime/drumbeat/deciders.d.ts +0 -32
  71. package/dist/runtime/drumbeat/deciders.d.ts.map +0 -1
  72. package/dist/runtime/drumbeat/deciders.js +0 -65
  73. package/dist/runtime/drumbeat/deciders.js.map +0 -1
  74. package/dist/runtime/drumbeat/decisions.d.ts +0 -19
  75. package/dist/runtime/drumbeat/decisions.d.ts.map +0 -1
  76. package/dist/runtime/drumbeat/decisions.js +0 -34
  77. package/dist/runtime/drumbeat/decisions.js.map +0 -1
  78. package/dist/runtime/drumbeat/inbox-relance.d.ts +0 -29
  79. package/dist/runtime/drumbeat/inbox-relance.d.ts.map +0 -1
  80. package/dist/runtime/drumbeat/inbox-relance.js +0 -101
  81. package/dist/runtime/drumbeat/inbox-relance.js.map +0 -1
  82. package/dist/runtime/drumbeat/index.d.ts +0 -8
  83. package/dist/runtime/drumbeat/index.d.ts.map +0 -1
  84. package/dist/runtime/drumbeat/index.js +0 -8
  85. package/dist/runtime/drumbeat/index.js.map +0 -1
  86. package/dist/runtime/drumbeat/registry.d.ts +0 -60
  87. package/dist/runtime/drumbeat/registry.d.ts.map +0 -1
  88. package/dist/runtime/drumbeat/registry.js +0 -115
  89. package/dist/runtime/drumbeat/registry.js.map +0 -1
  90. package/dist/runtime/drumbeat/relaunchers.d.ts +0 -111
  91. package/dist/runtime/drumbeat/relaunchers.d.ts.map +0 -1
  92. package/dist/runtime/drumbeat/relaunchers.js +0 -242
  93. package/dist/runtime/drumbeat/relaunchers.js.map +0 -1
  94. package/dist/runtime/drumbeat/scan.d.ts +0 -36
  95. package/dist/runtime/drumbeat/scan.d.ts.map +0 -1
  96. package/dist/runtime/drumbeat/scan.js +0 -41
  97. package/dist/runtime/drumbeat/scan.js.map +0 -1
  98. package/dist/runtime/drumbeat/watch.d.ts +0 -74
  99. package/dist/runtime/drumbeat/watch.d.ts.map +0 -1
  100. package/dist/runtime/drumbeat/watch.js +0 -216
  101. package/dist/runtime/drumbeat/watch.js.map +0 -1
  102. package/dist/runtime/escalation/index.d.ts +0 -2
  103. package/dist/runtime/escalation/index.d.ts.map +0 -1
  104. package/dist/runtime/escalation/index.js +0 -2
  105. package/dist/runtime/escalation/index.js.map +0 -1
  106. package/dist/runtime/escalation/registry.d.ts +0 -44
  107. package/dist/runtime/escalation/registry.d.ts.map +0 -1
  108. package/dist/runtime/escalation/registry.js +0 -79
  109. package/dist/runtime/escalation/registry.js.map +0 -1
  110. package/dist/runtime/governance/claims.d.ts +0 -43
  111. package/dist/runtime/governance/claims.d.ts.map +0 -1
  112. package/dist/runtime/governance/claims.js +0 -93
  113. package/dist/runtime/governance/claims.js.map +0 -1
  114. package/dist/runtime/governance/conductor.d.ts +0 -79
  115. package/dist/runtime/governance/conductor.d.ts.map +0 -1
  116. package/dist/runtime/governance/conductor.js +0 -106
  117. package/dist/runtime/governance/conductor.js.map +0 -1
  118. package/dist/runtime/governance/index.d.ts +0 -5
  119. package/dist/runtime/governance/index.d.ts.map +0 -1
  120. package/dist/runtime/governance/index.js +0 -5
  121. package/dist/runtime/governance/index.js.map +0 -1
  122. package/dist/runtime/governance/launch-check.d.ts +0 -93
  123. package/dist/runtime/governance/launch-check.d.ts.map +0 -1
  124. package/dist/runtime/governance/launch-check.js +0 -113
  125. package/dist/runtime/governance/launch-check.js.map +0 -1
  126. package/dist/runtime/governance/spawns.d.ts +0 -50
  127. package/dist/runtime/governance/spawns.d.ts.map +0 -1
  128. package/dist/runtime/governance/spawns.js +0 -91
  129. package/dist/runtime/governance/spawns.js.map +0 -1
  130. package/dist/runtime/identity/bindings.d.ts +0 -84
  131. package/dist/runtime/identity/bindings.d.ts.map +0 -1
  132. package/dist/runtime/identity/bindings.js +0 -112
  133. package/dist/runtime/identity/bindings.js.map +0 -1
  134. package/dist/runtime/identity/index.d.ts +0 -11
  135. package/dist/runtime/identity/index.d.ts.map +0 -1
  136. package/dist/runtime/identity/index.js +0 -6
  137. package/dist/runtime/identity/index.js.map +0 -1
  138. package/dist/runtime/identity/live.d.ts +0 -26
  139. package/dist/runtime/identity/live.d.ts.map +0 -1
  140. package/dist/runtime/identity/live.js +0 -232
  141. package/dist/runtime/identity/live.js.map +0 -1
  142. package/dist/runtime/identity/migration.d.ts +0 -55
  143. package/dist/runtime/identity/migration.d.ts.map +0 -1
  144. package/dist/runtime/identity/migration.js +0 -100
  145. package/dist/runtime/identity/migration.js.map +0 -1
  146. package/dist/runtime/identity/readers.d.ts +0 -42
  147. package/dist/runtime/identity/readers.d.ts.map +0 -1
  148. package/dist/runtime/identity/readers.js +0 -248
  149. package/dist/runtime/identity/readers.js.map +0 -1
  150. package/dist/runtime/identity/resolver.d.ts +0 -50
  151. package/dist/runtime/identity/resolver.d.ts.map +0 -1
  152. package/dist/runtime/identity/resolver.js +0 -61
  153. package/dist/runtime/identity/resolver.js.map +0 -1
  154. package/dist/runtime/identity/workspace-id.d.ts +0 -43
  155. package/dist/runtime/identity/workspace-id.d.ts.map +0 -1
  156. package/dist/runtime/identity/workspace-id.js +0 -138
  157. package/dist/runtime/identity/workspace-id.js.map +0 -1
  158. package/dist/runtime/local-files/index.d.ts +0 -8
  159. package/dist/runtime/local-files/index.d.ts.map +0 -1
  160. package/dist/runtime/local-files/index.js +0 -8
  161. package/dist/runtime/local-files/index.js.map +0 -1
  162. package/dist/runtime/local-files/lease.d.ts +0 -62
  163. package/dist/runtime/local-files/lease.d.ts.map +0 -1
  164. package/dist/runtime/local-files/lease.js +0 -249
  165. package/dist/runtime/local-files/lease.js.map +0 -1
  166. package/dist/runtime/local-files/locks.d.ts +0 -43
  167. package/dist/runtime/local-files/locks.d.ts.map +0 -1
  168. package/dist/runtime/local-files/locks.js +0 -197
  169. package/dist/runtime/local-files/locks.js.map +0 -1
  170. package/dist/runtime/local-files/migrate.d.ts +0 -31
  171. package/dist/runtime/local-files/migrate.d.ts.map +0 -1
  172. package/dist/runtime/local-files/migrate.js +0 -104
  173. package/dist/runtime/local-files/migrate.js.map +0 -1
  174. package/dist/runtime/local-files/paths.d.ts +0 -146
  175. package/dist/runtime/local-files/paths.d.ts.map +0 -1
  176. package/dist/runtime/local-files/paths.js +0 -233
  177. package/dist/runtime/local-files/paths.js.map +0 -1
  178. package/dist/runtime/local-files/presence.d.ts +0 -80
  179. package/dist/runtime/local-files/presence.d.ts.map +0 -1
  180. package/dist/runtime/local-files/presence.js +0 -250
  181. package/dist/runtime/local-files/presence.js.map +0 -1
  182. package/dist/runtime/local-files/schema.d.ts +0 -47
  183. package/dist/runtime/local-files/schema.d.ts.map +0 -1
  184. package/dist/runtime/local-files/schema.js +0 -77
  185. package/dist/runtime/local-files/schema.js.map +0 -1
  186. package/dist/runtime/local-files/store.d.ts +0 -132
  187. package/dist/runtime/local-files/store.d.ts.map +0 -1
  188. package/dist/runtime/local-files/store.js +0 -997
  189. package/dist/runtime/local-files/store.js.map +0 -1
  190. package/dist/runtime/mcp/handlers.d.ts +0 -196
  191. package/dist/runtime/mcp/handlers.d.ts.map +0 -1
  192. package/dist/runtime/mcp/handlers.js +0 -1025
  193. package/dist/runtime/mcp/handlers.js.map +0 -1
  194. package/dist/runtime/mcp/index.d.ts +0 -7
  195. package/dist/runtime/mcp/index.d.ts.map +0 -1
  196. package/dist/runtime/mcp/index.js +0 -7
  197. package/dist/runtime/mcp/index.js.map +0 -1
  198. package/dist/runtime/mcp/notifications.d.ts +0 -47
  199. package/dist/runtime/mcp/notifications.d.ts.map +0 -1
  200. package/dist/runtime/mcp/notifications.js +0 -204
  201. package/dist/runtime/mcp/notifications.js.map +0 -1
  202. package/dist/runtime/mcp/server.d.ts +0 -45
  203. package/dist/runtime/mcp/server.d.ts.map +0 -1
  204. package/dist/runtime/mcp/server.js +0 -94
  205. package/dist/runtime/mcp/server.js.map +0 -1
  206. package/dist/runtime/mcp/sessions.d.ts +0 -68
  207. package/dist/runtime/mcp/sessions.d.ts.map +0 -1
  208. package/dist/runtime/mcp/sessions.js +0 -152
  209. package/dist/runtime/mcp/sessions.js.map +0 -1
  210. package/dist/runtime/mcp/stdio.d.ts +0 -72
  211. package/dist/runtime/mcp/stdio.d.ts.map +0 -1
  212. package/dist/runtime/mcp/stdio.js +0 -274
  213. package/dist/runtime/mcp/stdio.js.map +0 -1
  214. package/dist/runtime/mcp/tools.d.ts +0 -19
  215. package/dist/runtime/mcp/tools.d.ts.map +0 -1
  216. package/dist/runtime/mcp/tools.js +0 -525
  217. package/dist/runtime/mcp/tools.js.map +0 -1
  218. package/dist/runtime/mcp-http/app.d.ts +0 -33
  219. package/dist/runtime/mcp-http/app.d.ts.map +0 -1
  220. package/dist/runtime/mcp-http/app.js +0 -162
  221. package/dist/runtime/mcp-http/app.js.map +0 -1
  222. package/dist/runtime/mcp-http/hosted-mcp-server.d.ts +0 -14
  223. package/dist/runtime/mcp-http/hosted-mcp-server.d.ts.map +0 -1
  224. package/dist/runtime/mcp-http/hosted-mcp-server.js +0 -38
  225. package/dist/runtime/mcp-http/hosted-mcp-server.js.map +0 -1
  226. package/dist/runtime/mcp-http/index.d.ts +0 -18
  227. package/dist/runtime/mcp-http/index.d.ts.map +0 -1
  228. package/dist/runtime/mcp-http/index.js +0 -18
  229. package/dist/runtime/mcp-http/index.js.map +0 -1
  230. package/dist/runtime/mcp-http/main.d.ts +0 -2
  231. package/dist/runtime/mcp-http/main.d.ts.map +0 -1
  232. package/dist/runtime/mcp-http/main.js +0 -14
  233. package/dist/runtime/mcp-http/main.js.map +0 -1
  234. package/dist/runtime/mcp-http/oauth/broker-login.d.ts +0 -50
  235. package/dist/runtime/mcp-http/oauth/broker-login.d.ts.map +0 -1
  236. package/dist/runtime/mcp-http/oauth/broker-login.js +0 -41
  237. package/dist/runtime/mcp-http/oauth/broker-login.js.map +0 -1
  238. package/dist/runtime/mcp-http/oauth/broker-routes.d.ts +0 -45
  239. package/dist/runtime/mcp-http/oauth/broker-routes.d.ts.map +0 -1
  240. package/dist/runtime/mcp-http/oauth/broker-routes.js +0 -54
  241. package/dist/runtime/mcp-http/oauth/broker-routes.js.map +0 -1
  242. package/dist/runtime/mcp-http/oauth/config.d.ts +0 -49
  243. package/dist/runtime/mcp-http/oauth/config.d.ts.map +0 -1
  244. package/dist/runtime/mcp-http/oauth/config.js +0 -69
  245. package/dist/runtime/mcp-http/oauth/config.js.map +0 -1
  246. package/dist/runtime/mcp-http/oauth/crypto.d.ts +0 -14
  247. package/dist/runtime/mcp-http/oauth/crypto.d.ts.map +0 -1
  248. package/dist/runtime/mcp-http/oauth/crypto.js +0 -29
  249. package/dist/runtime/mcp-http/oauth/crypto.js.map +0 -1
  250. package/dist/runtime/mcp-http/oauth/file-store.d.ts +0 -48
  251. package/dist/runtime/mcp-http/oauth/file-store.d.ts.map +0 -1
  252. package/dist/runtime/mcp-http/oauth/file-store.js +0 -101
  253. package/dist/runtime/mcp-http/oauth/file-store.js.map +0 -1
  254. package/dist/runtime/mcp-http/oauth/hono-oauth-router.d.ts +0 -5
  255. package/dist/runtime/mcp-http/oauth/hono-oauth-router.d.ts.map +0 -1
  256. package/dist/runtime/mcp-http/oauth/hono-oauth-router.js +0 -92
  257. package/dist/runtime/mcp-http/oauth/hono-oauth-router.js.map +0 -1
  258. package/dist/runtime/mcp-http/oauth/oidc-rp.d.ts +0 -56
  259. package/dist/runtime/mcp-http/oauth/oidc-rp.d.ts.map +0 -1
  260. package/dist/runtime/mcp-http/oauth/oidc-rp.js +0 -63
  261. package/dist/runtime/mcp-http/oauth/oidc-rp.js.map +0 -1
  262. package/dist/runtime/mcp-http/oauth/redirect-uri.d.ts +0 -11
  263. package/dist/runtime/mcp-http/oauth/redirect-uri.d.ts.map +0 -1
  264. package/dist/runtime/mcp-http/oauth/redirect-uri.js +0 -29
  265. package/dist/runtime/mcp-http/oauth/redirect-uri.js.map +0 -1
  266. package/dist/runtime/mcp-http/oauth/single-tenant-provider.d.ts +0 -70
  267. package/dist/runtime/mcp-http/oauth/single-tenant-provider.d.ts.map +0 -1
  268. package/dist/runtime/mcp-http/oauth/single-tenant-provider.js +0 -247
  269. package/dist/runtime/mcp-http/oauth/single-tenant-provider.js.map +0 -1
  270. package/dist/runtime/mcp-http/oauth/tenancy.d.ts +0 -3
  271. package/dist/runtime/mcp-http/oauth/tenancy.d.ts.map +0 -1
  272. package/dist/runtime/mcp-http/oauth/tenancy.js +0 -18
  273. package/dist/runtime/mcp-http/oauth/tenancy.js.map +0 -1
  274. package/dist/runtime/mcp-http/readonly-allowlist.d.ts +0 -24
  275. package/dist/runtime/mcp-http/readonly-allowlist.d.ts.map +0 -1
  276. package/dist/runtime/mcp-http/readonly-allowlist.js +0 -43
  277. package/dist/runtime/mcp-http/readonly-allowlist.js.map +0 -1
  278. package/dist/runtime/mcp-http/serve.d.ts +0 -39
  279. package/dist/runtime/mcp-http/serve.d.ts.map +0 -1
  280. package/dist/runtime/mcp-http/serve.js +0 -100
  281. package/dist/runtime/mcp-http/serve.js.map +0 -1
  282. package/dist/runtime/mirror/accept.d.ts +0 -57
  283. package/dist/runtime/mirror/accept.d.ts.map +0 -1
  284. package/dist/runtime/mirror/accept.js +0 -70
  285. package/dist/runtime/mirror/accept.js.map +0 -1
  286. package/dist/runtime/mirror/build.d.ts +0 -44
  287. package/dist/runtime/mirror/build.d.ts.map +0 -1
  288. package/dist/runtime/mirror/build.js +0 -37
  289. package/dist/runtime/mirror/build.js.map +0 -1
  290. package/dist/runtime/mirror/index.d.ts +0 -9
  291. package/dist/runtime/mirror/index.d.ts.map +0 -1
  292. package/dist/runtime/mirror/index.js +0 -9
  293. package/dist/runtime/mirror/index.js.map +0 -1
  294. package/dist/runtime/mirror/serve.d.ts +0 -21
  295. package/dist/runtime/mirror/serve.d.ts.map +0 -1
  296. package/dist/runtime/mirror/serve.js +0 -141
  297. package/dist/runtime/mirror/serve.js.map +0 -1
  298. package/dist/runtime/nhi.d.ts +0 -15
  299. package/dist/runtime/nhi.d.ts.map +0 -1
  300. package/dist/runtime/nhi.js +0 -26
  301. package/dist/runtime/nhi.js.map +0 -1
  302. package/dist/runtime/remote/accept.d.ts +0 -50
  303. package/dist/runtime/remote/accept.d.ts.map +0 -1
  304. package/dist/runtime/remote/accept.js +0 -54
  305. package/dist/runtime/remote/accept.js.map +0 -1
  306. package/dist/runtime/remote/client.d.ts +0 -27
  307. package/dist/runtime/remote/client.d.ts.map +0 -1
  308. package/dist/runtime/remote/client.js +0 -34
  309. package/dist/runtime/remote/client.js.map +0 -1
  310. package/dist/runtime/remote/index.d.ts +0 -5
  311. package/dist/runtime/remote/index.d.ts.map +0 -1
  312. package/dist/runtime/remote/index.js +0 -5
  313. package/dist/runtime/remote/index.js.map +0 -1
  314. package/dist/runtime/remote/serve.d.ts +0 -27
  315. package/dist/runtime/remote/serve.d.ts.map +0 -1
  316. package/dist/runtime/remote/serve.js +0 -26
  317. package/dist/runtime/remote/serve.js.map +0 -1
  318. package/dist/runtime/remote/server.d.ts +0 -36
  319. package/dist/runtime/remote/server.d.ts.map +0 -1
  320. package/dist/runtime/remote/server.js +0 -100
  321. package/dist/runtime/remote/server.js.map +0 -1
  322. package/dist/runtime/sysml/client.d.ts +0 -44
  323. package/dist/runtime/sysml/client.d.ts.map +0 -1
  324. package/dist/runtime/sysml/client.js +0 -52
  325. package/dist/runtime/sysml/client.js.map +0 -1
  326. package/dist/runtime/sysml/disclosure.d.ts +0 -23
  327. package/dist/runtime/sysml/disclosure.d.ts.map +0 -1
  328. package/dist/runtime/sysml/disclosure.js +0 -53
  329. package/dist/runtime/sysml/disclosure.js.map +0 -1
  330. package/dist/runtime/sysml/index.d.ts +0 -4
  331. package/dist/runtime/sysml/index.d.ts.map +0 -1
  332. package/dist/runtime/sysml/index.js +0 -4
  333. package/dist/runtime/sysml/index.js.map +0 -1
  334. package/dist/runtime/sysml/verify.d.ts +0 -32
  335. package/dist/runtime/sysml/verify.d.ts.map +0 -1
  336. package/dist/runtime/sysml/verify.js +0 -67
  337. package/dist/runtime/sysml/verify.js.map +0 -1
  338. package/dist/runtime/upgrade/index.d.ts +0 -96
  339. package/dist/runtime/upgrade/index.d.ts.map +0 -1
  340. package/dist/runtime/upgrade/index.js +0 -185
  341. package/dist/runtime/upgrade/index.js.map +0 -1
  342. package/dist/runtime/version/agent-version.d.ts +0 -15
  343. package/dist/runtime/version/agent-version.d.ts.map +0 -1
  344. package/dist/runtime/version/agent-version.js +0 -53
  345. package/dist/runtime/version/agent-version.js.map +0 -1
  346. package/skills/h2a/SKILL.md +0 -345
package/dist/cli.js DELETED
@@ -1,4530 +0,0 @@
1
- /**
2
- * `h2a` CLI dispatcher — stable JSON output contract + exit-code table (DEC-034).
3
- *
4
- * Output shapes
5
- * -------------
6
- * Every JSON-emitting verb writes ONE of three canonical envelopes on stdout:
7
- *
8
- * - **resource** — bare JSON of a single entity. Used by verbs that return the
9
- * persisted/loaded record itself (`negotiate open`, `negotiate status`,
10
- * `negotiate event`, `negotiate offer`, `negotiate counter`, `negotiate sign`,
11
- * `inbox pop`, `host setup --print`).
12
- * - **list** — bare JSON array. Used by `hosts`, `mcp-tools`, `discover`,
13
- * `inbox read`, `outbox read`, `negotiate journal`.
14
- * - **action** — `{ ok: true, ...details }` confirmation envelope. Used by
15
- * verbs that perform side effects without a natural entity to return
16
- * (`init`, `register`, `inbox put`, `outbox put`, `negotiate stabilize`,
17
- * `host setup --write`).
18
- *
19
- * Stderr lines always follow `h2a <verb> [sub]: <message>` so callers can
20
- * grep them deterministically. The `mcp-serve` verb is a long-running
21
- * JSON-RPC 2.0 stdio transport and does not fit the envelope contract.
22
- *
23
- * Exit codes
24
- * ----------
25
- *
26
- * - `0` — success.
27
- * - `1` — user error: missing/bad flag, invalid JSON, validation failure on
28
- * caller-supplied data, unknown verb/subverb/host.
29
- * - `2` — runtime/state error: store conflict or business-rule violation
30
- * (negotiation not found, already open, already stabilized, signature
31
- * fails verification, quorum incomplete, broken journal, divergent
32
- * pre-existing config file refusing merge without `--force`).
33
- * - `3` — I/O / OS error: file unreadable, permission denied, write
34
- * refused by the filesystem.
35
- *
36
- * The full machine-readable manifest lives in `./cli-contract.ts`
37
- * (`H2A_CLI_VERB_CONTRACTS`). Human-readable reference: `docs/cli-contract.md`.
38
- */
39
- import { execFileSync, spawnSync } from "node:child_process";
40
- import { generateKeyPairSync } from "node:crypto";
41
- import { copyFileSync, existsSync, mkdirSync, readdirSync, realpathSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
42
- import { homedir, hostname } from "node:os";
43
- import { dirname, join, resolve as resolvePath } from "node:path";
44
- import { fileURLToPath } from "node:url";
45
- import { H2A_ATTESTER_COMPREHENSION_RIGHT, H2A_COMPREHENSION_ATTESTATION_BODY_KIND, H2A_DECLARATION_INTERET_BODY_KIND, H2A_ORG_MANIFEST_FILENAME, H2A_ORG_PROPOSAL_BODY_KIND, H2A_ORG_RATIFIED_BODY_KIND, H2A_ROLES, H2A_WORK_STATUSES, auditNhiPosture, buildComprehensionAttestation, canAttestComprehension, checkEnvelopeFreshness, computeHash, createEnvelope, deriveWorkspaceId, diffOrgManifest, effectiveOrgInstances, isComprehensionAttestation, nhiAttestationEnvelope, nhiInventory, deriveConnectionConfidence, nhiTrustBundle, orgAssignmentEnvelope, parseOrgManifest, signCanonical, signEnvelope, subagentAddress, validateOrgManifest, verifyComprehensionAttestation } from "@sentropic/h2a";
46
- import { H2A_CLAUDE_HOST } from "./hosts/claude.js";
47
- import { H2A_CODEX_HOST } from "./hosts/codex.js";
48
- import { H2A_GEMINI_HOST } from "./hosts/gemini.js";
49
- import { H2A_AGY_HOST } from "./hosts/agy.js";
50
- import { H2A_CLI_MCP_TOOL_NAMES } from "./mcp.js";
51
- import { renderStopHook, claudeStopHookEntry, claudeDriveReceiveHookEntry, isH2ADriveReceiveHook, isH2ARecordHook, codexPluginManifest, codexMarketplaceManifest, codexPluginTrustCommands, H2A_CODEX_PLUGIN_NAME, H2A_HOST_PLUGIN_HOSTS } from "./hosts/plugin.js";
52
- import { H2A_STORE_SCHEMA_VERSION, assertHostQualifiedAddress, canonicalAddress, createLocalStore, listPresence, reapAllDeadPresence, reapDeadInstancePresence, readPresence, resolveRecipient, safePathSegment, sanitizeStorePaths, writePresence } from "./runtime/local-files/index.js";
53
- import { runMcpStdio } from "./runtime/mcp/index.js";
54
- import { renderK8sSidecar } from "./runtime/deploy/k8s-sidecar.js";
55
- import { renderK8sTenant } from "./runtime/deploy/k8s-tenant.js";
56
- import { remoteServerForStore, sendRemoteEnvelope } from "./runtime/remote/index.js";
57
- import { buildInstanceMirror, mirrorServerForStore } from "./runtime/mirror/index.js";
58
- import { recordStop, scanDrumbeat, clearDrumbeatEntry, runDrumbeatWatch as runDrumbeatWatchLoop, loggingRelauncher, localTmuxRelauncher, headlessRelauncher, remoteRelauncher, chainRelauncher, relanceFromInbox, loggingDecider, subagentDecider, H2A_DEFAULT_MAX_RELANCES } from "./runtime/drumbeat/index.js";
59
- import { gatherNhiSnapshot } from "./runtime/nhi.js";
60
- import { raiseBlockage, listBlockages, resolveBlockage } from "./runtime/blockage/index.js";
61
- import { recordEscalation, listEscalations, clearEscalation } from "./runtime/escalation/index.js";
62
- import { authorizeDrive, chainDriver, formatSignedDriveInstruction, headlessDriver, localTmuxDriver, loggingDriver, nativeBackchannelDriver, remoteDriveServerForStore, verifyDriveOnReceive } from "./runtime/drive/index.js";
63
- import { verifyEnvelopeSysmlRef } from "./runtime/sysml/index.js";
64
- import { checkUpgrade, performUpgrade, currentCliVersion, upgradeCachePath, canReexec, reexecSelf, H2A_AUTO_UPGRADE_CHECK_TTL_MS, H2A_REEXEC_GUARD_ENV, H2A_UPGRADE_CHECK_TTL_MS } from "./runtime/upgrade/index.js";
65
- import { resolveLiveIdentity } from "./runtime/identity/index.js";
66
- import { conductorFor } from "./runtime/governance/conductor.js";
67
- import { appendConductorClaim } from "./runtime/governance/claims.js";
68
- import { conductorLaunchCheck } from "./runtime/governance/launch-check.js";
69
- import { lastSpawnRequestAt, recordSpawnRequest, spawnAllowed } from "./runtime/governance/spawns.js";
70
- const HERE = dirname(fileURLToPath(import.meta.url));
71
- // `dist/cli.js` lives in `packages/h2a-cli/dist/`; skills are at
72
- // `packages/h2a-cli/skills/`. Two levels up from the dist file.
73
- const SKILLS_DIR = resolvePath(HERE, "..", "skills");
74
- /**
75
- * Pattern matchers used to map known store-level error messages to exit code
76
- * 2 (state/runtime conflict) instead of the default 1 (user error). Anything
77
- * not matched here keeps the conservative 1 — DEC-034 explicitly opts for
78
- * "don't over-promote".
79
- */
80
- const STORE_STATE_ERROR_PATTERNS = [
81
- /already registered/i,
82
- /already open/i,
83
- /already stabilized/i,
84
- /not found/i,
85
- /no such envelope/i,
86
- /fails verification/i,
87
- /no artifactHash has the full quorum/i,
88
- /no offer\/counter event matches/i,
89
- /stabilized artifact already on disk/i,
90
- /attester-comprehension/i,
91
- // DEC-069/070/072: subagent binding/routing/revocation preconditions.
92
- /not registered/i,
93
- /revoked/i,
94
- /key not active/i,
95
- /parent-not-agents/,
96
- /parent-instance-mismatch/,
97
- /capabilities-exceed-parent/
98
- ];
99
- function classifyStoreError(message) {
100
- for (const pattern of STORE_STATE_ERROR_PATTERNS) {
101
- if (pattern.test(message))
102
- return 2;
103
- }
104
- return 1;
105
- }
106
- const CLI_HOSTS = [
107
- H2A_CODEX_HOST,
108
- H2A_CLAUDE_HOST,
109
- H2A_GEMINI_HOST,
110
- H2A_AGY_HOST
111
- ];
112
- export function renderCliHelp() {
113
- return [
114
- "h2a",
115
- "",
116
- "Human-to-agent coordination CLI.",
117
- "",
118
- "Usage:",
119
- " h2a --help",
120
- " h2a hosts",
121
- " h2a mcp-tools",
122
- " h2a init [--root <path>]",
123
- " h2a register --json <json> [--root <path>]",
124
- " h2a discover [--role <role>] [--scope <scope>] [--root <path>]",
125
- " h2a subagent register --parent <instance> --name <name> [--capabilities a,b] [--root <path>]",
126
- " h2a subagent list [--parent <instance>] [--root <path>]",
127
- " h2a subagent route --to <subagent-address> --json <envelope> [--mailbox inbox|outbox] [--root <path>]",
128
- " h2a subagent inbox --parent <instance> [--root <path>]",
129
- " h2a subagent audit (--id <subagent-address> | --parent <instance>) [--root <path>]",
130
- " h2a subagent revoke --id <subagent-address> [--reason <text>] [--root <path>]",
131
- " h2a negotiate open --json <record-json> [--root <path>]",
132
- " h2a negotiate status --id <id> --status <status> [--root <path>]",
133
- " h2a negotiate event --id <id> --json <payload-json> [--causation-id <id>] [--correlation-id <id>] [--root <path>]",
134
- " h2a negotiate offer --id <id> --instance <id> --artifact <json> [--event-id <id>] [--causation-id <id>] [--correlation-id <id>] [--root <path>]",
135
- " h2a negotiate counter --id <id> --instance <id> --artifact <json> [--event-id <id>] [--causation-id <id>] [--correlation-id <id>] [--root <path>]",
136
- " h2a negotiate sign --id <id> --instance <id> --artifact <json> --private-key <pem-path> [--event-id <id>] [--causation-id <id>] [--correlation-id <id>] [--root <path>]",
137
- " h2a negotiate stabilize --id <id> [--event-id <id>] [--root <path>]",
138
- " h2a negotiate journal --id <id> [--root <path>]",
139
- " h2a declare-interest --negotiation <id> --instance <id> --interets <a,b> [--bindings <scope,...>] [--masque-impact-collectif] [--event-id <id>] [--root <path>]",
140
- " h2a conflict-posture --negotiation <id> [--root <path>]",
141
- " h2a dossier --negotiation <id> [--presenter <id>] [--advisory-gate] [--event-id <id>] [--root <path>]",
142
- " h2a confiance --negotiation <id> [--root <path>]",
143
- " h2a attest-comprehension --instance <id> --dossier <file|sha256:...> --private-key <pem-path> [--negotiation <id> | --to <instance>] [--role <role>] [--scope <scope>] [--root <path>]",
144
- " h2a comprehension list --negotiation <id> [--root <path>]",
145
- " h2a comprehension verify --json <event-or-envelope-json> --public-key <pem-file>",
146
- "",
147
- "Auto-propagation (DEC-033):",
148
- " offer/counter/sign/event inherit causationId from the previous journal",
149
- " entry's id, and correlationId from the previous entry's correlationId.",
150
- " Explicit --causation-id / --correlation-id flags always override the",
151
- " inherited default; pass them on the first offer to start a fresh thread.",
152
- " h2a inbox put --instance <id> --json <envelope> [--root <path>]",
153
- " h2a inbox read --instance <id> [--root <path>]",
154
- " h2a inbox pop --instance <id> --envelope <id> [--root <path>]",
155
- " h2a outbox put --instance <id> --json <envelope> [--root <path>]",
156
- " h2a outbox read --instance <id> [--root <path>]",
157
- " h2a mcp-serve [--root <path>] [--auto-open [--host <h>] [--instance <id>] [--scope <s>]] [--wake <native|logging>] [--upgrade-check | --auto-upgrade [--no-restart]] (--auto-open joins the bus at startup; --wake injects a signed h2a-tagged wake into the host on inbox arrival, EVO-1, needs --auto-open; --auto-upgrade self-updates + restarts in place; --upgrade-check = notice only; both opt-in/no network by default; /h2a disconnect to leave)",
158
- " h2a upgrade [--check] (--check: report current vs latest; bare: npm i -g @sentropic/h2a-cli@latest)",
159
- " h2a remote serve [--port <n>] [--host <h>] [--path </h2a/envelopes>] [--root <path>]",
160
- " h2a remote send --url <u> --instance <signer> --private-key <pem> --json <envelope>",
161
- " h2a remote mirror-serve [--port <n>] [--host <h>] [--path </h2a/mirror>] [--enrolled-keys-file <json>] [--root <path>] (EVO-13 instance-mirror ingester; enrolled keys also via H2A_MIRROR_ENROLLED_KEYS base64)",
162
- " h2a remote mirror --url <u> --instance <id> --private-key <pem> [--root <path>] (push this instance's registration to a remote ingester)",
163
- " h2a drive --from <instance> --to <instance> --instruction <text> --private-key <pem> [--driver logging|native|local-tmux|headless|auto] [--host <host>] [--root <path>]",
164
- " h2a drive receive --to <instance> (--line <signed-line> | --stdin) [--ignore-non-drive] [--root <path>] (verify-before-act gate for host hooks)",
165
- " h2a drive serve --to <instance> --inject-command <command> [--port <n>] [--host <h>] [--path </h2a/drive>] [--root <path>] (remote verify-before-inject service)",
166
- " h2a drumbeat record --instance <id> --status <working|paused|done|blocked|out-of-tokens> [--command <c>] [--resume-command <c>] [--tmux-session <s> --tmux-pane <p>] [--root <path>]",
167
- " h2a drumbeat scan [--max-relances <n>] [--root <path>]",
168
- " h2a drumbeat clear --instance <id> [--root <path>]",
169
- " h2a drumbeat escalations [--root <path>]",
170
- " h2a drumbeat relance-inbox [--instance <id>] [--relauncher logging|local-tmux|headless|auto] [--root <path>]",
171
- " h2a drumbeat watch [--interval-ms <n>] [--max-relances <n>] [--relauncher logging|local-tmux|remote|headless|auto] [--instance <signer> --private-key <pem>] [--decider logging|<command>] [--decider-after <k>] [--decider-enforce] [--root <path>]",
172
- " h2a host setup --host <codex|claude|gemini|agy> [--root <path>] [--print | --write <file>] [--force] [--no-wake] (renders mcp-serve --auto-open --auto-upgrade --wake local-tmux by default; --no-wake drops wake)",
173
- " h2a host status [--host <name>]",
174
- " h2a host plugin --host <codex|claude|gemini|agy> --instance <id> [--status <work-status>] [--root <path>] [--write <settings.json> [--force]] [--scaffold <dir>] (--write installs the Stop hook for claude|gemini|codex; --scaffold writes codex's full local marketplace + trust step; agy is poll-only)",
175
- " h2a store migrate [--from <v>] [--to <v>] [--sanitize-paths] [--dry-run] [--root <path>]",
176
- "",
177
- "High-level coordination (DEC-054):",
178
- " h2a connect --host <codex|claude|gemini|agy|remote> [--root <path>] [--instance <id>] [--name <display>]",
179
- " h2a conductor [--workspace <id|path>] [--root <path>] (who is the live conductor/owner of a workspace — derived from presence; conductor=role CONDUCTOR if set, else null; candidates=in-workspace live agents)",
180
- " h2a conductor-launch-check [--workspace <id|path>] [--root <path>] [--idle-ms <ms>] (DRY-RUN: polls track workspace-activity; recommends launching a conductor if work is stalled and none is live — h2a does NOT spawn; launch parked pending spawn policy + remote)",
181
- " h2a conductor-launch --workspace <id|path> [--root <path>] [--idle-ms <ms>] [--confirm] [--remote <instance>] [--instance <self>] (D3 EMIT: if stalled+no conductor, emits a launch-REQUEST envelope to a live remote agent — gated by --confirm + 1/30min/workspace cap; h2a NEVER spawns; remote does the actual spawn)",
182
- " h2a doctor [--root <path>] [--scan <dir>] [--prune] (--prune deletes host-less/phantom/orphan inbox dirs + stray buses; dry-run by default)",
183
- " h2a keepalive [--root <path>] [--interval <ms>] [--once] (external keepalive prober — refreshes presence for agents whose tmux pane is still alive)",
184
- " h2a rename --instance <id> --name <name> [--root <path>] (set a live session's display name so peers can find it via discover --name)",
185
- " h2a status [--root <path>] [--scope <s>] [--instance <i>]",
186
- " h2a sessions [--root <path>] [--scope <s>] [--instance <i>]",
187
- " h2a thread --id <threadId> --instance <self> [--root <path>] (the ordered conversation for a thread, from your inbox+outbox)",
188
- " h2a keys generate --instance <id> [--out <dir>] [--root <path>]",
189
- " h2a keys add --instance <id> --public-key <pem-file> [--root <path>]",
190
- " h2a keys list --instance <id> [--root <path>]",
191
- " h2a keys revoke --instance <id> --public-key <pem-file> [--root <path>]",
192
- " h2a nhi report [--long-lived-days <n>] [--root <path>]",
193
- " h2a nhi inventory [--long-lived-days <n>] [--root <path>]",
194
- " h2a nhi attest --instance <id> --private-key <pem-file> [--role <role>] [--scope <scope>] [--root <path>]",
195
- " h2a nhi offboard --instance <id> [--reason <text>] [--root <path>]",
196
- " h2a nhi export --instance <id> --trust-domain <domain> [--root <path>]",
197
- " h2a blockage raise --instance <id> --reason <text> [--scope <s>] [--needs <text>] [--root <path>]",
198
- " h2a blockage list [--scope <s>] [--active] [--root <path>]",
199
- " h2a blockage resolve --instance <id> [--by <id>] [--root <path>]",
200
- " h2a sysml verify --json <envelope> --public-key <pem-file> [--by <id>] [--content-integrity --api-base <url> [--auth <token>]]",
201
- " h2a install-skills --host <claude|codex|gemini|agy> [--scope user|project] [--force]",
202
- " h2a deploy k8s-sidecar [--instance <id>] [--host <h>] [--root <path>] [--image <ref>] [--cli-version <ver>] [--write <file>]",
203
- " h2a deploy k8s-tenant [--namespace <ns>] [--root <path>] [--replicas <n>] [--storage <size>] [--storage-class <sc>] [--lease-ms <ms>] [--image <ref>] [--cli-version <ver>] [--write <file>]",
204
- "",
205
- `Hosts: ${CLI_HOSTS.map((host) => host.host).join(", ")}`,
206
- `MCP tools: ${H2A_CLI_MCP_TOOL_NAMES.join(", ")}`
207
- ].join("\n");
208
- }
209
- function parseFlags(argv) {
210
- const [command, ...rest] = argv;
211
- const flags = {};
212
- for (let i = 0; i < rest.length; i++) {
213
- const token = rest[i];
214
- if (token.startsWith("--")) {
215
- const key = token.slice(2);
216
- const next = rest[i + 1];
217
- if (next !== undefined && !next.startsWith("--")) {
218
- flags[key] = next;
219
- i++;
220
- }
221
- else {
222
- flags[key] = "true";
223
- }
224
- }
225
- }
226
- return { command, flags };
227
- }
228
- /**
229
- * Resolve the h2a store root, with its provenance.
230
- *
231
- * Precedence: explicit `--root` flag → `H2A_ROOT` env → the shared global
232
- * default `~/h2a-workspace/.h2a`. The global default lets all agents on the
233
- * same machine share one bus without any explicit configuration — eliminating
234
- * the split-brain failure (F4) that the old `cwd/.h2a` fallback caused.
235
- * Callers on long-lived paths (`mcp-serve`, `connect`) warn when a repo-local
236
- * `.h2a` exists and is being ignored in favour of the shared bus.
237
- */
238
- function resolveRootInfo(flags, cwd) {
239
- if (flags.root)
240
- return { root: flags.root, source: "flag" };
241
- const env = process.env.H2A_ROOT;
242
- if (env && env.length > 0)
243
- return { root: env, source: "env" };
244
- return { root: join(homedir(), "h2a-workspace", ".h2a"), source: "default" };
245
- }
246
- function resolveRoot(flags, cwd) {
247
- return resolveRootInfo(flags, cwd).root;
248
- }
249
- /**
250
- * Warn (once, to stderr) when the resolved root (from the shared default OR
251
- * from H2A_ROOT env) differs from a repo-local `.h2a` that exists in the
252
- * current working directory. The user may have intended to use the local bus —
253
- * so we alert them to pass `--root <cwd>/.h2a` if that was their intent. When
254
- * no local `.h2a` exists around the cwd, or when an explicit `--root` flag was
255
- * used (the user was unambiguous), stay silent. Used by `mcp-serve` and `connect`.
256
- */
257
- function warnIfCwdRootFallback(flags, cwd, streams) {
258
- const info = resolveRootInfo(flags, cwd);
259
- // Only warn for implicit resolution (default or env). An explicit --root flag
260
- // means the user was unambiguous about which bus to use.
261
- if (info.source === "flag")
262
- return;
263
- const cwdLocal = join(cwd(), ".h2a");
264
- try {
265
- if (!existsSync(cwdLocal))
266
- return;
267
- const resolvedLocal = realpathSync(cwdLocal);
268
- const resolvedRoot = (() => {
269
- try {
270
- return realpathSync(info.root);
271
- }
272
- catch {
273
- return info.root;
274
- }
275
- })();
276
- if (resolvedLocal === resolvedRoot)
277
- return;
278
- streams.stderr.write(`h2a: a repo-local .h2a exists here but I'm using the shared bus ${info.root}. ` +
279
- `Pass --root ${cwdLocal} if you meant the local one.\n`);
280
- }
281
- catch {
282
- // silently ignore filesystem errors
283
- }
284
- }
285
- /**
286
- * Resolve `causationId` / `correlationId` for a new negotiation event.
287
- *
288
- * - Explicit `--causation-id` / `--correlation-id` flags always win.
289
- * - Otherwise, the values are inherited from the **previous journal entry**
290
- * on the same negotiation (DEC-033): `causationId` defaults to the previous
291
- * entry's `id`, `correlationId` is propagated as-is so the whole negotiation
292
- * acts as a single correlation thread by default.
293
- */
294
- function resolveCausationCorrelation(flags, previous) {
295
- const explicitCausation = flags["causation-id"];
296
- const explicitCorrelation = flags["correlation-id"];
297
- const out = {};
298
- if (explicitCausation) {
299
- out.causationId = explicitCausation;
300
- }
301
- else if (previous) {
302
- out.causationId = previous.id;
303
- }
304
- if (explicitCorrelation) {
305
- out.correlationId = explicitCorrelation;
306
- }
307
- else if (previous && previous.correlationId !== undefined) {
308
- out.correlationId = previous.correlationId;
309
- }
310
- return out;
311
- }
312
- function cmdInit(flags, streams) {
313
- const cwd = streams.cwd ?? (() => process.cwd());
314
- const root = resolveRoot(flags, cwd);
315
- try {
316
- const store = createLocalStore({ root });
317
- streams.stdout.write(`${JSON.stringify({ ok: true, root: store.paths.root }, null, 2)}\n`);
318
- return 0;
319
- }
320
- catch (error) {
321
- // `createLocalStore` is the one place this verb can fail, and the only
322
- // failure mode in practice is filesystem-level (cannot mkdir under root,
323
- // permission denied, …). Surface those as exit code 3.
324
- streams.stderr.write(`h2a init: ${error.message}\n`);
325
- return 3;
326
- }
327
- }
328
- function cmdRegister(flags, streams) {
329
- if (!flags.json) {
330
- streams.stderr.write("h2a register: --json <registration-json> is required\n");
331
- return 1;
332
- }
333
- let registration;
334
- try {
335
- registration = JSON.parse(flags.json);
336
- }
337
- catch (error) {
338
- streams.stderr.write(`h2a register: invalid JSON (${error.message})\n`);
339
- return 1;
340
- }
341
- const cwd = streams.cwd ?? (() => process.cwd());
342
- const root = resolveRoot(flags, cwd);
343
- const store = createLocalStore({ root });
344
- try {
345
- store.registerInstance(registration);
346
- }
347
- catch (error) {
348
- const message = error.message;
349
- streams.stderr.write(`h2a register: ${message}\n`);
350
- return classifyStoreError(message);
351
- }
352
- streams.stdout.write(`${JSON.stringify({ ok: true, id: registration.id, root: store.paths.root }, null, 2)}\n`);
353
- return 0;
354
- }
355
- function cmdSubagent(argv, streams) {
356
- // DEC-068 (V2): manage addressable subagent bindings under a parent AGENTS
357
- // instance. `register` constructs + persists a binding; `list` enumerates.
358
- const { command: sub, flags } = parseFlags(argv);
359
- const cwd = streams.cwd ?? (() => process.cwd());
360
- const root = resolveRoot(flags, cwd);
361
- const store = createLocalStore({ root });
362
- if (sub === "list") {
363
- const entries = flags.parent
364
- ? store.listSubagentsOf(flags.parent)
365
- : store.listSubagents();
366
- // DEC-072: annotate each binding with its derived lifecycle status.
367
- const annotated = entries.map((b) => ({
368
- ...b,
369
- status: store.subagentStatus(b.id)
370
- }));
371
- streams.stdout.write(`${JSON.stringify(annotated, null, 2)}\n`);
372
- return 0;
373
- }
374
- if (sub === "revoke") {
375
- if (!flags.id) {
376
- streams.stderr.write("h2a subagent revoke: --id <subagent-address> is required\n");
377
- return 1;
378
- }
379
- try {
380
- store.revokeSubagent(flags.id, flags.reason);
381
- }
382
- catch (error) {
383
- const message = error.message;
384
- streams.stderr.write(`h2a subagent revoke: ${message}\n`);
385
- return classifyStoreError(message);
386
- }
387
- streams.stdout.write(`${JSON.stringify({ ok: true, id: flags.id, status: "revoked", ...(flags.reason ? { reason: flags.reason } : {}) }, null, 2)}\n`);
388
- return 0;
389
- }
390
- if (sub === "route") {
391
- if (!flags.to || !flags.json) {
392
- streams.stderr.write("h2a subagent route: --to <subagent-address> and --json <envelope-json> are required\n");
393
- return 1;
394
- }
395
- const mailbox = flags.mailbox === "outbox" ? "outbox" : "inbox";
396
- if (flags.mailbox && flags.mailbox !== "inbox" && flags.mailbox !== "outbox") {
397
- streams.stderr.write(`h2a subagent route: --mailbox must be inbox or outbox (got "${flags.mailbox}")\n`);
398
- return 1;
399
- }
400
- let envelope;
401
- try {
402
- envelope = JSON.parse(flags.json);
403
- }
404
- catch (error) {
405
- streams.stderr.write(`h2a subagent route: invalid JSON (${error.message})\n`);
406
- return 1;
407
- }
408
- try {
409
- store.routeToSubagent(flags.to, envelope, mailbox);
410
- }
411
- catch (error) {
412
- const message = error.message;
413
- streams.stderr.write(`h2a subagent route: ${message}\n`);
414
- return classifyStoreError(message);
415
- }
416
- streams.stdout.write(`${JSON.stringify({ ok: true, id: envelope.id, to: flags.to, mailbox }, null, 2)}\n`);
417
- return 0;
418
- }
419
- if (sub === "inbox") {
420
- if (!flags.parent) {
421
- streams.stderr.write("h2a subagent inbox: --parent <instance> is required\n");
422
- return 1;
423
- }
424
- const fanIn = store.readSubagentInboxes(flags.parent);
425
- streams.stdout.write(`${JSON.stringify(fanIn, null, 2)}\n`);
426
- return 0;
427
- }
428
- if (sub === "audit") {
429
- if (!flags.id && !flags.parent) {
430
- streams.stderr.write("h2a subagent audit: --id <subagent-address> or --parent <instance> is required\n");
431
- return 1;
432
- }
433
- const events = flags.id
434
- ? store.readSubagentAudit(flags.id)
435
- : store.readSubagentAuditOf(flags.parent);
436
- streams.stdout.write(`${JSON.stringify(events, null, 2)}\n`);
437
- return 0;
438
- }
439
- if (sub === "register") {
440
- if (!flags.parent || !flags.name) {
441
- streams.stderr.write("h2a subagent register: --parent <instance> and --name <name> are required\n");
442
- return 1;
443
- }
444
- const capabilities = flags.capabilities
445
- ? flags.capabilities.split(",").map((c) => c.trim()).filter((c) => c.length > 0)
446
- : undefined;
447
- const binding = {
448
- id: subagentAddress(flags.parent, flags.name),
449
- parentInstance: flags.parent,
450
- name: flags.name,
451
- ...(capabilities ? { capabilities } : {}),
452
- createdAt: new Date().toISOString()
453
- };
454
- try {
455
- store.registerSubagent(binding);
456
- }
457
- catch (error) {
458
- const message = error.message;
459
- streams.stderr.write(`h2a subagent register: ${message}\n`);
460
- return classifyStoreError(message);
461
- }
462
- streams.stdout.write(`${JSON.stringify({ ok: true, id: binding.id, parentInstance: binding.parentInstance, root: store.paths.root }, null, 2)}\n`);
463
- return 0;
464
- }
465
- streams.stderr.write(`h2a subagent: subcommand required (supported: register, list, route, inbox, audit, revoke)\n`);
466
- return 1;
467
- }
468
- function cmdMailbox(argv, mailbox, streams) {
469
- const { command: sub, flags } = parseFlags(argv);
470
- const cwd = streams.cwd ?? (() => process.cwd());
471
- const root = resolveRoot(flags, cwd);
472
- const store = createLocalStore({ root });
473
- if (!flags.instance) {
474
- streams.stderr.write(`h2a ${mailbox} ${sub ?? ""}: --instance <id> required\n`);
475
- return 1;
476
- }
477
- if (sub === "put") {
478
- if (!flags.json) {
479
- streams.stderr.write(`h2a ${mailbox} put: --json <envelope-json> required\n`);
480
- return 1;
481
- }
482
- let envelope;
483
- try {
484
- envelope = JSON.parse(flags.json);
485
- }
486
- catch (error) {
487
- streams.stderr.write(`h2a ${mailbox} put: invalid JSON (${error.message})\n`);
488
- return 1;
489
- }
490
- if (mailbox === "inbox") {
491
- try {
492
- assertHostQualifiedAddress(flags.instance, "recipient");
493
- }
494
- catch (error) {
495
- streams.stderr.write(`\n${error.message}\n`);
496
- return 1;
497
- }
498
- // WP-2: resolve-before-send — legibility gate (does NOT change destination).
499
- const root = resolveRoot(flags, streams.cwd ?? (() => process.cwd()));
500
- const liveSessions = listPresence(root).map((s) => s.instance);
501
- const registeredIds = store.listInstances().map((i) => i.instance ?? i.id);
502
- const resolution = resolveRecipient({
503
- target: flags.instance,
504
- liveInstances: liveSessions,
505
- registeredInstances: registeredIds
506
- });
507
- if (resolution.kind === "refuse") {
508
- streams.stderr.write(`\nh2a: ${resolution.reason}${resolution.candidates ? `\ncandidates: ${resolution.candidates.join(", ")}` : ""}\n`);
509
- return 1;
510
- }
511
- // deliver / deliver-dormant / deliver-hint → proceed with put below.
512
- }
513
- try {
514
- if (mailbox === "inbox") {
515
- store.putInboxMessage(flags.instance, envelope);
516
- }
517
- else {
518
- store.putOutboxMessage(flags.instance, envelope);
519
- }
520
- // Bug-2 backstop: tell the caller whether the recipient is actually live
521
- // (the write always succeeds — a dormant deposit-for-wake is legitimate).
522
- const recipientLive = mailbox === "inbox"
523
- ? listPresence(store.paths.root).some((s) => canonicalAddress(s.instance) === canonicalAddress(flags.instance))
524
- : undefined;
525
- // WP-2: enrich stdout with resolution metadata.
526
- let resolutionMeta = {};
527
- if (mailbox === "inbox") {
528
- const root2 = resolveRoot(flags, streams.cwd ?? (() => process.cwd()));
529
- const liveSessions2 = listPresence(root2).map((s) => s.instance);
530
- const registeredIds2 = store.listInstances().map((i) => i.instance ?? i.id);
531
- const resolution2 = resolveRecipient({
532
- target: flags.instance,
533
- liveInstances: liveSessions2,
534
- registeredInstances: registeredIds2
535
- });
536
- resolutionMeta = {
537
- resolution: resolution2.kind,
538
- ...(resolution2.kind === "deliver-hint" ? { liveCandidate: resolution2.liveCandidate, reason: resolution2.reason } : {}),
539
- ...(resolution2.kind === "deliver-dormant" ? { reason: resolution2.reason, dormant: true } : {})
540
- };
541
- }
542
- streams.stdout.write(`${JSON.stringify({ ok: true, id: envelope.id, mailbox, instance: flags.instance, ...(recipientLive !== undefined ? { recipientLive } : {}), ...resolutionMeta }, null, 2)}\n`);
543
- return 0;
544
- }
545
- catch (error) {
546
- const message = error.message;
547
- streams.stderr.write(`h2a ${mailbox} put: ${message}\n`);
548
- // Envelope-shape failures are user/validation errors (exit 1); only
549
- // state-level conflicts escalate to exit 2 (none currently emitted here,
550
- // but the classifier keeps the door open).
551
- return classifyStoreError(message);
552
- }
553
- }
554
- if (sub === "read") {
555
- const messages = mailbox === "inbox" ? store.readInbox(flags.instance) : store.readOutbox(flags.instance);
556
- streams.stdout.write(`${JSON.stringify(messages, null, 2)}\n`);
557
- return 0;
558
- }
559
- if (sub === "pop" && mailbox === "inbox") {
560
- if (!flags.envelope) {
561
- streams.stderr.write("h2a inbox pop: --envelope <id> required\n");
562
- return 1;
563
- }
564
- const popped = store.popInboxMessage(flags.instance, flags.envelope);
565
- if (!popped) {
566
- streams.stderr.write(`h2a inbox pop: no such envelope ${flags.envelope}\n`);
567
- // State conflict against the local store (the envelope is not where the
568
- // caller expected it). Exit code 2 per DEC-034.
569
- return 2;
570
- }
571
- streams.stdout.write(`${JSON.stringify(popped, null, 2)}\n`);
572
- return 0;
573
- }
574
- streams.stderr.write(`Unknown ${mailbox} subcommand: ${sub ?? "<none>"}\n`);
575
- streams.stderr.write(mailbox === "inbox" ? "Use one of: put, read, pop\n" : "Use one of: put, read\n");
576
- return 1;
577
- }
578
- function cmdNegotiate(argv, streams) {
579
- const { command: sub, flags } = parseFlags(argv);
580
- const cwd = streams.cwd ?? (() => process.cwd());
581
- const root = resolveRoot(flags, cwd);
582
- const store = createLocalStore({ root });
583
- if (sub === "open") {
584
- if (!flags.json) {
585
- streams.stderr.write("h2a negotiate open: --json <record-json> required\n");
586
- return 1;
587
- }
588
- let record;
589
- try {
590
- record = JSON.parse(flags.json);
591
- }
592
- catch (error) {
593
- streams.stderr.write(`h2a negotiate open: invalid JSON (${error.message})\n`);
594
- return 1;
595
- }
596
- try {
597
- const opened = store.openNegotiation(record);
598
- streams.stdout.write(`${JSON.stringify(opened, null, 2)}\n`);
599
- return 0;
600
- }
601
- catch (error) {
602
- const message = error.message;
603
- streams.stderr.write(`h2a negotiate open: ${message}\n`);
604
- return classifyStoreError(message);
605
- }
606
- }
607
- if (sub === "status") {
608
- if (!flags.id || !flags.status) {
609
- streams.stderr.write("h2a negotiate status: --id <id> and --status <status> required\n");
610
- return 1;
611
- }
612
- try {
613
- const updated = store.updateNegotiationStatus(flags.id, flags.status);
614
- streams.stdout.write(`${JSON.stringify(updated, null, 2)}\n`);
615
- return 0;
616
- }
617
- catch (error) {
618
- const message = error.message;
619
- streams.stderr.write(`h2a negotiate status: ${message}\n`);
620
- return classifyStoreError(message);
621
- }
622
- }
623
- if (sub === "offer" || sub === "counter") {
624
- if (!flags.id || !flags.instance || !flags.artifact) {
625
- streams.stderr.write(`h2a negotiate ${sub}: --id <id> --instance <id> --artifact <json> required\n`);
626
- return 1;
627
- }
628
- const record = store.readNegotiation(flags.id);
629
- if (!record) {
630
- streams.stderr.write(`h2a negotiate ${sub}: negotiation ${flags.id} not found\n`);
631
- return 2;
632
- }
633
- let artifact;
634
- try {
635
- artifact = JSON.parse(flags.artifact);
636
- }
637
- catch (error) {
638
- streams.stderr.write(`h2a negotiate ${sub}: invalid --artifact JSON (${error.message})\n`);
639
- return 1;
640
- }
641
- const existing = store.readNegotiationJournal(flags.id);
642
- const previous = existing[existing.length - 1];
643
- const chain = resolveCausationCorrelation(flags, previous);
644
- const payload = {
645
- id: flags["event-id"] ?? `evt-${Date.now().toString(36)}`,
646
- type: sub === "offer" ? "propose" : "counter",
647
- actor: { instance: flags.instance, role: "CONDUCTOR", scope: record.scope },
648
- body: { artifact },
649
- createdAt: new Date().toISOString(),
650
- ...chain
651
- };
652
- try {
653
- const entry = store.appendNegotiationEvent(flags.id, payload);
654
- streams.stdout.write(`${JSON.stringify(entry, null, 2)}\n`);
655
- return 0;
656
- }
657
- catch (error) {
658
- const message = error.message;
659
- streams.stderr.write(`h2a negotiate ${sub}: ${message}\n`);
660
- return classifyStoreError(message);
661
- }
662
- }
663
- if (sub === "sign") {
664
- if (!flags.id || !flags.instance || !flags.artifact || !flags["private-key"]) {
665
- streams.stderr.write("h2a negotiate sign: --id <id> --instance <id> --artifact <json> --private-key <pem-path> required\n");
666
- return 1;
667
- }
668
- const record = store.readNegotiation(flags.id);
669
- if (!record) {
670
- streams.stderr.write(`h2a negotiate sign: negotiation ${flags.id} not found\n`);
671
- return 2;
672
- }
673
- let artifact;
674
- try {
675
- artifact = JSON.parse(flags.artifact);
676
- }
677
- catch (error) {
678
- streams.stderr.write(`h2a negotiate sign: invalid --artifact JSON (${error.message})\n`);
679
- return 1;
680
- }
681
- let privateKeyPem;
682
- try {
683
- privateKeyPem = readFileSync(flags["private-key"], "utf8");
684
- }
685
- catch (error) {
686
- streams.stderr.write(`h2a negotiate sign: cannot read private key at ${flags["private-key"]} (${error.message})\n`);
687
- // File/OS error — exit code 3 per DEC-034.
688
- return 3;
689
- }
690
- const artifactHash = computeHash(artifact);
691
- const signature = signCanonical({ artifactHash }, { by: flags.instance, privateKeyPem });
692
- const existingForSign = store.readNegotiationJournal(flags.id);
693
- const previousForSign = existingForSign[existingForSign.length - 1];
694
- const signChain = resolveCausationCorrelation(flags, previousForSign);
695
- const payload = {
696
- id: flags["event-id"] ?? `evt-sign-${Date.now().toString(36)}`,
697
- type: "event",
698
- actor: { instance: flags.instance, role: "CONDUCTOR", scope: record.scope },
699
- body: { kind: "signature", artifactHash, signature },
700
- createdAt: new Date().toISOString(),
701
- ...signChain
702
- };
703
- try {
704
- const entry = store.appendNegotiationEvent(flags.id, payload);
705
- streams.stdout.write(`${JSON.stringify(entry, null, 2)}\n`);
706
- return 0;
707
- }
708
- catch (error) {
709
- const message = error.message;
710
- streams.stderr.write(`h2a negotiate sign: ${message}\n`);
711
- return classifyStoreError(message);
712
- }
713
- }
714
- if (sub === "stabilize") {
715
- if (!flags.id) {
716
- streams.stderr.write("h2a negotiate stabilize: --id <id> required\n");
717
- return 1;
718
- }
719
- try {
720
- const result = store.stabilizeNegotiation(flags.id, { eventId: flags["event-id"] });
721
- streams.stdout.write(`${JSON.stringify({
722
- ok: true,
723
- record: result.record,
724
- artifactHash: result.artifactHash,
725
- signers: result.signers,
726
- artifactPath: result.artifactPath,
727
- advisoryEvents: result.advisoryEvents.map((entry) => ({
728
- id: entry.id,
729
- sequence: entry.sequence
730
- })),
731
- finalEvent: { id: result.finalEvent.id, sequence: result.finalEvent.sequence }
732
- }, null, 2)}\n`);
733
- return 0;
734
- }
735
- catch (error) {
736
- const message = error.message;
737
- streams.stderr.write(`h2a negotiate stabilize: ${message}\n`);
738
- return classifyStoreError(message);
739
- }
740
- }
741
- if (sub === "event") {
742
- if (!flags.id || !flags.json) {
743
- streams.stderr.write("h2a negotiate event: --id <id> and --json <payload-json> required\n");
744
- return 1;
745
- }
746
- let payload;
747
- try {
748
- payload = JSON.parse(flags.json);
749
- }
750
- catch (error) {
751
- streams.stderr.write(`h2a negotiate event: invalid JSON (${error.message})\n`);
752
- return 1;
753
- }
754
- const existingForEvent = store.readNegotiationJournal(flags.id);
755
- const previousForEvent = existingForEvent[existingForEvent.length - 1];
756
- const eventChain = resolveCausationCorrelation(flags, previousForEvent);
757
- // Explicit fields inside the user-supplied payload always take precedence
758
- // over the CLI-resolved defaults: this preserves the existing "just append
759
- // whatever JSON I gave you" contract while still adding the chain when the
760
- // user did not opt in.
761
- payload = { ...eventChain, ...payload };
762
- try {
763
- const entry = store.appendNegotiationEvent(flags.id, payload);
764
- streams.stdout.write(`${JSON.stringify(entry, null, 2)}\n`);
765
- return 0;
766
- }
767
- catch (error) {
768
- const message = error.message;
769
- streams.stderr.write(`h2a negotiate event: ${message}\n`);
770
- return classifyStoreError(message);
771
- }
772
- }
773
- if (sub === "journal") {
774
- if (!flags.id) {
775
- streams.stderr.write("h2a negotiate journal: --id <id> required\n");
776
- return 1;
777
- }
778
- try {
779
- const entries = store.readNegotiationJournal(flags.id);
780
- streams.stdout.write(`${JSON.stringify(entries, null, 2)}\n`);
781
- return 0;
782
- }
783
- catch (error) {
784
- const message = error.message;
785
- streams.stderr.write(`h2a negotiate journal: ${message}\n`);
786
- return classifyStoreError(message);
787
- }
788
- }
789
- streams.stderr.write(`Unknown negotiate subcommand: ${sub ?? "<none>"}\n`);
790
- streams.stderr.write("Use one of: open, status, event, offer, counter, sign, stabilize, journal\n");
791
- return 1;
792
- }
793
- function splitCsvFlag(value) {
794
- if (!value)
795
- return [];
796
- return value
797
- .split(",")
798
- .map((item) => item.trim())
799
- .filter((item) => item.length > 0);
800
- }
801
- const SHA256_HASH_PATTERN = /^sha256:[a-f0-9]{64}$/;
802
- function findRegisteredInstance(store, instance) {
803
- return store.findInstance(instance) ?? store.listInstances().find((entry) => entry.instance === instance);
804
- }
805
- function parseRoleFlag(value) {
806
- if (!value)
807
- return undefined;
808
- return H2A_ROLES.includes(value) ? value : undefined;
809
- }
810
- function resolveDossierHash(dossier) {
811
- if (SHA256_HASH_PATTERN.test(dossier)) {
812
- return dossier;
813
- }
814
- const content = readFileSync(dossier, "utf8");
815
- try {
816
- return computeHash(JSON.parse(content));
817
- }
818
- catch {
819
- return computeHash(content);
820
- }
821
- }
822
- function comprehensionActorFor(registration, flags) {
823
- const role = parseRoleFlag(flags.role) ?? registration.roles[0];
824
- if (!role) {
825
- throw new Error(`${registration.instance} has no role and --role was not provided`);
826
- }
827
- if (flags.role && !parseRoleFlag(flags.role)) {
828
- throw new Error(`unknown role ${flags.role}`);
829
- }
830
- const scope = flags.scope ?? registration.scopes[0];
831
- if (!scope) {
832
- throw new Error(`${registration.instance} has no scope and --scope was not provided`);
833
- }
834
- return { instance: registration.instance, role, scope };
835
- }
836
- function cmdDeclareInteret(flags, streams) {
837
- if (!flags.negotiation || !flags.instance || !flags.interets) {
838
- streams.stderr.write("h2a declare-interest: --negotiation <id> --instance <id> --interets <a,b> required\n");
839
- return 1;
840
- }
841
- const interets = splitCsvFlag(flags.interets);
842
- if (interets.length === 0) {
843
- streams.stderr.write("h2a declare-interest: --interets must contain at least one value\n");
844
- return 1;
845
- }
846
- const bindings = splitCsvFlag(flags.bindings);
847
- const cwd = streams.cwd ?? (() => process.cwd());
848
- const root = resolveRoot(flags, cwd);
849
- const store = createLocalStore({ root });
850
- const declaration = {
851
- kind: H2A_DECLARATION_INTERET_BODY_KIND,
852
- subject: flags.instance,
853
- interets,
854
- at: flags.at ?? new Date().toISOString()
855
- };
856
- if (bindings.length > 0)
857
- declaration.bindings = bindings;
858
- if (flags["masque-impact-collectif"] !== undefined) {
859
- declaration.masqueImpactCollectif = true;
860
- }
861
- try {
862
- const entry = store.declareConflitInteret(flags.negotiation, declaration, {
863
- eventId: flags["event-id"]
864
- });
865
- streams.stdout.write(`${JSON.stringify(entry, null, 2)}\n`);
866
- return 0;
867
- }
868
- catch (error) {
869
- const message = error.message;
870
- streams.stderr.write(`h2a declare-interest: ${message}\n`);
871
- return classifyStoreError(message);
872
- }
873
- }
874
- function cmdConflictPosture(flags, streams) {
875
- if (!flags.negotiation) {
876
- streams.stderr.write("h2a conflict-posture: --negotiation <id> required\n");
877
- return 1;
878
- }
879
- const cwd = streams.cwd ?? (() => process.cwd());
880
- const root = resolveRoot(flags, cwd);
881
- const store = createLocalStore({ root });
882
- try {
883
- streams.stdout.write(`${JSON.stringify({
884
- negotiationId: flags.negotiation,
885
- postures: store.derivePosturesConflit(flags.negotiation)
886
- }, null, 2)}\n`);
887
- return 0;
888
- }
889
- catch (error) {
890
- const message = error.message;
891
- streams.stderr.write(`h2a conflict-posture: ${message}\n`);
892
- return classifyStoreError(message);
893
- }
894
- }
895
- function cmdDossier(flags, streams) {
896
- if (!flags.negotiation) {
897
- streams.stderr.write("h2a dossier: --negotiation <id> required\n");
898
- return 1;
899
- }
900
- const cwd = streams.cwd ?? (() => process.cwd());
901
- const root = resolveRoot(flags, cwd);
902
- const store = createLocalStore({ root });
903
- try {
904
- const result = store.deriveDecisionDossier(flags.negotiation, {
905
- presenter: flags.presenter,
906
- advisoryGate: flags["advisory-gate"] === "true",
907
- eventId: flags["event-id"]
908
- });
909
- streams.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
910
- return 0;
911
- }
912
- catch (error) {
913
- const message = error.message;
914
- streams.stderr.write(`h2a dossier: ${message}\n`);
915
- return classifyStoreError(message);
916
- }
917
- }
918
- function cmdConfiance(flags, streams) {
919
- if (!flags.negotiation) {
920
- streams.stderr.write("h2a confiance: --negotiation <id> required\n");
921
- return 1;
922
- }
923
- const cwd = streams.cwd ?? (() => process.cwd());
924
- const root = resolveRoot(flags, cwd);
925
- const store = createLocalStore({ root });
926
- try {
927
- const posture = store.derivePostureConfiance(flags.negotiation);
928
- streams.stdout.write(`${JSON.stringify({ negotiationId: flags.negotiation, posture }, null, 2)}\n`);
929
- return 0;
930
- }
931
- catch (error) {
932
- const message = error.message;
933
- streams.stderr.write(`h2a confiance: ${message}\n`);
934
- return classifyStoreError(message);
935
- }
936
- }
937
- function cmdAttestComprehension(flags, streams) {
938
- if (!flags.instance || !flags.dossier || !flags["private-key"]) {
939
- streams.stderr.write("h2a attest-comprehension: --instance <id> --dossier <file|sha256:...> --private-key <pem-path> required\n");
940
- return 1;
941
- }
942
- if (flags.negotiation && flags.to) {
943
- streams.stderr.write("h2a attest-comprehension: use either --negotiation or --to, not both\n");
944
- return 1;
945
- }
946
- const cwd = streams.cwd ?? (() => process.cwd());
947
- const root = resolveRoot(flags, cwd);
948
- const store = createLocalStore({ root });
949
- const registration = findRegisteredInstance(store, flags.instance);
950
- if (!registration) {
951
- streams.stderr.write(`h2a attest-comprehension: instance ${flags.instance} not registered\n`);
952
- return 2;
953
- }
954
- let actor;
955
- try {
956
- actor = comprehensionActorFor(registration, flags);
957
- }
958
- catch (error) {
959
- streams.stderr.write(`h2a attest-comprehension: ${error.message}\n`);
960
- return 1;
961
- }
962
- if (!canAttestComprehension(actor.role, registration.capabilities)) {
963
- streams.stderr.write(`h2a attest-comprehension: role ${actor.role} for ${flags.instance} cannot attest comprehension; AGENTS require ${H2A_ATTESTER_COMPREHENSION_RIGHT}\n`);
964
- return 2;
965
- }
966
- let privateKeyPem;
967
- try {
968
- privateKeyPem = readFileSync(flags["private-key"], "utf8");
969
- }
970
- catch (error) {
971
- streams.stderr.write(`h2a attest-comprehension: cannot read private key at ${flags["private-key"]} (${error.message})\n`);
972
- return 3;
973
- }
974
- let dossierHash;
975
- try {
976
- dossierHash = resolveDossierHash(flags.dossier);
977
- }
978
- catch (error) {
979
- streams.stderr.write(`h2a attest-comprehension: cannot read dossier at ${flags.dossier} (${error.message})\n`);
980
- return 3;
981
- }
982
- let body;
983
- try {
984
- body = buildComprehensionAttestation({
985
- subject: flags.instance,
986
- dossierHash,
987
- ...(flags.at ? { at: flags.at } : {})
988
- });
989
- }
990
- catch (error) {
991
- streams.stderr.write(`h2a attest-comprehension: ${error.message}\n`);
992
- return 1;
993
- }
994
- const signature = signCanonical(body, { by: flags.instance, privateKeyPem });
995
- if (flags.negotiation) {
996
- const record = store.readNegotiation(flags.negotiation);
997
- if (!record) {
998
- streams.stderr.write(`h2a attest-comprehension: negotiation ${flags.negotiation} not found\n`);
999
- return 2;
1000
- }
1001
- const existing = store.readNegotiationJournal(flags.negotiation);
1002
- const previous = existing[existing.length - 1];
1003
- const chain = resolveCausationCorrelation(flags, previous);
1004
- const payload = {
1005
- id: flags["event-id"] ?? `evt-comprehension-${Date.now().toString(36)}`,
1006
- type: "event",
1007
- actor: { ...actor, scope: flags.scope ?? record.scope },
1008
- body,
1009
- signatures: [signature],
1010
- createdAt: body.at,
1011
- ...chain
1012
- };
1013
- try {
1014
- const entry = store.appendNegotiationEvent(flags.negotiation, payload);
1015
- streams.stdout.write(`${JSON.stringify(entry, null, 2)}\n`);
1016
- return 0;
1017
- }
1018
- catch (error) {
1019
- const message = error.message;
1020
- streams.stderr.write(`h2a attest-comprehension: ${message}\n`);
1021
- return classifyStoreError(message);
1022
- }
1023
- }
1024
- const envelope = createEnvelope({
1025
- id: flags["event-id"] ?? `env-comprehension-${Date.now().toString(36)}`,
1026
- type: "event",
1027
- actor,
1028
- ...(flags.to ? { target: { instance: flags.to } } : {}),
1029
- body,
1030
- signatures: [signature],
1031
- createdAt: body.at
1032
- });
1033
- if (flags.to) {
1034
- try {
1035
- assertHostQualifiedAddress(flags.to, "recipient");
1036
- }
1037
- catch (error) {
1038
- streams.stderr.write(`\n${error.message}\n`);
1039
- return 1;
1040
- }
1041
- try {
1042
- store.putInboxMessage(flags.to, envelope);
1043
- }
1044
- catch (error) {
1045
- const message = error.message;
1046
- streams.stderr.write(`h2a attest-comprehension: ${message}\n`);
1047
- return classifyStoreError(message);
1048
- }
1049
- }
1050
- streams.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
1051
- return 0;
1052
- }
1053
- function cmdComprehension(argv, streams) {
1054
- const { command: sub, flags } = parseFlags(argv);
1055
- const cwd = streams.cwd ?? (() => process.cwd());
1056
- const root = resolveRoot(flags, cwd);
1057
- if (sub === "list") {
1058
- if (!flags.negotiation) {
1059
- streams.stderr.write("h2a comprehension list: --negotiation <id> required\n");
1060
- return 1;
1061
- }
1062
- const store = createLocalStore({ root });
1063
- if (!store.readNegotiation(flags.negotiation)) {
1064
- streams.stderr.write(`h2a comprehension list: negotiation ${flags.negotiation} not found\n`);
1065
- return 2;
1066
- }
1067
- try {
1068
- const entries = store.readNegotiationJournal(flags.negotiation).filter((entry) => isComprehensionAttestation(entry.body));
1069
- streams.stdout.write(`${JSON.stringify(entries, null, 2)}\n`);
1070
- return 0;
1071
- }
1072
- catch (error) {
1073
- const message = error.message;
1074
- streams.stderr.write(`h2a comprehension list: ${message}\n`);
1075
- return classifyStoreError(message);
1076
- }
1077
- }
1078
- if (sub === "verify") {
1079
- if (!flags.json || !flags["public-key"]) {
1080
- streams.stderr.write("h2a comprehension verify: --json <event-or-envelope-json> --public-key <pem-file> required\n");
1081
- return 1;
1082
- }
1083
- let envelopeOrEntry;
1084
- try {
1085
- envelopeOrEntry = JSON.parse(flags.json);
1086
- }
1087
- catch (error) {
1088
- streams.stderr.write(`h2a comprehension verify: invalid JSON (${error.message})\n`);
1089
- return 1;
1090
- }
1091
- let publicKeyPem;
1092
- try {
1093
- publicKeyPem = readFileSync(flags["public-key"], "utf8");
1094
- }
1095
- catch (error) {
1096
- streams.stderr.write(`h2a comprehension verify: cannot read public key at ${flags["public-key"]} (${error.message})\n`);
1097
- return 3;
1098
- }
1099
- const ok = verifyComprehensionAttestation(envelopeOrEntry, [publicKeyPem]);
1100
- const body = isComprehensionAttestation(envelopeOrEntry?.body)
1101
- ? envelopeOrEntry.body
1102
- : undefined;
1103
- streams.stdout.write(`${JSON.stringify({
1104
- ok,
1105
- ...(body
1106
- ? {
1107
- kind: H2A_COMPREHENSION_ATTESTATION_BODY_KIND,
1108
- subject: body.subject,
1109
- dossierHash: body.dossierHash
1110
- }
1111
- : {})
1112
- }, null, 2)}\n`);
1113
- return ok ? 0 : 2;
1114
- }
1115
- streams.stderr.write(`Unknown comprehension subcommand: ${sub ?? "<none>"}\n`);
1116
- streams.stderr.write("Use one of: list, verify\n");
1117
- return 1;
1118
- }
1119
- /**
1120
- * `h2a mcp-serve` binds directly to the real process std streams because it
1121
- * is a long-running JSON-RPC loop. The test-friendly `streams` interface
1122
- * (write-only) cannot express a readable stdin; tests cover `runMcpStdio`
1123
- * with `PassThrough` streams instead of going through this verb.
1124
- */
1125
- /**
1126
- * DEC-105 (EVO-6): resolve the auto-open session config from `mcp-serve` flags.
1127
- * `--auto-open` enables it; the instance is `--instance <id>` or, if absent,
1128
- * `<host>:<cwd-leaf>` (host = `--host` or "agent"). Pure + total so it can be
1129
- * unit-tested without spawning the server. Returns undefined when not enabled.
1130
- */
1131
- export function resolveAutoOpen(flags, cwd) {
1132
- if (flags["auto-open"] === undefined)
1133
- return undefined;
1134
- const host = flags.host;
1135
- const identity = resolveLiveIdentity({
1136
- root: resolveRoot(flags, cwd),
1137
- host: host ?? "agent",
1138
- cwd: cwd(),
1139
- ...(flags.instance !== undefined ? { explicitInstance: flags.instance } : {}),
1140
- ...(flags.name !== undefined ? { name: flags.name } : {}),
1141
- ...(flags.scope !== undefined ? { scopes: [flags.scope] } : {})
1142
- });
1143
- return {
1144
- instance: identity.instance,
1145
- ...(host ? { host } : {}),
1146
- ...(identity.workspace !== undefined ? { workspace: identity.workspace } : {}),
1147
- ...(identity.name !== undefined ? { name: identity.name } : {}),
1148
- ...(flags.scope ? { scopes: [flags.scope] } : {}),
1149
- ...(identity.migrationNotice !== undefined
1150
- ? { migrationNotice: identity.migrationNotice }
1151
- : {}),
1152
- ...(identity.privateKeyPath !== undefined
1153
- ? { privateKeyPath: identity.privateKeyPath }
1154
- : {})
1155
- };
1156
- }
1157
- /**
1158
- * `h2a upgrade [--check]` (DEC-107, EVO-8 level 1): explicit self-upgrade.
1159
- * `--check` reports current vs latest (no install); bare runs the global
1160
- * install of `@latest`. Sync (spawnSync). `runtime` is injectable for tests.
1161
- */
1162
- export function cmdUpgrade(flags, streams, runtime) {
1163
- const current = currentCliVersion();
1164
- if (flags.check !== undefined) {
1165
- const result = checkUpgrade(current, { ...(runtime ? { runtime } : {}), force: true });
1166
- streams.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
1167
- return 0;
1168
- }
1169
- const result = checkUpgrade(current, { ...(runtime ? { runtime } : {}), force: true });
1170
- if (!result.upgradeAvailable) {
1171
- streams.stdout.write(`${JSON.stringify({ ok: true, current, latest: result.latest ?? current, upgraded: false, reason: "already current or registry unreachable" }, null, 2)}\n`);
1172
- return 0;
1173
- }
1174
- const ok = performUpgrade(runtime);
1175
- streams.stdout.write(`${JSON.stringify({ ok, current, latest: result.latest, upgraded: ok, package: "@sentropic/h2a-cli" }, null, 2)}\n`);
1176
- if (!ok) {
1177
- streams.stderr.write("h2a upgrade: global install failed — run `npm i -g @sentropic/h2a-cli@latest` manually (or check your install method).\n");
1178
- return 1;
1179
- }
1180
- return 0;
1181
- }
1182
- /**
1183
- * `h2a presence-reap` — false-live janitor. Deletes presence files whose owning
1184
- * process is provably dead: the stale "live" presence a host (Claude Code /
1185
- * Codex) leaves when it drops the MCP stdio connection WITHOUT killing the
1186
- * child, so the lingering process's blind heartbeat kept the presence fresh and
1187
- * peers kept routing to an unreachable agent. Safe by construction — a dead
1188
- * process owns no live session — and it never mints identity, so it is safe to
1189
- * call from a SessionStart hook. Default reaps EVERY dead-pid presence
1190
- * (host-wide; assumes a single-machine bus); `--instance <id>` scopes it to one
1191
- * instance (cross-machine-safe). Prints the reaped set as JSON for audit.
1192
- */
1193
- export function cmdPresenceReap(flags, streams) {
1194
- const cwd = streams.cwd ?? (() => process.cwd());
1195
- const root = resolveRoot(flags, cwd);
1196
- const instance = flags.instance;
1197
- const reaped = instance
1198
- ? reapDeadInstancePresence(root, instance, "").map((sessionId) => ({
1199
- sessionId,
1200
- instance
1201
- }))
1202
- : reapAllDeadPresence(root);
1203
- streams.stdout.write(`${JSON.stringify({ ok: true, root, scope: instance ?? "all-dead-pid", count: reaped.length, reaped }, null, 2)}\n`);
1204
- return 0;
1205
- }
1206
- export async function runMcpServe(flags, io = {
1207
- stdin: process.stdin,
1208
- stdout: process.stdout,
1209
- stderr: process.stderr
1210
- }) {
1211
- const cwd = io.cwd ?? (() => process.cwd());
1212
- warnIfCwdRootFallback(flags, cwd, { stderr: io.stderr, stdout: io.stdout, cwd });
1213
- const root = resolveRoot(flags, cwd);
1214
- const autoOpen = resolveAutoOpen(flags, cwd);
1215
- // DEC-107/108 (EVO-8 levels 2/3): version handling at boot. **Opt-in** — no
1216
- // network on a default boot. `--auto-upgrade` self-installs @latest then
1217
- // re-execs in place via process.execve (same PID/stdio, host stays connected;
1218
- // falls back to next-launch where execve is unavailable or `--no-restart`).
1219
- // `--upgrade-check` (notice only) prints a cached, bounded availability hint.
1220
- // The H2A_REEXEC_GUARD_ENV flag (set across the re-exec) prevents a re-loop.
1221
- const wantAutoUpgrade = flags["auto-upgrade"] !== undefined;
1222
- const wantCheckOnly = flags["upgrade-check"] !== undefined;
1223
- if ((wantAutoUpgrade || wantCheckOnly) &&
1224
- process.env[H2A_REEXEC_GUARD_ENV] === undefined) {
1225
- try {
1226
- const current = currentCliVersion();
1227
- // `--auto-upgrade` is opt-in to stay current → short (1h) cache so a
1228
- // same-day release isn't masked by the 24h notice cache; the passive
1229
- // `--upgrade-check` notice keeps the 24h throttle.
1230
- const ttlMs = wantAutoUpgrade ? H2A_AUTO_UPGRADE_CHECK_TTL_MS : H2A_UPGRADE_CHECK_TTL_MS;
1231
- const result = checkUpgrade(current, { cachePath: upgradeCachePath(root), ttlMs });
1232
- if (result.upgradeAvailable) {
1233
- if (wantAutoUpgrade) {
1234
- const ok = performUpgrade();
1235
- if (ok && flags["no-restart"] === undefined && canReexec()) {
1236
- io.stderr.write(`h2a mcp-serve: auto-upgraded ${current} → ${result.latest}; restarting into the new version…\n`);
1237
- reexecSelf(); // on success the image is replaced (never returns)
1238
- }
1239
- io.stderr.write(ok
1240
- ? `h2a mcp-serve: auto-upgraded ${current} → ${result.latest} (applies on next launch)\n`
1241
- : `h2a mcp-serve: auto-upgrade to ${result.latest} failed; staying on ${current}\n`);
1242
- }
1243
- else {
1244
- io.stderr.write(`h2a mcp-serve: h2a ${result.latest} available (current ${current}) — run \`h2a upgrade\`\n`);
1245
- }
1246
- }
1247
- }
1248
- catch {
1249
- // best-effort: a check/upgrade failure must never block serving.
1250
- }
1251
- }
1252
- // EVO-1 inbox wake (bug #3): --wake <driver-kind> injects a signed wake on
1253
- // inbox arrival (requires --auto-open + a resolvable private key).
1254
- const WAKE_KINDS = ["logging", "native", "local-tmux", "headless", "auto"];
1255
- let wake;
1256
- if (flags.wake !== undefined && !WAKE_KINDS.includes(flags.wake)) {
1257
- io.stderr.write("h2a mcp-serve: --wake must be one of logging|native|local-tmux|headless|auto; ignored\n");
1258
- }
1259
- else if (flags.wake === "headless") {
1260
- // A self-wake must NEVER spawn a new agent. headless does exactly that.
1261
- io.stderr.write("h2a mcp-serve: --wake headless is unsafe (it would spawn a NEW agent on inbox arrival, not wake this one); use local-tmux. ignored\n");
1262
- }
1263
- else if (flags.wake !== undefined && autoOpen?.privateKeyPath) {
1264
- try {
1265
- const privateKeyPem = readFileSync(autoOpen.privateKeyPath, "utf8");
1266
- const log = (line) => io.stderr.write(`${line}\n`);
1267
- // `auto` for a self-wake is native→local-tmux ONLY (no headless leg — its
1268
- // fallback spawns a new agent, wrong for waking yourself).
1269
- const driver = flags.wake === "auto"
1270
- ? chainDriver(nativeBackchannelDriver(), localTmuxDriver({ log }))
1271
- : buildDriveDriver(flags.wake, log);
1272
- wake = { driver, privateKeyPem };
1273
- }
1274
- catch (err) {
1275
- io.stderr.write(`h2a mcp-serve: --wake disabled (cannot read key): ${err.message}\n`);
1276
- }
1277
- }
1278
- else if (flags.wake !== undefined && !autoOpen) {
1279
- io.stderr.write("h2a mcp-serve: --wake requires --auto-open; ignored\n");
1280
- }
1281
- try {
1282
- if (autoOpen?.migrationNotice) {
1283
- io.stderr.write(`h2a mcp-serve: ${autoOpen.migrationNotice}\n`);
1284
- }
1285
- await runMcpStdio({
1286
- root,
1287
- stdin: io.stdin,
1288
- stdout: io.stdout,
1289
- stderr: io.stderr,
1290
- ...(autoOpen ? { autoOpen } : {}),
1291
- ...(wake ? { wake } : {}),
1292
- ...(io.signal ? { signal: io.signal } : {})
1293
- });
1294
- return 0;
1295
- }
1296
- catch (err) {
1297
- io.stderr.write(`h2a mcp-serve: ${err.message}\n`);
1298
- return 1;
1299
- }
1300
- }
1301
- /**
1302
- * `h2a remote serve` (DEC-077): long-running HTTP listener that authenticates
1303
- * POSTed envelopes against the store registry and delivers them to local
1304
- * inboxes. Async + blocking, so it is dispatched from bin.ts (like mcp-serve),
1305
- * not the synchronous runCli. Binds 127.0.0.1 by default — never expose to all
1306
- * interfaces implicitly; pass `--host 0.0.0.0` to opt in.
1307
- */
1308
- export async function runRemoteServe(flags, io = { stdout: process.stdout, stderr: process.stderr }) {
1309
- const cwd = io.cwd ?? (() => process.cwd());
1310
- const root = resolveRoot(flags, cwd);
1311
- const host = flags.host ?? "127.0.0.1";
1312
- const port = flags.port ? Number.parseInt(flags.port, 10) : 8787;
1313
- if (!Number.isInteger(port) || port < 0 || port > 65535) {
1314
- io.stderr.write(`h2a remote serve: invalid --port "${flags.port}"\n`);
1315
- return 1;
1316
- }
1317
- let store;
1318
- try {
1319
- store = createLocalStore({ root });
1320
- }
1321
- catch (error) {
1322
- io.stderr.write(`h2a remote serve: ${error.message}\n`);
1323
- return 1;
1324
- }
1325
- const server = remoteServerForStore(store, { path: flags.path });
1326
- return await new Promise((resolve) => {
1327
- server.on("error", (err) => {
1328
- io.stderr.write(`h2a remote serve: ${err.message}\n`);
1329
- resolve(1);
1330
- });
1331
- server.listen(port, host, () => {
1332
- const addr = server.address();
1333
- const boundPort = typeof addr === "object" && addr ? addr.port : port;
1334
- io.stdout.write(`h2a remote serve: listening on http://${host}:${boundPort}${flags.path ?? "/h2a/envelopes"} (root ${root})\n`);
1335
- io.onListening?.(server);
1336
- });
1337
- server.on("close", () => resolve(0));
1338
- });
1339
- }
1340
- function commandDriveInjector(command, io) {
1341
- return (payload, signedLine) => {
1342
- const result = spawnSync(command, {
1343
- shell: true,
1344
- input: `${signedLine}\n`,
1345
- encoding: "utf8",
1346
- env: {
1347
- ...process.env,
1348
- H2A_DRIVE_LINE: signedLine,
1349
- H2A_DRIVE_FROM: payload.from,
1350
- H2A_DRIVE_TO: payload.to,
1351
- H2A_DRIVE_INSTRUCTION: payload.instruction
1352
- },
1353
- timeout: 30_000
1354
- });
1355
- if (result.error) {
1356
- io.stderr.write(`h2a drive serve: injector command failed: ${result.error.message}\n`);
1357
- return false;
1358
- }
1359
- if (result.status !== 0) {
1360
- io.stderr.write(`h2a drive serve: injector command exited ${result.status ?? "unknown"}\n`);
1361
- return false;
1362
- }
1363
- return true;
1364
- };
1365
- }
1366
- /**
1367
- * `h2a drive serve` (EVO-1 E1d): long-running HTTP endpoint for remote/sidecar
1368
- * injection. It verifies signature, target, authority, freshness, and replay
1369
- * before crossing the remote trust boundary into the caller-provided injector.
1370
- */
1371
- export async function runDriveServe(flags, io = { stdout: process.stdout, stderr: process.stderr }, options = {}) {
1372
- if (!flags.to) {
1373
- io.stderr.write("h2a drive serve: --to is required\n");
1374
- return 1;
1375
- }
1376
- const inject = options.inject ?? (flags["inject-command"] ? commandDriveInjector(flags["inject-command"], io) : undefined);
1377
- if (!inject) {
1378
- io.stderr.write("h2a drive serve: --inject-command is required\n");
1379
- return 1;
1380
- }
1381
- const cwd = io.cwd ?? (() => process.cwd());
1382
- const root = resolveRoot(flags, cwd);
1383
- const host = flags.host ?? "127.0.0.1";
1384
- const port = flags.port ? Number.parseInt(flags.port, 10) : 8788;
1385
- if (!Number.isInteger(port) || port < 0 || port > 65535) {
1386
- io.stderr.write(`h2a drive serve: invalid --port "${flags.port}"\n`);
1387
- return 1;
1388
- }
1389
- let store;
1390
- try {
1391
- store = createLocalStore({ root });
1392
- }
1393
- catch (error) {
1394
- io.stderr.write(`h2a drive serve: ${error.message}\n`);
1395
- return 1;
1396
- }
1397
- const server = remoteDriveServerForStore(store, {
1398
- to: flags.to,
1399
- path: flags.path,
1400
- inject
1401
- });
1402
- return await new Promise((resolve) => {
1403
- server.on("error", (err) => {
1404
- io.stderr.write(`h2a drive serve: ${err.message}\n`);
1405
- resolve(1);
1406
- });
1407
- server.listen(port, host, () => {
1408
- const addr = server.address();
1409
- const boundPort = typeof addr === "object" && addr ? addr.port : port;
1410
- io.stdout.write(`h2a drive serve: listening on http://${host}:${boundPort}${flags.path ?? "/h2a/drive"} (to ${flags.to}, root ${root})\n`);
1411
- io.onListening?.(server);
1412
- });
1413
- server.on("close", () => resolve(0));
1414
- });
1415
- }
1416
- /**
1417
- * `h2a remote send` (DEC-077): sign an envelope and POST it to a remote h2a
1418
- * endpoint. Async (network), so dispatched from bin.ts. Exit 0 on a 2xx,
1419
- * 1 otherwise; prints `{ status, body }`.
1420
- */
1421
- export async function runRemoteSend(flags, streams = { stdout: process.stdout, stderr: process.stderr }) {
1422
- if (!flags.url || !flags.instance || !flags["private-key"] || !flags.json) {
1423
- streams.stderr.write("h2a remote send: --url, --instance, --private-key and --json are required\n");
1424
- return 1;
1425
- }
1426
- let envelope;
1427
- try {
1428
- envelope = JSON.parse(flags.json);
1429
- }
1430
- catch (error) {
1431
- streams.stderr.write(`h2a remote send: invalid --json (${error.message})\n`);
1432
- return 1;
1433
- }
1434
- let privateKeyPem;
1435
- try {
1436
- privateKeyPem = readFileSync(flags["private-key"], "utf8");
1437
- }
1438
- catch (error) {
1439
- streams.stderr.write(`h2a remote send: cannot read --private-key (${error.message})\n`);
1440
- return 1;
1441
- }
1442
- let result;
1443
- try {
1444
- result = await sendRemoteEnvelope(flags.url, envelope, {
1445
- by: flags.instance,
1446
- privateKeyPem
1447
- });
1448
- }
1449
- catch (error) {
1450
- streams.stderr.write(`h2a remote send: ${error.message}\n`);
1451
- return 1;
1452
- }
1453
- streams.stdout.write(`${JSON.stringify({ status: result.status, body: result.body }, null, 2)}\n`);
1454
- return result.status >= 200 && result.status < 300 ? 0 : 1;
1455
- }
1456
- /**
1457
- * Operator-enrolled key PEMs the mirror ingester trusts (EVO-13). Source order:
1458
- * `--enrolled-keys-file` (JSON array of PEM strings), else `H2A_MIRROR_ENROLLED_KEYS`
1459
- * (base64 of that JSON array — newline-safe for a k8s Secret env). Empty = refuse
1460
- * every not-yet-registered signer (no TOFU on the wire).
1461
- */
1462
- function loadEnrolledKeys(flags, env) {
1463
- const raw = flags["enrolled-keys-file"]
1464
- ? readFileSync(flags["enrolled-keys-file"], "utf8")
1465
- : env.H2A_MIRROR_ENROLLED_KEYS
1466
- ? Buffer.from(env.H2A_MIRROR_ENROLLED_KEYS, "base64").toString("utf8")
1467
- : "[]";
1468
- const parsed = JSON.parse(raw);
1469
- if (!Array.isArray(parsed) || parsed.some((k) => typeof k !== "string")) {
1470
- throw new Error("enrolled keys must be a JSON array of PEM strings");
1471
- }
1472
- return parsed;
1473
- }
1474
- /**
1475
- * `h2a remote mirror-serve` (EVO-13 P1): long-running ingester that applies
1476
- * signed instance mirrors to the store registry. Authority = an operator-enrolled
1477
- * key or an already-registered key (never a self-declared id). Binds 127.0.0.1
1478
- * unless `--host 0.0.0.0`. Async + blocking → dispatched from bin.ts.
1479
- */
1480
- export async function runMirrorServe(flags, io = { stdout: process.stdout, stderr: process.stderr }) {
1481
- const cwd = io.cwd ?? (() => process.cwd());
1482
- const root = resolveRoot(flags, cwd);
1483
- const host = flags.host ?? "127.0.0.1";
1484
- const port = flags.port ? Number.parseInt(flags.port, 10) : 8788;
1485
- if (!Number.isInteger(port) || port < 0 || port > 65535) {
1486
- io.stderr.write(`h2a remote mirror-serve: invalid --port "${flags.port}"\n`);
1487
- return 1;
1488
- }
1489
- let enrolledKeys;
1490
- try {
1491
- enrolledKeys = loadEnrolledKeys(flags, process.env);
1492
- }
1493
- catch (error) {
1494
- io.stderr.write(`h2a remote mirror-serve: ${error.message}\n`);
1495
- return 1;
1496
- }
1497
- let store;
1498
- try {
1499
- store = createLocalStore({ root });
1500
- }
1501
- catch (error) {
1502
- io.stderr.write(`h2a remote mirror-serve: ${error.message}\n`);
1503
- return 1;
1504
- }
1505
- const server = mirrorServerForStore(store, { enrolledKeys, ...(flags.path ? { path: flags.path } : {}) });
1506
- return await new Promise((resolve) => {
1507
- server.on("error", (err) => {
1508
- io.stderr.write(`h2a remote mirror-serve: ${err.message}\n`);
1509
- resolve(1);
1510
- });
1511
- server.listen(port, host, () => {
1512
- io.stdout.write(`h2a remote mirror-serve: listening on http://${host}:${port}${flags.path ?? "/h2a/mirror"} (root ${root}, ${enrolledKeys.length} enrolled keys)\n`);
1513
- io.onListening?.(server);
1514
- });
1515
- server.on("close", () => resolve(0));
1516
- });
1517
- }
1518
- /**
1519
- * `h2a remote mirror` (EVO-13 P1): build the local instance's own registration
1520
- * mirror, sign it with `--private-key`, and POST it to a remote ingester `--url`.
1521
- * Exit 0 on a 2xx. Async (network) → dispatched from bin.ts.
1522
- */
1523
- export async function runMirrorPush(flags, streams = { stdout: process.stdout, stderr: process.stderr }) {
1524
- if (!flags.url || !flags.instance || !flags["private-key"]) {
1525
- streams.stderr.write("h2a remote mirror: --url, --instance and --private-key are required\n");
1526
- return 1;
1527
- }
1528
- const cwd = streams.cwd ?? (() => process.cwd());
1529
- const root = resolveRoot(flags, cwd);
1530
- let envelope;
1531
- try {
1532
- envelope = buildInstanceMirror(createLocalStore({ root }), flags.instance, Date.now());
1533
- }
1534
- catch (error) {
1535
- streams.stderr.write(`h2a remote mirror: ${error.message}\n`);
1536
- return 1;
1537
- }
1538
- let privateKeyPem;
1539
- try {
1540
- privateKeyPem = readFileSync(flags["private-key"], "utf8");
1541
- }
1542
- catch (error) {
1543
- streams.stderr.write(`h2a remote mirror: cannot read --private-key (${error.message})\n`);
1544
- return 1;
1545
- }
1546
- let result;
1547
- try {
1548
- result = await sendRemoteEnvelope(flags.url, envelope, { by: flags.instance, privateKeyPem });
1549
- }
1550
- catch (error) {
1551
- streams.stderr.write(`h2a remote mirror: ${error.message}\n`);
1552
- return 1;
1553
- }
1554
- streams.stdout.write(`${JSON.stringify({ status: result.status, body: result.body }, null, 2)}\n`);
1555
- return result.status >= 200 && result.status < 300 ? 0 : 1;
1556
- }
1557
- function cmdDrumbeat(argv, streams) {
1558
- // DEC-086 (D2): durable anti-stall registry — record stops, scan candidates,
1559
- // clear. The long-running `watch` daemon is async (dispatched from bin.ts).
1560
- const { command: sub, flags } = parseFlags(argv);
1561
- const cwd = streams.cwd ?? (() => process.cwd());
1562
- const root = resolveRoot(flags, cwd);
1563
- if (sub === "record") {
1564
- if (!flags.instance || !flags.status) {
1565
- streams.stderr.write("h2a drumbeat record: --instance <id> and --status <work-status> are required\n");
1566
- return 1;
1567
- }
1568
- if (!H2A_WORK_STATUSES.includes(flags.status)) {
1569
- streams.stderr.write(`h2a drumbeat record: --status must be one of ${H2A_WORK_STATUSES.join("|")} (got "${flags.status}")\n`);
1570
- return 1;
1571
- }
1572
- const launchContext = flags.command
1573
- ? {
1574
- cwd: flags.cwd ?? cwd(),
1575
- command: flags.command,
1576
- ...(flags["resume-command"] ? { resumeCommand: flags["resume-command"] } : {}),
1577
- ...(flags.tty ? { tty: flags.tty } : {}),
1578
- ...(flags["tmux-session"] && flags["tmux-pane"]
1579
- ? { tmux: { session: flags["tmux-session"], pane: flags["tmux-pane"], ...(flags["tmux-window"] ? { window: flags["tmux-window"] } : {}) } }
1580
- : {})
1581
- }
1582
- : undefined;
1583
- const entry = recordStop(root, {
1584
- instance: flags.instance,
1585
- workStatus: flags.status,
1586
- ...(launchContext ? { launchContext } : {})
1587
- });
1588
- streams.stdout.write(`${JSON.stringify({ ok: true, instance: entry.instance, workStatus: entry.workStatus }, null, 2)}\n`);
1589
- return 0;
1590
- }
1591
- if (sub === "scan") {
1592
- const maxRelances = flags["max-relances"] ? Number.parseInt(flags["max-relances"], 10) : undefined;
1593
- const result = scanDrumbeat(root, maxRelances !== undefined ? { maxRelances } : {});
1594
- streams.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
1595
- return 0;
1596
- }
1597
- if (sub === "clear") {
1598
- if (!flags.instance) {
1599
- streams.stderr.write("h2a drumbeat clear: --instance <id> is required\n");
1600
- return 1;
1601
- }
1602
- clearDrumbeatEntry(root, flags.instance);
1603
- // DEC-095 (D7): a clean resume/finish also clears any open escalation.
1604
- clearEscalation(root, flags.instance);
1605
- streams.stdout.write(`${JSON.stringify({ ok: true, instance: flags.instance, cleared: true }, null, 2)}\n`);
1606
- return 0;
1607
- }
1608
- if (sub === "escalations") {
1609
- // DEC-095 (D7): the open escalations the daemon raised on relance-exhaustion.
1610
- streams.stdout.write(`${JSON.stringify(listEscalations(root), null, 2)}\n`);
1611
- return 0;
1612
- }
1613
- streams.stderr.write("h2a drumbeat: subcommand required (record, scan, clear, escalations, watch)\n");
1614
- return 1;
1615
- }
1616
- /**
1617
- * `h2a blockage` (DEC-092, EVO-3): the peer-facing blockage feedback loop —
1618
- * distinct from the drumbeat (silent stall) and escalation (→ authority). Raise
1619
- * a blockage so peers in scope are notified (`peer.blocked` push); resolve it
1620
- * when unblocked (`peer.unblocked`).
1621
- */
1622
- function cmdBlockage(argv, streams) {
1623
- const { command: sub, flags } = parseFlags(argv);
1624
- const cwd = streams.cwd ?? (() => process.cwd());
1625
- const root = resolveRoot(flags, cwd);
1626
- if (sub === "raise") {
1627
- if (!flags.instance || !flags.reason) {
1628
- streams.stderr.write("h2a blockage raise: --instance <id> and --reason <text> are required\n");
1629
- return 1;
1630
- }
1631
- const blockage = raiseBlockage(root, {
1632
- instance: flags.instance,
1633
- scope: flags.scope ?? "",
1634
- reason: flags.reason,
1635
- ...(flags.needs ? { needs: flags.needs } : {})
1636
- });
1637
- streams.stdout.write(`${JSON.stringify({ ok: true, ...blockage }, null, 2)}\n`);
1638
- return 0;
1639
- }
1640
- if (sub === "list") {
1641
- let blockages = listBlockages(root);
1642
- // EVO-3 (DEC-110): one-shot poll digest for daemonless hosts (e.g. agy) —
1643
- // `--instance <id>` returns only blockages in scopes that instance is a
1644
- // member of, per the effective org view (registration ∪ provisioned grants).
1645
- // A daemonless agent can poll a single command instead of one per scope.
1646
- if (flags.instance) {
1647
- const store = createLocalStore({ root });
1648
- const effective = effectiveOrgInstances(store.listInstances(), store.listOrgMembership());
1649
- const mine = effective.find((e) => e.instance === flags.instance);
1650
- const scopes = new Set(mine?.scopes ?? []);
1651
- blockages = blockages.filter((b) => scopes.has(b.scope));
1652
- }
1653
- if (flags.scope)
1654
- blockages = blockages.filter((b) => b.scope === flags.scope);
1655
- if (flags.active !== undefined)
1656
- blockages = blockages.filter((b) => b.resolvedAt === undefined);
1657
- streams.stdout.write(`${JSON.stringify(blockages, null, 2)}\n`);
1658
- return 0;
1659
- }
1660
- if (sub === "resolve") {
1661
- if (!flags.instance) {
1662
- streams.stderr.write("h2a blockage resolve: --instance <id> is required\n");
1663
- return 1;
1664
- }
1665
- const resolved = resolveBlockage(root, flags.instance, flags.by ? { by: flags.by } : {});
1666
- if (!resolved) {
1667
- streams.stderr.write(`h2a blockage resolve: no blockage recorded for "${flags.instance}"\n`);
1668
- return 2;
1669
- }
1670
- streams.stdout.write(`${JSON.stringify({ ok: true, ...resolved }, null, 2)}\n`);
1671
- return 0;
1672
- }
1673
- streams.stderr.write(`h2a blockage: unknown subcommand "${sub ?? ""}" (raise, list, resolve)\n`);
1674
- return 1;
1675
- }
1676
- /** Resolve `--file` (default `org.h2a.yaml` in cwd) and read its text, or report an I/O error. */
1677
- function readOrgSource(flags, cwd, streams, verb) {
1678
- const file = flags.file
1679
- ? resolvePath(cwd(), flags.file)
1680
- : join(cwd(), H2A_ORG_MANIFEST_FILENAME);
1681
- try {
1682
- return readFileSync(file, "utf8");
1683
- }
1684
- catch (error) {
1685
- const code = error.code;
1686
- streams.stderr.write(`${verb}: cannot read ${file}: ${code ?? error.message}\n`);
1687
- return undefined;
1688
- }
1689
- }
1690
- /**
1691
- * `--deliver` for `coach propose`/`ratify`: drop the (un)signed org envelope into
1692
- * each declared instance's inbox so affected agents actually receive it and can
1693
- * have their say (counter) — closing the propose→counter→ratify loop over the
1694
- * existing mailbox primitives. The envelope itself stays the stdout payload;
1695
- * delivery is reported on stderr so the `resource` contract is preserved.
1696
- */
1697
- function deliverOrgEnvelope(flags, cwd, streams, manifest, envelope, verb) {
1698
- const store = createLocalStore({ root: resolveRoot(flags, cwd) });
1699
- const delivered = [];
1700
- for (const inst of manifest.instances) {
1701
- try {
1702
- store.putInboxMessage(inst.instance, envelope);
1703
- delivered.push(inst.instance);
1704
- }
1705
- catch (error) {
1706
- streams.stderr.write(`${verb}: deliver to ${inst.instance} failed: ${error.message}\n`);
1707
- }
1708
- }
1709
- streams.stderr.write(`${verb}: delivered to ${delivered.length} inbox(es)${delivered.length ? `: ${delivered.join(", ")}` : ""}\n`);
1710
- }
1711
- /**
1712
- * `h2a org` (EVO-7 slice 2, DEC-109): read-only tooling over the committed org
1713
- * manifest (`org.h2a.yaml`). `validate` parses + checks the h2a invariants;
1714
- * `show` prints the normalized manifest with its validation result. The coach's
1715
- * propose/ratify lifecycle is `h2a coach`; live provisioning is a later slice.
1716
- */
1717
- export function cmdOrg(argv, streams) {
1718
- const { command: sub, flags } = parseFlags(argv);
1719
- const cwd = streams.cwd ?? (() => process.cwd());
1720
- if (sub === "validate" || sub === "show") {
1721
- const source = readOrgSource(flags, cwd, streams, `h2a org ${sub}`);
1722
- if (source === undefined)
1723
- return 3;
1724
- const parsed = parseOrgManifest(source);
1725
- if (!parsed.manifest) {
1726
- streams.stdout.write(`${JSON.stringify({ ok: false, errors: parsed.errors }, null, 2)}\n`);
1727
- return 1;
1728
- }
1729
- const validation = validateOrgManifest(parsed.manifest);
1730
- if (sub === "validate") {
1731
- streams.stdout.write(`${JSON.stringify({ ok: validation.ok, errors: validation.errors }, null, 2)}\n`);
1732
- return validation.ok ? 0 : 1;
1733
- }
1734
- const m = parsed.manifest;
1735
- streams.stdout.write(`${JSON.stringify({
1736
- scope: m.scope,
1737
- ...(m.version !== undefined ? { version: m.version } : {}),
1738
- instances: m.instances,
1739
- ...(m.commEdges !== undefined ? { commEdges: m.commEdges } : {}),
1740
- validation
1741
- }, null, 2)}\n`);
1742
- return 0;
1743
- }
1744
- if (sub === "diff") {
1745
- const source = readOrgSource(flags, cwd, streams, "h2a org diff");
1746
- if (source === undefined)
1747
- return 3;
1748
- const parsed = parseOrgManifest(source);
1749
- if (!parsed.manifest) {
1750
- streams.stderr.write(`h2a org diff: ${parsed.errors.join("; ")}\n`);
1751
- return 1;
1752
- }
1753
- const store = createLocalStore({ root: resolveRoot(flags, cwd) });
1754
- const effective = effectiveOrgInstances(store.listInstances(), store.listOrgMembership());
1755
- const diff = diffOrgManifest(parsed.manifest, effective);
1756
- streams.stdout.write(`${JSON.stringify(diff, null, 2)}\n`);
1757
- return 0;
1758
- }
1759
- if (sub === "provision") {
1760
- const source = readOrgSource(flags, cwd, streams, "h2a org provision");
1761
- if (source === undefined)
1762
- return 3;
1763
- const parsed = parseOrgManifest(source);
1764
- if (!parsed.manifest) {
1765
- streams.stderr.write(`h2a org provision: ${parsed.errors.join("; ")}\n`);
1766
- return 1;
1767
- }
1768
- const validation = validateOrgManifest(parsed.manifest);
1769
- if (!validation.ok) {
1770
- streams.stderr.write(`h2a org provision: refusing to provision an invalid org (${validation.errors.join(", ")})\n`);
1771
- return 1;
1772
- }
1773
- const store = createLocalStore({ root: resolveRoot(flags, cwd) });
1774
- const at = new Date().toISOString();
1775
- const applied = [];
1776
- const unchanged = [];
1777
- const pending = [];
1778
- for (const inst of parsed.manifest.instances) {
1779
- // Re-derive the effective view each iteration so repeated declared
1780
- // instances and prior grants this run are reflected (idempotent).
1781
- const eff = effectiveOrgInstances(store.listInstances(), store.listOrgMembership());
1782
- const reg = eff.find((e) => e.instance === inst.instance);
1783
- if (!reg) {
1784
- pending.push({ instance: inst.instance, reason: "not registered (needs key + register)" });
1785
- continue;
1786
- }
1787
- const roleMissing = !reg.roles.includes(inst.role);
1788
- const missingScopes = inst.scopes.filter((s) => !reg.scopes.includes(s));
1789
- const scopesToGrant = roleMissing ? [...new Set(inst.scopes)] : missingScopes;
1790
- if (scopesToGrant.length === 0) {
1791
- unchanged.push(inst.instance);
1792
- continue;
1793
- }
1794
- for (const scope of scopesToGrant) {
1795
- store.grantOrgMembership({
1796
- instance: inst.instance,
1797
- role: inst.role,
1798
- scope,
1799
- ...(flags.by ? { by: flags.by } : {}),
1800
- at
1801
- });
1802
- }
1803
- applied.push({ instance: inst.instance, role: inst.role, grantedScopes: scopesToGrant });
1804
- }
1805
- streams.stdout.write(`${JSON.stringify({ ok: true, scope: parsed.manifest.scope, applied, unchanged, pending }, null, 2)}\n`);
1806
- return 0;
1807
- }
1808
- streams.stderr.write(`h2a org: unknown subcommand "${sub ?? ""}" (validate, show, diff, provision)\n`);
1809
- return 1;
1810
- }
1811
- /**
1812
- * `h2a coach` (EVO-7 slice 2, DEC-109): the coach **proposes, does not impose**.
1813
- * `propose` emits the *unsigned* `org-proposal` envelope for a validated
1814
- * manifest, signed (later) by the coach (a CONDUCTOR); affected agents may
1815
- * counter and the owning PRINCIPAL then ratifies. Read-only here — signing,
1816
- * persistence and provisioning are later slices.
1817
- */
1818
- export function cmdCoach(argv, streams) {
1819
- const { command: sub, flags } = parseFlags(argv);
1820
- const cwd = streams.cwd ?? (() => process.cwd());
1821
- if (sub === "propose") {
1822
- if (!flags.as) {
1823
- streams.stderr.write("h2a coach propose: --as <coach-instance> is required\n");
1824
- return 1;
1825
- }
1826
- const roleStr = flags.role ?? "CONDUCTOR";
1827
- if (!H2A_ROLES.includes(roleStr)) {
1828
- streams.stderr.write(`h2a coach propose: --role must be one of ${H2A_ROLES.join(", ")} (got "${flags.role}")\n`);
1829
- return 1;
1830
- }
1831
- const source = readOrgSource(flags, cwd, streams, "h2a coach propose");
1832
- if (source === undefined)
1833
- return 3;
1834
- const parsed = parseOrgManifest(source);
1835
- if (!parsed.manifest) {
1836
- streams.stderr.write(`h2a coach propose: ${parsed.errors.join("; ")}\n`);
1837
- return 1;
1838
- }
1839
- const validation = validateOrgManifest(parsed.manifest);
1840
- if (!validation.ok) {
1841
- streams.stderr.write(`h2a coach propose: refusing to propose an invalid org (${validation.errors.join(", ")})\n`);
1842
- return 1;
1843
- }
1844
- const envelope = orgAssignmentEnvelope({
1845
- manifest: parsed.manifest,
1846
- actor: {
1847
- instance: flags.as,
1848
- role: roleStr,
1849
- scope: flags.scope ?? parsed.manifest.scope
1850
- },
1851
- kind: H2A_ORG_PROPOSAL_BODY_KIND
1852
- });
1853
- if (flags.deliver !== undefined) {
1854
- deliverOrgEnvelope(flags, cwd, streams, parsed.manifest, envelope, "h2a coach propose");
1855
- }
1856
- streams.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
1857
- return 0;
1858
- }
1859
- if (sub === "ratify") {
1860
- if (!flags.as || !flags["private-key"]) {
1861
- streams.stderr.write("h2a coach ratify: --as <principal-instance> and --private-key <pem-file> are required\n");
1862
- return 1;
1863
- }
1864
- const roleStr = flags.role ?? "PRINCIPAL";
1865
- if (!H2A_ROLES.includes(roleStr)) {
1866
- streams.stderr.write(`h2a coach ratify: --role must be one of ${H2A_ROLES.join(", ")} (got "${flags.role}")\n`);
1867
- return 1;
1868
- }
1869
- let privateKeyPem;
1870
- try {
1871
- privateKeyPem = readFileSync(flags["private-key"], "utf8");
1872
- }
1873
- catch (error) {
1874
- streams.stderr.write(`h2a coach ratify: cannot read --private-key (${error.message})\n`);
1875
- return 1;
1876
- }
1877
- const source = readOrgSource(flags, cwd, streams, "h2a coach ratify");
1878
- if (source === undefined)
1879
- return 3;
1880
- const parsed = parseOrgManifest(source);
1881
- if (!parsed.manifest) {
1882
- streams.stderr.write(`h2a coach ratify: ${parsed.errors.join("; ")}\n`);
1883
- return 1;
1884
- }
1885
- const validation = validateOrgManifest(parsed.manifest);
1886
- if (!validation.ok) {
1887
- streams.stderr.write(`h2a coach ratify: refusing to ratify an invalid org (${validation.errors.join(", ")})\n`);
1888
- return 1;
1889
- }
1890
- const envelope = orgAssignmentEnvelope({
1891
- manifest: parsed.manifest,
1892
- actor: {
1893
- instance: flags.as,
1894
- role: roleStr,
1895
- scope: flags.scope ?? parsed.manifest.scope
1896
- },
1897
- kind: H2A_ORG_RATIFIED_BODY_KIND
1898
- });
1899
- const signed = signEnvelope(envelope, { by: flags.as, privateKeyPem });
1900
- if (flags.deliver !== undefined) {
1901
- deliverOrgEnvelope(flags, cwd, streams, parsed.manifest, signed, "h2a coach ratify");
1902
- }
1903
- streams.stdout.write(`${JSON.stringify(signed, null, 2)}\n`);
1904
- return 0;
1905
- }
1906
- streams.stderr.write(`h2a coach: unknown subcommand "${sub ?? ""}" (propose, ratify)\n`);
1907
- return 1;
1908
- }
1909
- /**
1910
- * `h2a nhi` (DEC-087): Non-Human-Identity posture over the local registry. P1
1911
- * ships `report` — derive the OWASP NHI Top 10 / NIST CSF posture from
1912
- * instances, subagent bindings and the keyring. See evaluations/nhi.md.
1913
- */
1914
- function cmdNhi(argv, streams) {
1915
- const { command: sub, flags } = parseFlags(argv);
1916
- const cwd = streams.cwd ?? (() => process.cwd());
1917
- if (sub === "report") {
1918
- let longLivedKeyMaxDays;
1919
- if (flags["long-lived-days"] !== undefined) {
1920
- longLivedKeyMaxDays = Number.parseInt(flags["long-lived-days"], 10);
1921
- if (!Number.isInteger(longLivedKeyMaxDays) || longLivedKeyMaxDays < 1) {
1922
- streams.stderr.write(`h2a nhi report: --long-lived-days must be a positive integer (got "${flags["long-lived-days"]}")\n`);
1923
- return 1;
1924
- }
1925
- }
1926
- const store = createLocalStore({ root: resolveRoot(flags, cwd) });
1927
- const { instances, subagents, keyEvents } = gatherNhiSnapshot(store);
1928
- const report = auditNhiPosture({
1929
- instances,
1930
- subagents,
1931
- keyEvents,
1932
- ...(longLivedKeyMaxDays !== undefined ? { longLivedKeyMaxDays } : {})
1933
- });
1934
- streams.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
1935
- return 0;
1936
- }
1937
- if (sub === "inventory") {
1938
- let longLivedKeyMaxDays;
1939
- if (flags["long-lived-days"] !== undefined) {
1940
- longLivedKeyMaxDays = Number.parseInt(flags["long-lived-days"], 10);
1941
- if (!Number.isInteger(longLivedKeyMaxDays) || longLivedKeyMaxDays < 1) {
1942
- streams.stderr.write(`h2a nhi inventory: --long-lived-days must be a positive integer (got "${flags["long-lived-days"]}")\n`);
1943
- return 1;
1944
- }
1945
- }
1946
- const store = createLocalStore({ root: resolveRoot(flags, cwd) });
1947
- const { instances, subagents, keyEvents, offboards } = gatherNhiSnapshot(store);
1948
- const inventory = nhiInventory({
1949
- instances,
1950
- subagents,
1951
- keyEvents,
1952
- offboards,
1953
- ...(longLivedKeyMaxDays !== undefined ? { longLivedKeyMaxDays } : {})
1954
- });
1955
- streams.stdout.write(`${JSON.stringify(inventory, null, 2)}\n`);
1956
- return 0;
1957
- }
1958
- if (sub === "export") {
1959
- if (!flags.instance || !flags["trust-domain"]) {
1960
- streams.stderr.write("h2a nhi export: --instance <id> and --trust-domain <domain> are required\n");
1961
- return 1;
1962
- }
1963
- const store = createLocalStore({ root: resolveRoot(flags, cwd) });
1964
- const activeKeys = store.listInstanceKeys(flags.instance);
1965
- try {
1966
- const bundle = nhiTrustBundle({
1967
- instance: flags.instance,
1968
- trustDomain: flags["trust-domain"],
1969
- activeKeys
1970
- });
1971
- streams.stdout.write(`${JSON.stringify(bundle, null, 2)}\n`);
1972
- return 0;
1973
- }
1974
- catch (error) {
1975
- streams.stderr.write(`h2a nhi export: ${error.message}\n`);
1976
- return 1;
1977
- }
1978
- }
1979
- if (sub === "attest") {
1980
- if (!flags.instance || !flags["private-key"]) {
1981
- streams.stderr.write("h2a nhi attest: --instance <id> and --private-key <pem-file> are required\n");
1982
- return 1;
1983
- }
1984
- let privateKeyPem;
1985
- try {
1986
- privateKeyPem = readFileSync(flags["private-key"], "utf8");
1987
- }
1988
- catch (error) {
1989
- streams.stderr.write(`h2a nhi attest: cannot read --private-key (${error.message})\n`);
1990
- return 1;
1991
- }
1992
- const store = createLocalStore({ root: resolveRoot(flags, cwd) });
1993
- const registration = store.findInstance(flags.instance);
1994
- const role = flags.role ?? registration?.roles?.[0];
1995
- const scope = flags.scope ?? registration?.scopes?.[0];
1996
- if (!role || !scope) {
1997
- streams.stderr.write(`h2a nhi attest: cannot resolve actor for "${flags.instance}" — register it first, or pass --role and --scope\n`);
1998
- return 2;
1999
- }
2000
- if (!H2A_ROLES.includes(role)) {
2001
- streams.stderr.write(`h2a nhi attest: --role must be one of ${H2A_ROLES.join("|")} (got "${role}")\n`);
2002
- return 1;
2003
- }
2004
- const { instances, subagents, keyEvents } = gatherNhiSnapshot(store);
2005
- const report = auditNhiPosture({ instances, subagents, keyEvents });
2006
- const envelope = nhiAttestationEnvelope({
2007
- report,
2008
- actor: { instance: flags.instance, role: role, scope }
2009
- });
2010
- const signed = signEnvelope(envelope, { by: flags.instance, privateKeyPem });
2011
- streams.stdout.write(`${JSON.stringify(signed, null, 2)}\n`);
2012
- return 0;
2013
- }
2014
- if (sub === "offboard") {
2015
- if (!flags.instance) {
2016
- streams.stderr.write("h2a nhi offboard: --instance <id> is required\n");
2017
- return 1;
2018
- }
2019
- const store = createLocalStore({ root: resolveRoot(flags, cwd) });
2020
- let tombstone;
2021
- try {
2022
- tombstone = store.offboardInstance(flags.instance, flags.reason);
2023
- }
2024
- catch (error) {
2025
- const message = error.message;
2026
- streams.stderr.write(`h2a nhi offboard: ${message}\n`);
2027
- return classifyStoreError(message);
2028
- }
2029
- streams.stdout.write(`${JSON.stringify({ ok: true, ...tombstone }, null, 2)}\n`);
2030
- return 0;
2031
- }
2032
- streams.stderr.write(`h2a nhi: unknown subcommand "${sub ?? ""}" (report, inventory, attest, offboard, export)\n`);
2033
- return 1;
2034
- }
2035
- /**
2036
- * Build the local relauncher used when consuming D4 resume envelopes on the
2037
- * receiving host. `auto` here is intentionally local-only: resume envelopes
2038
- * must not be re-relayed.
2039
- */
2040
- function buildLocalInboxRelauncher(kind, log) {
2041
- switch (kind) {
2042
- case "local-tmux":
2043
- return localTmuxRelauncher({ log });
2044
- case "headless":
2045
- return headlessRelauncher({ log });
2046
- case "logging":
2047
- return loggingRelauncher(log);
2048
- case "remote":
2049
- case "auto":
2050
- case undefined:
2051
- return chainRelauncher(localTmuxRelauncher({ log }), headlessRelauncher({ log }));
2052
- }
2053
- }
2054
- function resolveRemoteActor(root, flags) {
2055
- if (!flags.instance || !flags["private-key"]) {
2056
- return "--instance and --private-key are required for --relauncher remote|auto";
2057
- }
2058
- const store = createLocalStore({ root });
2059
- const registration = store.findInstance(flags.instance);
2060
- // The actor role here is only metadata stamped on the signed resume envelope.
2061
- // The receiving host re-derives its own `launchContext` and never grants the
2062
- // sender authority from this role, so the `AGENTS` default cannot widen any
2063
- // privilege — it is the safe least-authority fallback (DEC-117).
2064
- const role = flags.role ?? registration?.roles?.[0] ?? "AGENTS";
2065
- const scope = flags.scope ?? registration?.scopes?.[0] ?? "scope:default";
2066
- if (!H2A_ROLES.includes(role)) {
2067
- return `--role must be one of ${H2A_ROLES.join("|")} (got "${role}")`;
2068
- }
2069
- return { instance: flags.instance, role: role, scope };
2070
- }
2071
- function readPrivateKeyFlag(label, flags) {
2072
- try {
2073
- return readFileSync(flags["private-key"], "utf8");
2074
- }
2075
- catch (error) {
2076
- return new Error(`${label}: cannot read --private-key (${error.message})`);
2077
- }
2078
- }
2079
- function latestLaunchContext(root, instance) {
2080
- return listPresence(root)
2081
- .filter((session) => session.instance === instance && session.launchContext)
2082
- .sort((a, b) => Date.parse(b.heartbeatAt) - Date.parse(a.heartbeatAt))[0]?.launchContext;
2083
- }
2084
- function buildDriveDriver(kind, log) {
2085
- switch (kind) {
2086
- case "logging":
2087
- return loggingDriver(log);
2088
- case "native":
2089
- return nativeBackchannelDriver();
2090
- case "local-tmux":
2091
- return localTmuxDriver({ log });
2092
- case "headless":
2093
- return headlessDriver({ log });
2094
- case "auto":
2095
- return {
2096
- drive(request) {
2097
- for (const driver of [
2098
- nativeBackchannelDriver(),
2099
- localTmuxDriver({ log }),
2100
- headlessDriver({ log })
2101
- ]) {
2102
- const result = driver.drive(request);
2103
- if (result === true)
2104
- return true;
2105
- }
2106
- return false;
2107
- }
2108
- };
2109
- }
2110
- }
2111
- function driveReplayGuard(root) {
2112
- const dir = join(root, "drive-replay");
2113
- return {
2114
- accept(envelope, now = Date.now()) {
2115
- const freshness = checkEnvelopeFreshness(envelope, { now });
2116
- if (!freshness.ok)
2117
- return freshness;
2118
- mkdirSync(dir, { recursive: true });
2119
- const file = join(dir, `${safePathSegment(envelope.id)}.json`);
2120
- try {
2121
- writeFileSync(file, `${JSON.stringify({ id: envelope.id, createdAt: envelope.createdAt })}\n`, { encoding: "utf8", flag: "wx" });
2122
- }
2123
- catch (error) {
2124
- if (error.code === "EEXIST") {
2125
- return { ok: false, reason: "replayed" };
2126
- }
2127
- throw error;
2128
- }
2129
- return { ok: true };
2130
- },
2131
- size() {
2132
- try {
2133
- return readdirSync(dir).filter((entry) => entry.endsWith(".json")).length;
2134
- }
2135
- catch (error) {
2136
- if (error.code === "ENOENT")
2137
- return 0;
2138
- throw error;
2139
- }
2140
- }
2141
- };
2142
- }
2143
- function cmdDrive(flags, streams) {
2144
- const cwd = streams.cwd ?? (() => process.cwd());
2145
- if (!flags.from || !flags.to || !flags.instruction || !flags["private-key"]) {
2146
- streams.stderr.write("h2a drive: --from, --to, --instruction and --private-key are required\n");
2147
- return 1;
2148
- }
2149
- const kind = (flags.driver ?? "auto");
2150
- if (!["logging", "native", "local-tmux", "headless", "auto"].includes(kind)) {
2151
- streams.stderr.write(`h2a drive: --driver must be one of logging|native|local-tmux|headless|auto (got "${flags.driver}")\n`);
2152
- return 1;
2153
- }
2154
- const privateKeyPem = readPrivateKeyFlag("h2a drive", flags);
2155
- if (privateKeyPem instanceof Error) {
2156
- streams.stderr.write(`${privateKeyPem.message}\n`);
2157
- return 1;
2158
- }
2159
- const root = resolveRoot(flags, cwd);
2160
- const store = createLocalStore({ root });
2161
- const auth = authorizeDrive(store, { from: flags.from, to: flags.to });
2162
- if (!auth.ok) {
2163
- streams.stderr.write(`h2a drive: ${auth.reason} (${flags.from} -> ${flags.to})\n`);
2164
- return 2;
2165
- }
2166
- const instructionLine = formatSignedDriveInstruction({
2167
- from: flags.from,
2168
- to: flags.to,
2169
- instruction: flags.instruction,
2170
- privateKeyPem,
2171
- nonce: flags.nonce,
2172
- at: flags.at
2173
- });
2174
- const log = (line) => void streams.stderr.write(`${line}\n`);
2175
- const driver = buildDriveDriver(kind, log);
2176
- const result = driver.drive({
2177
- to: flags.to,
2178
- host: flags.host ?? flags.to.split(":", 1)[0],
2179
- instructionLine,
2180
- launchContext: latestLaunchContext(root, flags.to)
2181
- });
2182
- if (typeof result?.then === "function") {
2183
- streams.stderr.write("h2a drive: async driver unavailable in synchronous CLI path\n");
2184
- return 1;
2185
- }
2186
- const driven = result === true;
2187
- streams.stdout.write(`${JSON.stringify({
2188
- ok: true,
2189
- from: flags.from,
2190
- to: flags.to,
2191
- driver: kind,
2192
- driven,
2193
- instructionLine
2194
- }, null, 2)}\n`);
2195
- return driven ? 0 : 2;
2196
- }
2197
- function stdinText(streams) {
2198
- if (typeof streams.stdinText === "function")
2199
- return streams.stdinText();
2200
- return streams.stdinText ?? readFileSync(0, "utf8");
2201
- }
2202
- function driveLineFromStdin(raw) {
2203
- return driveLineFromHookInput(raw) ?? "";
2204
- }
2205
- function cmdDriveReceive(flags, streams) {
2206
- const cwd = streams.cwd ?? (() => process.cwd());
2207
- const line = flags.line ?? (flags.stdin ? driveLineFromStdin(stdinText(streams) ?? "") : undefined);
2208
- if (!flags.to) {
2209
- streams.stderr.write("h2a drive receive: --to is required\n");
2210
- return 1;
2211
- }
2212
- if (!line) {
2213
- if (flags["ignore-non-drive"]) {
2214
- streams.stdout.write(`${JSON.stringify({ ok: true, ignored: true, reason: "non-drive" }, null, 2)}\n`);
2215
- return 0;
2216
- }
2217
- streams.stderr.write("h2a drive receive: --line or --stdin is required\n");
2218
- return 1;
2219
- }
2220
- if (flags["ignore-non-drive"] && !line.trim().startsWith("[h2a ")) {
2221
- streams.stdout.write(`${JSON.stringify({ ok: true, ignored: true, reason: "non-drive" }, null, 2)}\n`);
2222
- return 0;
2223
- }
2224
- const root = resolveRoot(flags, cwd);
2225
- const store = createLocalStore({ root });
2226
- const now = flags.now ? Number.parseInt(flags.now, 10) : undefined;
2227
- if (flags.now && Number.isNaN(now)) {
2228
- streams.stderr.write(`h2a drive receive: --now must be a millisecond timestamp (got "${flags.now}")\n`);
2229
- return 1;
2230
- }
2231
- let result;
2232
- try {
2233
- result = verifyDriveOnReceive(store, line, {
2234
- to: flags.to,
2235
- guard: driveReplayGuard(root),
2236
- ...(now !== undefined ? { now } : {})
2237
- });
2238
- }
2239
- catch (error) {
2240
- streams.stderr.write(`h2a drive receive: ${error.message}\n`);
2241
- return 3;
2242
- }
2243
- if (!result.ok) {
2244
- streams.stderr.write(`h2a drive receive: ${result.reason}\n`);
2245
- return 2;
2246
- }
2247
- streams.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
2248
- return 0;
2249
- }
2250
- export async function runDrumbeatRelanceInbox(flags, io = {
2251
- stdout: process.stdout,
2252
- stderr: process.stderr
2253
- }) {
2254
- const cwd = io.cwd ?? (() => process.cwd());
2255
- const root = resolveRoot(flags, cwd);
2256
- const kind = (flags.relauncher ?? "auto");
2257
- if (!["logging", "local-tmux", "headless", "auto"].includes(kind)) {
2258
- io.stderr.write(`h2a drumbeat relance-inbox: --relauncher must be one of logging|local-tmux|headless|auto (got "${flags.relauncher}")\n`);
2259
- return 1;
2260
- }
2261
- const log = (line) => void io.stdout.write(`${line}\n`);
2262
- const result = await relanceFromInbox(root, {
2263
- ...(flags.instance ? { instances: [flags.instance] } : {}),
2264
- relauncher: buildLocalInboxRelauncher(kind, log)
2265
- });
2266
- io.stdout.write(`${JSON.stringify({ ok: true, ...result }, null, 2)}\n`);
2267
- return 0;
2268
- }
2269
- /**
2270
- * `h2a drumbeat watch` (DEC-086): long-running anti-stall daemon. Async +
2271
- * blocking, dispatched from bin.ts like mcp-serve. Uses the logging relauncher
2272
- * by default; concrete relaunchers (local-tmux / remote) land in D3/D4.
2273
- */
2274
- export async function runDrumbeatWatch(flags, io = {
2275
- stdout: process.stdout,
2276
- stderr: process.stderr
2277
- }) {
2278
- const cwd = io.cwd ?? (() => process.cwd());
2279
- const root = resolveRoot(flags, cwd);
2280
- const intervalMs = flags["interval-ms"] ? Number.parseInt(flags["interval-ms"], 10) : 30_000;
2281
- const maxRelances = flags["max-relances"] ? Number.parseInt(flags["max-relances"], 10) : undefined;
2282
- if (!Number.isInteger(intervalMs) || intervalMs < 1000) {
2283
- io.stderr.write(`h2a drumbeat watch: --interval-ms must be >= 1000 (got "${flags["interval-ms"]}")\n`);
2284
- return 1;
2285
- }
2286
- const kind = (flags.relauncher ?? "logging");
2287
- const log = (line) => void io.stdout.write(`${line}\n`);
2288
- let relauncher;
2289
- let remoteActor;
2290
- let remotePrivateKeyPem;
2291
- if (kind === "remote" || kind === "auto") {
2292
- const actor = resolveRemoteActor(root, flags);
2293
- if (typeof actor === "string") {
2294
- io.stderr.write(`h2a drumbeat watch: ${actor}\n`);
2295
- return 1;
2296
- }
2297
- const pem = readPrivateKeyFlag("h2a drumbeat watch", flags);
2298
- if (pem instanceof Error) {
2299
- io.stderr.write(`${pem.message}\n`);
2300
- return 1;
2301
- }
2302
- remoteActor = actor;
2303
- remotePrivateKeyPem = pem;
2304
- }
2305
- // DEC-091/117: select the relauncher adapter. `auto` = local-tmux, then the
2306
- // D4 remote relay, then headless fallback; `logging` stays the dry-run default.
2307
- switch (kind) {
2308
- case "local-tmux":
2309
- relauncher = localTmuxRelauncher({ log });
2310
- break;
2311
- case "headless":
2312
- relauncher = headlessRelauncher({ log });
2313
- break;
2314
- case "remote":
2315
- relauncher = remoteRelauncher({
2316
- root,
2317
- actor: remoteActor,
2318
- privateKeyPem: remotePrivateKeyPem,
2319
- log
2320
- });
2321
- break;
2322
- case "auto":
2323
- relauncher = chainRelauncher(localTmuxRelauncher({ log }), remoteRelauncher({
2324
- root,
2325
- actor: remoteActor,
2326
- privateKeyPem: remotePrivateKeyPem,
2327
- log
2328
- }), headlessRelauncher({ log }));
2329
- break;
2330
- case "logging":
2331
- relauncher = loggingRelauncher(log);
2332
- break;
2333
- default:
2334
- io.stderr.write(`h2a drumbeat watch: --relauncher must be one of logging|local-tmux|remote|headless|auto (got "${flags.relauncher}")\n`);
2335
- return 1;
2336
- }
2337
- // DEC-111 (D5): the reflexive watchdog. Opt-in via --decider; consulted only
2338
- // after --decider-after relances; decisions applied only with --decider-enforce.
2339
- const deciderAfter = flags["decider-after"] ? Number.parseInt(flags["decider-after"], 10) : 1;
2340
- if (!Number.isInteger(deciderAfter) || deciderAfter < 1) {
2341
- io.stderr.write(`h2a drumbeat watch: --decider-after must be a positive integer (got "${flags["decider-after"]}")\n`);
2342
- return 1;
2343
- }
2344
- const enforce = flags["decider-enforce"] !== undefined;
2345
- let decider;
2346
- let deciderLabel = "logging";
2347
- if (flags.decider !== undefined && flags.decider !== "logging") {
2348
- decider = subagentDecider({ command: flags.decider });
2349
- deciderLabel = "subagent";
2350
- }
2351
- else if (flags.decider === "logging") {
2352
- decider = loggingDecider();
2353
- }
2354
- const effectiveMax = maxRelances ?? H2A_DEFAULT_MAX_RELANCES;
2355
- if (decider && deciderAfter >= effectiveMax) {
2356
- io.stderr.write(`h2a drumbeat watch: --decider-after (${deciderAfter}) must be < --max-relances (${effectiveMax})\n`);
2357
- return 1;
2358
- }
2359
- io.stdout.write(`h2a drumbeat watch: watching ${root} every ${intervalMs}ms (relauncher=${kind}, decider=${deciderLabel}${decider ? `, enforce=${enforce}` : ""})\n`);
2360
- await runDrumbeatWatchLoop(root, relauncher, {
2361
- intervalMs,
2362
- ...(maxRelances !== undefined ? { maxRelances } : {}),
2363
- signal: io.signal,
2364
- // Gov D2/D4: pass the drumbeat runner's own identity so conductor-ownership
2365
- // and cross-workspace CoI checks are active. `--instance` is required for
2366
- // the remote relauncher (the signer); when present it is also the self-id
2367
- // for governance. Without it the checks are silently skipped (default-allow).
2368
- root,
2369
- ...(flags.instance !== undefined ? { selfInstance: flags.instance } : {}),
2370
- log,
2371
- beforeScan: async () => {
2372
- const result = await relanceFromInbox(root, {
2373
- relauncher: buildLocalInboxRelauncher(kind, log)
2374
- });
2375
- if (result.relanced.length || result.skipped.length) {
2376
- io.stdout.write(`drumbeat inbox: relanced=[${result.relanced.join(",")}] skipped=${result.skipped.length}\n`);
2377
- }
2378
- return result.relanced;
2379
- },
2380
- ...(decider ? { decider, deciderAfter, enforce, deciderLabel } : {}),
2381
- // DEC-095 (D7): an exhausted relance budget stops the loop for that agent
2382
- // and escalates to the PRINCIPAL (the anti-loop cap is `maxRelances`).
2383
- onExhausted: (entry) => {
2384
- const record = recordEscalation(root, {
2385
- instance: entry.instance,
2386
- reason: "relance-exhausted",
2387
- relanceCount: entry.relanceCount
2388
- });
2389
- io.stdout.write(`drumbeat: ESCALATE ${record.instance} → ${record.to} (${record.channel}, relances=${record.relanceCount})\n`);
2390
- },
2391
- // DEC-111 (D5): the decider's escalate/reroute verdicts route through the
2392
- // same escalation registry (reroute = escalate-with-hint in v1).
2393
- onEscalate: (finding, decision) => {
2394
- recordEscalation(root, {
2395
- instance: finding.instance,
2396
- reason: "watchdog-escalate",
2397
- relanceCount: finding.relanceCount
2398
- });
2399
- io.stdout.write(`drumbeat: WATCHDOG-ESCALATE ${finding.instance}${decision.reason ? ` — ${decision.reason}` : ""}\n`);
2400
- },
2401
- onReroute: (finding, decision) => {
2402
- recordEscalation(root, {
2403
- instance: finding.instance,
2404
- reason: "reroute-suggested",
2405
- relanceCount: finding.relanceCount
2406
- });
2407
- io.stdout.write(`drumbeat: REROUTE-SUGGESTED ${finding.instance}${decision.reason ? ` — ${decision.reason}` : ""}\n`);
2408
- },
2409
- onTick: (r) => {
2410
- if (r.relanced.length || r.exhausted.length) {
2411
- io.stdout.write(`drumbeat tick: relanced=[${r.relanced.join(",")}] exhausted=[${r.exhausted.join(",")}]\n`);
2412
- }
2413
- }
2414
- });
2415
- return 0;
2416
- }
2417
- /**
2418
- * `h2a sysml verify` (DEC-099, S3): verify an envelope's embedded SysML ref —
2419
- * commit-trust (signature) by default; add `--content-integrity` to re-fetch +
2420
- * re-hash the element (network). Async → dispatched from bin.ts like remote.
2421
- */
2422
- export async function runSysmlVerify(flags, io = {
2423
- stdout: process.stdout,
2424
- stderr: process.stderr
2425
- }) {
2426
- if (!flags.json || !flags["public-key"]) {
2427
- io.stderr.write("h2a sysml verify: --json <envelope> and --public-key <pem-file> are required\n");
2428
- return 1;
2429
- }
2430
- let envelope;
2431
- try {
2432
- envelope = JSON.parse(flags.json);
2433
- }
2434
- catch (error) {
2435
- io.stderr.write(`h2a sysml verify: --json is not valid JSON (${error.message})\n`);
2436
- return 1;
2437
- }
2438
- let publicKeyPem;
2439
- try {
2440
- publicKeyPem = readFileSync(flags["public-key"], "utf8");
2441
- }
2442
- catch (error) {
2443
- io.stderr.write(`h2a sysml verify: cannot read --public-key (${error.message})\n`);
2444
- return 1;
2445
- }
2446
- const result = await verifyEnvelopeSysmlRef(envelope, {
2447
- publicKeyPem,
2448
- ...(flags.by ? { by: flags.by } : {}),
2449
- ...(flags["content-integrity"] ? { contentIntegrity: true } : {}),
2450
- ...(flags["api-base"] ? { apiBase: flags["api-base"] } : {}),
2451
- ...(flags.auth ? { auth: flags.auth } : {})
2452
- });
2453
- io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
2454
- // Verification failure is a state/business outcome → exit 2 (DEC-034).
2455
- return result.ok ? 0 : 2;
2456
- }
2457
- function isPlainObject(value) {
2458
- return (typeof value === "object" &&
2459
- value !== null &&
2460
- !Array.isArray(value));
2461
- }
2462
- function configsEqual(a, b) {
2463
- return JSON.stringify(a) === JSON.stringify(b);
2464
- }
2465
- function driveLineFromText(text) {
2466
- const matchingLine = text
2467
- .split(/\r?\n/)
2468
- .map((line) => line.trim())
2469
- .find((line) => line.startsWith("[h2a "));
2470
- return matchingLine ?? (text.trim() ? text.trim() : undefined);
2471
- }
2472
- function hookPromptText(value) {
2473
- if (typeof value === "string")
2474
- return value;
2475
- if (Array.isArray(value)) {
2476
- for (const item of value) {
2477
- const text = hookPromptText(item);
2478
- if (text)
2479
- return text;
2480
- }
2481
- return undefined;
2482
- }
2483
- if (!isPlainObject(value))
2484
- return undefined;
2485
- for (const key of ["prompt", "line", "message", "text", "input"]) {
2486
- const text = hookPromptText(value[key]);
2487
- if (text)
2488
- return text;
2489
- }
2490
- if (isPlainObject(value.params)) {
2491
- const text = hookPromptText(value.params);
2492
- if (text)
2493
- return text;
2494
- }
2495
- return undefined;
2496
- }
2497
- function driveLineFromHookInput(raw) {
2498
- try {
2499
- const parsed = JSON.parse(raw);
2500
- const promptText = hookPromptText(parsed);
2501
- if (promptText)
2502
- return driveLineFromText(promptText);
2503
- }
2504
- catch {
2505
- // Non-JSON hook payloads may already be the prompt text.
2506
- }
2507
- return driveLineFromText(raw);
2508
- }
2509
- function cmdHostSetup(flags, streams) {
2510
- const host = flags.host;
2511
- if (!host) {
2512
- streams.stderr.write("h2a host setup: --host <codex|claude|gemini|agy> is required\n");
2513
- return 1;
2514
- }
2515
- // Coordination-by-default (EVO-1 #3): the rendered config joins the bus
2516
- // (--auto-open), stays current (--auto-upgrade), and is WAKEABLE on inbox
2517
- // arrival (--wake local-tmux: inject the wake into the agent's tmux pane,
2518
- // which mcp-serve self-detects). Wake is an essential coordination element,
2519
- // so it is ON by default; opt out with --no-wake (a warning is printed).
2520
- // local-tmux (NOT auto) is deliberate: in a tmux pane it wakes; OUTSIDE tmux
2521
- // it cleanly no-ops. `auto` would fall through to the headless driver and
2522
- // SPAWN a new agent when no pane is found — wrong for a self-wake.
2523
- const wakeEnabled = flags["no-wake"] !== "true";
2524
- const serveArgs = [
2525
- "mcp-serve",
2526
- "--auto-open",
2527
- "--host",
2528
- host,
2529
- "--auto-upgrade",
2530
- ...(wakeEnabled ? ["--wake", "local-tmux"] : [])
2531
- ];
2532
- if (!wakeEnabled) {
2533
- streams.stderr.write("h2a host setup: WARNING --no-wake — this agent will NOT be woken on inbox arrival; coordination is degraded (peers' messages wait until you manually run /h2a receive).\n");
2534
- }
2535
- const renderOpts = { ...(flags.root ? { root: flags.root } : {}), args: serveArgs };
2536
- let snippet;
2537
- if (host === "codex") {
2538
- snippet = H2A_CODEX_HOST.renderMcpConfig(renderOpts);
2539
- }
2540
- else if (host === "claude") {
2541
- snippet = H2A_CLAUDE_HOST.renderMcpConfig(renderOpts);
2542
- }
2543
- else if (host === "gemini") {
2544
- snippet = H2A_GEMINI_HOST.renderMcpConfig(renderOpts);
2545
- }
2546
- else if (host === "agy") {
2547
- snippet = H2A_AGY_HOST.renderMcpConfig(renderOpts);
2548
- }
2549
- else {
2550
- streams.stderr.write(`h2a host setup: unknown --host "${host}". Supported: codex, claude, gemini, agy.\n`);
2551
- return 1;
2552
- }
2553
- const targetPath = flags.write;
2554
- const printMode = flags.print === "true" || !targetPath;
2555
- if (printMode && !targetPath) {
2556
- streams.stdout.write(`${JSON.stringify(snippet.config, null, 2)}\n`);
2557
- streams.stderr.write(`# ${host} — paste this snippet under \`mcpServers\` in:\n# ${snippet.path.hint}\n# example path: ${snippet.path.example}\n`);
2558
- return 0;
2559
- }
2560
- // --write path: merge into the target file.
2561
- let existing = {};
2562
- if (existsSync(targetPath)) {
2563
- let raw;
2564
- try {
2565
- raw = readFileSync(targetPath, "utf8");
2566
- }
2567
- catch (error) {
2568
- streams.stderr.write(`h2a host setup: cannot read ${targetPath} (${error.message})\n`);
2569
- // File/OS error — exit code 3 per DEC-034.
2570
- return 3;
2571
- }
2572
- if (raw.trim().length > 0) {
2573
- try {
2574
- const parsed = JSON.parse(raw);
2575
- if (!isPlainObject(parsed)) {
2576
- streams.stderr.write(`h2a host setup: ${targetPath} is valid JSON but not a JSON object; refusing to merge.\n`);
2577
- // Pre-existing on-disk state we refuse to overwrite — state conflict.
2578
- return 2;
2579
- }
2580
- existing = parsed;
2581
- }
2582
- catch (error) {
2583
- streams.stderr.write(`h2a host setup: ${targetPath} is not valid JSON (${error.message}). Use --force to overwrite intentionally.\n`);
2584
- if (flags.force !== "true") {
2585
- // Pre-existing malformed file refused without --force — state conflict.
2586
- return 2;
2587
- }
2588
- existing = {};
2589
- }
2590
- }
2591
- }
2592
- const existingMcpServers = isPlainObject(existing.mcpServers)
2593
- ? existing.mcpServers
2594
- : {};
2595
- const previous = existingMcpServers.h2a;
2596
- const incoming = snippet.config.mcpServers.h2a;
2597
- if (previous !== undefined &&
2598
- !configsEqual(previous, incoming) &&
2599
- flags.force !== "true") {
2600
- streams.stderr.write(`h2a host setup: ${targetPath} already has a different mcpServers.h2a entry. Re-run with --force to overwrite.\n`);
2601
- // Divergent pre-existing entry — state conflict (exit 2).
2602
- return 2;
2603
- }
2604
- const merged = {
2605
- ...existing,
2606
- mcpServers: {
2607
- ...existingMcpServers,
2608
- h2a: incoming
2609
- }
2610
- };
2611
- try {
2612
- const dir = dirname(targetPath);
2613
- if (dir && !existsSync(dir)) {
2614
- mkdirSync(dir, { recursive: true });
2615
- }
2616
- writeFileSync(targetPath, `${JSON.stringify(merged, null, 2)}\n`);
2617
- }
2618
- catch (error) {
2619
- streams.stderr.write(`h2a host setup: cannot write ${targetPath} (${error.message})\n`);
2620
- // File/OS error — exit code 3 per DEC-034.
2621
- return 3;
2622
- }
2623
- streams.stdout.write(`${JSON.stringify({ ok: true, host, path: targetPath, merged: true }, null, 2)}\n`);
2624
- streams.stderr.write(`# wrote mcpServers.h2a for host=${host} to ${targetPath}\n# ${snippet.path.hint}\n`);
2625
- return 0;
2626
- }
2627
- function cmdHostStatus(flags, streams) {
2628
- const filter = flags.host;
2629
- let selection = CLI_HOSTS;
2630
- if (filter) {
2631
- const match = CLI_HOSTS.find((h) => h.host === filter);
2632
- if (!match) {
2633
- streams.stderr.write(`h2a host status: unknown --host "${filter}". Supported: ${CLI_HOSTS.map((h) => h.host).join(", ")}.\n`);
2634
- return 1;
2635
- }
2636
- selection = [match];
2637
- }
2638
- const hosts = selection.map((descriptor) => {
2639
- const mcpAdapterShipped = true; // both stdio + in-process MCP back every host
2640
- const hostSetupShipped = typeof descriptor.renderMcpConfig === "function";
2641
- const hostScenarioShipped = descriptor.hostScenarioShipped === true;
2642
- const summary = descriptor.wave === 1 && hostSetupShipped && hostScenarioShipped
2643
- ? `wave 1 — host setup + MCP scenario shipped; MCP adapter (stdio + local) wired`
2644
- : descriptor.wave === 1 && hostSetupShipped
2645
- ? `wave 1 — host setup snippet shipped; MCP adapter (stdio + local) wired`
2646
- : descriptor.wave === 2
2647
- ? `wave 2 — descriptor only (DEC-028 defers full enablement)`
2648
- : `wave 1 — MCP adapter wired but no setup snippet`;
2649
- return {
2650
- host: descriptor.host,
2651
- wave: descriptor.wave,
2652
- mcpAdapterShipped,
2653
- hostSetupShipped,
2654
- hostScenarioShipped,
2655
- summary
2656
- };
2657
- });
2658
- streams.stdout.write(`${JSON.stringify({ ok: true, hosts }, null, 2)}\n`);
2659
- return 0;
2660
- }
2661
- /**
2662
- * `h2a host plugin` (DEC-093, D6): render the per-host stop-hook command + where
2663
- * it goes, so a stop is recorded with a launch context the drumbeat/D3 can
2664
- * relance. Mirrors `host setup` (h2a renders, the host runtime places it).
2665
- */
2666
- function cmdHostPlugin(flags, streams) {
2667
- if (!flags.host) {
2668
- streams.stderr.write(`h2a host plugin: --host <${H2A_HOST_PLUGIN_HOSTS.join("|")}> is required\n`);
2669
- return 1;
2670
- }
2671
- if (!flags.instance) {
2672
- streams.stderr.write("h2a host plugin: --instance <id> is required\n");
2673
- return 1;
2674
- }
2675
- const render = renderStopHook(flags.host, {
2676
- instance: flags.instance,
2677
- ...(flags.root ? { root: flags.root } : {}),
2678
- ...(flags.status ? { status: flags.status } : {})
2679
- });
2680
- if (!render) {
2681
- streams.stderr.write(`h2a host plugin: unknown --host "${flags.host}". Supported: ${H2A_HOST_PLUGIN_HOSTS.join(", ")}.\n`);
2682
- return 1;
2683
- }
2684
- // DEC-113 (D6 done): --scaffold <dir> writes codex's FULL local marketplace —
2685
- // verified live, codex installs a plugin only from a marketplace dir, NOT a
2686
- // bare plugin dir. Layout mirrors codex's own openai-curated marketplace:
2687
- // <dir>/.agents/plugins/marketplace.json (marketplace manifest)
2688
- // <dir>/plugins/<name>/.codex-plugin/plugin.json (plugin manifest)
2689
- // <dir>/plugins/<name>/hooks/hooks.json (Claude-format Stop hook)
2690
- // then emits the trust step (codex plugin marketplace add → plugin add).
2691
- // codex-only: the manifests are codex-specific (claude/gemini merge a single
2692
- // settings.json via --write; agy polls).
2693
- if (flags.scaffold) {
2694
- if (flags.host !== "codex") {
2695
- streams.stderr.write(`h2a host plugin: --scaffold is codex-only (the marketplace/plugin manifests are codex-specific). ` +
2696
- `For ${flags.host}, use --write <settings.json> (claude/gemini) or the poll path (agy).\n`);
2697
- return 1;
2698
- }
2699
- const marketplaceDir = flags.scaffold;
2700
- const pluginDir = join(marketplaceDir, "plugins", H2A_CODEX_PLUGIN_NAME);
2701
- const hooksPath = join(pluginDir, "hooks", "hooks.json");
2702
- const manifestPath = join(pluginDir, ".codex-plugin", "plugin.json");
2703
- const marketplacePath = join(marketplaceDir, ".agents", "plugins", "marketplace.json");
2704
- // The hooks.json is the same idempotent Claude-format merge as --write.
2705
- const hooksMerge = mergeStopHooksFile(hooksPath, render.record, render.receive, flags.force === "true", streams);
2706
- if (typeof hooksMerge === "number")
2707
- return hooksMerge;
2708
- // Manifests are stable identities — rewriting them is idempotent (same bytes).
2709
- const writes = [
2710
- [manifestPath, codexPluginManifest(H2A_CODEX_PLUGIN_NAME)],
2711
- [marketplacePath, codexMarketplaceManifest(H2A_CODEX_PLUGIN_NAME)]
2712
- ];
2713
- for (const [path, body] of writes) {
2714
- try {
2715
- const d = dirname(path);
2716
- if (!existsSync(d))
2717
- mkdirSync(d, { recursive: true });
2718
- writeFileSync(path, `${JSON.stringify(body, null, 2)}\n`);
2719
- }
2720
- catch (error) {
2721
- streams.stderr.write(`h2a host plugin: cannot write ${path} (${error.message})\n`);
2722
- return 3;
2723
- }
2724
- }
2725
- streams.stdout.write(`${JSON.stringify({
2726
- ok: true,
2727
- host: "codex",
2728
- scaffolded: marketplaceDir,
2729
- marketplace: marketplacePath,
2730
- manifest: manifestPath,
2731
- hooks: hooksPath,
2732
- mechanism: render.mechanism,
2733
- hook: "hooks.Stop + hooks.UserPromptSubmit",
2734
- // The trust step is surfaced, not silently dropped: codex cannot be
2735
- // auto-trusted from outside, so run these to load the plugin.
2736
- trust: codexPluginTrustCommands(marketplaceDir, H2A_CODEX_PLUGIN_NAME)
2737
- }, null, 2)}\n`);
2738
- return 0;
2739
- }
2740
- // DEC-102/103/104 (D6 slice b): --write installs the Claude-format `hooks.Stop`
2741
- // entry for the three hosts that accept it — **claude** + **gemini** (single
2742
- // settings.json; gemini via `gemini hooks migrate --from-claude`) and **codex**
2743
- // (its plugin `hooks.json` uses the identical Claude-format hooks object;
2744
- // verified against `~/.codex/.../hooks/hooks.json`). agy is poll-only (no
2745
- // daemon), so --write is refused for it and the rendered hook + hint surface.
2746
- if (flags.write) {
2747
- if (flags.host === "agy") {
2748
- streams.stderr.write(`h2a host plugin: --write is not available for agy (poll-only, no daemon). ` +
2749
- `Use the poll path: ${render.poll}\n`);
2750
- return 1;
2751
- }
2752
- const targetPath = flags.write;
2753
- const merge = mergeStopHooksFile(targetPath, render.record, render.receive, flags.force === "true", streams);
2754
- if (typeof merge === "number")
2755
- return merge;
2756
- streams.stdout.write(`${JSON.stringify({
2757
- ok: true,
2758
- host: flags.host,
2759
- written: targetPath,
2760
- mechanism: render.mechanism,
2761
- hook: "hooks.Stop + hooks.UserPromptSubmit",
2762
- // codex loads hooks via a plugin (manifest → ./hooks/hooks.json); the
2763
- // file written above is a valid codex hooks.json, but codex still
2764
- // needs the manifest + trust. `--scaffold <dir>` does the full job;
2765
- // this hint covers the manual wrap when only the hooks.json is wanted.
2766
- ...(flags.host === "codex"
2767
- ? {
2768
- pluginHint: "this is a codex hooks.json — run `h2a host plugin --host codex --scaffold <dir>` " +
2769
- "to also write the .codex-plugin/plugin.json manifest + emit the trust step " +
2770
- "(codex plugin marketplace add <dir> → codex plugin add <name>@<marketplace>)."
2771
- }
2772
- : {})
2773
- }, null, 2)}\n`);
2774
- return 0;
2775
- }
2776
- streams.stdout.write(`${JSON.stringify(render, null, 2)}\n`);
2777
- return 0;
2778
- }
2779
- /**
2780
- * Idempotently merge the h2a drumbeat-record `Stop` hook and the drive receive
2781
- * `UserPromptSubmit` gate into a Claude-format hooks file (claude/gemini
2782
- * `settings.json` or a codex `hooks.json`). Drops prior h2a hooks, appends the
2783
- * freshly-rendered ones, and preserves unrelated keys/hooks. Returns an exit
2784
- * code on failure, or `undefined` on success.
2785
- * DEC-102/103/104; reused by `--scaffold` (DEC-113).
2786
- */
2787
- function mergeStopHooksFile(targetPath, record, receive, force, streams) {
2788
- let existing = {};
2789
- if (existsSync(targetPath)) {
2790
- let raw;
2791
- try {
2792
- raw = readFileSync(targetPath, "utf8");
2793
- }
2794
- catch (error) {
2795
- streams.stderr.write(`h2a host plugin: cannot read ${targetPath} (${error.message})\n`);
2796
- return 3;
2797
- }
2798
- if (raw.trim().length > 0) {
2799
- try {
2800
- const parsed = JSON.parse(raw);
2801
- if (!isPlainObject(parsed)) {
2802
- streams.stderr.write(`h2a host plugin: ${targetPath} is valid JSON but not an object; refusing to merge.\n`);
2803
- return 2;
2804
- }
2805
- existing = parsed;
2806
- }
2807
- catch (error) {
2808
- streams.stderr.write(`h2a host plugin: ${targetPath} is not valid JSON (${error.message}). Use --force to overwrite.\n`);
2809
- if (!force)
2810
- return 2;
2811
- existing = {};
2812
- }
2813
- }
2814
- }
2815
- const existingHooks = isPlainObject(existing.hooks) ? existing.hooks : {};
2816
- const existingStop = Array.isArray(existingHooks.Stop) ? [...existingHooks.Stop] : [];
2817
- const existingUserPromptSubmit = Array.isArray(existingHooks.UserPromptSubmit)
2818
- ? [...existingHooks.UserPromptSubmit]
2819
- : [];
2820
- // Idempotent: drop any prior h2a drumbeat-record Stop hook, then append ours.
2821
- const withoutH2A = existingStop.filter((e) => !isH2ARecordHook(e));
2822
- const mergedStop = [...withoutH2A, claudeStopHookEntry(record)];
2823
- const withoutDriveReceive = existingUserPromptSubmit.filter((e) => !isH2ADriveReceiveHook(e));
2824
- const mergedUserPromptSubmit = [
2825
- ...withoutDriveReceive,
2826
- claudeDriveReceiveHookEntry(receive)
2827
- ];
2828
- const merged = {
2829
- ...existing,
2830
- hooks: {
2831
- ...existingHooks,
2832
- Stop: mergedStop,
2833
- UserPromptSubmit: mergedUserPromptSubmit
2834
- }
2835
- };
2836
- try {
2837
- const dir = dirname(targetPath);
2838
- if (dir && !existsSync(dir))
2839
- mkdirSync(dir, { recursive: true });
2840
- writeFileSync(targetPath, `${JSON.stringify(merged, null, 2)}\n`);
2841
- }
2842
- catch (error) {
2843
- streams.stderr.write(`h2a host plugin: cannot write ${targetPath} (${error.message})\n`);
2844
- return 3;
2845
- }
2846
- return undefined;
2847
- }
2848
- function cmdHost(argv, streams) {
2849
- const { command: sub, flags } = parseFlags(argv);
2850
- if (sub === "setup")
2851
- return cmdHostSetup(flags, streams);
2852
- if (sub === "status")
2853
- return cmdHostStatus(flags, streams);
2854
- if (sub === "plugin")
2855
- return cmdHostPlugin(flags, streams);
2856
- streams.stderr.write(`Unknown host subcommand: ${sub ?? "<none>"}\n`);
2857
- streams.stderr.write("Use: h2a host setup --host <codex|claude|gemini|agy> ...\n" +
2858
- " h2a host status [--host <name>]\n" +
2859
- " h2a host plugin --host <codex|claude|gemini|agy> --instance <id>\n");
2860
- return 1;
2861
- }
2862
- function cmdStoreMigrate(flags, streams) {
2863
- const cwd = streams.cwd ?? (() => process.cwd());
2864
- const root = resolveRoot(flags, cwd);
2865
- const from = flags.from ?? H2A_STORE_SCHEMA_VERSION;
2866
- const to = flags.to ?? H2A_STORE_SCHEMA_VERSION;
2867
- const dryRun = flags["dry-run"] === "true";
2868
- // DEC-064: opt-in within-version maintenance pass. Renames pre-DEC-062
2869
- // store entries with `:` (or other forbidden chars) in their names to the
2870
- // safePathSegment form, so a store created by <=0.1.23 works on Windows and
2871
- // is consistent on every OS. Does not touch the schema version.
2872
- if (flags["sanitize-paths"] === "true") {
2873
- const result = sanitizeStorePaths(root, { dryRun });
2874
- streams.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
2875
- // Conflicts (target already exists) are a state issue → exit 2.
2876
- return result.ok ? 0 : 2;
2877
- }
2878
- const KNOWN_VERSIONS = [H2A_STORE_SCHEMA_VERSION];
2879
- if (!KNOWN_VERSIONS.includes(from)) {
2880
- streams.stderr.write(`h2a store migrate: unknown --from version "${from}". Known versions: ${KNOWN_VERSIONS.join(",")}\n`);
2881
- return 1;
2882
- }
2883
- if (!KNOWN_VERSIONS.includes(to)) {
2884
- streams.stderr.write(`h2a store migrate: unknown --to version "${to}". Known versions: ${KNOWN_VERSIONS.join(",")}\n`);
2885
- return 1;
2886
- }
2887
- // V1 → V1: no-op. Future bumps will branch here.
2888
- if (from === H2A_STORE_SCHEMA_VERSION && to === H2A_STORE_SCHEMA_VERSION) {
2889
- streams.stdout.write(`${JSON.stringify({
2890
- ok: true,
2891
- fromVersion: from,
2892
- toVersion: to,
2893
- changed: false,
2894
- dryRun,
2895
- root
2896
- }, null, 2)}\n`);
2897
- return 0;
2898
- }
2899
- // Unreachable today (only one known version) — kept for future ramps.
2900
- streams.stderr.write(`h2a store migrate: no migration registered for ${from} → ${to}\n`);
2901
- return 1;
2902
- }
2903
- function cmdStore(argv, streams) {
2904
- const { command: sub, flags } = parseFlags(argv);
2905
- if (sub === "migrate")
2906
- return cmdStoreMigrate(flags, streams);
2907
- streams.stderr.write(`Unknown store subcommand: ${sub ?? "<none>"}\n`);
2908
- streams.stderr.write("Use: h2a store migrate [--from <v>] [--to <v>] [--dry-run] [--root <path>]\n");
2909
- return 1;
2910
- }
2911
- function cmdDiscover(flags, streams) {
2912
- const cwd = streams.cwd ?? (() => process.cwd());
2913
- const root = resolveRoot(flags, cwd);
2914
- const store = createLocalStore({ root });
2915
- let entries = store.listInstances();
2916
- // DEC-110: filter by the EFFECTIVE org view (registration scopes/roles ∪
2917
- // provisioned membership grants), so a `provision`-granted scope actually
2918
- // gates discovery at runtime. Output stays the raw registration (grants only
2919
- // widen the match set, never narrow it).
2920
- const effective = effectiveOrgInstances(entries, store.listOrgMembership());
2921
- const effByInstance = new Map(effective.map((e) => [e.instance, e]));
2922
- if (flags.role) {
2923
- const role = flags.role;
2924
- entries = entries.filter((entry) => effByInstance.get(entry.instance)?.roles.includes(role));
2925
- }
2926
- if (flags.scope) {
2927
- const scope = flags.scope;
2928
- entries = entries.filter((entry) => effByInstance.get(entry.instance)?.scopes.includes(scope));
2929
- }
2930
- // `--live` (WP-C/WP-F): the answer to "which of these can I actually reach
2931
- // RIGHT NOW?" — the full registry is the perennial ledger (every past
2932
- // conversation mints an id, so it grows to hundreds and drowns the live ones).
2933
- // Filter to instances with a fresh presence session and annotate each with
2934
- // its connection-confidence (active = MCP channel carried traffic recently;
2935
- // idle-uncertain = process alive but channel silent — possibly a false-live;
2936
- // unknown = legacy/mirrored presence with no activity stamp). Compact output
2937
- // (no public keys) so the reachable id is obvious. Heartbeat-fresh ≠ reachable.
2938
- if (flags.live !== undefined) {
2939
- const now = Date.now();
2940
- // PRESENCE-FIRST: liveness lives in the presence files, not the registry.
2941
- // An agent can hold a fresh presence on this bus without a registration here
2942
- // (it registered on a forked repo-local bus — the split-brain that strands
2943
- // mail). Enumerate live sessions and annotate from the registry where known,
2944
- // so a presence-but-unregistered peer still surfaces as reachable.
2945
- const sessions = listPresence(root);
2946
- const regByInstance = new Map(entries.map((e) => [e.instance, e]));
2947
- const byInstance = new Map();
2948
- for (const s of sessions) {
2949
- const arr = byInstance.get(s.instance) ?? [];
2950
- arr.push(s);
2951
- byInstance.set(s.instance, arr);
2952
- }
2953
- const rank = { active: 0, "idle-uncertain": 1, unknown: 2 };
2954
- const live = Array.from(byInstance.entries())
2955
- .map(([instance, ss]) => {
2956
- const reg = regByInstance.get(instance);
2957
- const eff = effByInstance.get(instance);
2958
- // Best (most-reachable) confidence across this instance's sessions.
2959
- let best = "unknown";
2960
- let freshestActivity;
2961
- let name = reg?.name;
2962
- for (const s of ss) {
2963
- const c = deriveConnectionConfidence(s, { now });
2964
- if (rank[c] < rank[best])
2965
- best = c;
2966
- const a = s.lastMcpActivityAt;
2967
- if (a && (!freshestActivity || a > freshestActivity))
2968
- freshestActivity = a;
2969
- const sn = s.name;
2970
- if (!name && sn)
2971
- name = sn;
2972
- }
2973
- return {
2974
- instance,
2975
- ...(name !== undefined ? { name } : {}),
2976
- ...(reg?.workspace !== undefined ? { workspace: reg.workspace } : {}),
2977
- registered: reg !== undefined,
2978
- roles: eff?.roles ?? reg?.roles ?? [],
2979
- scopes: eff?.scopes ?? reg?.scopes ?? [],
2980
- sessions: ss.length,
2981
- connectionConfidence: best,
2982
- ...(freshestActivity ? { lastMcpActivityAt: freshestActivity } : {})
2983
- };
2984
- })
2985
- .filter((r) => {
2986
- // honor --role / --scope filters against the annotated values
2987
- if (flags.role && !r.roles.includes(flags.role))
2988
- return false;
2989
- if (flags.scope && !r.scopes.includes(flags.scope))
2990
- return false;
2991
- return true;
2992
- })
2993
- // Most-reachable first, so the addressable agent is at the top.
2994
- .sort((a, b) => rank[a.connectionConfidence] - rank[b.connectionConfidence]);
2995
- streams.stdout.write(`${JSON.stringify(live, null, 2)}\n`);
2996
- return 0;
2997
- }
2998
- streams.stdout.write(`${JSON.stringify(entries, null, 2)}\n`);
2999
- return 0;
3000
- }
3001
- /**
3002
- * `h2a thread --id <threadId> --instance <self> [--root <path>]`
3003
- *
3004
- * Derived read-only view: union of inbox and outbox for the given instance,
3005
- * filtered to `envelope.threadId === id`, deduped by envelope id, sorted
3006
- * ascending by `createdAt`. No new store — pure in-memory derivation.
3007
- */
3008
- function cmdThread(flags, streams) {
3009
- if (!flags.id || !flags.instance) {
3010
- streams.stderr.write("h2a thread: --id <threadId> and --instance <self> are required\n");
3011
- return 1;
3012
- }
3013
- const cwd = streams.cwd ?? (() => process.cwd());
3014
- const root = resolveRoot(flags, cwd);
3015
- let store;
3016
- try {
3017
- store = createLocalStore({ root });
3018
- }
3019
- catch (error) {
3020
- streams.stderr.write(`h2a thread: ${error.message}\n`);
3021
- return 3;
3022
- }
3023
- let inboxEnvelopes;
3024
- let outboxEnvelopes;
3025
- try {
3026
- inboxEnvelopes = store.readInbox(flags.instance);
3027
- outboxEnvelopes = store.readOutbox(flags.instance);
3028
- }
3029
- catch (error) {
3030
- streams.stderr.write(`h2a thread: ${error.message}\n`);
3031
- return 3;
3032
- }
3033
- const threadId = flags.id;
3034
- const seen = new Set();
3035
- const thread = [];
3036
- for (const env of [...inboxEnvelopes, ...outboxEnvelopes]) {
3037
- if (env.threadId === threadId && !seen.has(env.id)) {
3038
- seen.add(env.id);
3039
- thread.push(env);
3040
- }
3041
- }
3042
- thread.sort((a, b) => a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0);
3043
- streams.stdout.write(`${JSON.stringify(thread, null, 2)}\n`);
3044
- return 0;
3045
- }
3046
- function cmdSessions(flags, streams) {
3047
- const cwd = streams.cwd ?? (() => process.cwd());
3048
- const root = resolveRoot(flags, cwd);
3049
- try {
3050
- let sessions = listPresence(root);
3051
- if (flags.scope) {
3052
- const wanted = flags.scope;
3053
- sessions = sessions.filter((s) => s.interests.scopes.includes(wanted));
3054
- }
3055
- if (flags.instance) {
3056
- const wanted = flags.instance;
3057
- sessions = sessions.filter((s) => s.instance === wanted);
3058
- }
3059
- if (flags.name) {
3060
- const needle = flags.name.toLowerCase();
3061
- sessions = sessions.filter((s) => typeof s.name === "string" && s.name.toLowerCase().includes(needle));
3062
- }
3063
- streams.stdout.write(`${JSON.stringify(sessions, null, 2)}\n`);
3064
- return 0;
3065
- }
3066
- catch (error) {
3067
- streams.stderr.write(`h2a sessions: ${error.message}\n`);
3068
- return 3;
3069
- }
3070
- }
3071
- function cmdStatus(flags, streams) {
3072
- const cwd = streams.cwd ?? (() => process.cwd());
3073
- const root = resolveRoot(flags, cwd);
3074
- try {
3075
- let sessions = listPresence(root);
3076
- if (flags.scope) {
3077
- const wanted = flags.scope;
3078
- sessions = sessions.filter((s) => s.interests.scopes.includes(wanted));
3079
- }
3080
- if (flags.instance) {
3081
- const wanted = flags.instance;
3082
- sessions = sessions.filter((s) => s.instance === wanted);
3083
- }
3084
- if (flags.name) {
3085
- const needle = flags.name.toLowerCase();
3086
- sessions = sessions.filter((s) => typeof s.name === "string" && s.name.toLowerCase().includes(needle));
3087
- }
3088
- const indirect = sessions.filter((s) => typeof s.mirroredAt === "string");
3089
- const direct = sessions.filter((s) => typeof s.mirroredAt !== "string");
3090
- streams.stdout.write(`${JSON.stringify({
3091
- ok: true,
3092
- root,
3093
- counts: { direct: direct.length, indirect: indirect.length, total: sessions.length },
3094
- direct,
3095
- indirect
3096
- }, null, 2)}\n`);
3097
- return 0;
3098
- }
3099
- catch (error) {
3100
- streams.stderr.write(`h2a status: ${error.message}\n`);
3101
- return 3;
3102
- }
3103
- }
3104
- /**
3105
- * Read the stable machine-id for workspace derivation (mirrors live.ts).
3106
- * Used only by `cmdConductor` when the caller passes a filesystem path.
3107
- */
3108
- function cliReadMachineId() {
3109
- for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
3110
- try {
3111
- const id = readFileSync(p, "utf8").trim();
3112
- if (id.length > 0)
3113
- return id;
3114
- }
3115
- catch {
3116
- // try next source
3117
- }
3118
- }
3119
- return hostname() || "unknown-machine";
3120
- }
3121
- /**
3122
- * Resolve a workspace id from CLI flags (workspace flag or cwd default).
3123
- * Shared by `h2a conductor`, `h2a conductor claim`, and `h2a conductor release`.
3124
- */
3125
- function resolveConductorWorkspaceId(flags, cwd) {
3126
- const wsFlag = flags.workspace;
3127
- if (!wsFlag) {
3128
- // Default to cwd
3129
- const cwdPath = cwd();
3130
- let realPath = cwdPath;
3131
- try {
3132
- realPath = realpathSync(cwdPath);
3133
- }
3134
- catch { /* use cwd as-is */ }
3135
- return deriveWorkspaceId({ machineId: cliReadMachineId(), path: realPath });
3136
- }
3137
- else if (wsFlag.startsWith("ws:")) {
3138
- return wsFlag;
3139
- }
3140
- else {
3141
- // Treat as a filesystem path
3142
- let realPath = wsFlag;
3143
- try {
3144
- realPath = realpathSync(wsFlag);
3145
- }
3146
- catch { /* use as-is */ }
3147
- return deriveWorkspaceId({ machineId: cliReadMachineId(), path: realPath });
3148
- }
3149
- }
3150
- /**
3151
- * `h2a conductor [claim|release] [--instance <self>] [--workspace <id|path>] [--root <path>]`
3152
- *
3153
- * - No subverb: resolve the live conductor/owner (WP-G1, read-only).
3154
- * - `claim`: append a claim event and return the post-claim resolution.
3155
- * - `release`: append a release event and return the post-release resolution.
3156
- *
3157
- * Output shape: resource (JSON of ConductorResolution). Exit 0 on success,
3158
- * exit 1 on user error (missing --instance for claim/release).
3159
- */
3160
- function cmdConductor(argv, streams) {
3161
- // Only "claim"/"release" are subverbs. Otherwise the first token is a flag
3162
- // (e.g. `conductor --workspace …`) — parseFlags would consume it as the
3163
- // command and DROP --workspace, silently defaulting to cwd. Prepend a
3164
- // placeholder so all flags parse from the full argv in the no-subverb case.
3165
- const sub = argv[0] === "claim" || argv[0] === "release" ? argv[0] : undefined;
3166
- const { flags } = parseFlags(sub ? argv : ["", ...argv]);
3167
- const cwd = streams.cwd ?? (() => process.cwd());
3168
- const root = resolveRoot(flags, cwd);
3169
- // Subverbs: claim / release
3170
- if (sub === "claim" || sub === "release") {
3171
- if (!flags.instance) {
3172
- streams.stderr.write(`h2a conductor ${sub}: --instance <self> is required\n`);
3173
- return 1;
3174
- }
3175
- const workspaceId = resolveConductorWorkspaceId(flags, cwd);
3176
- try {
3177
- appendConductorClaim(root, {
3178
- type: sub,
3179
- workspaceId,
3180
- instance: flags.instance,
3181
- at: new Date().toISOString()
3182
- });
3183
- const result = conductorFor({ root, workspaceId });
3184
- streams.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
3185
- return 0;
3186
- }
3187
- catch (error) {
3188
- streams.stderr.write(`h2a conductor ${sub}: ${error.message}\n`);
3189
- return 1;
3190
- }
3191
- }
3192
- // No subverb (or unrecognized subverb treated as workspace flag for back-compat):
3193
- // resolve the conductor (read-only).
3194
- const workspaceId = resolveConductorWorkspaceId(flags, cwd);
3195
- try {
3196
- const result = conductorFor({ root, workspaceId });
3197
- streams.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
3198
- return 0;
3199
- }
3200
- catch (error) {
3201
- streams.stderr.write(`h2a conductor: ${error.message}\n`);
3202
- return 1;
3203
- }
3204
- }
3205
- /**
3206
- * `h2a conductor-launch-check [--workspace <id|path>] [--root <path>] [--idle-ms <ms>]`
3207
- *
3208
- * DRY-RUN: polls `track workspace-activity` and RECOMMENDS launching a
3209
- * conductor if work is durably stalled and no conductor is live.
3210
- *
3211
- * h2a does NOT spawn anything. A `recommendation === "launch"` result is
3212
- * purely advisory. The actual launch is parked pending a spawn policy and
3213
- * remote-trigger support.
3214
- *
3215
- * Exit 0 on success. Exit 1 on bad flag value.
3216
- * Output shape: resource (JSON of ConductorLaunchCheckResult).
3217
- */
3218
- function cmdConductorLaunchCheck(argv, streams) {
3219
- const { flags } = parseFlags(["", ...argv]);
3220
- const cwd = streams.cwd ?? (() => process.cwd());
3221
- const root = resolveRoot(flags, cwd);
3222
- const workspaceId = resolveConductorWorkspaceId(flags, cwd);
3223
- let idleMs;
3224
- if (flags["idle-ms"] !== undefined) {
3225
- const parsed = Number(flags["idle-ms"]);
3226
- if (!Number.isFinite(parsed) || parsed <= 0) {
3227
- streams.stderr.write(`h2a conductor-launch-check: --idle-ms must be a positive number (got "${flags["idle-ms"]}")\n`);
3228
- return 1;
3229
- }
3230
- idleMs = parsed;
3231
- }
3232
- try {
3233
- const result = conductorLaunchCheck({ root, workspaceId, idleMs });
3234
- if (result.recommendation === "launch") {
3235
- streams.stderr.write("h2a conductor-launch-check: DRY-RUN — h2a does NOT spawn. " +
3236
- "This is a recommendation only; the launch is parked pending a spawn policy + remote.\n");
3237
- }
3238
- streams.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
3239
- return 0;
3240
- }
3241
- catch (error) {
3242
- streams.stderr.write(`h2a conductor-launch-check: ${error.message}\n`);
3243
- return 1;
3244
- }
3245
- }
3246
- /**
3247
- * `h2a conductor-launch --workspace <id|path> [--root] [--idle-ms <ms>] [--confirm] [--remote <instance>] [--instance <self>]`
3248
- *
3249
- * D3 EMISSION: when `conductorLaunchCheck` returns recommendation="launch",
3250
- * h2a emits a launch-REQUEST envelope to a live remote agent.
3251
- *
3252
- * Gates:
3253
- * 1. `conductorLaunchCheck` recommendation must be "launch".
3254
- * 2. Cooldown: at most 1 request per 30 min per workspace (checked via spawns store).
3255
- * 3. Human confirmation: WITHOUT `--confirm`, only PREVIEW the request (dry-run).
3256
- * WITH `--confirm`, emit + record the marker.
3257
- *
3258
- * h2a NEVER spawns a process itself. It only puts a request envelope to remote.
3259
- * The remote agent reads the envelope and executes the actual spawn.
3260
- *
3261
- * Exit 0 (success: none/cooldown/would-emit/no-remote/emitted).
3262
- * Exit 1 (user error: missing --instance when --confirm given).
3263
- */
3264
- /**
3265
- * `h2a wake-request --to <instance>` (WP-F, Codex reachability): emit a signed
3266
- * `wake-request` envelope to a live remote/launcher agent so IT wakes the TARGET
3267
- * agent's tmux pane out-of-band.
3268
- *
3269
- * Why: a Codex agent tears down its h2a stdio MCP child on transport drop and
3270
- * never reconnects, so h2a deletes its presence and the in-process EVO-1
3271
- * self-wake can no longer fire — the agent is unreachable even though its pane
3272
- * is alive. The launcher (remote) holds the target's DURABLE pane and performs
3273
- * the actual wake; h2a never types into a pane here, so there is no stale-pane /
3274
- * wrong-pane risk on the h2a side. Claude does NOT need this (its presence stays
3275
- * live → the self-wake fires in-process).
3276
- *
3277
- * Flags: --to <target> (required), --instance <self> signer (required to emit),
3278
- * --remote <r> (override; else first live remote, pane-preferred), --dry-run,
3279
- * --reason <text>.
3280
- */
3281
- export function cmdWakeRequest(argv, streams) {
3282
- const { flags } = parseFlags(["", ...argv]);
3283
- const cwd = streams.cwd ?? (() => process.cwd());
3284
- const root = resolveRoot(flags, cwd);
3285
- const target = flags.to;
3286
- if (!target) {
3287
- streams.stderr.write("h2a wake-request: --to <instance> is required (the agent to wake)\n");
3288
- return 1;
3289
- }
3290
- const request = {
3291
- kind: "wake-request",
3292
- target,
3293
- reason: flags.reason ?? "peer not live on the bus; wake its pane out-of-band",
3294
- // Self-describing wake: the EXACT line the launcher should type into the
3295
- // target's pane. The target's MCP is likely dead (that's why it's being
3296
- // woken), so it must read its inbox via the CLI, not the MCP — and the bare
3297
- // `h2a inbox read` errors without --instance. h2a composes the precise
3298
- // command since it knows the target + root. Launchers SHOULD type this
3299
- // verbatim (literal send-keys) rather than hard-coding their own line.
3300
- instructionLine: `h2a inbox read --instance ${target} --root ${root}`
3301
- };
3302
- // --dry-run → preview only; never emit.
3303
- if (flags["dry-run"] !== undefined) {
3304
- streams.stdout.write(`${JSON.stringify({
3305
- action: "would-emit",
3306
- request,
3307
- note: "DRY-RUN — pass --instance <self> (without --dry-run) to emit; remote performs the actual pane wake"
3308
- }, null, 2)}\n`);
3309
- return 0;
3310
- }
3311
- // Emitting requires the signer/sender identity.
3312
- if (!flags.instance) {
3313
- streams.stderr.write("h2a wake-request: --instance <self> is required to emit (the signer/sender identity); or use --dry-run\n");
3314
- return 1;
3315
- }
3316
- const selfInstance = flags.instance;
3317
- // Resolve the target remote/launcher instance.
3318
- let remoteInstance;
3319
- if (flags.remote) {
3320
- remoteInstance = flags.remote;
3321
- }
3322
- else {
3323
- const sessions = listPresence(root);
3324
- const remoteSessions = sessions.filter((s) => s.host === "remote" || (s.instance && s.instance.startsWith("remote:")));
3325
- if (remoteSessions.length === 0) {
3326
- streams.stdout.write(`${JSON.stringify({
3327
- action: "no-remote",
3328
- reason: "no live remote/launcher agent to perform the out-of-band wake"
3329
- }, null, 2)}\n`);
3330
- return 0;
3331
- }
3332
- const withPane = remoteSessions.filter((s) => s.launchContext?.tmux?.pane);
3333
- remoteInstance = (withPane.length > 0 ? withPane[0] : remoteSessions[0]).instance;
3334
- }
3335
- const store = createLocalStore({ root });
3336
- const envelope = createEnvelope({
3337
- id: `env-wake-${Date.now().toString(36)}`,
3338
- type: "event",
3339
- actor: { instance: selfInstance, role: "AGENTS", scope: "scope:default" },
3340
- target: { instance: remoteInstance },
3341
- body: {
3342
- kind: "message",
3343
- topic: "wake-request",
3344
- text: `Wake request for ${target}: ${request.reason}`,
3345
- request
3346
- },
3347
- createdAt: new Date().toISOString()
3348
- });
3349
- try {
3350
- store.putInboxMessage(remoteInstance, envelope);
3351
- }
3352
- catch (error) {
3353
- streams.stderr.write(`h2a wake-request: failed to deliver to ${remoteInstance}: ${error.message}\n`);
3354
- return 1;
3355
- }
3356
- streams.stdout.write(`${JSON.stringify({ action: "emitted", to: remoteInstance, request }, null, 2)}\n`);
3357
- return 0;
3358
- }
3359
- export function cmdConductorLaunch(argv, streams, opts) {
3360
- const { flags } = parseFlags(["", ...argv]);
3361
- const cwd = streams.cwd ?? (() => process.cwd());
3362
- const root = resolveRoot(flags, cwd);
3363
- const workspaceId = resolveConductorWorkspaceId(flags, cwd);
3364
- let idleMs;
3365
- if (flags["idle-ms"] !== undefined) {
3366
- const parsed = Number(flags["idle-ms"]);
3367
- if (!Number.isFinite(parsed) || parsed <= 0) {
3368
- streams.stderr.write(`h2a conductor-launch: --idle-ms must be a positive number (got "${flags["idle-ms"]}")\n`);
3369
- return 1;
3370
- }
3371
- idleMs = parsed;
3372
- }
3373
- // 1. Compute the launch recommendation (or use injected value for tests).
3374
- let check;
3375
- if (opts?.injectedCheck !== undefined) {
3376
- check = opts.injectedCheck;
3377
- }
3378
- else {
3379
- check = conductorLaunchCheck({ root, workspaceId, ...(idleMs !== undefined ? { idleMs } : {}) });
3380
- }
3381
- if (check.recommendation !== "launch") {
3382
- streams.stdout.write(`${JSON.stringify({ action: "none", ...check }, null, 2)}\n`);
3383
- return 0;
3384
- }
3385
- // 2. Cooldown gate.
3386
- const last = lastSpawnRequestAt(root, workspaceId);
3387
- if (!spawnAllowed({ lastSpawnAt: last, now: Date.now() })) {
3388
- streams.stdout.write(`${JSON.stringify({
3389
- action: "cooldown",
3390
- reason: "a launch request was emitted < 30min ago for this workspace",
3391
- lastSpawnAt: last
3392
- }, null, 2)}\n`);
3393
- return 0;
3394
- }
3395
- // 3. Build the request object.
3396
- const request = {
3397
- kind: "conductor-launch-request",
3398
- workspaceId,
3399
- hostPref: ["claude", "codex", "agy"],
3400
- stalled: check.stalled,
3401
- reason: check.reason
3402
- };
3403
- // 4. Without --confirm → preview only (dry-run). Never emit, never record.
3404
- if (!flags.confirm) {
3405
- streams.stdout.write(`${JSON.stringify({
3406
- action: "would-emit",
3407
- request,
3408
- note: "DRY-RUN — pass --confirm to emit this launch request to remote (h2a never spawns; remote does)"
3409
- }, null, 2)}\n`);
3410
- return 0;
3411
- }
3412
- // 5. With --confirm: --instance is required (the signer/sender identity).
3413
- if (!flags.instance) {
3414
- streams.stderr.write("h2a conductor-launch: --instance <self> is required with --confirm (the signer/sender identity)\n");
3415
- return 1;
3416
- }
3417
- const selfInstance = flags.instance;
3418
- // 6. Resolve the target remote instance.
3419
- let remoteInstance;
3420
- if (flags.remote) {
3421
- remoteInstance = flags.remote;
3422
- }
3423
- else {
3424
- // Find a live session whose host is "remote".
3425
- const sessions = listPresence(root);
3426
- const remoteSessions = sessions.filter((s) => s.host === "remote" || (s.instance && s.instance.startsWith("remote:")));
3427
- if (remoteSessions.length === 0) {
3428
- streams.stdout.write(`${JSON.stringify({
3429
- action: "no-remote",
3430
- reason: "no live remote agent to receive the launch request"
3431
- }, null, 2)}\n`);
3432
- return 0;
3433
- }
3434
- // Prefer one with a launchContext/pane (they can actually spawn); else take first.
3435
- const withPaneCandidates = remoteSessions.filter((s) => s.launchContext?.tmux?.pane);
3436
- const chosen = withPaneCandidates.length > 0 ? withPaneCandidates[0] : remoteSessions[0];
3437
- remoteInstance = chosen.instance;
3438
- }
3439
- // 7. Compose the H2AEnvelope and deliver it to the remote's inbox.
3440
- const store = createLocalStore({ root });
3441
- const envelope = createEnvelope({
3442
- id: `env-conductor-launch-${Date.now().toString(36)}`,
3443
- type: "event",
3444
- actor: { instance: selfInstance, role: "CONDUCTOR", scope: "scope:default" },
3445
- target: { instance: remoteInstance },
3446
- body: {
3447
- kind: "message",
3448
- topic: "conductor-launch-request",
3449
- text: `Conductor-launch request for workspace ${workspaceId}: ${check.reason}`,
3450
- request
3451
- },
3452
- createdAt: new Date().toISOString()
3453
- });
3454
- try {
3455
- store.putInboxMessage(remoteInstance, envelope);
3456
- }
3457
- catch (error) {
3458
- streams.stderr.write(`h2a conductor-launch: failed to deliver to ${remoteInstance}: ${error.message}\n`);
3459
- return 1;
3460
- }
3461
- // 8. Record the spawn marker (after successful delivery).
3462
- recordSpawnRequest(root, {
3463
- workspaceId,
3464
- at: new Date().toISOString(),
3465
- to: remoteInstance
3466
- });
3467
- streams.stdout.write(`${JSON.stringify({ action: "emitted", to: remoteInstance, request }, null, 2)}\n`);
3468
- return 0;
3469
- }
3470
- function cmdDoctor(flags, streams) {
3471
- const cwd = streams.cwd ?? (() => process.cwd());
3472
- const root = resolveRoot(flags, cwd);
3473
- const report = {
3474
- ok: true,
3475
- root,
3476
- warnings: [],
3477
- checks: {}
3478
- };
3479
- const checks = report.checks;
3480
- const warnings = report.warnings;
3481
- // 1. Root reachable
3482
- if (!existsSync(root)) {
3483
- checks.rootExists = { ok: false, message: `root does not exist: ${root}` };
3484
- report.ok = false;
3485
- }
3486
- else {
3487
- checks.rootExists = { ok: true };
3488
- }
3489
- // 2. Schema sentinel present
3490
- const schemaPath = join(root, ".h2a-schema.json");
3491
- if (!existsSync(schemaPath)) {
3492
- checks.schemaSentinel = {
3493
- ok: false,
3494
- message: `missing ${schemaPath}; run \`h2a init --root ${root}\``
3495
- };
3496
- report.ok = false;
3497
- }
3498
- else {
3499
- try {
3500
- const parsed = JSON.parse(readFileSync(schemaPath, "utf8"));
3501
- checks.schemaSentinel = { ok: true, version: parsed.version };
3502
- if (parsed.version !== H2A_STORE_SCHEMA_VERSION) {
3503
- report.ok = false;
3504
- checks.schemaSentinel = {
3505
- ok: false,
3506
- version: parsed.version,
3507
- message: `expected schema version ${H2A_STORE_SCHEMA_VERSION}`
3508
- };
3509
- }
3510
- }
3511
- catch (error) {
3512
- report.ok = false;
3513
- checks.schemaSentinel = {
3514
- ok: false,
3515
- message: `cannot read schema sentinel: ${error.message}`
3516
- };
3517
- }
3518
- }
3519
- // 3. Live sessions on the bus
3520
- try {
3521
- const sessions = listPresence(root);
3522
- checks.liveSessions = { ok: true, count: sessions.length };
3523
- }
3524
- catch (error) {
3525
- report.ok = false;
3526
- checks.liveSessions = {
3527
- ok: false,
3528
- message: error.message
3529
- };
3530
- }
3531
- // 4. h2a binary reachable (self-check via existing API)
3532
- checks.cliBinary = { ok: true };
3533
- // ── Warning checks (do NOT flip report.ok) ──────────────────────────────
3534
- // (a) rootSource: record where the root came from
3535
- const rootInfo = resolveRootInfo(flags, cwd);
3536
- report.rootSource = rootInfo.source;
3537
- if (rootInfo.source === "default") {
3538
- warnings.push({
3539
- check: "rootSource",
3540
- message: `The bus root is the global shared default (${root}). ` +
3541
- `Set H2A_ROOT or pass --root <path> to use a different bus. ` +
3542
- `Agents on different roots cannot exchange messages.`
3543
- });
3544
- }
3545
- // (b) splitBrain: a repo-local .h2a exists alongside a DIFFERENT active root
3546
- const cwdLocal = join(cwd(), ".h2a");
3547
- try {
3548
- if (existsSync(cwdLocal)) {
3549
- const resolvedLocal = realpathSync(cwdLocal);
3550
- const resolvedRoot = (() => {
3551
- try {
3552
- return realpathSync(root);
3553
- }
3554
- catch {
3555
- return root;
3556
- }
3557
- })();
3558
- if (resolvedLocal !== resolvedRoot) {
3559
- warnings.push({
3560
- check: "splitBrain",
3561
- message: `A repo-local .h2a (${cwdLocal}) exists alongside a DIFFERENT active root (${root}). ` +
3562
- `Messages written to the repo-local bus are invisible to peers on ${root} — split-brain.`
3563
- });
3564
- }
3565
- }
3566
- }
3567
- catch {
3568
- // silently ignore (the cwd-local path may not be resolvable)
3569
- }
3570
- // (c) inboxHygiene: scan inbox for case/slug duplicates, host-less dirs, phantom 3-segment dirs
3571
- const inboxDir = join(root, "inbox");
3572
- // Collect dirs to prune (with --prune only).
3573
- const pruned = [];
3574
- if (existsSync(inboxDir)) {
3575
- let inboxEntries = [];
3576
- try {
3577
- inboxEntries = readdirSync(inboxDir);
3578
- }
3579
- catch {
3580
- // not readable — skip hygiene check
3581
- }
3582
- if (inboxEntries.length > 0) {
3583
- const KNOWN_HOSTS = new Set(["claude", "codex", "gemini", "remote"]);
3584
- // Reconstruct an address from a dir name: double-underscore → colon.
3585
- function dirToAddress(name) {
3586
- return name.replace(/__/g, ":");
3587
- }
3588
- // Case/slug duplicates: two dir names that map to the same canonicalAddress
3589
- const byCanonical = new Map();
3590
- for (const entry of inboxEntries) {
3591
- const addr = dirToAddress(entry);
3592
- let canonical;
3593
- try {
3594
- canonical = canonicalAddress(addr);
3595
- }
3596
- catch {
3597
- canonical = addr.toLowerCase();
3598
- }
3599
- const group = byCanonical.get(canonical) ?? [];
3600
- group.push(entry);
3601
- byCanonical.set(canonical, group);
3602
- }
3603
- const dupGroups = [...byCanonical.values()].filter((g) => g.length > 1);
3604
- if (dupGroups.length > 0) {
3605
- const examples = dupGroups.slice(0, 5).map((g) => g.join(" | "));
3606
- warnings.push({
3607
- check: "inboxHygiene",
3608
- kind: "caseDuplicates",
3609
- count: dupGroups.length,
3610
- examples,
3611
- message: `${dupGroups.length} case/slug duplicate group(s) in inbox — ` +
3612
- `different dir names map to the same canonical address. ` +
3613
- `Examples: ${examples.join(", ")}. (Not pruned — too risky; resolve manually.)`
3614
- });
3615
- }
3616
- // Host-less dirs: first segment (before first __) is not a known host
3617
- const hostless = inboxEntries.filter((entry) => {
3618
- const firstSeg = entry.split("__")[0];
3619
- return !KNOWN_HOSTS.has(firstSeg);
3620
- });
3621
- if (hostless.length > 0) {
3622
- const examples = hostless.slice(0, 5);
3623
- warnings.push({
3624
- check: "inboxHygiene",
3625
- kind: "hostlessDirs",
3626
- count: hostless.length,
3627
- examples,
3628
- message: `${hostless.length} host-less inbox dir(s) (first segment is not claude/codex/gemini/remote). ` +
3629
- `Examples: ${examples.join(", ")}`
3630
- });
3631
- if (flags.prune !== undefined) {
3632
- for (const name of hostless) {
3633
- const dir = join(inboxDir, name);
3634
- try {
3635
- rmSync(dir, { recursive: true, force: true });
3636
- pruned.push({ name, path: dir });
3637
- }
3638
- catch (error) {
3639
- streams.stderr.write(`h2a doctor --prune: cannot remove ${dir}: ${error.message}\n`);
3640
- }
3641
- }
3642
- }
3643
- }
3644
- // Phantom 3-segment dirs: exactly 3 __ segments but 3rd is not a 12-char hex session id
3645
- const VALID_TAIL_RE = /^[0-9a-f]{12}$/i;
3646
- const phantom = inboxEntries.filter((entry) => {
3647
- const segs = entry.split("__");
3648
- if (segs.length !== 3)
3649
- return false;
3650
- return !VALID_TAIL_RE.test(segs[2]);
3651
- });
3652
- if (phantom.length > 0) {
3653
- const examples = phantom.slice(0, 5);
3654
- warnings.push({
3655
- check: "inboxHygiene",
3656
- kind: "phantomThreeSegment",
3657
- count: phantom.length,
3658
- examples,
3659
- message: `${phantom.length} phantom 3-segment inbox dir(s) where the tail is not a 12-char hex session id. ` +
3660
- `Examples: ${examples.join(", ")}`
3661
- });
3662
- if (flags.prune !== undefined) {
3663
- for (const name of phantom) {
3664
- const dir = join(inboxDir, name);
3665
- try {
3666
- rmSync(dir, { recursive: true, force: true });
3667
- pruned.push({ name, path: dir });
3668
- }
3669
- catch (error) {
3670
- streams.stderr.write(`h2a doctor --prune: cannot remove ${dir}: ${error.message}\n`);
3671
- }
3672
- }
3673
- }
3674
- }
3675
- // Orphan 3-segment dirs: 3 segments, valid hex tail, but UUID is NOT in registry/instances.jsonl
3676
- if (flags.prune !== undefined) {
3677
- const instancesFile = join(root, "registry", "instances.jsonl");
3678
- let registeredUuids = new Set();
3679
- try {
3680
- const content = readFileSync(instancesFile, "utf8");
3681
- for (const line of content.split("\n")) {
3682
- const trimmed = line.trim();
3683
- if (!trimmed)
3684
- continue;
3685
- try {
3686
- const obj = JSON.parse(trimmed);
3687
- const id = typeof obj.instance === "string" ? obj.instance : (typeof obj.id === "string" ? obj.id : "");
3688
- // Extract the hex tail segment from the instance id (e.g. host:label:abc123456789)
3689
- const parts = id.split(":");
3690
- if (parts.length >= 3) {
3691
- const tail = parts[parts.length - 1];
3692
- if (VALID_TAIL_RE.test(tail))
3693
- registeredUuids.add(tail.toLowerCase());
3694
- }
3695
- }
3696
- catch {
3697
- // skip malformed
3698
- }
3699
- }
3700
- }
3701
- catch {
3702
- // instances file absent — keep set empty (prune all orphans below)
3703
- }
3704
- const orphan3seg = inboxEntries.filter((entry) => {
3705
- const segs = entry.split("__");
3706
- if (segs.length !== 3)
3707
- return false;
3708
- if (!VALID_TAIL_RE.test(segs[2]))
3709
- return false; // already handled as phantom
3710
- return !registeredUuids.has(segs[2].toLowerCase());
3711
- });
3712
- for (const name of orphan3seg) {
3713
- const dir = join(inboxDir, name);
3714
- try {
3715
- rmSync(dir, { recursive: true, force: true });
3716
- pruned.push({ name, path: dir });
3717
- }
3718
- catch (error) {
3719
- streams.stderr.write(`h2a doctor --prune: cannot remove ${dir}: ${error.message}\n`);
3720
- }
3721
- }
3722
- }
3723
- }
3724
- }
3725
- // (d) --scan <dir>: find immediate child buses (ONE level deep)
3726
- const strayBuses = [];
3727
- if (flags.scan) {
3728
- try {
3729
- const scanChildren = readdirSync(flags.scan);
3730
- for (const child of scanChildren) {
3731
- const candidateBus = join(flags.scan, child, ".h2a");
3732
- if (existsSync(candidateBus)) {
3733
- const instancesFile = join(candidateBus, "registry", "instances.jsonl");
3734
- let instances = 0;
3735
- try {
3736
- const content = readFileSync(instancesFile, "utf8");
3737
- instances = content.split("\n").filter((l) => l.trim().length > 0).length;
3738
- }
3739
- catch {
3740
- instances = 0;
3741
- }
3742
- strayBuses.push({ path: candidateBus, instances });
3743
- }
3744
- }
3745
- }
3746
- catch (error) {
3747
- streams.stderr.write(`h2a doctor: --scan ${flags.scan}: ${error.message}\n`);
3748
- }
3749
- if (strayBuses.length > 0) {
3750
- warnings.push({
3751
- check: "strayBuses",
3752
- count: strayBuses.length,
3753
- buses: strayBuses,
3754
- message: `${strayBuses.length} stray repo-local .h2a bus(es) found under ${flags.scan} — ` +
3755
- `candidate split-brain forks. Each may have agents on a different root.`
3756
- });
3757
- if (flags.prune !== undefined) {
3758
- for (const bus of strayBuses) {
3759
- const busPath = bus.path;
3760
- try {
3761
- rmSync(busPath, { recursive: true, force: true });
3762
- pruned.push({ name: busPath, path: busPath });
3763
- }
3764
- catch (error) {
3765
- streams.stderr.write(`h2a doctor --prune: cannot remove ${busPath}: ${error.message}\n`);
3766
- }
3767
- }
3768
- }
3769
- }
3770
- }
3771
- if (flags.prune !== undefined) {
3772
- report.pruned = pruned;
3773
- }
3774
- streams.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
3775
- return report.ok ? 0 : 2;
3776
- }
3777
- function cmdKeysGenerate(flags, streams) {
3778
- if (!flags.instance) {
3779
- streams.stderr.write("h2a keys generate: --instance <id> is required\n");
3780
- return 1;
3781
- }
3782
- const cwd = streams.cwd ?? (() => process.cwd());
3783
- const outDir = flags.out ?? join(resolveRoot(flags, cwd), "keys");
3784
- try {
3785
- mkdirSync(outDir, { recursive: true });
3786
- }
3787
- catch (error) {
3788
- streams.stderr.write(`h2a keys generate: cannot create ${outDir} (${error.message})\n`);
3789
- return 3;
3790
- }
3791
- const { publicKey, privateKey } = generateKeyPairSync("ed25519");
3792
- const privatePem = privateKey
3793
- .export({ type: "pkcs8", format: "pem" })
3794
- .toString();
3795
- const publicPem = publicKey
3796
- .export({ type: "spki", format: "pem" })
3797
- .toString();
3798
- // File names are derived from the instance id with `:` and `/` replaced by `-`
3799
- // so they map cleanly to filesystems on every OS.
3800
- const safeName = flags.instance.replace(/[:/]/g, "-");
3801
- const privatePath = join(outDir, `${safeName}.key.pem`);
3802
- const publicPath = join(outDir, `${safeName}.pub.pem`);
3803
- try {
3804
- writeFileSync(privatePath, privatePem, { encoding: "utf8", mode: 0o600 });
3805
- writeFileSync(publicPath, publicPem, "utf8");
3806
- }
3807
- catch (error) {
3808
- streams.stderr.write(`h2a keys generate: cannot write key files (${error.message})\n`);
3809
- return 3;
3810
- }
3811
- streams.stdout.write(`${JSON.stringify({
3812
- ok: true,
3813
- instance: flags.instance,
3814
- privateKeyPath: privatePath,
3815
- publicKeyPath: publicPath,
3816
- publicKeyPem: publicPem
3817
- }, null, 2)}\n`);
3818
- return 0;
3819
- }
3820
- function cmdKeysAdd(flags, streams) {
3821
- // DEC-078: append a public key to an instance's keyring (rotate-in).
3822
- if (!flags.instance || !flags["public-key"]) {
3823
- streams.stderr.write("h2a keys add: --instance <id> and --public-key <pem-file> are required\n");
3824
- return 1;
3825
- }
3826
- let publicKeyPem;
3827
- try {
3828
- publicKeyPem = readFileSync(flags["public-key"], "utf8");
3829
- }
3830
- catch (error) {
3831
- streams.stderr.write(`h2a keys add: cannot read --public-key (${error.message})\n`);
3832
- return 1;
3833
- }
3834
- const cwd = streams.cwd ?? (() => process.cwd());
3835
- const store = createLocalStore({ root: resolveRoot(flags, cwd) });
3836
- try {
3837
- store.addInstanceKey(flags.instance, publicKeyPem);
3838
- }
3839
- catch (error) {
3840
- const message = error.message;
3841
- streams.stderr.write(`h2a keys add: ${message}\n`);
3842
- return classifyStoreError(message);
3843
- }
3844
- streams.stdout.write(`${JSON.stringify({ ok: true, instance: flags.instance, keys: store.listInstanceKeys(flags.instance).length }, null, 2)}\n`);
3845
- return 0;
3846
- }
3847
- function cmdKeysList(flags, streams) {
3848
- if (!flags.instance) {
3849
- streams.stderr.write("h2a keys list: --instance <id> is required\n");
3850
- return 1;
3851
- }
3852
- const cwd = streams.cwd ?? (() => process.cwd());
3853
- const store = createLocalStore({ root: resolveRoot(flags, cwd) });
3854
- streams.stdout.write(`${JSON.stringify(store.listInstanceKeys(flags.instance), null, 2)}\n`);
3855
- return 0;
3856
- }
3857
- function cmdKeysRevoke(flags, streams) {
3858
- // DEC-079: revoke a public key from an instance's keyring (rotate-out).
3859
- if (!flags.instance || !flags["public-key"]) {
3860
- streams.stderr.write("h2a keys revoke: --instance <id> and --public-key <pem-file> are required\n");
3861
- return 1;
3862
- }
3863
- let publicKeyPem;
3864
- try {
3865
- publicKeyPem = readFileSync(flags["public-key"], "utf8");
3866
- }
3867
- catch (error) {
3868
- streams.stderr.write(`h2a keys revoke: cannot read --public-key (${error.message})\n`);
3869
- return 1;
3870
- }
3871
- const cwd = streams.cwd ?? (() => process.cwd());
3872
- const store = createLocalStore({ root: resolveRoot(flags, cwd) });
3873
- try {
3874
- store.revokeInstanceKey(flags.instance, publicKeyPem);
3875
- }
3876
- catch (error) {
3877
- const message = error.message;
3878
- streams.stderr.write(`h2a keys revoke: ${message}\n`);
3879
- return classifyStoreError(message);
3880
- }
3881
- streams.stdout.write(`${JSON.stringify({ ok: true, instance: flags.instance, status: "revoked", keys: store.listInstanceKeys(flags.instance).length }, null, 2)}\n`);
3882
- return 0;
3883
- }
3884
- function cmdConnect(flags, streams) {
3885
- if (!flags.host) {
3886
- streams.stderr.write("h2a connect: --host <codex|claude|gemini|agy|remote> is required\n");
3887
- return 1;
3888
- }
3889
- if (!["codex", "claude", "gemini", "agy", "remote"].includes(flags.host)) {
3890
- streams.stderr.write(`h2a connect: unknown --host "${flags.host}". Supported: codex, claude, gemini, agy, remote.\n`);
3891
- return 1;
3892
- }
3893
- const cwd = streams.cwd ?? (() => process.cwd());
3894
- warnIfCwdRootFallback(flags, cwd, streams);
3895
- const root = resolveRoot(flags, cwd);
3896
- const identity = resolveLiveIdentity({
3897
- root,
3898
- host: flags.host,
3899
- cwd: cwd(),
3900
- ...(flags.instance !== undefined ? { explicitInstance: flags.instance } : {}),
3901
- ...(flags.name !== undefined ? { name: flags.name } : {})
3902
- });
3903
- const instance = identity.instance;
3904
- const summary = {
3905
- ok: true,
3906
- root,
3907
- host: flags.host,
3908
- instance,
3909
- identity,
3910
- steps: []
3911
- };
3912
- const steps = summary.steps;
3913
- // 1. h2a init
3914
- try {
3915
- createLocalStore({ root });
3916
- steps.push({ step: "init", ok: true, root });
3917
- }
3918
- catch (error) {
3919
- streams.stderr.write(`h2a connect: init failed (${error.message})\n`);
3920
- return 3;
3921
- }
3922
- // 2. host setup snippet (print only — write requires explicit --write)
3923
- if (flags.host === "remote") {
3924
- steps.push({
3925
- step: "host-setup",
3926
- ok: true,
3927
- pathHint: "@sentropic/remote bridge environment",
3928
- pathExample: "remote session Pod env",
3929
- snippet: {
3930
- H2A_HOST: "remote",
3931
- H2A_INSTANCE: instance,
3932
- H2A_ROOT: root
3933
- }
3934
- });
3935
- }
3936
- else {
3937
- const hostDescriptor = flags.host === "codex"
3938
- ? H2A_CODEX_HOST
3939
- : flags.host === "claude"
3940
- ? H2A_CLAUDE_HOST
3941
- : flags.host === "gemini"
3942
- ? H2A_GEMINI_HOST
3943
- : H2A_AGY_HOST;
3944
- const snippet = hostDescriptor.renderMcpConfig({ root });
3945
- steps.push({
3946
- step: "host-setup",
3947
- ok: true,
3948
- pathHint: snippet.path.hint,
3949
- pathExample: snippet.path.example,
3950
- snippet: snippet.config
3951
- });
3952
- }
3953
- // 3. Print follow-up instructions
3954
- const followUp = [
3955
- flags.host === "remote"
3956
- ? "Use the bridge-provided environment in the remote session Pod"
3957
- : `Merge the snippet above under \`mcpServers\` in ${steps[1].pathExample}`,
3958
- identity.privateKeyPath
3959
- ? `keypair ready: ${identity.privateKeyPath}`
3960
- : `legacy override: run h2a keys generate --instance ${instance} --root ${root} if needed`,
3961
- flags.host === "remote" ? "remote bridge skills are managed by the host" : `and: h2a install-skills --host ${flags.host}`
3962
- ];
3963
- summary.followUp = followUp;
3964
- streams.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
3965
- return 0;
3966
- }
3967
- /**
3968
- * Parse a SKILL.md file into its YAML-frontmatter `name`/`description` and
3969
- * the body markdown that follows. The parser is intentionally minimal: it
3970
- * accepts the canonical `---\nname: ...\ndescription: ...\n---\n<body>` shape
3971
- * that every shipped h2a skill uses. Multi-line description values are
3972
- * supported via simple line-continuation indent.
3973
- */
3974
- function parseSkill(raw) {
3975
- const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/m.exec(raw);
3976
- if (!match) {
3977
- throw new Error("skill file missing YAML frontmatter (expected `---` delimiters)");
3978
- }
3979
- const [, fmRaw, body] = match;
3980
- const frontmatter = {};
3981
- let currentKey;
3982
- for (const line of fmRaw.split(/\r?\n/)) {
3983
- const kv = /^([a-zA-Z][\w-]*)\s*:\s*(.*)$/.exec(line);
3984
- if (kv) {
3985
- currentKey = kv[1];
3986
- frontmatter[currentKey] = kv[2].trim().replace(/^["']|["']$/g, "");
3987
- }
3988
- else if (currentKey && /^\s+/.test(line)) {
3989
- frontmatter[currentKey] = `${frontmatter[currentKey]} ${line.trim()}`;
3990
- }
3991
- }
3992
- if (!frontmatter.name || !frontmatter.description) {
3993
- throw new Error("skill frontmatter must declare both `name` and `description`");
3994
- }
3995
- return {
3996
- name: frontmatter.name,
3997
- description: frontmatter.description,
3998
- body: body.trimStart()
3999
- };
4000
- }
4001
- /**
4002
- * Render a parsed skill as a Gemini CLI custom-command TOML file
4003
- * (`~/.gemini/commands/<name>.toml`). Gemini consumes a top-level
4004
- * `description` plus a `prompt` (multiline triple-quoted) which is what the
4005
- * agent receives when the user types `/<name>`. We include the original
4006
- * frontmatter so the prompt itself remains self-describing.
4007
- */
4008
- function renderSkillAsGeminiToml(skill) {
4009
- const escapedDescription = skill.description.replace(/"/g, '\\"');
4010
- const promptHeader = `You are the ${skill.name} custom command for Gemini CLI.\n\n`;
4011
- const promptBody = `${promptHeader}${skill.body}`;
4012
- // TOML multiline literal: '''...''' is verbatim, no escaping needed beyond
4013
- // disallowing the closing triple-quote in content. The bundled skills do
4014
- // not contain '''.
4015
- return [
4016
- `description = "${escapedDescription}"`,
4017
- `prompt = '''`,
4018
- promptBody,
4019
- `'''`,
4020
- ""
4021
- ].join("\n");
4022
- }
4023
- function targetSpecFor(host, cwd) {
4024
- if (host === "claude") {
4025
- return {
4026
- host: "claude",
4027
- userBase: join(homedir(), ".claude", "skills"),
4028
- projectBase: join(cwd, ".claude", "skills"),
4029
- extension: "SKILL.md",
4030
- write: (base, skillName, _parsed, raw) => {
4031
- const dir = join(base, skillName);
4032
- mkdirSync(dir, { recursive: true });
4033
- const target = join(dir, "SKILL.md");
4034
- writeFileSync(target, raw, "utf8");
4035
- return target;
4036
- }
4037
- };
4038
- }
4039
- if (host === "codex") {
4040
- return {
4041
- host: "codex",
4042
- userBase: join(homedir(), ".codex", "skills"),
4043
- projectBase: join(cwd, ".codex", "skills"),
4044
- extension: "SKILL.md",
4045
- write: (base, skillName, _parsed, raw) => {
4046
- const dir = join(base, skillName);
4047
- mkdirSync(dir, { recursive: true });
4048
- const target = join(dir, "SKILL.md");
4049
- writeFileSync(target, raw, "utf8");
4050
- return target;
4051
- }
4052
- };
4053
- }
4054
- if (host === "gemini") {
4055
- return {
4056
- host: "gemini",
4057
- userBase: join(homedir(), ".gemini", "commands"),
4058
- projectBase: join(cwd, ".gemini", "commands"),
4059
- extension: ".toml",
4060
- write: (base, skillName, parsed) => {
4061
- mkdirSync(base, { recursive: true });
4062
- const target = join(base, `${skillName}.toml`);
4063
- writeFileSync(target, renderSkillAsGeminiToml(parsed), "utf8");
4064
- return target;
4065
- }
4066
- };
4067
- }
4068
- if (host === "agy") {
4069
- // DEC-096/101: agy (Antigravity) is in the Gemini ecosystem and shares
4070
- // `~/.gemini/`; it has no own skill store but imports plugins from gemini
4071
- // or claude (`agy plugin import gemini`). So installing the gemini TOML
4072
- // command IS the agy source — same write/location as gemini; the import
4073
- // step is surfaced as `importHint` in the summary.
4074
- return {
4075
- host: "agy",
4076
- userBase: join(homedir(), ".gemini", "commands"),
4077
- projectBase: join(cwd, ".gemini", "commands"),
4078
- extension: ".toml",
4079
- write: (base, skillName, parsed) => {
4080
- mkdirSync(base, { recursive: true });
4081
- const target = join(base, `${skillName}.toml`);
4082
- writeFileSync(target, renderSkillAsGeminiToml(parsed), "utf8");
4083
- return target;
4084
- }
4085
- };
4086
- }
4087
- return undefined;
4088
- }
4089
- /**
4090
- * Skill names shipped before DEC-057 that consolidated into a single `h2a`
4091
- * skill. The installer removes them on a fresh install so users do not end up
4092
- * with both the legacy three-skill bundle and the unified one.
4093
- */
4094
- const LEGACY_SKILL_NAMES = ["h2a-connect", "h2a-discover", "h2a-send"];
4095
- function pruneLegacy(spec, base) {
4096
- const pruned = [];
4097
- for (const legacyName of LEGACY_SKILL_NAMES) {
4098
- if (spec.host === "gemini" || spec.host === "agy") {
4099
- const file = join(base, `${legacyName}.toml`);
4100
- if (existsSync(file)) {
4101
- try {
4102
- unlinkSync(file);
4103
- pruned.push({ name: legacyName, path: file });
4104
- }
4105
- catch {
4106
- // best-effort
4107
- }
4108
- }
4109
- }
4110
- else {
4111
- const dir = join(base, legacyName);
4112
- if (existsSync(dir)) {
4113
- try {
4114
- rmSync(dir, { recursive: true, force: true });
4115
- pruned.push({ name: legacyName, path: dir });
4116
- }
4117
- catch {
4118
- // best-effort
4119
- }
4120
- }
4121
- }
4122
- }
4123
- return pruned;
4124
- }
4125
- function cmdInstallSkills(flags, streams) {
4126
- const host = flags.host;
4127
- if (!host) {
4128
- streams.stderr.write("h2a install-skills: --host <claude|codex|gemini|agy> is required\n");
4129
- return 1;
4130
- }
4131
- const cwd = streams.cwd ?? (() => process.cwd());
4132
- const spec = targetSpecFor(host, cwd());
4133
- if (!spec) {
4134
- streams.stderr.write(`h2a install-skills: unknown --host "${host}". Supported: claude, codex, gemini, agy.\n`);
4135
- return 1;
4136
- }
4137
- if (!existsSync(SKILLS_DIR)) {
4138
- streams.stderr.write(`h2a install-skills: skills bundle missing at ${SKILLS_DIR}\n`);
4139
- return 3;
4140
- }
4141
- const scope = flags.scope ?? "user";
4142
- if (scope !== "user" && scope !== "project") {
4143
- streams.stderr.write(`h2a install-skills: --scope must be 'user' or 'project'\n`);
4144
- return 1;
4145
- }
4146
- const targetBase = scope === "user" ? spec.userBase : spec.projectBase;
4147
- // DEC-057: prune the pre-consolidation skill names if they linger on disk.
4148
- const prunedLegacy = pruneLegacy(spec, targetBase);
4149
- const entries = readdirSync(SKILLS_DIR, { withFileTypes: true });
4150
- const installed = [];
4151
- const skipped = [];
4152
- for (const entry of entries) {
4153
- if (!entry.isDirectory())
4154
- continue;
4155
- const skillName = entry.name;
4156
- const src = join(SKILLS_DIR, skillName, "SKILL.md");
4157
- if (!existsSync(src))
4158
- continue;
4159
- let raw;
4160
- try {
4161
- raw = readFileSync(src, "utf8");
4162
- }
4163
- catch (error) {
4164
- streams.stderr.write(`h2a install-skills: cannot read ${src} (${error.message})\n`);
4165
- return 3;
4166
- }
4167
- let parsed;
4168
- try {
4169
- parsed = parseSkill(raw);
4170
- }
4171
- catch (error) {
4172
- streams.stderr.write(`h2a install-skills: ${skillName}: ${error.message}\n`);
4173
- return 2;
4174
- }
4175
- // Probe the would-be target file before writing so existing files are
4176
- // surfaced as skipped (mirrors the previous Claude-only behavior).
4177
- const probeTarget = spec.host === "gemini" || spec.host === "agy"
4178
- ? join(targetBase, `${skillName}.toml`)
4179
- : join(targetBase, skillName, "SKILL.md");
4180
- if (existsSync(probeTarget) && flags.force !== "true") {
4181
- skipped.push({
4182
- name: skillName,
4183
- reason: `${probeTarget} already exists (use --force to overwrite)`
4184
- });
4185
- continue;
4186
- }
4187
- try {
4188
- const targetPath = spec.write(targetBase, skillName, parsed, raw);
4189
- installed.push(targetPath);
4190
- }
4191
- catch (error) {
4192
- streams.stderr.write(`h2a install-skills: failed to write ${probeTarget} (${error.message})\n`);
4193
- return 3;
4194
- }
4195
- }
4196
- streams.stdout.write(`${JSON.stringify({
4197
- ok: skipped.length === 0,
4198
- host,
4199
- scope,
4200
- targetBase,
4201
- installed,
4202
- skipped,
4203
- prunedLegacy,
4204
- // DEC-101: agy has no own skill store — it imports from gemini/claude.
4205
- // The TOML above is written to the shared ~/.gemini location; the user
4206
- // then pulls it into agy with `agy plugin import gemini`.
4207
- ...(host === "agy"
4208
- ? { importHint: "agy plugin import gemini # then: agy plugin enable h2a" }
4209
- : {})
4210
- }, null, 2)}\n`);
4211
- return skipped.length === 0 ? 0 : 2;
4212
- }
4213
- function cmdDeploy(argv, streams) {
4214
- const { command: sub, flags } = parseFlags(argv);
4215
- if (!sub) {
4216
- streams.stderr.write("h2a deploy: subcommand required (supported: k8s-sidecar, k8s-tenant)\n");
4217
- return 1;
4218
- }
4219
- if (sub === "k8s-tenant") {
4220
- return cmdDeployTenant(flags, streams);
4221
- }
4222
- if (sub !== "k8s-sidecar") {
4223
- streams.stderr.write(`h2a deploy: unknown subcommand "${sub}". Supported: k8s-sidecar, k8s-tenant.\n`);
4224
- return 1;
4225
- }
4226
- // DEC-058: render the Kubernetes sidecar fragment for Scenario A
4227
- // of DEC-056 (remote session sidecar).
4228
- const fragment = renderK8sSidecar({
4229
- instance: flags.instance,
4230
- host: flags.host,
4231
- root: flags.root,
4232
- image: flags.image,
4233
- cliVersion: flags["cli-version"]
4234
- });
4235
- const writeTo = flags.write;
4236
- if (writeTo) {
4237
- try {
4238
- mkdirSync(dirname(writeTo), { recursive: true });
4239
- writeFileSync(writeTo, fragment.yaml, "utf8");
4240
- }
4241
- catch (error) {
4242
- streams.stderr.write(`h2a deploy k8s-sidecar: cannot write ${writeTo} (${error.message})\n`);
4243
- return 3;
4244
- }
4245
- streams.stdout.write(`${JSON.stringify({ ok: true, target: "k8s-sidecar", path: writeTo }, null, 2)}\n`);
4246
- return 0;
4247
- }
4248
- // Default: emit a JSON resource envelope (DEC-034). Programmatic callers
4249
- // can consume the structured `container` / `volume` / `volumeMount`
4250
- // pieces; humans pipe `.yaml` to kubectl: `h2a deploy k8s-sidecar | jq -r .yaml`.
4251
- streams.stdout.write(`${JSON.stringify({
4252
- target: "k8s-sidecar",
4253
- container: fragment.container,
4254
- volume: fragment.volume,
4255
- mainContainerVolumeMount: fragment.mainContainerVolumeMount,
4256
- yaml: fragment.yaml
4257
- }, null, 2)}\n`);
4258
- return 0;
4259
- }
4260
- function cmdDeployTenant(flags, streams) {
4261
- // DEC-067: render the full cluster-tenant manifest for Scenario B of DEC-056
4262
- // (shared RWX store coordinated by the lease lock, DEC-065/066).
4263
- const replicas = flags.replicas ? Number.parseInt(flags.replicas, 10) : undefined;
4264
- if (flags.replicas && (!Number.isFinite(replicas) || replicas < 1)) {
4265
- streams.stderr.write(`h2a deploy k8s-tenant: --replicas must be a positive integer (got "${flags.replicas}")\n`);
4266
- return 1;
4267
- }
4268
- const leaseMs = flags["lease-ms"] ? Number.parseInt(flags["lease-ms"], 10) : undefined;
4269
- if (flags["lease-ms"] && (!Number.isFinite(leaseMs) || leaseMs < 1)) {
4270
- streams.stderr.write(`h2a deploy k8s-tenant: --lease-ms must be a positive integer (got "${flags["lease-ms"]}")\n`);
4271
- return 1;
4272
- }
4273
- const manifest = renderK8sTenant({
4274
- namespace: flags.namespace,
4275
- root: flags.root,
4276
- replicas,
4277
- storage: flags.storage,
4278
- storageClass: flags["storage-class"],
4279
- leaseMs,
4280
- image: flags.image,
4281
- cliVersion: flags["cli-version"]
4282
- });
4283
- const writeTo = flags.write;
4284
- if (writeTo) {
4285
- try {
4286
- mkdirSync(dirname(writeTo), { recursive: true });
4287
- writeFileSync(writeTo, manifest.yaml, "utf8");
4288
- }
4289
- catch (error) {
4290
- streams.stderr.write(`h2a deploy k8s-tenant: cannot write ${writeTo} (${error.message})\n`);
4291
- return 3;
4292
- }
4293
- streams.stdout.write(`${JSON.stringify({ ok: true, target: "k8s-tenant", path: writeTo }, null, 2)}\n`);
4294
- return 0;
4295
- }
4296
- streams.stdout.write(`${JSON.stringify({
4297
- target: "k8s-tenant",
4298
- documents: manifest.documents,
4299
- yaml: manifest.yaml
4300
- }, null, 2)}\n`);
4301
- return 0;
4302
- }
4303
- // ── WP-5: keepalive ────────────────────────────────────────────────────────
4304
- /**
4305
- * Single-pass keepalive logic, testable without real tmux.
4306
- *
4307
- * For each presence file under `root`, if the session has a
4308
- * `launchContext.tmux.pane` that is in `livePanes`, rewrite its `heartbeatAt`
4309
- * to `now` so the session does not expire.
4310
- */
4311
- export function keepaliveOnce(opts) {
4312
- const { root, livePanes } = opts;
4313
- const nowIso = (opts.now ?? new Date()).toISOString();
4314
- const refreshed = [];
4315
- // includeExpired=true so we can refresh sessions that are about to expire
4316
- const sessions = listPresence(root, { includeExpired: true });
4317
- for (const session of sessions) {
4318
- const pane = session.launchContext?.tmux?.pane;
4319
- if (!pane)
4320
- continue;
4321
- if (!livePanes.has(pane))
4322
- continue;
4323
- // Rewrite heartbeatAt to now
4324
- const updated = { ...session, heartbeatAt: nowIso };
4325
- try {
4326
- writePresence(root, updated);
4327
- refreshed.push({ instance: session.instance, sessionId: session.sessionId, pane });
4328
- }
4329
- catch {
4330
- // best-effort — ignore write failures
4331
- }
4332
- }
4333
- return refreshed;
4334
- }
4335
- /**
4336
- * `h2a keepalive [--root <path>] [--interval <ms>] [--once]`
4337
- *
4338
- * External keepalive prober: runs by the launcher/remote so a host-suspended
4339
- * `mcp-serve` still shows live as long as its tmux pane is alive. `--once`
4340
- * does a single pass then exits 0. Without `--once`, loops on an unref'd
4341
- * interval (default 30 000 ms).
4342
- */
4343
- export async function cmdKeepalive(flags, streams) {
4344
- const cwd = streams.cwd ?? (() => process.cwd());
4345
- const root = resolveRoot(flags, cwd);
4346
- const intervalMs = flags.interval ? Number.parseInt(flags.interval, 10) : 30_000;
4347
- if (!Number.isInteger(intervalMs) || intervalMs < 1000) {
4348
- streams.stderr.write(`h2a keepalive: --interval must be >= 1000 ms (got "${flags.interval}")\n`);
4349
- return 1;
4350
- }
4351
- function getLivePanes() {
4352
- try {
4353
- const out = execFileSync("tmux", ["list-panes", "-aF", "#{pane_id}"], {
4354
- encoding: "utf8",
4355
- timeout: 5000
4356
- });
4357
- const panes = new Set(out
4358
- .split("\n")
4359
- .map((l) => l.trim())
4360
- .filter((l) => l.length > 0));
4361
- return panes;
4362
- }
4363
- catch {
4364
- streams.stderr.write("h2a keepalive: tmux not available or returned an error — treating live-pane set as empty.\n");
4365
- return new Set();
4366
- }
4367
- }
4368
- function runOnce() {
4369
- const livePanes = getLivePanes();
4370
- const refreshed = keepaliveOnce({ root, livePanes });
4371
- for (const item of refreshed) {
4372
- streams.stdout.write(`h2a keepalive: refreshed ${item.instance} (session ${item.sessionId}, pane ${item.pane})\n`);
4373
- }
4374
- }
4375
- runOnce();
4376
- if (flags.once !== undefined) {
4377
- return 0;
4378
- }
4379
- // Long-running loop — unref the interval so it does not keep the process alive.
4380
- return new Promise((resolve) => {
4381
- const timer = setInterval(() => {
4382
- runOnce();
4383
- }, intervalMs);
4384
- timer.unref();
4385
- // The process will exit naturally when there is nothing else keeping it alive.
4386
- // For tests that want to abort, they should pass --once.
4387
- void resolve; // keep linter happy
4388
- });
4389
- }
4390
- export function runCli(argv = process.argv.slice(2), streams = {
4391
- stdout: process.stdout,
4392
- stderr: process.stderr
4393
- }) {
4394
- const { command, flags } = parseFlags(argv);
4395
- if (!command || command === "--help" || command === "-h" || command === "help") {
4396
- streams.stdout.write(`${renderCliHelp()}\n`);
4397
- return 0;
4398
- }
4399
- if (command === "hosts") {
4400
- streams.stdout.write(`${JSON.stringify(CLI_HOSTS, null, 2)}\n`);
4401
- return 0;
4402
- }
4403
- if (command === "mcp-tools") {
4404
- streams.stdout.write(`${JSON.stringify(H2A_CLI_MCP_TOOL_NAMES, null, 2)}\n`);
4405
- return 0;
4406
- }
4407
- if (command === "init")
4408
- return cmdInit(flags, streams);
4409
- if (command === "register")
4410
- return cmdRegister(flags, streams);
4411
- if (command === "discover")
4412
- return cmdDiscover(flags, streams);
4413
- if (command === "subagent")
4414
- return cmdSubagent(argv.slice(1), streams);
4415
- if (command === "drumbeat") {
4416
- const sub = argv[1];
4417
- if (sub === "watch") {
4418
- streams.stderr.write("h2a drumbeat watch: async daemon — run via the h2a binary, not the synchronous API.\n");
4419
- return 1;
4420
- }
4421
- return cmdDrumbeat(argv.slice(1), streams);
4422
- }
4423
- if (command === "upgrade")
4424
- return cmdUpgrade(flags, streams);
4425
- if (command === "nhi")
4426
- return cmdNhi(argv.slice(1), streams);
4427
- if (command === "org")
4428
- return cmdOrg(argv.slice(1), streams);
4429
- if (command === "coach")
4430
- return cmdCoach(argv.slice(1), streams);
4431
- if (command === "blockage")
4432
- return cmdBlockage(argv.slice(1), streams);
4433
- if (command === "sysml") {
4434
- const sub = argv[1];
4435
- if (sub === "verify") {
4436
- streams.stderr.write("h2a sysml verify: async verb — run via the h2a binary, not the synchronous API.\n");
4437
- return 1;
4438
- }
4439
- streams.stderr.write("h2a sysml: subcommand required (verify).\n");
4440
- return 1;
4441
- }
4442
- if (command === "negotiate")
4443
- return cmdNegotiate(argv.slice(1), streams);
4444
- if (command === "declare-interest")
4445
- return cmdDeclareInteret(flags, streams);
4446
- if (command === "conflict-posture")
4447
- return cmdConflictPosture(flags, streams);
4448
- if (command === "dossier")
4449
- return cmdDossier(flags, streams);
4450
- if (command === "confiance")
4451
- return cmdConfiance(flags, streams);
4452
- if (command === "attest-comprehension")
4453
- return cmdAttestComprehension(flags, streams);
4454
- if (command === "comprehension")
4455
- return cmdComprehension(argv.slice(1), streams);
4456
- if (command === "inbox")
4457
- return cmdMailbox(argv.slice(1), "inbox", streams);
4458
- if (command === "outbox")
4459
- return cmdMailbox(argv.slice(1), "outbox", streams);
4460
- if (command === "host")
4461
- return cmdHost(argv.slice(1), streams);
4462
- if (command === "store")
4463
- return cmdStore(argv.slice(1), streams);
4464
- if (command === "thread")
4465
- return cmdThread(flags, streams);
4466
- if (command === "sessions")
4467
- return cmdSessions(flags, streams);
4468
- if (command === "status")
4469
- return cmdStatus(flags, streams);
4470
- if (command === "doctor")
4471
- return cmdDoctor(flags, streams);
4472
- if (command === "presence-reap")
4473
- return cmdPresenceReap(flags, streams);
4474
- if (command === "connect")
4475
- return cmdConnect(flags, streams);
4476
- if (command === "conductor")
4477
- return cmdConductor(argv.slice(1), streams);
4478
- if (command === "conductor-launch-check")
4479
- return cmdConductorLaunchCheck(argv.slice(1), streams);
4480
- if (command === "conductor-launch")
4481
- return cmdConductorLaunch(argv.slice(1), streams);
4482
- if (command === "wake-request")
4483
- return cmdWakeRequest(argv.slice(1), streams);
4484
- if (command === "keepalive") {
4485
- streams.stderr.write("h2a keepalive: async command — run via the h2a binary, not the synchronous API.\n");
4486
- return 1;
4487
- }
4488
- if (command === "install-skills")
4489
- return cmdInstallSkills(flags, streams);
4490
- if (command === "deploy")
4491
- return cmdDeploy(argv.slice(1), streams);
4492
- if (command === "drive") {
4493
- if (argv[1] === "receive") {
4494
- return cmdDriveReceive(parseFlags(argv.slice(1)).flags, streams);
4495
- }
4496
- if (argv[1] === "serve") {
4497
- streams.stderr.write("h2a drive serve: async verb — run via the h2a binary, not the synchronous API.\n");
4498
- return 1;
4499
- }
4500
- return cmdDrive(flags, streams);
4501
- }
4502
- if (command === "remote") {
4503
- // `remote serve`/`remote send` are async and dispatched from bin.ts; if we
4504
- // reach here it is a misuse (e.g. via the sync runCli) or an unknown sub.
4505
- const sub = argv[1];
4506
- if (sub === "serve" || sub === "send") {
4507
- streams.stderr.write(`h2a remote ${sub}: async verb — run via the h2a binary, not the synchronous API.\n`);
4508
- return 1;
4509
- }
4510
- streams.stderr.write(`h2a remote: subcommand required (serve, send).\n`);
4511
- return 1;
4512
- }
4513
- if (command === "keys") {
4514
- const { command: sub, flags: subFlags } = parseFlags(argv.slice(1));
4515
- if (sub === "generate")
4516
- return cmdKeysGenerate(subFlags, streams);
4517
- if (sub === "add")
4518
- return cmdKeysAdd(subFlags, streams);
4519
- if (sub === "list")
4520
- return cmdKeysList(subFlags, streams);
4521
- if (sub === "revoke")
4522
- return cmdKeysRevoke(subFlags, streams);
4523
- streams.stderr.write(`h2a keys: unknown subcommand "${sub ?? ""}" (generate, add, list, revoke)\n`);
4524
- return 1;
4525
- }
4526
- streams.stderr.write(`Unknown command: ${command}\n`);
4527
- streams.stderr.write("Run `h2a --help`.\n");
4528
- return 1;
4529
- }
4530
- //# sourceMappingURL=cli.js.map