@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,385 @@
1
+ // Messaging on /api/agent (design §3, §8; decisions jswt9p22): thin routes over the delivery engine (./delivery).
2
+ //
3
+ // Routes (user actors always; session tokens; app tokens by scope):
4
+ // POST /api/agent/msg {to, text, wake?, urgent?, all?} messages:send → 201 {message, delivery?} | {sent: [...]} (all)
5
+ // POST /api/agent/ask {to, text, wake?, urgent?, all?, expiresIn?, wait?} messages:send → 201 {message, delivery?, answer?}
6
+ // POST /api/agent/reply {replyTo, text, wake?} messages:send → 201 {message, delivery?, original}
7
+ // POST /api/agent/decline {replyTo, why} messages:send → 201 {message, delivery?, original}
8
+ // GET /api/agent/inbox messages:read → {asks, waiting, messages, muted}
9
+ // GET /api/agent/messages/:id?wait= messages:read → {message, answer?}
10
+ // POST /api/agent/mute {session} / POST /api/agent/unmute {session} messages:send → {muted}
11
+ // GET /api/agent/messaging / PUT /api/agent/messaging {enabled} user actors only: the per-user switch
12
+ // GET /api/agent/decisions?state= / GET /api/agent/decisions/:id messages:read → {decisions} / {decision}
13
+ //
14
+ // - @me (design §12.1): msg @me is stored for the user; ask @me opens a Decision (./decisions) with the extra
15
+ // fields {options: [{id, label, safe?}], recommendation: {option, why?}, urgency, tier, deadline (seconds),
16
+ // onTimeout, about: [addresses]} → 201 {message, decision, answer?}. Those fields on an ask to anyone else are
17
+ // invalid. Only the user answers a Decision, on the console (/api/decisions/:id/answer): its ask cannot be
18
+ // replied to or declined here. Decisions read for their asker (and the user); others get not_found.
19
+ //
20
+ // - The sender is only ever the authenticated actor; any from/sender in a body is ignored. A session cannot
21
+ // message itself (invalid). Replies and declines come only from the message's recipient; anyone else, and any
22
+ // unknown or foreign id, gets not_found. Non-session senders (the user, apps) have a stored-only inbox.
23
+ // - expiresIn and wait are seconds. wait (0–WAIT_MAX_S) long-polls an ask until it is answered, declined or
24
+ // expired; longer waits loop on GET /messages/:id?wait=. A message reads for its sender and recipient (and the
25
+ // user, who owns every party); others get not_found.
26
+ // - Messaging off (UserRecord.agent.messagingPaused): msg, ask, reply and decline answer paused; reading works.
27
+ // - Every stored message is published on the MessageFeed (GET /api/agent/watch 'message' events, waiters).
28
+ import { Feed } from "../cluster/feed"
29
+ import type { Store } from "../store"
30
+ import type { AgentActor } from "./actor"
31
+ import { type AskInput, DECISION_STATES, type DecisionState, type Decisions, type Tier, type Urgency } from "./decisions"
32
+ import type { Delivery, SendOptions, Sent } from "./delivery"
33
+ import type { Directory } from "./directory"
34
+ import { partyFromActor, partyKey } from "./envelope"
35
+ import { AgentError } from "./errors"
36
+ import { TEXT_MAX, WHY_MAX, type Message } from "./messages"
37
+ import { AGENT_PREFIX, type AgentRoute } from "./routes"
38
+
39
+ /** The longest wait of one request (seconds); clients loop for longer ones. */
40
+ export const WAIT_MAX_S = 60
41
+ /** How often a waiting request looks at the message besides the feed (expiry, other hub processes). */
42
+ export const WAIT_POLL_MS = 1_000
43
+
44
+ /** Message events' Bus topic (cluster mode). */
45
+ export const MESSAGES_TOPIC = "feed.messages"
46
+
47
+ /** Stored messages of every user, as they are sent (shared with the other pods once attached; see ../cluster/feed). */
48
+ export class MessageFeed extends Feed<Message> {
49
+ constructor() {
50
+ super(MESSAGES_TOPIC, (cause) => console.error("[hub] message listener failed:", cause))
51
+ }
52
+ }
53
+
54
+ export interface MessageDeps {
55
+ readonly delivery: Delivery
56
+ readonly directory: Pick<Directory, "resolveOne">
57
+ readonly store: Pick<Store, "agentData" | "updateAgentData" | "audit">
58
+ readonly feed: MessageFeed
59
+ /** Decisions (ask @me); without them an ask to @me is a plain stored ask. */
60
+ readonly decisions?: Decisions
61
+ /** Default WAIT_POLL_MS. */
62
+ readonly pollMs?: number
63
+ }
64
+
65
+ const NO_STORE = { "cache-control": "no-store" }
66
+ const ok = (body: unknown, status = 200) => Response.json(body, { status, headers: NO_STORE })
67
+ const SEND = { session: true, scope: "messages:send" } as const
68
+ const READ = { session: true, scope: "messages:read" } as const
69
+
70
+ type Body = Record<string, unknown>
71
+
72
+ async function bodyOf(request: Request): Promise<Body> {
73
+ const body = await request.json()
74
+ if (typeof body !== "object" || body === null || Array.isArray(body)) throw new AgentError("invalid", "Body must be a JSON object")
75
+ return body as Body
76
+ }
77
+
78
+ function string(body: Body, key: string, max?: number): string {
79
+ const value = body[key]
80
+ if (typeof value !== "string" || !value.trim()) throw new AgentError("invalid", `${key} must be a non-empty string`)
81
+ if (max !== undefined && value.length > max) throw new AgentError("invalid", `${key} is longer than ${max} characters`)
82
+ return value
83
+ }
84
+
85
+ function flag(body: Body, key: string): boolean | undefined {
86
+ const value = body[key]
87
+ if (value === undefined) return undefined
88
+ if (typeof value !== "boolean") throw new AgentError("invalid", `${key} must be true or false`)
89
+ return value
90
+ }
91
+
92
+ function seconds(value: unknown, key: string, max = Number.POSITIVE_INFINITY): number | undefined {
93
+ if (value === undefined) return undefined
94
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > max) {
95
+ const range = Number.isFinite(max) ? ` from 0 to ${max}` : ""
96
+ throw new AgentError("invalid", `${key} must be a number of seconds${range}`, key === "wait" ? { hint: `Loop on GET ${AGENT_PREFIX}/messages/:id?wait=${WAIT_MAX_S} for longer waits` } : {})
97
+ }
98
+ return value
99
+ }
100
+
101
+ /** The wait query parameter (seconds), or undefined. */
102
+ function waitQuery(raw: string | null): number | undefined {
103
+ if (raw === null || raw === "") return undefined
104
+ return seconds(/^\d+(\.\d+)?$/.test(raw) ? Number(raw) : Number.NaN, "wait", WAIT_MAX_S)
105
+ }
106
+
107
+ const sendOptions = (body: Body): SendOptions => {
108
+ const wake = flag(body, "wake")
109
+ const urgent = flag(body, "urgent")
110
+ return { ...(wake === undefined ? {} : { wake }), ...(urgent === undefined ? {} : { urgent }) }
111
+ }
112
+
113
+ /** The user's address (also the malformed `@me.project`, which the delivery engine refuses). */
114
+ const isMe = (to: string) => to === "@me" || to.startsWith("@me.")
115
+
116
+ const DECISION_FIELDS = ["options", "recommendation", "urgency", "tier", "deadline", "onTimeout", "about"] as const
117
+
118
+ /** The state query parameter of a Decision listing, or undefined. */
119
+ export function decisionStateQuery(raw: string | null): DecisionState | undefined {
120
+ if (raw === null || raw === "") return undefined
121
+ if (!DECISION_STATES.includes(raw as DecisionState)) throw new AgentError("invalid", `state is one of ${DECISION_STATES.join(", ")}`)
122
+ return raw as DecisionState
123
+ }
124
+
125
+ /** The Decision fields of an ask body (shapes only; ./decisions validates the rest); about stays addresses. */
126
+ function decisionFields(body: Body): Omit<AskInput, "question" | "about" | "expiresInMs"> & { about?: string[] } {
127
+ const out: Record<string, unknown> = {}
128
+ if (body.options !== undefined) {
129
+ if (!Array.isArray(body.options)) throw new AgentError("invalid", "options must be a list of {id, label, safe?}")
130
+ out.options = body.options.map((option: unknown) => {
131
+ if (typeof option !== "object" || option === null) throw new AgentError("invalid", "options must be a list of {id, label, safe?}")
132
+ const { id, label, safe } = option as Record<string, unknown>
133
+ if (safe !== undefined && typeof safe !== "boolean") throw new AgentError("invalid", "safe must be true or false")
134
+ return { id, label, ...(safe === true ? { safe } : {}) }
135
+ })
136
+ }
137
+ if (body.recommendation !== undefined) {
138
+ const r = body.recommendation as Record<string, unknown> | null
139
+ if (typeof r !== "object" || r === null || Array.isArray(r)) throw new AgentError("invalid", "recommendation must be {option, why?}")
140
+ out.recommendation = { option: r.option, ...(r.why === undefined ? {} : { why: r.why }) }
141
+ }
142
+ for (const key of ["urgency", "tier", "onTimeout"] as const) {
143
+ if (body[key] === undefined) continue
144
+ if (typeof body[key] !== "string") throw new AgentError("invalid", `${key} must be a string`)
145
+ out[key] = body[key]
146
+ }
147
+ const deadline = seconds(body.deadline, "deadline")
148
+ if (deadline !== undefined) out.deadlineMs = Math.round(deadline * 1000)
149
+ if (body.about !== undefined) {
150
+ if (!Array.isArray(body.about) || body.about.some((a) => typeof a !== "string")) throw new AgentError("invalid", "about must be a list of addresses")
151
+ out.about = body.about as string[]
152
+ }
153
+ return out as Omit<AskInput, "question" | "about" | "expiresInMs"> & { urgency?: Urgency; tier?: Tier; about?: string[] }
154
+ }
155
+
156
+ const sentView = ({ message, delivery }: Sent) => ({ message, ...(delivery ? { delivery } : {}) })
157
+
158
+ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
159
+ const { delivery, feed } = deps
160
+ const pollMs = deps.pollMs ?? WAIT_POLL_MS
161
+
162
+ const paused = (userID: string) => deps.store.agentData(userID).messagingPaused === true
163
+ const ensureOn = (userID: string) => {
164
+ if (paused(userID)) throw new AgentError("paused", "Messaging is off for this user", { hint: "Ask the user to turn messaging back on (it is their switch: PUT /api/agent/messaging {\"enabled\": true}; no console switch yet)" })
165
+ }
166
+
167
+ /** The message, when the actor may read it (its sender or recipient, or the user). */
168
+ const readable = async (userID: string, actor: AgentActor, id: string): Promise<Message> => {
169
+ const message = await delivery.messages.get(userID, id)
170
+ const me = partyKey(partyFromActor(actor))
171
+ if (!message || (actor.kind !== "user" && partyKey(message.from) !== me && partyKey(message.recipient) !== me)) {
172
+ throw new AgentError("not_found", "No such message")
173
+ }
174
+ return message
175
+ }
176
+
177
+ /** Resolves on the feed's answer to `id`, after `ms`, or on abort. */
178
+ const nudge = (userID: string, id: string, ms: number, signal: AbortSignal) =>
179
+ new Promise<void>((resolve) => {
180
+ const done = () => {
181
+ clearTimeout(timer)
182
+ unsubscribe()
183
+ signal.removeEventListener("abort", done)
184
+ resolve()
185
+ }
186
+ const timer = setTimeout(done, ms)
187
+ const unsubscribe = feed.subscribe((user, message) => {
188
+ if (user === userID && message.replyTo === id) done()
189
+ })
190
+ signal.addEventListener("abort", done, { once: true })
191
+ })
192
+
193
+ /** The message and its answer, after waiting up to `waitS` seconds for an open ask to close. */
194
+ const settled = async (userID: string, message: Message, waitS: number | undefined, signal: AbortSignal) => {
195
+ const deadline = Date.now() + (waitS ?? 0) * 1000
196
+ let current = message
197
+ while (current.state === "open" && !signal.aborted) {
198
+ const left = deadline - Date.now()
199
+ if (left <= 0) break
200
+ await nudge(userID, current.id, Math.min(pollMs, left), signal)
201
+ current = (await delivery.messages.get(userID, current.id)) ?? current
202
+ }
203
+ const answer = current.answer ? await delivery.messages.get(userID, current.answer.id) : undefined
204
+ return { message: current, ...(answer ? { answer } : {}) }
205
+ }
206
+
207
+ /** A Decision's ask is answered only on the console (the user is its only possible replier). */
208
+ const notADecision = async (userID: string, actor: AgentActor, replyTo: string) => {
209
+ if (actor.kind !== "user" || !deps.decisions) return
210
+ const decision = await deps.decisions.byMessage(userID, replyTo)
211
+ if (decision) throw new AgentError("invalid", `${replyTo} carries decision ${decision.id}`, { hint: "Answer it on the console's Home page (its decision card)" })
212
+ }
213
+
214
+ /** Decisions the actor may read: the user all, others those they asked. */
215
+ const decisionsFor = (userID: string, actor: AgentActor, state?: DecisionState) =>
216
+ deps.decisions!.list(userID, { ...(state ? { state } : {}), ...(actor.kind === "user" ? {} : { from: partyFromActor(actor) }) })
217
+
218
+ /** Sends to one session the address names, or (all) to every one; published on the feed. */
219
+ async function send(userID: string, actor: AgentActor, to: string, kind: "msg" | "ask", text: string, options: SendOptions, all: boolean | undefined): Promise<Sent[]> {
220
+ ensureOn(userID)
221
+ const sent = all ? await delivery.sendAll(userID, actor, to, kind, text, options) : [await delivery.send(userID, actor, to, kind, text, options)]
222
+ for (const { message } of sent) feed.publish(userID, message)
223
+ return sent
224
+ }
225
+
226
+ return [
227
+ {
228
+ method: "POST",
229
+ path: `${AGENT_PREFIX}/msg`,
230
+ allow: SEND,
231
+ async handle({ request, user, actor }) {
232
+ const body = await bodyOf(request)
233
+ const to = string(body, "to")
234
+ const text = string(body, "text", TEXT_MAX)
235
+ const all = flag(body, "all")
236
+ const sent = await send(user.id, actor, to, "msg", text, sendOptions(body), all)
237
+ return ok(all ? { sent: sent.map(sentView) } : sentView(sent[0]!), 201)
238
+ },
239
+ },
240
+ {
241
+ method: "POST",
242
+ path: `${AGENT_PREFIX}/ask`,
243
+ allow: SEND,
244
+ async handle({ request, user, actor }) {
245
+ const body = await bodyOf(request)
246
+ const to = string(body, "to")
247
+ const text = string(body, "text", TEXT_MAX)
248
+ const all = flag(body, "all")
249
+ const expiresIn = seconds(body.expiresIn, "expiresIn")
250
+ const wait = seconds(body.wait, "wait", WAIT_MAX_S)
251
+ if (all && wait !== undefined) throw new AgentError("invalid", "wait cannot be combined with all")
252
+ const options: SendOptions = { ...sendOptions(body), ...(expiresIn === undefined ? {} : { expiresInMs: Math.round(expiresIn * 1000) }) }
253
+ const decisionAsked = DECISION_FIELDS.some((key) => body[key] !== undefined)
254
+ if (isMe(to) && deps.decisions) {
255
+ if (all) throw new AgentError("invalid", "all cannot be combined with @me")
256
+ ensureOn(user.id)
257
+ const { about, ...fields } = decisionFields(body)
258
+ const sessions = about?.map((addr) => deps.directory.resolveOne(user.id, addr).sessionID)
259
+ const { message, decision } = await deps.decisions.ask(user.id, actor, {
260
+ question: text,
261
+ ...fields,
262
+ ...(sessions ? { about: sessions } : {}),
263
+ ...(options.expiresInMs === undefined ? {} : { expiresInMs: options.expiresInMs }),
264
+ })
265
+ feed.publish(user.id, message)
266
+ const done = await settled(user.id, message, wait, request.signal)
267
+ return ok({ ...done, decision: (await deps.decisions.get(user.id, decision.id)) ?? decision }, 201)
268
+ }
269
+ if (decisionAsked) throw new AgentError("invalid", "options, recommendation, urgency, tier, deadline, onTimeout and about are for asks to @me")
270
+ const sent = await send(user.id, actor, to, "ask", text, options, all)
271
+ if (all) return ok({ sent: sent.map(sentView) }, 201)
272
+ const { message, delivery: how } = sent[0]!
273
+ return ok({ ...sentView({ message, ...(how ? { delivery: how } : {}) }), ...(await settled(user.id, message, wait, request.signal)) }, 201)
274
+ },
275
+ },
276
+ {
277
+ method: "POST",
278
+ path: `${AGENT_PREFIX}/reply`,
279
+ allow: SEND,
280
+ async handle({ request, user, actor }) {
281
+ const body = await bodyOf(request)
282
+ const replyTo = string(body, "replyTo")
283
+ const text = string(body, "text", TEXT_MAX)
284
+ const wake = flag(body, "wake")
285
+ ensureOn(user.id)
286
+ await notADecision(user.id, actor, replyTo)
287
+ const replied = await delivery.reply(user.id, actor, replyTo, text, wake === undefined ? {} : { wake })
288
+ feed.publish(user.id, replied.message)
289
+ return ok({ ...sentView(replied), original: replied.original }, 201)
290
+ },
291
+ },
292
+ {
293
+ method: "POST",
294
+ path: `${AGENT_PREFIX}/decline`,
295
+ allow: SEND,
296
+ async handle({ request, user, actor }) {
297
+ const body = await bodyOf(request)
298
+ const replyTo = string(body, "replyTo")
299
+ const why = string(body, "why", WHY_MAX)
300
+ ensureOn(user.id)
301
+ await notADecision(user.id, actor, replyTo)
302
+ const declined = await delivery.decline(user.id, actor, replyTo, why)
303
+ feed.publish(user.id, declined.message)
304
+ return ok({ ...sentView(declined), original: declined.original }, 201)
305
+ },
306
+ },
307
+ {
308
+ method: "GET",
309
+ path: `${AGENT_PREFIX}/inbox`,
310
+ allow: READ,
311
+ async handle({ user, actor }) {
312
+ const party = partyFromActor(actor)
313
+ const inbox = await delivery.messages.inbox(user.id, party)
314
+ return ok({ ...inbox, muted: await delivery.messages.muted(user.id, party) })
315
+ },
316
+ },
317
+ {
318
+ method: "GET",
319
+ path: `${AGENT_PREFIX}/messages/:id`,
320
+ allow: READ,
321
+ async handle({ user, actor, params, url, request }) {
322
+ const wait = waitQuery(url.searchParams.get("wait"))
323
+ const message = await readable(user.id, actor, params.id!)
324
+ return ok(await settled(user.id, message, wait, request.signal))
325
+ },
326
+ },
327
+ ...(deps.decisions
328
+ ? [
329
+ {
330
+ method: "GET",
331
+ path: `${AGENT_PREFIX}/decisions`,
332
+ allow: READ,
333
+ handle: async ({ user, actor, url }) => ok({ decisions: await decisionsFor(user.id, actor, decisionStateQuery(url.searchParams.get("state"))) }),
334
+ } satisfies AgentRoute,
335
+ {
336
+ method: "GET",
337
+ path: `${AGENT_PREFIX}/decisions/:id`,
338
+ allow: READ,
339
+ async handle({ user, actor, params }) {
340
+ const decision = await deps.decisions!.get(user.id, params.id!)
341
+ if (!decision || (actor.kind !== "user" && partyKey(decision.from) !== partyKey(partyFromActor(actor)))) throw new AgentError("not_found", "No such decision")
342
+ return ok({ decision })
343
+ },
344
+ } satisfies AgentRoute,
345
+ ]
346
+ : []),
347
+ ...(["mute", "unmute"] as const).map(
348
+ (verb): AgentRoute => ({
349
+ method: "POST",
350
+ path: `${AGENT_PREFIX}/${verb}`,
351
+ allow: SEND,
352
+ async handle({ request, user, actor }) {
353
+ const body = await bodyOf(request)
354
+ const entry = deps.directory.resolveOne(user.id, string(body, "session"))
355
+ if (actor.kind === "session" && entry.sessionID === actor.sessionID) throw new AgentError("invalid", "A session cannot mute itself")
356
+ const party = partyFromActor(actor)
357
+ if (verb === "mute") await delivery.messages.mute(user.id, party, entry.sessionID)
358
+ else await delivery.messages.unmute(user.id, party, entry.sessionID)
359
+ return ok({ muted: await delivery.messages.muted(user.id, party) })
360
+ },
361
+ }),
362
+ ),
363
+ {
364
+ method: "GET",
365
+ path: `${AGENT_PREFIX}/messaging`,
366
+ allow: {},
367
+ handle: ({ user }) => ok({ enabled: !paused(user.id) }),
368
+ },
369
+ {
370
+ method: "PUT",
371
+ path: `${AGENT_PREFIX}/messaging`,
372
+ allow: {},
373
+ async handle({ request, user }) {
374
+ const enabled = flag(await bodyOf(request), "enabled")
375
+ if (enabled === undefined) throw new AgentError("invalid", "enabled must be true or false")
376
+ await deps.store.updateAgentData(user.id, (data) => {
377
+ if (enabled) delete data.messagingPaused
378
+ else data.messagingPaused = true
379
+ })
380
+ await deps.store.audit({ actor: user.id, action: enabled ? "messaging.resume" : "messaging.pause" })
381
+ return ok({ enabled })
382
+ },
383
+ },
384
+ ]
385
+ }