@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,168 @@
1
+ // EventLog on Valkey Streams (design §9): one stream per user, so Last-Event-ID resumes on any pod.
2
+ //
3
+ // Ids are `<epoch>.<n>` like the in-memory log's: n counts per user from 1 and is the entry's stream id (`n-0`,
4
+ // given explicitly rather than by the server clock), so a gap is exact: the entry after the client's id must
5
+ // still be there. The epoch lives in its own key next to the stream and is made the first time a user's log is
6
+ // touched; it goes when the log expires (both keys carry a ttl, renewed on every append and latest), so an id
7
+ // from an earlier log is never mistaken for a current one. Making an epoch deletes any stream left behind, so
8
+ // the stream always outlives its epoch (its ttl is a little longer) and never replays old entries.
9
+ //
10
+ // Bounds: XADD MAXLEN ~ max (approximate: Valkey trims whole nodes, so a log keeps at least `max` events) and,
11
+ // on read, `maxAgeMs` against the `now` clock. since() replays the entries after an id (XRANGE, exclusive) and
12
+ // asks for a resync when it cannot cover the gap: an unknown, another or an expired epoch, an id it never gave
13
+ // out, or the next entry trimmed or older than maxAgeMs.
14
+ import { RedisClient } from "bun"
15
+ import { EVENT_LOG_AGE_MS, EVENT_LOG_MAX } from "../events-log"
16
+ import type { Clock, EventLog, EventMessage, LogEvent, SinceResult } from "./types"
17
+
18
+ export interface ValkeyEventLogOptions {
19
+ readonly now?: Clock
20
+ /** Events kept per user, at least (default EVENT_LOG_MAX). */
21
+ readonly max?: number
22
+ /** Events older than this are not replayed (default EVENT_LOG_AGE_MS). */
23
+ readonly maxAgeMs?: number
24
+ /** Key prefix (default "hub:"). */
25
+ readonly prefix?: string
26
+ /** How long an idle user's log is kept in Valkey, server time (default maxAgeMs + 60 s). */
27
+ readonly ttlMs?: number
28
+ }
29
+
30
+ /** Where a user's log lives. Both keys share a hash tag, so a script may use them together in a Valkey cluster. */
31
+ export function valkeyEventKeys(userID: string, prefix = "hub:"): { stream: string; epoch: string } {
32
+ const base = `${prefix}ev:{${encodeURIComponent(userID)}}`
33
+ return { stream: base, epoch: `${base}:epoch` }
34
+ }
35
+
36
+ /** The stream outlives its epoch by this much (see above). */
37
+ const STREAM_TTL_MARGIN_MS = 1000
38
+
39
+ // KEYS[1] stream, KEYS[2] epoch; ARGV[1] a new epoch (used when there is none), ARGV[2] ttl ms.
40
+ const OPEN = `
41
+ local epoch = redis.call('GET', KEYS[2])
42
+ if not epoch then
43
+ epoch = ARGV[1]
44
+ redis.call('DEL', KEYS[1])
45
+ end
46
+ redis.call('SET', KEYS[2], epoch, 'PX', ARGV[2])
47
+ local function renew()
48
+ redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[2]) + ${STREAM_TTL_MARGIN_MS})
49
+ end
50
+ `
51
+
52
+ // The n of the user's last entry (0: none yet); trimming keeps the stream's last id.
53
+ const LAST = `
54
+ local function lastN(key)
55
+ if redis.call('EXISTS', key) == 0 then return 0 end
56
+ local r = redis.call('XINFO', 'STREAM', key)
57
+ for i = 1, #r, 2 do
58
+ if r[i] == 'last-generated-id' then return tonumber(string.match(r[i + 1], '^(%d+)')) end
59
+ end
60
+ return 0
61
+ end
62
+ `
63
+
64
+ // ARGV[3] max, ARGV[4] message JSON, ARGV[5] at, ARGV[6] exceptDevice ('' for none).
65
+ const APPEND = `${OPEN}${LAST}
66
+ local n = string.format('%d', lastN(KEYS[1]) + 1)
67
+ if ARGV[6] ~= '' then
68
+ redis.call('XADD', KEYS[1], 'MAXLEN', '~', ARGV[3], n .. '-0', 'm', ARGV[4], 'at', ARGV[5], 'x', ARGV[6])
69
+ else
70
+ redis.call('XADD', KEYS[1], 'MAXLEN', '~', ARGV[3], n .. '-0', 'm', ARGV[4], 'at', ARGV[5])
71
+ end
72
+ renew()
73
+ return {epoch, n}
74
+ `
75
+
76
+ const LATEST = `${OPEN}${LAST}
77
+ if redis.call('EXISTS', KEYS[1]) == 1 then renew() end
78
+ return {epoch, string.format('%d', lastN(KEYS[1]))}
79
+ `
80
+
81
+ // ARGV[1] the n the client holds. Returns {} when the user has no log.
82
+ const SINCE = `${LAST}
83
+ local epoch = redis.call('GET', KEYS[2])
84
+ if not epoch then return {} end
85
+ return {epoch, string.format('%d', lastN(KEYS[1])), redis.call('XRANGE', KEYS[1], '(' .. ARGV[1] .. '-0', '+')}
86
+ `
87
+
88
+ const ID = /^([a-z0-9]+)\.(\d+)$/
89
+
90
+ const newEpoch = () => `${Date.now().toString(36)}${crypto.randomUUID().replaceAll("-", "").slice(0, 8)}`
91
+
92
+ type RawEntry = [string, string[]]
93
+
94
+ export class ValkeyEventLog implements EventLog {
95
+ private readonly client: RedisClient
96
+ private readonly ownsClient: boolean
97
+ private readonly now: Clock
98
+ private readonly max: number
99
+ private readonly maxAgeMs: number
100
+ private readonly prefix: string
101
+ private readonly ttlMs: number
102
+
103
+ /** On a url (its own connection, made on first use) or a client the caller owns. */
104
+ constructor(valkey: string | RedisClient, options: ValkeyEventLogOptions = {}) {
105
+ this.ownsClient = typeof valkey === "string"
106
+ this.client = typeof valkey === "string" ? new RedisClient(valkey) : valkey
107
+ this.now = options.now ?? Date.now
108
+ this.max = options.max ?? EVENT_LOG_MAX
109
+ this.maxAgeMs = options.maxAgeMs ?? EVENT_LOG_AGE_MS
110
+ this.prefix = options.prefix ?? "hub:"
111
+ this.ttlMs = options.ttlMs ?? this.maxAgeMs + 60_000
112
+ }
113
+
114
+ async append(userID: string, message: EventMessage, options: { readonly exceptDevice?: string } = {}): Promise<string> {
115
+ const [epoch, n] = (await this.eval(APPEND, userID, [
116
+ newEpoch(),
117
+ String(this.ttlMs),
118
+ String(this.max),
119
+ JSON.stringify(message),
120
+ String(this.now()),
121
+ options.exceptDevice ?? "",
122
+ ])) as [string, string]
123
+ return `${epoch}.${n}`
124
+ }
125
+
126
+ async latest(userID: string): Promise<string> {
127
+ const [epoch, n] = (await this.eval(LATEST, userID, [newEpoch(), String(this.ttlMs)])) as [string, string]
128
+ return `${epoch}.${n}`
129
+ }
130
+
131
+ async since(userID: string, lastID: string): Promise<SinceResult> {
132
+ const resync = { resync: true } as const
133
+ const match = ID.exec(lastID)
134
+ const held = Number(match?.[2])
135
+ if (!match || !Number.isSafeInteger(held)) return resync
136
+ const reply = (await this.eval(SINCE, userID, [String(held)])) as [] | [string, string, RawEntry[]]
137
+ if (reply.length === 0) return resync
138
+ const [epoch, last, entries] = reply
139
+ if (epoch !== match[1] || held > Number(last)) return resync
140
+ const events = entries.map(([id, fields]) => this.event(epoch, id, fields))
141
+ if (held === Number(last)) return { events: [] }
142
+ // The next event the client needs (held + 1) must still be there, and not too old.
143
+ const next = events[0]
144
+ if (!next || next.id !== `${epoch}.${held + 1}` || next.at < this.now() - this.maxAgeMs) return resync
145
+ return { events }
146
+ }
147
+
148
+ /** Closes the connection when the log made it. */
149
+ close(): void {
150
+ if (this.ownsClient) this.client.close()
151
+ }
152
+
153
+ private eval(script: string, userID: string, args: string[]): Promise<unknown> {
154
+ const keys = valkeyEventKeys(userID, this.prefix)
155
+ return this.client.send("EVAL", [script, "2", keys.stream, keys.epoch, ...args])
156
+ }
157
+
158
+ private event(epoch: string, id: string, fields: string[]): LogEvent {
159
+ const values: Record<string, string> = {}
160
+ for (let i = 0; i + 1 < fields.length; i += 2) values[fields[i]!] = fields[i + 1]!
161
+ return {
162
+ id: `${epoch}.${id.slice(0, id.indexOf("-"))}`,
163
+ message: JSON.parse(values.m ?? "{}") as EventMessage,
164
+ at: Number(values.at),
165
+ ...(values.x ? { exceptDevice: values.x } : {}),
166
+ }
167
+ }
168
+ }
@@ -0,0 +1,54 @@
1
+ // Kv in Valkey: SET [PX] [NX], GET, GETDEL and DEL under a key prefix (default "kv:"). Expiry is server-side,
2
+ // so it follows the server's clock, not an injected one.
3
+ import type { Kv, KvSetOptions } from "./types"
4
+ import { ValkeyConnection, type ValkeySource } from "./valkey-client"
5
+
6
+ export interface ValkeyKvOptions {
7
+ /** Prepended to every key (default "kv:"). */
8
+ readonly prefix?: string
9
+ }
10
+
11
+ export class ValkeyKv implements Kv {
12
+ private readonly valkey: ValkeyConnection
13
+ private readonly prefix: string
14
+
15
+ constructor(source: ValkeySource, options: ValkeyKvOptions = {}) {
16
+ this.valkey = new ValkeyConnection(source)
17
+ this.prefix = options.prefix ?? "kv:"
18
+ }
19
+
20
+ async set(key: string, value: string, options: KvSetOptions = {}): Promise<boolean> {
21
+ const { px, nx } = options
22
+ if (px !== undefined && !(Number.isInteger(px) && px > 0)) throw new Error("Kv.set: px must be a positive whole number of milliseconds")
23
+ const args = [this.prefix + key, value]
24
+ if (px !== undefined) args.push("PX", String(px))
25
+ if (nx) args.push("NX")
26
+ return (await this.valkey.send("SET", args)) === "OK"
27
+ }
28
+
29
+ async get(key: string): Promise<string | undefined> {
30
+ return text(await this.valkey.send("GET", [this.prefix + key]))
31
+ }
32
+
33
+ async getdel(key: string): Promise<string | undefined> {
34
+ return text(await this.valkey.send("GETDEL", [this.prefix + key]))
35
+ }
36
+
37
+ async del(key: string): Promise<boolean> {
38
+ return Number(await this.valkey.send("DEL", [this.prefix + key])) > 0
39
+ }
40
+
41
+ /** Readiness: PING (rejects when the server does not answer). */
42
+ async ping(): Promise<void> {
43
+ await this.valkey.send("PING", [])
44
+ }
45
+
46
+ /** Closes the connection when it was opened from a url. */
47
+ async close(): Promise<void> {
48
+ this.valkey.close()
49
+ }
50
+ }
51
+
52
+ function text(reply: unknown): string | undefined {
53
+ return reply === null || reply === undefined ? undefined : String(reply)
54
+ }
@@ -0,0 +1,92 @@
1
+ // Locks in Valkey: a lease is one key "lock:<key>" set with PX (server-side expiry) holding
2
+ // "<fence> <expiresAt> <owner>"; a new holder takes the next value of the counter "fence:<key>", which never
3
+ // expires, so fences keep growing across expiries and releases. Taking (SET PX only when the key is absent or
4
+ // already the caller's), renewing and releasing compare the owner inside Lua scripts, so each is atomic.
5
+ //
6
+ // Expiry follows the server's clock; `expiresAt` in a Lease is stamped by the injected clock (`now`) and is
7
+ // informational. Fence counters stay after their lock goes away (one small key per lock name ever used).
8
+ import type { Clock, Lease, Locks } from "./types"
9
+ import { ValkeyConnection, type ValkeySource } from "./valkey-client"
10
+
11
+ /** KEYS: lock, fence. ARGV: owner, ttl, expiresAt, mode ("acquire" or "renew"). Returns the lease value or nil. */
12
+ const TAKE = `local cur = redis.call('GET', KEYS[1])
13
+ local fence
14
+ if cur then
15
+ local f, _, owner = string.match(cur, '^(%d+) (%S+) (.*)$')
16
+ if owner ~= ARGV[1] then return false end
17
+ fence = f
18
+ elseif ARGV[4] == 'acquire' then
19
+ fence = tostring(redis.call('INCR', KEYS[2]))
20
+ else
21
+ return false
22
+ end
23
+ local value = fence .. ' ' .. ARGV[3] .. ' ' .. ARGV[1]
24
+ redis.call('SET', KEYS[1], value, 'PX', ARGV[2])
25
+ return value`
26
+
27
+ /** KEYS: lock. ARGV: owner. Deletes the lock when the owner holds it; returns 1 or 0. */
28
+ const RELEASE = `local cur = redis.call('GET', KEYS[1])
29
+ if not cur then return 0 end
30
+ local _, _, owner = string.match(cur, '^(%d+) (%S+) (.*)$')
31
+ if owner ~= ARGV[1] then return 0 end
32
+ redis.call('DEL', KEYS[1])
33
+ return 1`
34
+
35
+ export interface ValkeyLocksOptions {
36
+ /** Stamps Lease.expiresAt (default Date.now). Expiry itself is server-side. */
37
+ readonly now?: Clock
38
+ /** Prepended to the lock keys (default "lock:"); fence counters use `fencePrefix` (default "fence:"). */
39
+ readonly prefix?: string
40
+ readonly fencePrefix?: string
41
+ }
42
+
43
+ export class ValkeyLocks implements Locks {
44
+ private readonly valkey: ValkeyConnection
45
+ private readonly now: Clock
46
+ private readonly prefix: string
47
+ private readonly fencePrefix: string
48
+
49
+ constructor(source: ValkeySource, options: ValkeyLocksOptions = {}) {
50
+ this.valkey = new ValkeyConnection(source)
51
+ this.now = options.now ?? Date.now
52
+ this.prefix = options.prefix ?? "lock:"
53
+ this.fencePrefix = options.fencePrefix ?? "fence:"
54
+ }
55
+
56
+ acquire(key: string, owner: string, ttlMs: number): Promise<Lease | undefined> {
57
+ return this.take("acquire", key, owner, ttlMs)
58
+ }
59
+
60
+ renew(key: string, owner: string, ttlMs: number): Promise<Lease | undefined> {
61
+ return this.take("renew", key, owner, ttlMs)
62
+ }
63
+
64
+ async release(key: string, owner: string): Promise<boolean> {
65
+ return Number(await this.valkey.send("EVAL", [RELEASE, "1", this.prefix + key, owner])) === 1
66
+ }
67
+
68
+ async holder(key: string): Promise<Lease | undefined> {
69
+ return parseLease(key, await this.valkey.send("GET", [this.prefix + key]))
70
+ }
71
+
72
+ /** Closes the connection when it was opened from a url. */
73
+ async close(): Promise<void> {
74
+ this.valkey.close()
75
+ }
76
+
77
+ private async take(mode: "acquire" | "renew", key: string, owner: string, ttlMs: number): Promise<Lease | undefined> {
78
+ if (!(Number.isInteger(ttlMs) && ttlMs > 0)) throw new Error(`Locks.${mode}: ttl must be a positive whole number of milliseconds`)
79
+ const expiresAt = this.now() + ttlMs
80
+ const keys = [this.prefix + key, this.fencePrefix + key]
81
+ return parseLease(key, await this.valkey.send("EVAL", [TAKE, "2", ...keys, owner, String(ttlMs), String(expiresAt), mode]))
82
+ }
83
+ }
84
+
85
+ /** A lease from its stored "<fence> <expiresAt> <owner>" (the owner may hold any character). */
86
+ function parseLease(key: string, reply: unknown): Lease | undefined {
87
+ if (reply === null || reply === undefined) return undefined
88
+ const value = String(reply)
89
+ const first = value.indexOf(" ")
90
+ const second = value.indexOf(" ", first + 1)
91
+ return { key, owner: value.slice(second + 1), fence: Number(value.slice(0, first)), expiresAt: Number(value.slice(first + 1, second)) }
92
+ }
@@ -0,0 +1,141 @@
1
+ // The connection directory (design §9, decision vkxkqiuf §5): which pod holds each spoke or node connection, in the
2
+ // shared Kv so any pod can route an op to it (./rpc.ts). Each pod is the single writer of its own connections:
3
+ // dir:<connID> → {"pod","machineID"} written when the hello completes, removed when the socket closes
4
+ // pod:<podID> → "1" the pod's liveness
5
+ // Both expire after DIRECTORY_TTL_MS and are refreshed every DIRECTORY_HEARTBEAT_MS, so a pod that dies without
6
+ // cleaning up drops out within the TTL. A lookup that finds an entry of a pod whose liveness key is gone removes it
7
+ // on the spot (the dead-pod sweep); callers also forget an entry when its pod has no RPC responder. Connection IDs
8
+ // are never reused, so the plain DEL on close cannot remove another pod's entry.
9
+ import { realTimers } from "../backend/memory-bus"
10
+ import type { Kv, Timers } from "../backend/types"
11
+
12
+ export const DIRECTORY_HEARTBEAT_MS = 15_000
13
+ export const DIRECTORY_TTL_MS = 45_000
14
+
15
+ const ENTRY = "dir:"
16
+ const POD = "pod:"
17
+
18
+ /** Where a connection lives. */
19
+ export interface DirectoryEntry {
20
+ readonly pod: string
21
+ readonly machineID: string
22
+ }
23
+
24
+ export interface ConnDirectoryOptions {
25
+ readonly kv: Kv
26
+ /** This pod (HUB_POD_ID). */
27
+ readonly podID: string
28
+ readonly timers?: Timers
29
+ readonly heartbeatMs?: number
30
+ readonly ttlMs?: number
31
+ /** Called when a Kv write fails (default: logged). The directory keeps going; the next heartbeat retries. */
32
+ readonly onError?: (error: unknown) => void
33
+ }
34
+
35
+ export class ConnDirectory {
36
+ readonly podID: string
37
+ private readonly kv: Kv
38
+ private readonly timers: Timers
39
+ private readonly heartbeatMs: number
40
+ private readonly ttlMs: number
41
+ private readonly onError: (error: unknown) => void
42
+ /** This pod's connections: connID → machineID. */
43
+ private readonly own = new Map<string, string>()
44
+ private timer: unknown
45
+ private running = false
46
+
47
+ constructor(options: ConnDirectoryOptions) {
48
+ this.kv = options.kv
49
+ this.podID = options.podID
50
+ this.timers = options.timers ?? realTimers
51
+ this.heartbeatMs = options.heartbeatMs ?? DIRECTORY_HEARTBEAT_MS
52
+ this.ttlMs = options.ttlMs ?? DIRECTORY_TTL_MS
53
+ this.onError = options.onError ?? ((error) => console.error(`[hub] directory: ${error instanceof Error ? error.message : String(error)}`))
54
+ }
55
+
56
+ /** Marks the pod alive and starts the heartbeat. */
57
+ async start(): Promise<void> {
58
+ if (this.running) return
59
+ this.running = true
60
+ await this.kv.set(POD + this.podID, "1", { px: this.ttlMs })
61
+ this.arm()
62
+ }
63
+
64
+ /** Stops the heartbeat and removes the pod's entries and liveness key (a clean shutdown). */
65
+ async stop(): Promise<void> {
66
+ this.running = false
67
+ if (this.timer !== undefined) this.timers.clear(this.timer)
68
+ this.timer = undefined
69
+ const conns = [...this.own.keys()]
70
+ this.own.clear()
71
+ await Promise.all([...conns.map((connID) => this.kv.del(ENTRY + connID)), this.kv.del(POD + this.podID)])
72
+ }
73
+
74
+ /** Records a connection this pod holds. */
75
+ async register(connID: string, machineID: string): Promise<void> {
76
+ this.own.set(connID, machineID)
77
+ await this.write(connID, machineID)
78
+ }
79
+
80
+ /** Removes a connection this pod held (its socket closed). */
81
+ async unregister(connID: string): Promise<void> {
82
+ if (!this.own.delete(connID)) return
83
+ await this.kv.del(ENTRY + connID)
84
+ }
85
+
86
+ /** Where a connection lives; undefined when unknown, expired, or its pod is dead (the entry is then removed). */
87
+ async lookup(connID: string): Promise<DirectoryEntry | undefined> {
88
+ const entry = parse(await this.kv.get(ENTRY + connID))
89
+ if (!entry) return undefined
90
+ if (entry.pod !== this.podID && !(await this.podAlive(entry.pod))) {
91
+ await this.forget(connID, entry.pod)
92
+ return undefined
93
+ }
94
+ return entry
95
+ }
96
+
97
+ /** Removes a connection's entry if it still names `pod` (that pod is gone). */
98
+ async forget(connID: string, pod: string): Promise<void> {
99
+ const entry = parse(await this.kv.get(ENTRY + connID))
100
+ if (entry?.pod === pod) await this.kv.del(ENTRY + connID)
101
+ }
102
+
103
+ async podAlive(pod: string): Promise<boolean> {
104
+ return (await this.kv.get(POD + pod)) !== undefined
105
+ }
106
+
107
+ /** This pod's registered connections. */
108
+ local(): string[] {
109
+ return [...this.own.keys()]
110
+ }
111
+
112
+ private write(connID: string, machineID: string): Promise<boolean> {
113
+ return this.kv.set(ENTRY + connID, JSON.stringify({ pod: this.podID, machineID } satisfies DirectoryEntry), { px: this.ttlMs })
114
+ }
115
+
116
+ private arm(): void {
117
+ this.timer = this.timers.set(() => void this.beat(), this.heartbeatMs)
118
+ }
119
+
120
+ /** Refreshes the pod's liveness and every entry it holds, then re-arms. */
121
+ private async beat(): Promise<void> {
122
+ if (!this.running) return
123
+ try {
124
+ await this.kv.set(POD + this.podID, "1", { px: this.ttlMs })
125
+ await Promise.all([...this.own].map(([connID, machineID]) => this.write(connID, machineID)))
126
+ } catch (error) {
127
+ this.onError(error)
128
+ }
129
+ if (this.running) this.arm()
130
+ }
131
+ }
132
+
133
+ function parse(raw: string | undefined): DirectoryEntry | undefined {
134
+ if (raw === undefined) return undefined
135
+ try {
136
+ const value = JSON.parse(raw) as Partial<DirectoryEntry>
137
+ return typeof value?.pod === "string" && typeof value.machineID === "string" ? { pod: value.pod, machineID: value.machineID } : undefined
138
+ } catch {
139
+ return undefined
140
+ }
141
+ }
@@ -0,0 +1,84 @@
1
+ // A per-user feed of items (stored agent messages, launch changes) that a cluster shares over the Bus (design §9,
2
+ // decision vkxkqiuf §6). publish() runs this pod's listeners at once and, once attached, sends the item to the other
3
+ // pods, whose listeners run when it arrives (at most once). A listener subscribed with `origin: true` runs only for
4
+ // items published on its own pod: work that must happen once per item (the console broadcast, which the live
5
+ // fan-out already sends to every pod; webhooks; closing a launch's connections, which pod RPC already reaches).
6
+ // Unattached (one process), every listener runs for every item, as before.
7
+ import type { Bus, Unsubscribe } from "../backend/types"
8
+
9
+ export type FeedListener<T> = (userID: string, item: T) => void
10
+
11
+ export interface FeedSubscribeOptions {
12
+ /** Only items published on this pod. */
13
+ readonly origin?: boolean
14
+ }
15
+
16
+ export interface FeedAttachOptions {
17
+ readonly bus: Bus
18
+ /** This pod (HUB_POD_ID). */
19
+ readonly podID: string
20
+ }
21
+
22
+ interface Envelope<T> {
23
+ readonly pod: string
24
+ readonly userID: string
25
+ readonly item: T
26
+ }
27
+
28
+ export class Feed<T> {
29
+ private readonly listeners = new Map<FeedListener<T>, boolean>()
30
+ private attached?: { readonly bus: Bus; readonly podID: string; readonly unsubscribe: Promise<Unsubscribe> }
31
+
32
+ constructor(
33
+ /** The Bus topic. */
34
+ private readonly topic: string,
35
+ /** Logs a throwing listener (never stops the others). */
36
+ private readonly report: (cause: unknown) => void,
37
+ ) {}
38
+
39
+ subscribe(listener: FeedListener<T>, options: FeedSubscribeOptions = {}): () => void {
40
+ // Wrapped so subscribing one function twice gives two independent subscriptions.
41
+ const own: FeedListener<T> = (userID, item) => listener(userID, item)
42
+ this.listeners.set(own, options.origin === true)
43
+ return () => void this.listeners.delete(own)
44
+ }
45
+
46
+ publish(userID: string, item: T): void {
47
+ this.run(userID, item, true)
48
+ const attached = this.attached
49
+ if (attached) {
50
+ attached.bus.publish(this.topic, { pod: attached.podID, userID, item } satisfies Envelope<T>).catch((cause: unknown) => this.report(cause))
51
+ }
52
+ }
53
+
54
+ /** Shares the feed with the other pods on the Bus. */
55
+ async attach(options: FeedAttachOptions): Promise<void> {
56
+ if (this.attached) return
57
+ const unsubscribe = options.bus.subscribe(this.topic, (raw) => {
58
+ const envelope = raw as Envelope<T> | null
59
+ if (typeof envelope !== "object" || envelope === null || typeof envelope.userID !== "string") return
60
+ if (envelope.pod === options.podID) return
61
+ this.run(envelope.userID, envelope.item, false)
62
+ })
63
+ this.attached = { ...options, unsubscribe }
64
+ await unsubscribe
65
+ }
66
+
67
+ /** Stops sharing; the Bus subscription is dropped once it is made (a Bus that never connected must not hold up a stop). */
68
+ async detach(): Promise<void> {
69
+ const attached = this.attached
70
+ this.attached = undefined
71
+ void attached?.unsubscribe.then((off) => off()).catch((cause: unknown) => this.report(cause))
72
+ }
73
+
74
+ private run(userID: string, item: T, local: boolean): void {
75
+ for (const [listener, origin] of [...this.listeners]) {
76
+ if (origin && !local) continue
77
+ try {
78
+ listener(userID, item)
79
+ } catch (cause) {
80
+ this.report(cause)
81
+ }
82
+ }
83
+ }
84
+ }