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