@huynle/hub 0.6.1 → 0.7.2

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 (355) hide show
  1. package/CHANGELOG.md +870 -0
  2. package/Dockerfile +33 -0
  3. package/README.md +1007 -66
  4. package/package.json +47 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +156 -0
  21. package/packages/cli/dist/hub-cli.js +21647 -0
  22. package/packages/cli/package.json +11 -0
  23. package/packages/cli/src/acting.ts +70 -0
  24. package/packages/cli/src/args.ts +91 -0
  25. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  26. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  27. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  28. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  29. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  30. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  31. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  32. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  33. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  34. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  35. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  36. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  37. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  38. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  39. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  40. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  41. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  42. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  43. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  44. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  45. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  46. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  47. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  48. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  49. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  50. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  51. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  52. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  53. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  54. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  55. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  56. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  57. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  58. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  59. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  60. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  61. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  62. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  63. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  64. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  65. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  66. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  67. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  68. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  69. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  70. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  71. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  72. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  73. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  74. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  75. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  76. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  77. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  78. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  79. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  80. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  81. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  82. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  83. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  84. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  85. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  86. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  87. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  88. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  89. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  90. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  91. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  92. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  93. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  94. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  95. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  96. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  97. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  98. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  99. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  100. package/packages/cli/src/bootstrap/release/README.md +115 -0
  101. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  102. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  103. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  104. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  105. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  106. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  107. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  108. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  109. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  110. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  111. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  112. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  113. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  114. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  115. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  116. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  117. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  118. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  119. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  120. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  121. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  122. package/packages/cli/src/cli.ts +814 -0
  123. package/packages/cli/src/credentials.ts +75 -0
  124. package/packages/cli/src/decisions.ts +106 -0
  125. package/packages/cli/src/format.ts +185 -0
  126. package/packages/cli/src/grants.ts +105 -0
  127. package/packages/cli/src/http.ts +86 -0
  128. package/packages/cli/src/launches.ts +157 -0
  129. package/packages/cli/src/login.ts +160 -0
  130. package/packages/cli/src/main.ts +63 -0
  131. package/packages/cli/src/mcp.ts +879 -0
  132. package/packages/cli/src/messaging.ts +108 -0
  133. package/packages/cli/src/node.ts +279 -0
  134. package/packages/cli/src/openshell.ts +166 -0
  135. package/packages/cli/src/setup/commands.ts +809 -0
  136. package/packages/cli/src/setup/contributors.ts +171 -0
  137. package/packages/cli/src/setup/dispatch.ts +74 -0
  138. package/packages/cli/src/setup/launch-driver.ts +109 -0
  139. package/packages/cli/src/setup/runtime.ts +456 -0
  140. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  141. package/packages/cli/src/setup/state.ts +244 -0
  142. package/packages/cli/src/setup/user-bin.ts +39 -0
  143. package/packages/cli/src/spoke-services.ts +109 -0
  144. package/packages/cli/src/watch.ts +28 -0
  145. package/packages/codex/package.json +12 -0
  146. package/packages/codex/src/app-server.ts +44 -0
  147. package/packages/codex/src/ids.ts +28 -0
  148. package/packages/codex/src/index.ts +11 -0
  149. package/packages/codex/src/jsonrpc.ts +168 -0
  150. package/packages/codex/src/main.ts +123 -0
  151. package/packages/codex/src/paths.ts +65 -0
  152. package/packages/codex/src/sessions.ts +578 -0
  153. package/packages/codex/src/spoke.ts +252 -0
  154. package/packages/codex/src/stdio.ts +101 -0
  155. package/packages/codex/src/websocket.ts +206 -0
  156. package/packages/codex/src/wrapper.ts +104 -0
  157. package/packages/node/package.json +12 -0
  158. package/packages/node/src/config.ts +184 -0
  159. package/packages/node/src/connection.ts +283 -0
  160. package/packages/node/src/daemon.ts +281 -0
  161. package/packages/node/src/git.ts +107 -0
  162. package/packages/node/src/harnesses.ts +95 -0
  163. package/packages/node/src/host.ts +319 -0
  164. package/packages/node/src/index.ts +17 -0
  165. package/packages/node/src/main.ts +11 -0
  166. package/packages/node/src/openshell.ts +73 -0
  167. package/packages/node/src/run.ts +106 -0
  168. package/packages/preset/package.json +12 -0
  169. package/packages/preset/presets/aider.json +8 -0
  170. package/packages/preset/presets/goose.json +8 -0
  171. package/packages/preset/src/index.ts +2 -0
  172. package/packages/preset/src/main.ts +195 -0
  173. package/packages/preset/src/presets.ts +87 -0
  174. package/packages/preset/src/spoke.ts +251 -0
  175. package/packages/preset/src/tmux.ts +160 -0
  176. package/packages/protocol/package.json +12 -0
  177. package/packages/protocol/src/harness-events.ts +207 -0
  178. package/packages/protocol/src/index.ts +1215 -0
  179. package/packages/protocol/src/launch.ts +172 -0
  180. package/packages/protocol/src/node.ts +409 -0
  181. package/packages/protocol/src/preset.ts +144 -0
  182. package/packages/protocol/src/webhook.ts +62 -0
  183. package/packages/protocol/src/wire.ts +54 -0
  184. package/packages/runtime-openshell/image/Dockerfile +36 -0
  185. package/packages/runtime-openshell/image/README.md +78 -0
  186. package/packages/runtime-openshell/image/build.ts +203 -0
  187. package/packages/runtime-openshell/image/entry.sh +163 -0
  188. package/packages/runtime-openshell/image/image.ts +119 -0
  189. package/packages/runtime-openshell/image/start.sh +24 -0
  190. package/packages/runtime-openshell/package.json +12 -0
  191. package/packages/runtime-openshell/src/cli.ts +584 -0
  192. package/packages/runtime-openshell/src/index.ts +6 -0
  193. package/packages/runtime-openshell/src/policy.ts +155 -0
  194. package/packages/runtime-openshell/src/profiles.ts +181 -0
  195. package/packages/runtime-openshell/src/runtime.ts +475 -0
  196. package/packages/runtime-sdk/package.json +12 -0
  197. package/packages/runtime-sdk/src/index.ts +87 -0
  198. package/packages/sdk/package.json +12 -0
  199. package/packages/sdk/src/address.ts +25 -0
  200. package/packages/sdk/src/client.ts +553 -0
  201. package/packages/sdk/src/envelope.ts +108 -0
  202. package/packages/sdk/src/errors.ts +107 -0
  203. package/packages/sdk/src/index.ts +10 -0
  204. package/packages/sdk/src/sse.ts +70 -0
  205. package/packages/sdk/src/types.ts +517 -0
  206. package/packages/sdk/src/webhook.ts +102 -0
  207. package/packages/spoke-sdk/package.json +12 -0
  208. package/packages/spoke-sdk/src/activity.ts +3 -0
  209. package/packages/spoke-sdk/src/connection.ts +295 -0
  210. package/packages/spoke-sdk/src/delivery.ts +119 -0
  211. package/packages/spoke-sdk/src/enroll.ts +101 -0
  212. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  213. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  214. package/packages/spoke-sdk/src/index.ts +12 -0
  215. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  216. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  217. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  218. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  219. package/scripts/hub-claude-code.service +34 -0
  220. package/scripts/hub-codex-spoke.service +34 -0
  221. package/scripts/hub-node.service +35 -0
  222. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  223. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  224. package/scripts/io.github.huynle.hub-node.plist +52 -0
  225. package/src/central/actions.ts +233 -0
  226. package/src/central/agent/acting-routes.ts +133 -0
  227. package/src/central/agent/acting-switch.ts +93 -0
  228. package/src/central/agent/acting.ts +599 -0
  229. package/src/central/agent/actor.ts +193 -0
  230. package/src/central/agent/app-tokens.ts +152 -0
  231. package/src/central/agent/archive-inactive.ts +66 -0
  232. package/src/central/agent/audit.ts +185 -0
  233. package/src/central/agent/decisions.ts +530 -0
  234. package/src/central/agent/delivery.ts +678 -0
  235. package/src/central/agent/directory-routes.ts +328 -0
  236. package/src/central/agent/directory.ts +473 -0
  237. package/src/central/agent/envelope.ts +51 -0
  238. package/src/central/agent/errors.ts +74 -0
  239. package/src/central/agent/escalation.ts +219 -0
  240. package/src/central/agent/event-cursor.ts +49 -0
  241. package/src/central/agent/event-writers.ts +181 -0
  242. package/src/central/agent/events-routes.ts +148 -0
  243. package/src/central/agent/events.ts +277 -0
  244. package/src/central/agent/grant-requests.ts +221 -0
  245. package/src/central/agent/grant-routes.ts +70 -0
  246. package/src/central/agent/grants.ts +235 -0
  247. package/src/central/agent/launch-engine.ts +1025 -0
  248. package/src/central/agent/launch-routes.ts +252 -0
  249. package/src/central/agent/launch-spec.ts +289 -0
  250. package/src/central/agent/launch-store.ts +264 -0
  251. package/src/central/agent/launch-tokens.ts +206 -0
  252. package/src/central/agent/message-limits.ts +123 -0
  253. package/src/central/agent/message-routes.ts +385 -0
  254. package/src/central/agent/messages.ts +442 -0
  255. package/src/central/agent/names.ts +86 -0
  256. package/src/central/agent/placement.ts +419 -0
  257. package/src/central/agent/policy.ts +378 -0
  258. package/src/central/agent/read-routes.ts +385 -0
  259. package/src/central/agent/routes.ts +281 -0
  260. package/src/central/agent/session-tokens.ts +227 -0
  261. package/src/central/agent/versioned.ts +26 -0
  262. package/src/central/agent/watch.ts +112 -0
  263. package/src/central/agent/webhook-routes.ts +86 -0
  264. package/src/central/agent/webhooks.ts +493 -0
  265. package/src/central/agent-data.ts +177 -0
  266. package/src/central/auth.ts +350 -121
  267. package/src/central/backend/file-state.ts +240 -0
  268. package/src/central/backend/index.ts +116 -0
  269. package/src/central/backend/memory-bus.ts +93 -0
  270. package/src/central/backend/memory-catalog.ts +90 -0
  271. package/src/central/backend/memory-counters.ts +102 -0
  272. package/src/central/backend/memory-events.ts +45 -0
  273. package/src/central/backend/memory-kv.ts +38 -0
  274. package/src/central/backend/memory-state.ts +77 -0
  275. package/src/central/backend/postgres-migrate.ts +100 -0
  276. package/src/central/backend/postgres-state.ts +197 -0
  277. package/src/central/backend/types.ts +246 -0
  278. package/src/central/backend/valkey-bus.ts +425 -0
  279. package/src/central/backend/valkey-catalog.ts +190 -0
  280. package/src/central/backend/valkey-client.ts +36 -0
  281. package/src/central/backend/valkey-counters.ts +38 -0
  282. package/src/central/backend/valkey-events.ts +168 -0
  283. package/src/central/backend/valkey-kv.ts +54 -0
  284. package/src/central/backend/valkey-locks.ts +92 -0
  285. package/src/central/cluster/directory.ts +141 -0
  286. package/src/central/cluster/feed.ts +84 -0
  287. package/src/central/cluster/leader.ts +232 -0
  288. package/src/central/cluster/live.ts +151 -0
  289. package/src/central/cluster/recommend.ts +59 -0
  290. package/src/central/cluster/rpc.ts +150 -0
  291. package/src/central/cluster/view.ts +232 -0
  292. package/src/central/config.ts +197 -3
  293. package/src/central/done.ts +96 -0
  294. package/src/central/events-log.ts +100 -0
  295. package/src/central/http.ts +1149 -170
  296. package/src/central/import.ts +318 -0
  297. package/src/central/main.ts +211 -12
  298. package/src/central/mcp-http.ts +116 -0
  299. package/src/central/nodes.ts +314 -0
  300. package/src/central/oidc.ts +34 -14
  301. package/src/central/push-target.ts +118 -18
  302. package/src/central/push.ts +256 -82
  303. package/src/central/registry.ts +902 -92
  304. package/src/central/say-stream.ts +128 -0
  305. package/src/central/session-token-push.ts +233 -0
  306. package/src/central/speech/openai.ts +21 -14
  307. package/src/central/speech/openrouter.ts +27 -10
  308. package/src/central/speech/provider.ts +54 -0
  309. package/src/central/speech/service.ts +22 -7
  310. package/src/central/spoke-ws.ts +522 -44
  311. package/src/central/store-shared.ts +518 -0
  312. package/src/central/store.ts +625 -189
  313. package/src/central/token-keys.ts +165 -0
  314. package/src/config.ts +118 -7
  315. package/src/index.ts +195 -43
  316. package/src/protocol.ts +3 -650
  317. package/src/rpc.ts +27 -2
  318. package/src/spoke/client.ts +3 -272
  319. package/src/spoke/conversation.ts +16 -12
  320. package/src/spoke/deliver.ts +75 -0
  321. package/src/spoke/enroll.ts +2 -79
  322. package/src/spoke/env-inject.ts +160 -0
  323. package/src/spoke/hub-rpc.ts +115 -0
  324. package/src/spoke/hub-tool.ts +409 -0
  325. package/src/spoke/launch-source.ts +22 -0
  326. package/src/spoke/launchers.ts +174 -0
  327. package/src/spoke/locations.ts +3 -2
  328. package/src/spoke/node-roots.ts +61 -0
  329. package/src/spoke/prompt-refs.ts +116 -0
  330. package/src/spoke/session-tokens.ts +2 -0
  331. package/src/spoke/spoke.ts +465 -28
  332. package/src/spoke/taskboard.ts +243 -0
  333. package/src/spoke/timeline.ts +184 -6
  334. package/src/spoke/tracker.ts +79 -4
  335. package/src/status.ts +203 -10
  336. package/src/tui-commands.ts +13 -6
  337. package/src/tui-messaging.ts +93 -0
  338. package/src/tui.ts +15 -0
  339. package/web/dist/apple-touch-icon.png +0 -0
  340. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  341. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  342. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  343. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  344. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  345. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  346. package/web/dist/favicon.ico +0 -0
  347. package/web/dist/icon-192.png +0 -0
  348. package/web/dist/icon-512.png +0 -0
  349. package/web/dist/icon-maskable-512.png +0 -0
  350. package/web/dist/icon.svg +5 -5
  351. package/web/dist/index.html +17 -4
  352. package/web/dist/manifest.webmanifest +2 -2
  353. package/web/dist/sw.js +79 -15
  354. package/web/dist/assets/index-DClBvfLL.css +0 -1
  355. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -0,0 +1,295 @@
1
+ // The spoke's single outbound WebSocket to the central server: hello/welcome handshake, request/
2
+ // response correlation in both directions, and reconnect with jittered exponential backoff. A bye carrying
3
+ // retryAfterMs (a hub pod draining, design §10) is followed by one short jittered reconnect (0–retryAfterMs) instead.
4
+ import {
5
+ encode,
6
+ MAX_FRAME,
7
+ parseServerMessage,
8
+ PROTOCOL_VERSION,
9
+ type Hello,
10
+ type ResultFor,
11
+ type ServerRequest,
12
+ type SpokeMessage,
13
+ type SpokeRequest,
14
+ type Welcome,
15
+ } from "../../protocol/src/index.ts"
16
+
17
+ export type ConnectionState = "off" | "connecting" | "connected" | "revoked" | "error"
18
+
19
+ export interface Backoff {
20
+ readonly minMs: number
21
+ readonly maxMs: number
22
+ readonly random?: () => number
23
+ }
24
+
25
+ /** Minimal WebSocket surface used here (Bun's WebSocket accepts `{ headers }` as a 2nd argument). */
26
+ export interface SocketLike {
27
+ readonly readyState: number
28
+ onopen: ((ev: unknown) => void) | null
29
+ onmessage: ((ev: { data: unknown }) => void) | null
30
+ onclose: ((ev: { code?: number; reason?: string }) => void) | null
31
+ onerror: ((ev: unknown) => void) | null
32
+ send(data: string): void
33
+ close(code?: number, reason?: string): void
34
+ }
35
+
36
+ export interface ConnectionOptions {
37
+ readonly server: string
38
+ readonly token: string
39
+ /** Hello fields, computed per attempt so mode/directory are current. */
40
+ readonly hello: () => Omit<Hello, "t" | "v">
41
+ /** Executes a server request; a thrown error becomes `{ ok: false, error }`. */
42
+ readonly handle: (req: ServerRequest) => Promise<unknown>
43
+ readonly onWelcome?: (welcome: Welcome) => void
44
+ readonly onState?: (state: ConnectionState, error?: string) => void
45
+ readonly backoff?: Backoff
46
+ readonly requestTimeoutMs?: number
47
+ readonly socket?: (url: string, headers: Record<string, string>) => SocketLike
48
+ }
49
+
50
+ export function backoffDelay(attempt: number, backoff: Backoff): number {
51
+ const base = Math.min(backoff.maxMs, backoff.minMs * 2 ** Math.min(attempt, 30))
52
+ const random = (backoff.random ?? Math.random)()
53
+ return Math.round(base * (0.5 + 0.5 * random))
54
+ }
55
+
56
+ /** The reconnect delay a bye's retryAfterMs asks for: uniformly jittered over 0–retryAfterMs, so peers spread out. */
57
+ export function retryAfterDelay(retryAfterMs: number, random: () => number = Math.random): number {
58
+ return Math.round(retryAfterMs * random())
59
+ }
60
+
61
+ export function wsURL(server: string): string {
62
+ return `${server.replace(/\/+$/, "").replace(/^http/, "ws")}/spoke/ws`
63
+ }
64
+
65
+ const defaultSocket = (url: string, headers: Record<string, string>): SocketLike =>
66
+ new (WebSocket as unknown as new (url: string, options: { headers: Record<string, string> }) => SocketLike)(url, { headers })
67
+
68
+ const message = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
69
+
70
+ interface Pending {
71
+ resolve: (value: unknown) => void
72
+ reject: (error: Error) => void
73
+ timer: ReturnType<typeof setTimeout>
74
+ }
75
+
76
+ export class SpokeConnection {
77
+ state: ConnectionState = "off"
78
+ lastError?: string
79
+ publicURL?: string
80
+ machineID?: string
81
+
82
+ private socket?: SocketLike
83
+ private welcomed = false
84
+ /** Features the hub listed in its welcome on the current socket (none before a welcome or from an old hub). */
85
+ private hubFeatures: ReadonlySet<string> = new Set()
86
+ private stopped = true
87
+ private attempt = 0
88
+ private retry?: ReturnType<typeof setTimeout>
89
+ /** The last bye's retryAfterMs (a draining hub): the next reconnect waits about this instead of the backoff. */
90
+ private retryAfterMs?: number
91
+ private nextID = 0
92
+ private readonly pending = new Map<string, Pending>()
93
+ private readonly backoff: Backoff
94
+
95
+ constructor(private readonly options: ConnectionOptions) {
96
+ this.backoff = options.backoff ?? { minMs: 1000, maxMs: 60_000 }
97
+ }
98
+
99
+ start(): void {
100
+ if (!this.stopped) return
101
+ this.stopped = false
102
+ this.open()
103
+ }
104
+
105
+ stop(): void {
106
+ this.stopped = true
107
+ clearTimeout(this.retry)
108
+ this.drop(1000, "stop")
109
+ this.failPending("disconnected")
110
+ this.setState("off")
111
+ }
112
+
113
+ /** Whether the connected hub announced `feature`; send a request a newer hub added only when it did. */
114
+ hubSupports(feature: string): boolean {
115
+ return this.hubFeatures.has(feature)
116
+ }
117
+
118
+ /** Reconnects at once (e.g. after a local hub was started) instead of waiting out the backoff. */
119
+ retryNow(): void {
120
+ if (this.stopped || this.state === "connected" || this.state === "revoked" || this.socket) return
121
+ clearTimeout(this.retry)
122
+ this.attempt = 0
123
+ this.open()
124
+ }
125
+
126
+ /** Sends a frame once the server has welcomed us. Returns false when it could not be sent. */
127
+ send(frame: SpokeMessage): boolean {
128
+ if (!this.socket || !this.welcomed || this.socket.readyState !== 1) return false
129
+ try {
130
+ let text = encode(frame)
131
+ if (text.length > MAX_FRAME) {
132
+ // The hub drops the connection on an oversize frame; answer a request with an error instead.
133
+ if (frame.t !== "res") return false
134
+ text = encode({ t: "res", id: frame.id, ok: false, error: `Result too large to send (${text.length} characters, limit ${MAX_FRAME})` })
135
+ }
136
+ this.socket.send(text)
137
+ return true
138
+ } catch {
139
+ return false
140
+ }
141
+ }
142
+
143
+ request<R extends SpokeRequest>(req: R): Promise<ResultFor[R["op"]]> {
144
+ const id = `s${++this.nextID}`
145
+ return new Promise((resolve, reject) => {
146
+ if (this.state !== "connected") return reject(new Error("Not connected to the hub"))
147
+ const timer = setTimeout(() => {
148
+ this.pending.delete(id)
149
+ reject(new Error("The hub timed out"))
150
+ }, this.options.requestTimeoutMs ?? 15_000)
151
+ this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer })
152
+ if (!this.send({ t: "req", id, req })) {
153
+ clearTimeout(timer)
154
+ this.pending.delete(id)
155
+ reject(new Error("Not connected to the hub"))
156
+ }
157
+ })
158
+ }
159
+
160
+ private open(): void {
161
+ if (this.stopped) return
162
+ this.setState(this.state === "error" ? "error" : "connecting")
163
+ let socket: SocketLike
164
+ try {
165
+ socket = (this.options.socket ?? defaultSocket)(wsURL(this.options.server), {
166
+ Authorization: `Bearer ${this.options.token}`,
167
+ })
168
+ } catch (cause) {
169
+ this.fail(message(cause))
170
+ return
171
+ }
172
+ this.socket = socket
173
+ this.welcomed = false
174
+ socket.onopen = () => {
175
+ try {
176
+ socket.send(encode({ t: "hello", v: PROTOCOL_VERSION, ...this.options.hello() }))
177
+ } catch (cause) {
178
+ this.lastError = message(cause)
179
+ }
180
+ }
181
+ socket.onmessage = (ev) => this.receive(ev.data)
182
+ socket.onerror = () => {
183
+ this.lastError = this.welcomed ? "Connection error" : "Could not connect to the hub"
184
+ }
185
+ socket.onclose = (ev) => {
186
+ if (this.socket !== socket) return
187
+ this.socket = undefined
188
+ const wasWelcomed = this.welcomed
189
+ this.welcomed = false
190
+ this.hubFeatures = new Set()
191
+ this.failPending("disconnected")
192
+ if (this.stopped || this.state === "revoked") return
193
+ this.fail(wasWelcomed ? `Connection closed${ev.reason ? `: ${ev.reason}` : ""}` : (this.lastError ?? "Could not connect"))
194
+ }
195
+ }
196
+
197
+ private receive(data: unknown): void {
198
+ const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? Buffer.from(data).toString("utf8") : String(data)
199
+ const frame = parseServerMessage(text)
200
+ if (!frame) return
201
+ switch (frame.t) {
202
+ case "welcome":
203
+ this.welcomed = true
204
+ this.hubFeatures = new Set(frame.features ?? [])
205
+ this.attempt = 0
206
+ this.publicURL = frame.publicURL
207
+ this.machineID = frame.machineID
208
+ this.lastError = undefined
209
+ this.setState("connected")
210
+ try {
211
+ this.options.onWelcome?.(frame)
212
+ } catch (cause) {
213
+ console.error("[hub] welcome handler failed", cause)
214
+ }
215
+ return
216
+ case "bye":
217
+ if (frame.reason === "revoked") {
218
+ this.stopped = true
219
+ clearTimeout(this.retry)
220
+ this.lastError = "This machine was revoked on the hub"
221
+ this.setState("revoked")
222
+ this.drop(1000, "revoked")
223
+ this.failPending("revoked")
224
+ } else if (frame.retryAfterMs !== undefined) {
225
+ this.retryAfterMs = frame.retryAfterMs
226
+ }
227
+ return
228
+ case "res": {
229
+ const pending = this.pending.get(frame.id)
230
+ if (!pending) return
231
+ this.pending.delete(frame.id)
232
+ clearTimeout(pending.timer)
233
+ if (frame.ok) pending.resolve(frame.result)
234
+ else pending.reject(new Error(frame.error ?? "Request failed"))
235
+ return
236
+ }
237
+ case "req":
238
+ void this.answer(frame.id, frame.req)
239
+ return
240
+ }
241
+ }
242
+
243
+ private async answer(id: string, req: ServerRequest): Promise<void> {
244
+ let reply: SpokeMessage
245
+ try {
246
+ reply = { t: "res", id, ok: true, result: await this.options.handle(req) }
247
+ } catch (cause) {
248
+ reply = { t: "res", id, ok: false, error: message(cause) }
249
+ }
250
+ this.send(reply)
251
+ }
252
+
253
+ private fail(error: string): void {
254
+ this.lastError = error
255
+ this.setState("error")
256
+ if (this.stopped) return
257
+ clearTimeout(this.retry)
258
+ const retryAfterMs = this.retryAfterMs
259
+ this.retryAfterMs = undefined
260
+ const delay = retryAfterMs !== undefined ? retryAfterDelay(retryAfterMs, this.backoff.random) : backoffDelay(this.attempt++, this.backoff)
261
+ this.retry = setTimeout(() => this.open(), delay)
262
+ }
263
+
264
+ private drop(code: number, reason: string): void {
265
+ const socket = this.socket
266
+ this.socket = undefined
267
+ this.welcomed = false
268
+ this.hubFeatures = new Set()
269
+ if (!socket) return
270
+ socket.onopen = socket.onmessage = socket.onerror = socket.onclose = null
271
+ try {
272
+ socket.close(code, reason)
273
+ } catch {
274
+ // already closed
275
+ }
276
+ }
277
+
278
+ private failPending(reason: string): void {
279
+ for (const [id, pending] of this.pending) {
280
+ clearTimeout(pending.timer)
281
+ pending.reject(new Error(`Hub ${reason}`))
282
+ this.pending.delete(id)
283
+ }
284
+ }
285
+
286
+ private setState(state: ConnectionState): void {
287
+ if (state === this.state) return
288
+ this.state = state
289
+ try {
290
+ this.options.onState?.(state, this.lastError)
291
+ } catch {
292
+ // observers must not break the connection
293
+ }
294
+ }
295
+ }
@@ -0,0 +1,119 @@
1
+ // Harness-neutral delivery helpers (the deliver op): the provenance envelope an agent's prompt (kind "prompt") is
2
+ // rendered in, who put each inbox item into a session, and applying each hub message once (DeliveryLedger: the hub
3
+ // retries a deliver with the same messageID; a harness may not deduplicate it, so the spoke must).
4
+ import { sanitizeLabel, sanitizeText } from "../../sdk/src/envelope.ts"
5
+ import type { DeliverMeta, DeliverResult, InboxOrigin } from "../../protocol/src/index.ts"
6
+ import { PROMPT_SOURCE } from "./prompt-source.ts"
7
+
8
+ /** Body lines that could pass for a header or boundary (or an escaped one): escaped with a leading backslash. */
9
+ const FORGEABLE = /^\s*\\*(?:\[hub\]|-{3,}|↩)/i
10
+
11
+ /**
12
+ * The text a session sees for an agent's prompt (kind "prompt"): a provenance header naming the sender and the
13
+ * grant, then the sanitised body between two boundaries (the same shape as the hub's message envelope, so
14
+ * timelines read the body back). The header comes from the hub's meta, never from the body.
15
+ */
16
+ export function renderPromptEnvelope(input: { readonly messageID: string; readonly from: string; readonly grant: string; readonly text: string }): string {
17
+ const id = sanitizeLabel(input.messageID)
18
+ const boundary = `----- ${id} -----`
19
+ const body = sanitizeText(input.text)
20
+ .split("\n")
21
+ .map((line) => (FORGEABLE.test(line) ? `\\${line}` : line))
22
+ .join("\n")
23
+ return [`[hub] prompt from ${sanitizeLabel(input.from)} · grant ${sanitizeLabel(input.grant)} · ${id}`, boundary, body, boundary].join("\n")
24
+ }
25
+
26
+ /** What OpenCode gets for a deliver: a prompt wrapped in its provenance envelope, other kinds as the hub rendered them. */
27
+ export function deliverText(req: { readonly messageID: string; readonly text: string; readonly meta: DeliverMeta }): string {
28
+ if (req.meta.kind !== "prompt") return req.text
29
+ return renderPromptEnvelope({ messageID: req.messageID, from: req.meta.from, grant: req.meta.grant ?? "", text: req.text })
30
+ }
31
+
32
+ const record = (v: unknown): Record<string, any> => (typeof v === "object" && v !== null ? (v as Record<string, any>) : {})
33
+
34
+ /** A bounded insertion-ordered map: the oldest entry goes when it is full. */
35
+ class Bounded<V> {
36
+ private readonly map = new Map<string, V>()
37
+ constructor(private readonly max: number) {}
38
+ get(key: string): V | undefined {
39
+ return this.map.get(key)
40
+ }
41
+ set(key: string, value: V): void {
42
+ this.map.delete(key)
43
+ this.map.set(key, value)
44
+ if (this.map.size > this.max) this.map.delete(this.map.keys().next().value!)
45
+ }
46
+ delete(key: string): void {
47
+ this.map.delete(key)
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Who put each inbox item into a session, from OpenCode's session.inbox.enqueued events and the spoke's own
53
+ * deliveries: a user item is the user's (typed, or sent from the phone), a synthetic item with hub metadata is
54
+ * the hub's; anything else (OpenCode's own notes, compaction) has no origin.
55
+ */
56
+ export class InboxOrigins {
57
+ private readonly origins: Bounded<InboxOrigin>
58
+ constructor(max = 1000) {
59
+ this.origins = new Bounded(max)
60
+ }
61
+
62
+ observe(event: { readonly type: string; readonly data?: unknown }): void {
63
+ if (event.type !== "session.inbox.enqueued") return
64
+ const data = record(event.data)
65
+ if (typeof data.inboxID !== "string" || this.origins.get(data.inboxID) === "hub") return
66
+ const item = record(data.item)
67
+ const metadata = record(record(item.payload).metadata)
68
+ // A hub message (hub metadata with its messageID) is never the user's, whatever item type OpenCode gives it.
69
+ if (metadata.source === PROMPT_SOURCE && typeof metadata.messageID === "string" && (item.type === "synthetic" || item.type === "user")) this.origins.set(data.inboxID, "hub")
70
+ else if (item.type === "user") this.origins.set(data.inboxID, "user")
71
+ }
72
+
73
+ /** Marks an inbox item the spoke delivered for the hub. */
74
+ hub(inboxID: string): void {
75
+ this.origins.set(inboxID, "hub")
76
+ }
77
+
78
+ /** The item's origin, when known; forgets it (an item is delivered once). */
79
+ take(inboxID: string): InboxOrigin | undefined {
80
+ const origin = this.origins.get(inboxID)
81
+ this.origins.delete(inboxID)
82
+ return origin
83
+ }
84
+ }
85
+
86
+ export interface Admission {
87
+ readonly result: DeliverResult
88
+ /** Whether it was sent with resume (could start a turn). */
89
+ readonly resumed: boolean
90
+ }
91
+
92
+ /**
93
+ * Applies each hub message once: a duplicate messageID (the hub's retry, in flight or done) gets the first
94
+ * result without another admission. Only a retry that asks to wake a message first admitted without waking is
95
+ * sent again (OpenCode keeps one message for the id and wakes the session). Failures are not remembered.
96
+ */
97
+ export class DeliveryLedger {
98
+ private readonly entries: Bounded<Promise<Admission>>
99
+ constructor(max = 1000) {
100
+ this.entries = new Bounded(max)
101
+ }
102
+
103
+ async run(messageID: string, wake: boolean, send: () => Promise<Admission>): Promise<DeliverResult> {
104
+ const known = this.entries.get(messageID)
105
+ if (known) {
106
+ const admission = await known.catch(() => undefined)
107
+ if (admission && (!wake || admission.resumed)) return admission.result
108
+ if (this.entries.get(messageID) !== known) return this.run(messageID, wake, send)
109
+ }
110
+ const attempt = send()
111
+ this.entries.set(messageID, attempt)
112
+ try {
113
+ return (await attempt).result
114
+ } catch (cause) {
115
+ if (this.entries.get(messageID) === attempt) this.entries.delete(messageID)
116
+ throw cause
117
+ }
118
+ }
119
+ }
@@ -0,0 +1,101 @@
1
+ // Device-code enrollment of this machine with the central server (before it has a token).
2
+ // POST /spoke/enroll → EnrollStart; then POST /spoke/enroll/poll until the user approves it.
3
+ import type { EnrollPoll, EnrollStart } from "../../protocol/src/index.ts"
4
+
5
+ export type EnrollOutcome =
6
+ | { readonly status: "approved"; readonly machineID: string; readonly machineToken: string }
7
+ | { readonly status: "denied" | "expired" | "cancelled" }
8
+
9
+ export interface EnrollOptions {
10
+ readonly signal?: AbortSignal
11
+ readonly fetch?: typeof fetch
12
+ readonly now?: () => number
13
+ /**
14
+ * "follow" (default) follows HTTP redirects. "error" refuses them: a redirect could send the device code to
15
+ * another origin or downgrade to plain http. A refused redirect throws EnrollmentRefused, which polling does not
16
+ * retry.
17
+ */
18
+ readonly redirect?: "follow" | "error"
19
+ /**
20
+ * The hub hands out an approved credential exactly once. With keepRedeemed, `signal` never aborts a poll request
21
+ * in flight (its reply may carry that credential); it still stops polling before the next request.
22
+ */
23
+ readonly keepRedeemed?: boolean
24
+ }
25
+
26
+ /** The hub answered in a way the caller's policy refuses (a redirect under redirect: "error"); not retried. */
27
+ export class EnrollmentRefused extends Error {
28
+ override readonly name = "EnrollmentRefused"
29
+ }
30
+
31
+ const REQUEST_TIMEOUT_MS = 15_000
32
+
33
+ async function post(server: string, path: string, body: unknown, options: EnrollOptions, abortable = true): Promise<unknown> {
34
+ const doFetch = options.fetch ?? fetch
35
+ const signals = [AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...(options.signal && abortable ? [options.signal] : [])]
36
+ const strict = options.redirect === "error"
37
+ const response = await doFetch(`${server.replace(/\/+$/, "")}${path}`, {
38
+ method: "POST",
39
+ headers: { "content-type": "application/json" },
40
+ body: JSON.stringify(body),
41
+ signal: AbortSignal.any(signals),
42
+ ...(strict ? { redirect: "manual" as const } : {}),
43
+ })
44
+ if (strict && (response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400))) {
45
+ throw new EnrollmentRefused(`${path} was redirected; enrollment does not follow redirects`)
46
+ }
47
+ if (!response.ok) throw new Error(`${path} failed: HTTP ${response.status}`)
48
+ return response.json()
49
+ }
50
+
51
+ function isStart(v: any): v is EnrollStart {
52
+ return (
53
+ typeof v === "object" &&
54
+ v !== null &&
55
+ typeof v.userCode === "string" &&
56
+ typeof v.deviceCode === "string" &&
57
+ typeof v.approveURL === "string" &&
58
+ typeof v.intervalMs === "number" &&
59
+ typeof v.expiresAt === "number"
60
+ )
61
+ }
62
+
63
+ export async function startEnrollment(server: string, machineName: string, options: EnrollOptions = {}): Promise<EnrollStart> {
64
+ const body = await post(server, "/spoke/enroll", { machineName }, options)
65
+ if (!isStart(body)) throw new Error("Invalid enrollment reply from the hub")
66
+ return body
67
+ }
68
+
69
+ const sleep = (ms: number, signal?: AbortSignal) =>
70
+ new Promise<void>((resolve) => {
71
+ const timer = setTimeout(done, ms)
72
+ function done() {
73
+ clearTimeout(timer)
74
+ signal?.removeEventListener("abort", done)
75
+ resolve()
76
+ }
77
+ signal?.addEventListener("abort", done, { once: true })
78
+ })
79
+
80
+ /** Polls until approved, denied, expired, or aborted. Transient errors are retried until expiry. */
81
+ export async function pollEnrollment(server: string, start: EnrollStart, options: EnrollOptions = {}): Promise<EnrollOutcome> {
82
+ const now = options.now ?? Date.now
83
+ const interval = Math.max(1, start.intervalMs)
84
+ for (;;) {
85
+ if (options.signal?.aborted) return { status: "cancelled" }
86
+ if (now() > start.expiresAt) return { status: "expired" }
87
+ await sleep(interval, options.signal)
88
+ if (options.signal?.aborted) return { status: "cancelled" }
89
+ let reply: Partial<EnrollPoll> & Record<string, unknown>
90
+ try {
91
+ reply = (await post(server, "/spoke/enroll/poll", { deviceCode: start.deviceCode }, options, !options.keepRedeemed)) as typeof reply
92
+ } catch (cause) {
93
+ if (cause instanceof EnrollmentRefused) throw cause
94
+ continue
95
+ }
96
+ if (reply?.status === "approved" && typeof reply.machineID === "string" && typeof reply.machineToken === "string") {
97
+ return { status: "approved", machineID: reply.machineID, machineToken: reply.machineToken }
98
+ }
99
+ if (reply?.status === "denied" || reply?.status === "expired") return { status: reply.status }
100
+ }
101
+ }
@@ -0,0 +1,79 @@
1
+ // The hello of a spoke for a harness other than OpenCode (design §1.1, P7-T1): its harness id, version and evidence
2
+ // level, and every feature it serves. Unlike an OpenCode hello it implies nothing: each core op (CORE_OPS) the spoke
3
+ // serves is announced, and the hub sends only announced ops. Refuses what would mislead the hub instead of sending it.
4
+ import { randomBytes } from "node:crypto"
5
+ import {
6
+ CORE_OPS,
7
+ EVIDENCE_LEVELS,
8
+ HARNESS_OPENCODE,
9
+ KNOWN_SPOKE_FEATURES,
10
+ type CoreOp,
11
+ type EvidenceLevel,
12
+ type Hello,
13
+ type ProcessKind,
14
+ type SpokeFeature,
15
+ type SpokeMode,
16
+ } from "../../protocol/src/index.ts"
17
+
18
+ export interface HarnessHelloOptions {
19
+ /** The harness id: a lowercase token (letters, digits, `.`, `_`, `-`), not "opencode". */
20
+ readonly harness: string
21
+ readonly harnessVersion: string
22
+ /** How the spoke observes activity; the hub never treats a session as idle on weaker evidence than it claims. */
23
+ readonly evidence: EvidenceLevel
24
+ /** The spoke's own version. */
25
+ readonly pluginVersion: string
26
+ readonly directory: string
27
+ /** The core ops the spoke serves (a hooks spoke may serve "permission" but not "turn"). */
28
+ readonly ops: readonly CoreOp[]
29
+ /** The optional features it serves (e.g. "deliver", "sessionTokens"); never a core op. */
30
+ readonly features?: readonly SpokeFeature[]
31
+ readonly processID?: string
32
+ readonly kind?: ProcessKind
33
+ readonly mode?: SpokeMode
34
+ readonly pid?: number
35
+ readonly launchID?: string
36
+ }
37
+
38
+ /** The hello's harness id rule (as the hub parses it). */
39
+ const HARNESS = /^[a-z0-9][a-z0-9._-]*$/
40
+ const HARNESS_MAX = 64
41
+ const VERSION_MAX = 100
42
+ const CORE: readonly string[] = CORE_OPS
43
+ const KNOWN: readonly string[] = KNOWN_SPOKE_FEATURES
44
+
45
+ const unique = <T>(values: readonly T[]): T[] => [...new Set(values)]
46
+
47
+ /** The hello fields (without `t` and `v`, which the connection adds) for a non-OpenCode spoke. Throws on bad options. */
48
+ export function buildHarnessHello(options: HarnessHelloOptions): Omit<Hello, "t" | "v"> {
49
+ const { harness, harnessVersion, evidence } = options
50
+ if (harness === HARNESS_OPENCODE) throw new Error("A harness spoke cannot be harness \"opencode\": OpenCode hellos imply every core op; use the OpenCode spoke")
51
+ if (typeof harness !== "string" || harness.length > HARNESS_MAX || !HARNESS.test(harness)) {
52
+ throw new Error(`The harness id must be 1–${HARNESS_MAX} lowercase letters, digits, ".", "_" or "-" (got ${JSON.stringify(harness)})`)
53
+ }
54
+ if (typeof harnessVersion !== "string" || harnessVersion.length > VERSION_MAX) throw new Error(`The harness version must be text of at most ${VERSION_MAX} characters`)
55
+ if (!(EVIDENCE_LEVELS as readonly string[]).includes(evidence)) throw new Error(`The evidence level must be one of ${EVIDENCE_LEVELS.join(", ")} (got ${JSON.stringify(evidence)})`)
56
+ for (const op of options.ops) if (!CORE.includes(op)) throw new Error(`Unknown core op ${JSON.stringify(op)} (core ops: ${CORE_OPS.join(", ")})`)
57
+ const features = unique(options.features ?? [])
58
+ for (const feature of features) {
59
+ if (CORE.includes(feature)) throw new Error(`"${feature}" is a core op: list it in ops, not features`)
60
+ if (!KNOWN.includes(feature)) throw new Error(`Unknown spoke feature ${JSON.stringify(feature)}`)
61
+ }
62
+ if (features.includes("hubPrompt") && !features.includes("deliver")) throw new Error("The hubPrompt feature needs the deliver feature")
63
+ const ops = CORE_OPS.filter((op) => options.ops.includes(op))
64
+ return {
65
+ processID: options.processID ?? `${harness.replace(/[^A-Za-z0-9_-]/g, "_")}_${randomBytes(8).toString("hex")}`,
66
+ kind: options.kind ?? "service",
67
+ directory: options.directory,
68
+ pid: options.pid ?? process.pid,
69
+ mode: options.mode ?? "always",
70
+ // Hubs that predate `harness` require opencodeVersion from every spoke (protocol Hello).
71
+ opencodeVersion: "",
72
+ pluginVersion: options.pluginVersion,
73
+ features: [...ops, ...features],
74
+ harness,
75
+ harnessVersion,
76
+ evidence,
77
+ ...(options.launchID !== undefined ? { launchID: options.launchID } : {}),
78
+ }
79
+ }