@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,678 @@
1
+ // The delivery engine (design §3.3, §3.4; decisions jswt9p22, 52aqsopo): stores agent messages (./messages) and
2
+ // delivers them to their recipient sessions through the spokes. No routes: the message routes (P2-T6) call it.
3
+ //
4
+ // - Owner selection: the user's connections that report the session (Registry.sessionTargets, owner first) and
5
+ // announce "deliver"; one that fails or answers malformed is skipped for the next (same messageID: OpenCode
6
+ // dedupes it, even across restarts). Only spokes without "deliver" report the session: unsupported
7
+ // {feature: "deliver"} and nothing is stored. No fallback through steer/turn.
8
+ // - Modes: plain msg → delivery "queue" (after the running turn), ask/reply/decline/urgent → "steer" (also the
9
+ // first msg after a primer to an idle session: the two then share one model call, quirk hzmcqwpj); wake
10
+ // unless wake:false, a subagent recipient, the hop cap (alerted once when crossed) or a spent wake budget.
11
+ // - Roles: one holder, the idle one (by real evidence) else the most recently active, else (all offline) the
12
+ // one seen last; the message records the holder generation. sendAll: every holder, a message each. A session
13
+ // never messages itself: it is left out of a role it holds, and an address naming only it is invalid.
14
+ // - Pending: no deliver-capable connection reports the recipient (offline) or every one failed → kept for
15
+ // PENDING_MS in the StateStore and delivered oldest first on the user's next sessions frame (onSessions). In a
16
+ // cluster (deps.locks) each (user, session) flush holds a lease (deliver:<user>:<session>) and re-reads the
17
+ // entry under it, so a spoke reconnecting to another pod meanwhile never gets a message twice.
18
+ // OpenCode holds admitted messages itself across forms, interrupts and restarts (P2-T0), so there is no
19
+ // "retained" retry loop on the hub.
20
+ // - Re-wake: an open ask to an idle recipient (Registry.idle: native or hooks evidence) is woken again after
21
+ // each REWAKE_BACKOFF_MS delay with a short reminder (its own id), then the user is alerted once. Busy,
22
+ // offline or pending recipients push the wait back. State lives in the StateStore (survives restarts); the
23
+ // loop looks at deps.users (the hub passes every stored user; it runs on the leader pod only, see
24
+ // ../cluster/leader.ts), else at the users seen since start (a send or a sessions frame of theirs).
25
+ // - Primer (design §2.3, decision jswt9p22 #7): once per session, a short identity note (renderPrimer) is
26
+ // admitted without waking (wake:false, steer, meta.kind "primer") just before the first message the session
27
+ // gets, or when it is explicitly named (Directory.onNamed; derived names never prime). Only through
28
+ // deliver-capable spokes: an offline session is primed when its pending message is flushed. The directory
29
+ // persists the primed flag on the session record; a primer no spoke takes is tried again with the next
30
+ // message and never holds the message back.
31
+ // - @me (design §12.1): the user's address, never a session (even one named "me"). A msg or ask to @me is stored
32
+ // for the user (their stored-only inbox; nothing goes to a spoke); a msg alerts the user ("user"), an ask is a
33
+ // Decision's carrier (./decisions pushes it). The user cannot message @me; `@me.project` is malformed.
34
+ // - Loop protection (./message-limits): pair/user rate limits before storing, hop per message, human turns
35
+ // (console, voice, typed in the harness) reset a session's chain (humanTurn).
36
+ import type { DeliverRequest, DeliverResult, DeliveryMode, MessageKind as WireKind } from "../../protocol"
37
+ import { isDeliverResult } from "../../protocol"
38
+ import { randomBytes } from "node:crypto"
39
+ import type { Clock, Counters, Locks, StateStore, Timers } from "../backend/types"
40
+ import type { AgentActor } from "./actor"
41
+ import type { DirectoryEntry, NamedListener, Resolved } from "./directory"
42
+ import { type Party, type PartyInfo, partyFromActor, partyKey, partyLabel, renderEnvelope, renderPrimer } from "./envelope"
43
+ import { AgentError } from "./errors"
44
+ import { HOP_CAP, HopTracker, MessageLimiter, type MessageLimits, overHopCap } from "./message-limits"
45
+ import { parseAddress } from "./names"
46
+ import { type Delivery as DeliveryState, type Message, Messages, newMessageID } from "./messages"
47
+
48
+ /** What the engine reads from the Registry. */
49
+ export interface DeliveryRegistry {
50
+ /** Connections that report the session, the owner first (Registry.sessionTargets). */
51
+ sessionTargets(sessionID: string): string[]
52
+ supports(connID: string, feature: "deliver"): boolean
53
+ connTenant(connID: string): string | undefined
54
+ /** Idle by real evidence (Registry.idle). */
55
+ idle(sessionID: string): boolean
56
+ /** Higher: more recent activity. */
57
+ recency(sessionID: string): number
58
+ }
59
+
60
+ /** Address resolution (Directory.resolve) and the primer flag (without primed/markPrimed: no primer). */
61
+ export interface DeliveryDirectory {
62
+ resolve(userID: string, addr: string): Resolved
63
+ primed?(userID: string, sessionID: string): boolean
64
+ markPrimed?(userID: string, sessionID: string): void | Promise<void>
65
+ /** Explicit names (Directory.onNamed): an unprimed session is primed then. */
66
+ onNamed?(listener: NamedListener): () => void
67
+ }
68
+
69
+ /** Hub → spoke deliver request (SpokeHub.request). */
70
+ export type DeliverSend = (connID: string, req: DeliverRequest) => Promise<DeliverResult>
71
+
72
+ /**
73
+ * Something the user should know: a reply chain crossed the hop cap (its messages no longer wake anyone), or an
74
+ * ask stayed unanswered by an idle recipient after every re-wake.
75
+ */
76
+ export interface DeliveryAlert {
77
+ /** user: a session or app sent the user (@me) a message. */
78
+ readonly kind: "hopCap" | "unanswered" | "user"
79
+ /** The recipient session (user: the sending session, "" for an app). */
80
+ readonly sessionID: string
81
+ readonly messageID: string
82
+ readonly text: string
83
+ }
84
+
85
+ export interface DeliveryDeps {
86
+ readonly state: StateStore
87
+ readonly counters: Counters
88
+ readonly registry: DeliveryRegistry
89
+ readonly directory: DeliveryDirectory
90
+ readonly send: DeliverSend
91
+ readonly now?: Clock
92
+ readonly timers?: Timers
93
+ readonly newID?: () => string
94
+ /** Labels of a session sender (default: its @name from the directory). */
95
+ readonly describe?: (userID: string, sessionID: string) => PartyInfo
96
+ readonly alert?: (userID: string, alert: DeliveryAlert) => void
97
+ /** Limiter caps (default PAIR_LIMIT, USER_LIMIT, WAKE_BUDGET). */
98
+ readonly limits?: Partial<MessageLimits>
99
+ /** The users the re-wake loop looks at (every user of the hub; default: the users seen by this process since start). */
100
+ readonly users?: () => Iterable<string> | Promise<Iterable<string>>
101
+ /** Cluster mode: a pending flush holds a lease per (user, session), so two pods never deliver the same message. */
102
+ readonly locks?: Locks
103
+ /** This process, as lease owner (default: a random one). */
104
+ readonly owner?: string
105
+ /** How often the re-wake loop runs (default REWAKE_TICK_MS). */
106
+ readonly tickMs?: number
107
+ }
108
+
109
+ export interface SendOptions {
110
+ /** false: admit without starting a turn (reported queued). */
111
+ readonly wake?: boolean
112
+ /** Steer into a running turn, like an ask. */
113
+ readonly urgent?: boolean
114
+ /** Asks only. */
115
+ readonly expiresInMs?: number
116
+ }
117
+
118
+ export interface Sent {
119
+ readonly message: Message
120
+ /** How it reached the recipient; undefined when the recipient is not a session (stored only). */
121
+ readonly delivery?: DeliveryState
122
+ }
123
+
124
+ /** A reply or decline as sent, with the message it answered. */
125
+ export interface Replied extends Sent {
126
+ readonly original: Message
127
+ }
128
+
129
+ const defaultTimers: Timers = {
130
+ set: (fn, ms) => {
131
+ const handle = setTimeout(fn, ms)
132
+ ;(handle as { unref?: () => void }).unref?.()
133
+ return handle
134
+ },
135
+ clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
136
+ }
137
+
138
+ /** The user's own address (design §12.1). */
139
+ export const USER_ADDRESS = "@me"
140
+
141
+ /** True for `@me` (and the malformed `@me.project`, which send refuses). */
142
+ const isUserAddress = (addr: string): boolean => addr === USER_ADDRESS || addr.startsWith(`${USER_ADDRESS}.`)
143
+
144
+ /** How long an offline recipient's message waits for its session to come back. */
145
+ export const PENDING_MS = 24 * 60 * 60_000
146
+
147
+ /** Delays before each re-wake of an idle recipient with an open ask; one more after the last, then the user is told. */
148
+ export const REWAKE_BACKOFF_MS: readonly number[] = [5 * 60_000, 15 * 60_000, 45 * 60_000]
149
+ /** How often the re-wake loop looks for due asks. */
150
+ export const REWAKE_TICK_MS = 30_000
151
+
152
+ /** How long a pending flush may hold a (user, session) lease (it is released as soon as the flush is done). */
153
+ export const FLUSH_LEASE_MS = 120_000
154
+
155
+ /** StateStore collection: messageID → Pending. */
156
+ const PENDING = "message-pending"
157
+
158
+ /** StateStore collection: ask id → Rewake. */
159
+ const REWAKE = "message-rewake"
160
+
161
+ /** An open ask whose idle recipient the loop wakes again. */
162
+ interface Rewake {
163
+ readonly messageID: string
164
+ readonly sessionID: string
165
+ /** Re-wakes sent so far. */
166
+ readonly reminders: number
167
+ readonly nextAt: number
168
+ }
169
+
170
+ /** The wait after `reminders` re-wakes (the last delay repeats). */
171
+ const backoff = (reminders: number) => REWAKE_BACKOFF_MS[Math.min(reminders, REWAKE_BACKOFF_MS.length - 1)]!
172
+
173
+ /** A message waiting for its (offline) recipient session. */
174
+ interface Pending {
175
+ readonly messageID: string
176
+ readonly sessionID: string
177
+ readonly wake: boolean
178
+ readonly urgent: boolean
179
+ readonly at: number
180
+ readonly expiresAt: number
181
+ }
182
+
183
+ /** How a party appears in deliver meta: `@name`, `ses_…`, `@me`, `app:<tokenID>`. */
184
+ function wireAddress(party: Party): string {
185
+ switch (party.kind) {
186
+ case "session":
187
+ return party.name ? `@${party.name}` : party.sessionID
188
+ case "user":
189
+ return "@me"
190
+ case "app":
191
+ return `app:${party.tokenID}`
192
+ }
193
+ }
194
+
195
+ export class Delivery {
196
+ readonly messages: Messages
197
+ readonly hops: HopTracker
198
+ readonly limiter: MessageLimiter
199
+ private readonly deps: DeliveryDeps
200
+ private readonly now: Clock
201
+ private readonly timers: Timers
202
+ private readonly newID: () => string
203
+ /** Per user: the flush in progress (flushes run one at a time). */
204
+ private readonly flushing = new Map<string, Promise<void>>()
205
+ /** Per user and session: the primer in flight (one at a time); true once it was admitted. */
206
+ private readonly priming = new Map<string, Promise<boolean>>()
207
+ /** Users seen since start (sent a message, or one of their spokes reported sessions): the loop looks at these. */
208
+ private readonly users = new Set<string>()
209
+ private timer: unknown
210
+ private running = false
211
+ private readonly owner: string
212
+
213
+ constructor(deps: DeliveryDeps) {
214
+ this.deps = deps
215
+ const now = (this.now = deps.now ?? Date.now)
216
+ this.timers = deps.timers ?? defaultTimers
217
+ this.newID = deps.newID ?? newMessageID
218
+ this.owner = deps.owner ?? `delivery-${process.pid}-${randomBytes(4).toString("hex")}`
219
+ this.messages = new Messages({ state: deps.state, now, newID: this.newID })
220
+ this.hops = new HopTracker({ state: deps.state, now })
221
+ this.limiter = new MessageLimiter({ counters: deps.counters, now, ...(deps.limits ? { limits: deps.limits } : {}) })
222
+ // An explicit (re)name primes again, so the session learns its new address.
223
+ deps.directory.onNamed?.((userID, sessionID) => void this.prime(userID, sessionID, { again: true }))
224
+ }
225
+
226
+ /**
227
+ * Primes the session once (see the header; `again`: an explicit rename primes once more): no-op when it is primed, unknown, or no deliver-capable spoke of
228
+ * the user reports it. Resolves true when a primer was admitted now. Never throws.
229
+ */
230
+ prime(userID: string, sessionID: string, options: { again?: boolean } = {}): Promise<boolean> {
231
+ const key = `${userID}\u0000${sessionID}`
232
+ const running = this.priming.get(key)
233
+ if (running) return running
234
+ const run = this.primeNow(userID, sessionID, options.again === true)
235
+ .catch((cause) => {
236
+ console.error(`[hub] primer failed: ${cause instanceof Error ? cause.message : String(cause)}`)
237
+ return false
238
+ })
239
+ .finally(() => this.priming.delete(key))
240
+ this.priming.set(key, run)
241
+ return run
242
+ }
243
+
244
+ /**
245
+ * Stores a msg or ask from the actor to the session `addr` names and delivers it. A role goes to one holder:
246
+ * an idle one, else the most recently active, else (all offline) the one seen last; never the sending session
247
+ * itself (only itself: invalid). Throws invalid, not_found,
248
+ * ambiguous, unsupported {feature: "deliver"}, rate_limited or forbidden (muted) before anything is stored.
249
+ */
250
+ async send(userID: string, actor: AgentActor, addr: string, kind: "msg" | "ask", text: string, options: SendOptions = {}): Promise<Sent> {
251
+ if (isUserAddress(addr)) return this.sendToUser(userID, actor, addr, kind, text, options)
252
+ const from = this.party(userID, actor)
253
+ const resolved = this.others(actor, this.deps.directory.resolve(userID, addr))
254
+ return this.sendTo(userID, from, addr, resolved, this.pick(resolved), kind, text, options)
255
+ }
256
+
257
+ /** Like send, to every session the address names (each role holder gets its own message). */
258
+ async sendAll(userID: string, actor: AgentActor, addr: string, kind: "msg" | "ask", text: string, options: SendOptions = {}): Promise<Sent[]> {
259
+ if (isUserAddress(addr)) return [await this.sendToUser(userID, actor, addr, kind, text, options)]
260
+ const from = this.party(userID, actor)
261
+ const resolved = this.others(actor, this.deps.directory.resolve(userID, addr))
262
+ const sent: Sent[] = []
263
+ for (const entry of resolved.sessions) sent.push(await this.sendTo(userID, from, addr, resolved, entry, kind, text, options))
264
+ return sent
265
+ }
266
+
267
+ /** Replies to a message, from its recipient to its real sender (never via a role), and delivers the reply. */
268
+ reply(userID: string, actor: AgentActor, replyTo: string, text: string, options: SendOptions = {}): Promise<Replied> {
269
+ return this.answer(userID, actor, replyTo, options, (input) => this.messages.reply(userID, { ...input, text }))
270
+ }
271
+
272
+ /** Declines an ask, with a reason, and delivers the decline to the asker. */
273
+ decline(userID: string, actor: AgentActor, replyTo: string, why: string, options: SendOptions = {}): Promise<Replied> {
274
+ return this.answer(userID, actor, replyTo, options, (input) => this.messages.decline(userID, { ...input, why }))
275
+ }
276
+
277
+ /** A human turn in the session (console prompt or utterance, or typed in the harness): its hop chain starts over. */
278
+ humanTurn(userID: string, sessionID: string): Promise<void> {
279
+ return this.hops.humanTurn(userID, sessionID)
280
+ }
281
+
282
+ /** Delivers the user's pending messages whose sessions a deliver-capable spoke reports now, oldest first. */
283
+ flush(userID: string): Promise<void> {
284
+ const previous = this.flushing.get(userID) ?? Promise.resolve()
285
+ const run = previous.then(() => this.flushNow(userID))
286
+ const tracked = run.finally(() => {
287
+ if (this.flushing.get(userID) === tracked) this.flushing.delete(userID)
288
+ })
289
+ this.flushing.set(userID, tracked)
290
+ return run
291
+ }
292
+
293
+ /** Registry.onSessions: a spoke reported its sessions (connected, reconnected, or its periodic snapshot). */
294
+ async onSessions(connID: string): Promise<void> {
295
+ const userID = this.deps.registry.connTenant(connID)
296
+ if (userID === undefined) return
297
+ this.users.add(userID)
298
+ await this.flush(userID)
299
+ }
300
+
301
+ /** Starts the re-wake loop (every REWAKE_TICK_MS). */
302
+ start(): void {
303
+ if (this.running) return
304
+ this.running = true
305
+ this.schedule()
306
+ }
307
+
308
+ stop(): void {
309
+ this.running = false
310
+ if (this.timer !== undefined) this.timers.clear(this.timer)
311
+ this.timer = undefined
312
+ }
313
+
314
+ /** One pass of the re-wake loop over every known user (the loop calls it; tests may too). */
315
+ async tick(): Promise<void> {
316
+ let users: string[]
317
+ try {
318
+ users = this.deps.users ? [...(await this.deps.users())] : [...this.users]
319
+ } catch (cause) {
320
+ console.error(`[hub] re-wake failed: ${cause instanceof Error ? cause.message : String(cause)}`)
321
+ return
322
+ }
323
+ for (const userID of users) {
324
+ try {
325
+ await this.rewake(userID)
326
+ } catch (cause) {
327
+ console.error(`[hub] re-wake failed: ${cause instanceof Error ? cause.message : String(cause)}`)
328
+ }
329
+ }
330
+ }
331
+
332
+ // ---- internals ----------------------------------------------------------------------------------------
333
+
334
+ private async primeNow(userID: string, sessionID: string, again: boolean): Promise<boolean> {
335
+ const { directory } = this.deps
336
+ if (!directory.primed || !directory.markPrimed || (!again && directory.primed(userID, sessionID))) return false
337
+ if (!this.targets(userID, sessionID).length) return false
338
+ const entry = directory.resolve(userID, sessionID).sessions[0]
339
+ if (!entry) return false
340
+ const roles = entry.roles.map((r) => (r.project === undefined ? r.role : `${r.role}.${r.project}`))
341
+ const req: DeliverRequest = {
342
+ op: "deliver",
343
+ sessionID,
344
+ messageID: this.newID(),
345
+ text: renderPrimer({ ...(entry.name ? { name: entry.name } : {}), sessionID, roles }),
346
+ wake: false,
347
+ meta: { kind: "primer", from: "hub", to: entry.name ? `@${entry.name}` : sessionID, delivery: "steer" },
348
+ }
349
+ if (!(await this.request(userID, sessionID, req))) return false
350
+ await directory.markPrimed(userID, sessionID)
351
+ return true
352
+ }
353
+
354
+ /** A msg or ask to the user (@me): stored only; a msg alerts the user. */
355
+ private async sendToUser(userID: string, actor: AgentActor, addr: string, kind: "msg" | "ask", text: string, options: SendOptions): Promise<Sent> {
356
+ if (addr !== USER_ADDRESS) throw new AgentError("invalid", "@me takes no project scope")
357
+ if (actor.kind === "user") throw new AgentError("invalid", "You are @me: the user cannot message themselves")
358
+ const from = this.party(userID, actor)
359
+ const recipient: Party = { kind: "user" }
360
+ await this.limiter.admit(userID, from, recipient)
361
+ const hop = await this.hops.next(userID, from)
362
+ const message = await this.messages.send(userID, {
363
+ from,
364
+ to: USER_ADDRESS,
365
+ recipient,
366
+ kind,
367
+ text,
368
+ hop,
369
+ ...(options.expiresInMs === undefined ? {} : { expiresInMs: options.expiresInMs }),
370
+ })
371
+ this.users.add(userID)
372
+ if (kind === "msg") {
373
+ this.deps.alert?.(userID, { kind: "user", sessionID: from.kind === "session" ? from.sessionID : "", messageID: message.id, text: `${partyLabel(from)} sent you a message` })
374
+ }
375
+ return { message }
376
+ }
377
+
378
+ private async sendTo(userID: string, from: Party, addr: string, resolved: Resolved, entry: DirectoryEntry, kind: "msg" | "ask", text: string, options: SendOptions): Promise<Sent> {
379
+ const sessionID = entry.sessionID
380
+ const recipient: Party = { kind: "session", sessionID, ...(entry.name ? { name: entry.name } : {}) }
381
+ this.gate(userID, sessionID)
382
+ await this.limiter.admit(userID, from, recipient)
383
+ const hop = await this.hops.next(userID, from)
384
+ const generation = resolved.address.kind === "role" ? roleGeneration(entry, resolved.address) : undefined
385
+ const message = await this.messages.send(userID, {
386
+ from,
387
+ to: addr,
388
+ recipient,
389
+ kind,
390
+ text,
391
+ hop,
392
+ ...(generation === undefined ? {} : { generation }),
393
+ ...(options.expiresInMs === undefined ? {} : { expiresInMs: options.expiresInMs }),
394
+ })
395
+ await this.hops.received(userID, recipient, hop)
396
+ this.users.add(userID)
397
+ const wanted = options.wake !== false && entry.parentID === undefined
398
+ const wake = await this.wakeFor(userID, message, wanted)
399
+ if (kind === "ask" && wanted && !overHopCap(hop)) {
400
+ const rewake: Rewake = { messageID: message.id, sessionID, reminders: 0, nextAt: message.at + backoff(0) }
401
+ await this.deps.state.put(userID, REWAKE, message.id, rewake)
402
+ }
403
+ return this.deliver(userID, message, sessionID, wake, options.urgent === true)
404
+ }
405
+
406
+ private schedule(): void {
407
+ this.timer = this.timers.set(() => {
408
+ this.timer = undefined
409
+ void this.tick().finally(() => {
410
+ if (this.running && this.timer === undefined) this.schedule()
411
+ })
412
+ }, this.deps.tickMs ?? REWAKE_TICK_MS)
413
+ }
414
+
415
+ /** Re-wakes the user's idle recipients of due open asks; after the last re-wake, tells the user. */
416
+ private async rewake(userID: string): Promise<void> {
417
+ const { registry } = this.deps
418
+ for (const { key, value } of await this.deps.state.list<Rewake>(userID, REWAKE)) {
419
+ const t = this.now()
420
+ if (value.nextAt > t) continue
421
+ const ask = await this.messages.get(userID, value.messageID)
422
+ if (!ask || ask.state !== "open") {
423
+ await this.deps.state.delete(userID, REWAKE, key)
424
+ continue
425
+ }
426
+ const postpone = () => this.deps.state.put(userID, REWAKE, key, { ...value, nextAt: t + backoff(value.reminders) })
427
+ const reachable = ask.delivery !== undefined && ask.delivery !== "pending" && this.targets(userID, value.sessionID).length > 0
428
+ if (!reachable || !registry.idle(value.sessionID)) {
429
+ await postpone()
430
+ continue
431
+ }
432
+ if (value.reminders >= REWAKE_BACKOFF_MS.length) {
433
+ await this.deps.state.delete(userID, REWAKE, key)
434
+ this.deps.alert?.(userID, {
435
+ kind: "unanswered",
436
+ sessionID: value.sessionID,
437
+ messageID: ask.id,
438
+ text: `${partyLabel(ask.recipient)} has not answered ${partyLabel(ask.from)}'s ask after ${value.reminders} reminders`,
439
+ })
440
+ continue
441
+ }
442
+ if (!(await this.limiter.wake(userID, ask.recipient)) || !(await this.remind(userID, ask, value.sessionID))) {
443
+ await postpone()
444
+ continue
445
+ }
446
+ const reminders = value.reminders + 1
447
+ await this.deps.state.put(userID, REWAKE, key, { ...value, reminders, nextAt: t + backoff(reminders) })
448
+ }
449
+ }
450
+
451
+ /** Sends a short wake-up about an open ask (its own id: the ask itself was already admitted). */
452
+ private async remind(userID: string, ask: Message, sessionID: string): Promise<boolean> {
453
+ const id = this.newID()
454
+ const hint = `↩ hub reply ${ask.id} "…" · hub decline ${ask.id} --why "…"`
455
+ const req: DeliverRequest = {
456
+ op: "deliver",
457
+ sessionID,
458
+ messageID: id,
459
+ text: `[hub] reminder · ask ${ask.id} from ${partyLabel(ask.from)} is still open · ${id}\n${hint}`,
460
+ wake: true,
461
+ meta: { kind: "ask", from: wireAddress(ask.from), to: ask.to, delivery: "steer" },
462
+ }
463
+ return (await this.request(userID, sessionID, req)) !== undefined
464
+ }
465
+
466
+ /** The resolved sessions without the sending session itself; invalid when only it is left (no self-messages). */
467
+ private others(actor: AgentActor, resolved: Resolved): Resolved {
468
+ if (actor.kind !== "session") return resolved
469
+ const sessions = resolved.sessions.filter((s) => s.sessionID !== actor.sessionID)
470
+ if (!sessions.length) throw new AgentError("invalid", "A session cannot message itself")
471
+ return { ...resolved, sessions }
472
+ }
473
+
474
+ /** One session of a resolved address (see send). */
475
+ private pick(resolved: Resolved): DirectoryEntry {
476
+ const { sessions } = resolved
477
+ if (sessions.length === 1 || resolved.address.kind !== "role") return sessions[0]!
478
+ const { registry } = this.deps
479
+ const online = sessions.filter((s) => s.online)
480
+ if (!online.length) return latest(sessions, (s) => s.lastSeen ?? 0)
481
+ const idle = online.filter((s) => registry.idle(s.sessionID))
482
+ return latest(idle.length ? idle : online, (s) => registry.recency(s.sessionID))
483
+ }
484
+
485
+ private async answer(
486
+ userID: string,
487
+ actor: AgentActor,
488
+ replyTo: string,
489
+ options: SendOptions,
490
+ store: (input: { from: Party; replyTo: string; hop: number; generation?: number }) => Promise<{ message: Message; original: Message }>,
491
+ ): Promise<Replied> {
492
+ const from = this.party(userID, actor)
493
+ const original = await this.messages.get(userID, replyTo)
494
+ const to = original && partyKey(original.recipient) === partyKey(from) ? original.from : undefined
495
+ if (to?.kind === "session") this.gate(userID, to.sessionID)
496
+ if (to) await this.limiter.admit(userID, from, to)
497
+ const hop = await this.hops.next(userID, from)
498
+ const generation = original && actor.kind === "session" ? this.holderGeneration(userID, actor.sessionID, original) : undefined
499
+ const answered = await store({ from, replyTo, hop, ...(generation === undefined ? {} : { generation }) })
500
+ const { message } = answered
501
+ await this.hops.received(userID, message.recipient, hop)
502
+ this.users.add(userID)
503
+ if (message.recipient.kind !== "session") return answered
504
+ const sessionID = message.recipient.sessionID
505
+ const wake = await this.wakeFor(userID, message, options.wake !== false && !this.isSubagent(userID, sessionID))
506
+ return { ...(await this.deliver(userID, message, sessionID, wake, true)), original: answered.original }
507
+ }
508
+
509
+ /** Whether the message may start a turn: wanted, under the hop cap (alerting once at the cap), wake budget left. */
510
+ private async wakeFor(userID: string, message: Message, wanted: boolean): Promise<boolean> {
511
+ if (!wanted || message.recipient.kind !== "session") return false
512
+ if (overHopCap(message.hop)) {
513
+ if (message.hop === HOP_CAP + 1) {
514
+ this.deps.alert?.(userID, {
515
+ kind: "hopCap",
516
+ sessionID: message.recipient.sessionID,
517
+ messageID: message.id,
518
+ text: `Sessions messaged each other ${HOP_CAP} times without you; further messages no longer wake them`,
519
+ })
520
+ }
521
+ return false
522
+ }
523
+ return this.limiter.wake(userID, message.recipient)
524
+ }
525
+
526
+ /** The replier's generation of the role the original reached (undefined: not a role, or not held now). */
527
+ private holderGeneration(userID: string, sessionID: string, original: Message): number | undefined {
528
+ const address = parseAddress(original.to)
529
+ if (original.generation === undefined || address?.kind !== "role") return undefined
530
+ try {
531
+ const entry = this.deps.directory.resolve(userID, sessionID).sessions[0]
532
+ return entry && roleGeneration(entry, address)
533
+ } catch {
534
+ return undefined
535
+ }
536
+ }
537
+
538
+ private isSubagent(userID: string, sessionID: string): boolean {
539
+ try {
540
+ return this.deps.directory.resolve(userID, sessionID).sessions[0]?.parentID !== undefined
541
+ } catch {
542
+ return false
543
+ }
544
+ }
545
+
546
+ /** Refuses (unsupported) a session that only spokes without deliver report; an offline one is fine (pending). */
547
+ private gate(userID: string, sessionID: string): void {
548
+ const targets = this.deps.registry.sessionTargets(sessionID).filter((connID) => this.deps.registry.connTenant(connID) === userID)
549
+ if (targets.length && !targets.some((connID) => this.deps.registry.supports(connID, "deliver"))) {
550
+ throw new AgentError("unsupported", "The recipient's spoke cannot receive messages: update the hub plugin", { extra: { feature: "deliver" } })
551
+ }
552
+ }
553
+
554
+ /** The user's deliver-capable connections for the session, the owner first. */
555
+ private targets(userID: string, sessionID: string): string[] {
556
+ const { registry } = this.deps
557
+ return registry.sessionTargets(sessionID).filter((connID) => registry.connTenant(connID) === userID && registry.supports(connID, "deliver"))
558
+ }
559
+
560
+ /** Delivers now, or queues the message as pending when no spoke takes it. */
561
+ private async deliver(userID: string, message: Message, sessionID: string, wake: boolean, urgent: boolean): Promise<Sent> {
562
+ const delivered = await this.attempt(userID, message, sessionID, wake, urgent)
563
+ if (delivered) return delivered
564
+ const at = this.now()
565
+ const pending: Pending = { messageID: message.id, sessionID, wake, urgent, at, expiresAt: at + PENDING_MS }
566
+ await this.deps.state.put(userID, PENDING, message.id, pending)
567
+ return { message: (await this.messages.setDelivery(userID, message.id, "pending")) ?? message, delivery: "pending" }
568
+ }
569
+
570
+ /** Tries each deliver-capable connection in turn (same message id: the harness dedupes it). */
571
+ private async attempt(userID: string, message: Message, sessionID: string, wake: boolean, urgent: boolean): Promise<Sent | undefined> {
572
+ // A primer just admitted waits (steer, no resume) for the next turn. A queued msg waking an idle session would
573
+ // come after that turn's reply with a model call of its own (quirk hzmcqwpj): steer it too, one call for both.
574
+ const primed = await this.prime(userID, sessionID)
575
+ const steer = urgent || (primed && wake && this.deps.registry.idle(sessionID))
576
+ const result = await this.request(userID, sessionID, this.deliverRequest(message, sessionID, wake, steer))
577
+ if (!result) return undefined
578
+ const updated = (await this.messages.setDelivery(userID, message.id, result.delivered)) ?? message
579
+ return { message: updated, delivery: result.delivered }
580
+ }
581
+
582
+ /** Sends the request to each of the user's deliver-capable connections for the session until one takes it. */
583
+ private async request(userID: string, sessionID: string, req: DeliverRequest): Promise<DeliverResult | undefined> {
584
+ for (const connID of this.targets(userID, sessionID)) {
585
+ let result: unknown
586
+ try {
587
+ result = await this.deps.send(connID, req)
588
+ } catch {
589
+ continue
590
+ }
591
+ if (isDeliverResult(result)) return result
592
+ }
593
+ return undefined
594
+ }
595
+
596
+ private async flushNow(userID: string): Promise<void> {
597
+ const entries = (await this.deps.state.list<Pending>(userID, PENDING)).sort((a, b) => a.value.at - b.value.at || (a.key < b.key ? -1 : 1))
598
+ /** Cluster mode: the sessions whose lease this flush holds (true) or another process holds (false). */
599
+ const leases = new Map<string, boolean>()
600
+ try {
601
+ for (const { key, value, version } of entries) {
602
+ const message = value.expiresAt > this.now() ? await this.messages.get(userID, value.messageID) : undefined
603
+ // Gone, or an ask that closed (expired) while it waited: nothing to deliver.
604
+ if (!message || (message.kind === "ask" && message.state !== "open")) {
605
+ await this.deps.state.delete(userID, PENDING, key, { ifVersion: version })
606
+ continue
607
+ }
608
+ if (!this.targets(userID, value.sessionID).length) continue
609
+ if (!(await this.lease(userID, value.sessionID, leases))) continue
610
+ // Another process may have delivered it before this one took the lease.
611
+ if (this.deps.locks && !(await this.deps.state.get(userID, PENDING, key))) continue
612
+ if (await this.attempt(userID, message, value.sessionID, value.wake, value.urgent)) await this.deps.state.delete(userID, PENDING, key)
613
+ }
614
+ } finally {
615
+ const { locks } = this.deps
616
+ for (const [sessionID, held] of leases) {
617
+ if (held) await locks!.release(flushKey(userID, sessionID), this.owner).catch(() => false)
618
+ }
619
+ }
620
+ }
621
+
622
+ /** Takes the (user, session) flush lease once per flush (always true without locks); false: another process flushes it. */
623
+ private async lease(userID: string, sessionID: string, leases: Map<string, boolean>): Promise<boolean> {
624
+ const { locks } = this.deps
625
+ if (!locks) return true
626
+ const known = leases.get(sessionID)
627
+ if (known !== undefined) return known
628
+ let held = false
629
+ try {
630
+ held = (await locks.acquire(flushKey(userID, sessionID), this.owner, FLUSH_LEASE_MS)) !== undefined
631
+ } catch (cause) {
632
+ console.error(`[hub] message flush lease failed: ${cause instanceof Error ? cause.message : String(cause)}`)
633
+ }
634
+ leases.set(sessionID, held)
635
+ return held
636
+ }
637
+
638
+ /** `steer`: urgent, or the first msg after a primer to an idle session (see attempt). */
639
+ private deliverRequest(message: Message, sessionID: string, wake: boolean, steer: boolean): DeliverRequest {
640
+ const delivery: DeliveryMode = message.kind === "msg" && !steer ? "queue" : "steer"
641
+ return {
642
+ op: "deliver",
643
+ sessionID,
644
+ messageID: message.id,
645
+ text: renderEnvelope({ id: message.id, kind: message.kind, from: message.from, to: message.to, at: message.at, text: message.text, ...(message.replyTo ? { replyTo: message.replyTo } : {}), ...(message.expiresAt === undefined ? {} : { expiresAt: message.expiresAt }) }),
646
+ wake,
647
+ meta: { kind: message.kind as WireKind, from: wireAddress(message.from), to: message.to, ...(message.replyTo ? { replyTo: message.replyTo } : {}), delivery },
648
+ }
649
+ }
650
+
651
+ /** The sender, from the authenticated actor only. */
652
+ private party(userID: string, actor: AgentActor): Party {
653
+ if (actor.kind !== "session") return partyFromActor(actor)
654
+ return partyFromActor(actor, this.deps.describe?.(userID, actor.sessionID) ?? this.directoryInfo(userID, actor.sessionID))
655
+ }
656
+
657
+ private directoryInfo(userID: string, sessionID: string): PartyInfo {
658
+ try {
659
+ const name = this.deps.directory.resolve(userID, sessionID).sessions[0]?.name
660
+ return name ? { name } : {}
661
+ } catch {
662
+ return {}
663
+ }
664
+ }
665
+ }
666
+
667
+ /** The Locks key of a (user, session) pending flush. */
668
+ const flushKey = (userID: string, sessionID: string) => `deliver:${userID}:${sessionID}`
669
+
670
+ /** The session's holder generation of the role (and project scope) an address names; undefined: not held. */
671
+ function roleGeneration(entry: DirectoryEntry, address: { readonly role: string; readonly project?: string }): number | undefined {
672
+ return entry.roles.find((r) => r.role === address.role && (address.project === undefined || r.project === undefined || r.project === address.project))?.generation
673
+ }
674
+
675
+ /** The entry with the highest score (the first on ties). */
676
+ function latest<T>(list: readonly T[], score: (item: T) => number): T {
677
+ return list.reduce((best, item) => (score(item) > score(best) ? item : best))
678
+ }