@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
@@ -1,7 +1,10 @@
1
- // Push endpoints come from browsers, and the hub POSTs to them: without checks a subscription could make the
2
- // hub send requests into its own network (SSRF). An endpoint must be https on a public hostname; hosts are
3
- // resolved at subscribe time and rejected when any address is loopback, private, link-local, CGNAT, ULA,
4
- // unspecified or multicast. Real push services (FCM, Mozilla, Apple, WNS) are public names on public addresses.
1
+ // URLs users give the hub (push endpoints from browsers, webhook URLs from apps) are URLs the hub POSTs to: without
2
+ // checks a subscription could make the hub send requests into its own network (SSRF). publicURLProblem: https on a
3
+ // public hostname whose every resolved address is public (not loopback, private, link-local, CGNAT, ULA,
4
+ // unspecified or multicast); an admin opt-in (allowPrivate, webhooks only) admits http and private targets.
5
+ // Push checks endpoints at subscribe time (real push services are public names on public addresses); webhooks
6
+ // send through publicFetch, which re-checks (and re-resolves) right before every request, never follows a
7
+ // redirect, times out and reads a bounded body.
5
8
  import { lookup } from "node:dns/promises"
6
9
  import { isIPv4, isIPv6 } from "node:net"
7
10
 
@@ -10,7 +13,7 @@ export type ResolveHost = (host: string) => Promise<readonly string[]>
10
13
  /** Every address the system resolver returns for a host. */
11
14
  export const resolveHost: ResolveHost = async (host) => (await lookup(host, { all: true, verbatim: true })).map((a) => a.address)
12
15
 
13
- /** IPv4 [network, prefix length] blocks that never reach a public push service. */
16
+ /** IPv4 [network, prefix length] blocks that are never a public service. */
14
17
  const V4_BLOCKED: ReadonlyArray<readonly [string, number]> = [
15
18
  ["0.0.0.0", 8], // "this network", unspecified
16
19
  ["10.0.0.0", 8], // private
@@ -47,7 +50,7 @@ function v6Groups(ip: string): number[] {
47
50
  return [...left, ...fill, ...right].map((g) => parseInt(g, 16))
48
51
  }
49
52
 
50
- /** True for addresses a push endpoint must not resolve to. Anything that is not an IP counts as private. */
53
+ /** True for addresses a public URL must not resolve to. Anything that is not an IP counts as private. */
51
54
  export function isPrivateAddress(address: string): boolean {
52
55
  const ip = address.trim().replace(/^\[(.*)\]$/, "$1")
53
56
  if (isIPv4(ip)) return privateV4(ip)
@@ -70,28 +73,125 @@ export function isPrivateAddress(address: string): boolean {
70
73
 
71
74
  const LOCAL_SUFFIXES = [".localhost", ".local", ".internal", ".localdomain", ".home.arpa", ".lan"]
72
75
 
73
- /** Why an endpoint is not acceptable for Web Push, or undefined when it is. */
74
- export async function pushEndpointProblem(endpoint: string, resolve: ResolveHost): Promise<string | undefined> {
76
+ export interface PublicURLOptions {
77
+ readonly resolve: ResolveHost
78
+ /** How problems name the URL (default "URL"). */
79
+ readonly label?: string
80
+ /**
81
+ * The admin opt-in for private targets (hub.json agents.webhooks.allowPrivate): http, loopback, private
82
+ * addresses and local names pass without a lookup. Other schemes and credentials still fail.
83
+ */
84
+ readonly allowPrivate?: boolean
85
+ }
86
+
87
+ /** Why the hub must not send to a URL (SSRF), or undefined when it may. */
88
+ export async function publicURLProblem(raw: string, options: PublicURLOptions): Promise<string | undefined> {
89
+ const label = options.label ?? "URL"
75
90
  let url: URL
76
91
  try {
77
- url = new URL(endpoint)
92
+ url = new URL(raw)
78
93
  } catch {
79
- return "Push endpoint is not a URL"
94
+ return `${label} is not a URL`
80
95
  }
81
- if (url.protocol !== "https:") return "Push endpoint must be https"
82
- if (url.username || url.password) return "Push endpoint must not carry credentials"
96
+ if (options.allowPrivate) {
97
+ if (url.protocol !== "https:" && url.protocol !== "http:") return `${label} must be http or https`
98
+ } else if (url.protocol !== "https:") return `${label} must be https`
99
+ if (url.username || url.password) return `${label} must not carry credentials`
100
+ if (options.allowPrivate) return undefined
83
101
  const host = url.hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1").replace(/\.$/, "")
84
- if (isIPv4(host) || isIPv6(host)) return isPrivateAddress(host) ? "Push endpoint is a private or local address" : undefined
102
+ if (isIPv4(host) || isIPv6(host)) return isPrivateAddress(host) ? `${label} is a private or local address` : undefined
85
103
  if (host === "localhost" || !host.includes(".") || LOCAL_SUFFIXES.some((suffix) => host.endsWith(suffix))) {
86
- return "Push endpoint is a local hostname"
104
+ return `${label} is a local hostname`
87
105
  }
88
106
  let addresses: readonly string[]
89
107
  try {
90
- addresses = await resolve(host)
108
+ addresses = await options.resolve(host)
91
109
  } catch {
92
- return `Push endpoint host ${host} does not resolve`
110
+ return `${label} host ${host} does not resolve`
93
111
  }
94
- if (addresses.length === 0) return `Push endpoint host ${host} does not resolve`
95
- if (addresses.some(isPrivateAddress)) return "Push endpoint resolves to a private or local address"
112
+ if (addresses.length === 0) return `${label} host ${host} does not resolve`
113
+ if (addresses.some(isPrivateAddress)) return `${label} resolves to a private or local address`
96
114
  return undefined
97
115
  }
116
+
117
+ /** Why an endpoint is not acceptable for Web Push, or undefined when it is. */
118
+ export async function pushEndpointProblem(endpoint: string, resolve: ResolveHost): Promise<string | undefined> {
119
+ return publicURLProblem(endpoint, { resolve, label: "Push endpoint" })
120
+ }
121
+
122
+ /** The URL failed publicURLProblem at send time; nothing was sent. */
123
+ export class PublicURLError extends Error {
124
+ constructor(readonly problem: string) {
125
+ super(problem)
126
+ this.name = "PublicURLError"
127
+ }
128
+ }
129
+
130
+ export interface PublicFetchOptions extends PublicURLOptions {
131
+ /** For the whole exchange, body included. */
132
+ readonly timeoutMs: number
133
+ /** Bytes of the response body read at most; the rest is discarded. */
134
+ readonly maxBytes: number
135
+ readonly fetch?: typeof globalThis.fetch
136
+ }
137
+
138
+ export interface PublicFetchResult {
139
+ readonly status: number
140
+ /** The first maxBytes of the body (UTF-8, lossy). */
141
+ readonly body: string
142
+ readonly truncated: boolean
143
+ }
144
+
145
+ /**
146
+ * One request to a user-supplied URL: the URL is checked (and its host re-resolved) right before sending, so a
147
+ * name that changed to a private address since registration is refused; redirects are never followed (a 3xx is
148
+ * returned as is); the timeout covers the body; at most maxBytes of the body are read.
149
+ * A rebinding between this lookup and fetch's own remains possible; private targets need the admin opt-in.
150
+ */
151
+ export async function publicFetch(
152
+ url: string,
153
+ init: { readonly method: string; readonly headers?: Record<string, string>; readonly body: string },
154
+ options: PublicFetchOptions,
155
+ ): Promise<PublicFetchResult> {
156
+ const problem = await publicURLProblem(url, options)
157
+ if (problem) throw new PublicURLError(problem)
158
+ const controller = new AbortController()
159
+ let timedOut = false
160
+ const timer = setTimeout(() => {
161
+ timedOut = true
162
+ controller.abort()
163
+ }, options.timeoutMs)
164
+ try {
165
+ const response = await (options.fetch ?? globalThis.fetch)(url, { ...init, redirect: "manual", signal: controller.signal })
166
+ const { body, truncated } = await readCapped(response, options.maxBytes)
167
+ return { status: response.status, body, truncated }
168
+ } catch (cause) {
169
+ if (timedOut) throw new Error(`timed out after ${options.timeoutMs} ms`)
170
+ throw cause
171
+ } finally {
172
+ clearTimeout(timer)
173
+ }
174
+ }
175
+
176
+ async function readCapped(response: Response, maxBytes: number): Promise<{ body: string; truncated: boolean }> {
177
+ if (!response.body) return { body: "", truncated: false }
178
+ const reader = response.body.getReader()
179
+ const chunks: Uint8Array[] = []
180
+ let size = 0
181
+ let truncated = false
182
+ while (true) {
183
+ const { done, value } = await reader.read()
184
+ if (done) break
185
+ const room = maxBytes - size
186
+ if (value.byteLength > room) {
187
+ chunks.push(value.subarray(0, room))
188
+ size = maxBytes
189
+ truncated = true
190
+ await reader.cancel().catch(() => {})
191
+ break
192
+ }
193
+ chunks.push(value)
194
+ size += value.byteLength
195
+ }
196
+ return { body: Buffer.concat(chunks).toString("utf8"), truncated }
197
+ }
@@ -1,22 +1,52 @@
1
- // Web push (VAPID) for paired devices: hand-offs from the terminal, questions, permissions, finished and
2
- // failed runs, and (conversation mode, nobody watching) what the agent said. Payloads carry only {kind,
3
- // sessionID, title, body, requestID?}: no tokens, no transcript beyond one spoken text, shortened. Each device picks the kinds it wants, may set quiet hours, and users mute sessions.
1
+ // Web push (VAPID) for paired devices, one toggle per notification kind (docs/notifications.md): hand-offs, questions,
2
+ // permissions, all done, failed / stopped / retrying runs, every turn, subagents, scripts, long tools, spoken replies
3
+ // and the activity digest. Payloads carry only {kind, sessionID, title, body, requestID?, test?}: no tokens, no
4
+ // transcript beyond one spoken text or a short reason, shortened. Each device picks the kinds it wants, may set quiet
5
+ // hours, and users mute sessions.
4
6
  import webpush from "web-push"
5
- import { type PushPrefs, type PushTarget, type QuietHours, type Store } from "./store"
7
+ import { MemoryKv } from "./backend/memory-kv"
8
+ import type { Kv } from "./backend/types"
9
+ import { type PushPrefs, type PushTarget, type QuietHours, type Store, type VapidKeys } from "./store"
6
10
 
7
11
  export { DEFAULT_PUSH_PREFS, type PushPrefs } from "./store"
8
12
 
9
- /** What happened, as the registry and the spoke socket report it. */
10
- export type PushKind = "handoff" | "blocked" | "finished" | "say"
11
- /** What a blocked or finished notification is about. */
12
- export type PushDetail = "permission" | "form" | "succeeded" | "failed"
13
- /** The precise notification kind; one per device toggle. */
13
+ /** The notification kind; one per device toggle. */
14
14
  export type NotificationKind = keyof PushPrefs
15
15
 
16
+ /** Short label of each kind, shown in the title after the session name (and in Settings). */
17
+ export const NOTIFICATION_LABELS: Readonly<Record<NotificationKind, string>> = Object.freeze({
18
+ handoff: "Hand-off",
19
+ question: "Question",
20
+ permission: "Needs permission",
21
+ done: "All done",
22
+ failed: "Failed",
23
+ turn: "Turn finished",
24
+ stopped: "Stopped",
25
+ retry: "Retrying",
26
+ subagentDone: "Subagent finished",
27
+ subagentFailed: "Subagent failed",
28
+ jobDone: "Script finished",
29
+ jobFailed: "Script failed",
30
+ longTool: "Long tool finished",
31
+ say: "Spoken reply",
32
+ activity: "Activity",
33
+ decision: "Decision",
34
+ message: "Message",
35
+ })
36
+
37
+ /** True for a known notification kind (API input). */
38
+ export const isNotificationKind = (value: unknown): value is NotificationKind => typeof value === "string" && Object.hasOwn(NOTIFICATION_LABELS, value)
39
+
16
40
  /** Extra facts for one notification. */
17
41
  export interface PushExtra {
18
42
  /** permission: the request ID, so the notification can answer it. */
19
43
  readonly requestID?: string
44
+ /** What the notification is about, for the title: a subagent's title, a job's outcome ("exited 1"), a duration. */
45
+ readonly subject?: string
46
+ /** Identity of what ended (subagent session, job, tool call): the cooldown is per session, kind and key. Not sent. */
47
+ readonly key?: string
48
+ /** decision: the Decision, so a tap opens its card. */
49
+ readonly decisionID?: string
20
50
  }
21
51
 
22
52
  export interface PushPayload {
@@ -25,18 +55,9 @@ export interface PushPayload {
25
55
  readonly title: string
26
56
  readonly body: string
27
57
  readonly requestID?: string
28
- }
29
-
30
- /** The device toggle that decides an alert. */
31
- export function pushKindFor(kind: PushKind, detail?: PushDetail): NotificationKind {
32
- switch (kind) {
33
- case "blocked":
34
- return detail === "form" ? "question" : "permission"
35
- case "finished":
36
- return detail === "failed" ? "failed" : "finished"
37
- default:
38
- return kind
39
- }
58
+ readonly decisionID?: string
59
+ /** A test notification shaped like `kind`. */
60
+ readonly test?: true
40
61
  }
41
62
 
42
63
  const minutes = (clock: string): number => Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3, 5))
@@ -80,6 +101,7 @@ export const URGENT_TTL_SECONDS = 3600
80
101
  /** One test notification per device in this window. */
81
102
  export const TEST_COOLDOWN_MS = 10_000
82
103
  const SEND_TIMEOUT_MS = 10_000
104
+ const COOL_KEY = (key: string) => `push:cool:${key}`
83
105
  const MAX_NAME = 60
84
106
 
85
107
  const webPushSender: PushSender = (target, payload, options) =>
@@ -91,6 +113,51 @@ export interface PushOptions {
91
113
  readonly subject: string
92
114
  readonly sender?: PushSender
93
115
  readonly now?: () => number
116
+ /** Timers for delayed activity digests (tests). */
117
+ readonly timers?: { set(fn: () => void, ms: number): unknown; clear(handle: unknown): void }
118
+ /** Cooldowns (default: this process, on `now`; Valkey in a cluster, so pods share them). */
119
+ readonly kv?: Kv
120
+ }
121
+
122
+ /** One step of interest in a session tree, for activity updates. */
123
+ export interface ActivityStep {
124
+ /** tool: a short tool call (only counted); anything else is a line of the digest and sends it. */
125
+ readonly kind: "tool" | "long" | "subagent" | "job" | "idle"
126
+ readonly text: string
127
+ }
128
+
129
+ /** An activity step of a root session, as the registry reports it. */
130
+ export interface ActivityAlert {
131
+ readonly sessionID: string
132
+ readonly title: string
133
+ readonly step?: ActivityStep
134
+ readonly owner?: string
135
+ }
136
+
137
+ /** Lines of a digest kept (older ones become "+n more"). */
138
+ const DIGEST_LINES = 5
139
+
140
+ interface Digest {
141
+ title: string
142
+ tools: number
143
+ lines: string[]
144
+ last?: number
145
+ timer?: unknown
146
+ }
147
+
148
+ const realTimers = {
149
+ set: (fn: () => void, ms: number) => {
150
+ const handle = setTimeout(fn, ms)
151
+ ;(handle as { unref?: () => void }).unref?.()
152
+ return handle
153
+ },
154
+ clear: (handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>),
155
+ }
156
+
157
+ /** "3 tools · explore finished · bun run build exited 0". */
158
+ export function digestBody(tools: number, lines: readonly string[]): string {
159
+ const shown = lines.length > DIGEST_LINES ? [`+${lines.length - DIGEST_LINES + 1} more`, ...lines.slice(-(DIGEST_LINES - 1))] : [...lines]
160
+ return [...(tools ? [`${tools} tool${tools === 1 ? "" : "s"}`] : []), ...shown].join(" · ")
94
161
  }
95
162
 
96
163
  function clip(text: string, max: number): string {
@@ -100,48 +167,83 @@ function clip(text: string, max: number): string {
100
167
 
101
168
  const shorten = (title: string): string => clip(title, MAX_NAME) || "Session"
102
169
 
103
- export function pushPayload(
104
- kind: PushKind,
105
- sessionID: string,
106
- sessionTitle: string,
107
- detail?: PushDetail,
108
- text?: string,
109
- extra: PushExtra = {},
110
- ): PushPayload {
170
+ const MAX_SUBJECT = 40
171
+
172
+ /** The title after the session name: the kind's label, with its subject where it has one. */
173
+ function kindTitle(kind: NotificationKind, subject: string | undefined): string {
174
+ const about = subject ? clip(subject, MAX_SUBJECT) : ""
175
+ switch (kind) {
176
+ case "subagentDone":
177
+ return about ? `Subagent ${about} finished` : NOTIFICATION_LABELS.subagentDone
178
+ case "subagentFailed":
179
+ return about ? `Subagent ${about} failed` : NOTIFICATION_LABELS.subagentFailed
180
+ case "jobDone":
181
+ case "jobFailed":
182
+ return about ? `Script ${about}` : NOTIFICATION_LABELS[kind]
183
+ case "longTool":
184
+ return about ? `${NOTIFICATION_LABELS.longTool} (${about})` : NOTIFICATION_LABELS.longTool
185
+ default:
186
+ return NOTIFICATION_LABELS[kind]
187
+ }
188
+ }
189
+
190
+ const OPEN = "Tap to open the session."
191
+
192
+ export function pushPayload(kind: NotificationKind, sessionID: string, sessionTitle: string, text?: string, extra: PushExtra = {}): PushPayload {
111
193
  const name = shorten(sessionTitle)
112
- const precise = pushKindFor(kind, detail)
113
- switch (precise) {
194
+ const detail = clip(text ?? "", SAY_MAX_CHARS)
195
+ const title = `${name} · ${kindTitle(kind, extra.subject)}`
196
+ switch (kind) {
114
197
  case "say":
115
- return { kind: precise, sessionID, title: name, body: clip(text ?? "", SAY_MAX_CHARS) || "New reply. Tap to listen." }
198
+ // A spoken reply reads as the session talking.
199
+ return { kind, sessionID, title: name, body: detail || "New reply. Tap to listen." }
116
200
  case "handoff":
117
- return { kind: precise, sessionID, title: name, body: "Sent from the terminal. Tap to talk." }
201
+ return { kind, sessionID, title, body: "Sent from the terminal. Tap to talk." }
118
202
  case "question":
119
- return { kind: precise, sessionID, title: `${name} has a question`, body: "Tap to see it." }
203
+ return { kind, sessionID, title, body: "Tap to see it." }
204
+ // An agent wrote the question or message: never in the payload (the console shows it after sign-in).
205
+ case "decision":
206
+ return { kind, sessionID, title, body: "A decision waits for you. Tap to answer.", ...(extra.decisionID ? { decisionID: extra.decisionID } : {}) }
207
+ case "message":
208
+ return { kind, sessionID, title, body: "Tap to see it." }
120
209
  case "permission":
121
210
  // Never the request's summary (it may be a command line); the service worker fetches it from the hub.
122
- return {
123
- kind: precise,
124
- sessionID,
125
- title: `${name} needs permission`,
126
- body: "Tap to review and answer.",
127
- ...(extra.requestID ? { requestID: extra.requestID } : {}),
128
- }
129
- case "failed":
130
- return { kind: precise, sessionID, title: `${name} failed`, body: "Tap to open the session." }
131
- case "finished":
132
- return { kind: precise, sessionID, title: `${name} finished`, body: "Tap to open the session." }
211
+ return { kind, sessionID, title, body: "Tap to review and answer.", ...(extra.requestID ? { requestID: extra.requestID } : {}) }
212
+ case "done":
213
+ return { kind, sessionID, title, body: detail || "Nothing is running or waiting. Tap to open." }
214
+ case "activity":
215
+ return { kind, sessionID, title, body: detail || "New activity." }
216
+ default:
217
+ return { kind, sessionID, title, body: detail || OPEN }
133
218
  }
134
219
  }
135
220
 
221
+ /** What a test notification of each kind is about. */
222
+ const TEST_SUBJECT: Partial<Record<NotificationKind, string>> = { subagentDone: "explore", subagentFailed: "explore", jobDone: "exited 0", jobFailed: "exited 1", longTool: "45s" }
223
+
136
224
  /** web-push options per notification kind: urgent kinds wake a dozing phone and wait longer for it. */
137
225
  export function sendOptionsFor(kind: NotificationKind | "test"): { TTL: number; urgency: "high" | "normal" } {
138
226
  switch (kind) {
139
227
  case "question":
140
228
  case "permission":
141
229
  case "failed":
230
+ case "decision":
231
+ case "message":
142
232
  return { TTL: URGENT_TTL_SECONDS, urgency: "high" }
143
- case "finished":
233
+ case "done":
234
+ case "stopped":
235
+ case "subagentFailed":
236
+ case "jobFailed":
237
+ // Worth seeing later, not worth waking a dozing phone.
144
238
  return { TTL: URGENT_TTL_SECONDS, urgency: "normal" }
239
+ case "turn":
240
+ case "retry":
241
+ case "subagentDone":
242
+ case "jobDone":
243
+ case "longTool":
244
+ case "activity":
245
+ // Progress: stale soon, replaced by the next one.
246
+ return { TTL: PUSH_TTL_SECONDS, urgency: "normal" }
145
247
  default:
146
248
  return { TTL: PUSH_TTL_SECONDS, urgency: "high" }
147
249
  }
@@ -170,19 +272,96 @@ export class Push {
170
272
  private readonly subject: string
171
273
  private readonly sender: PushSender
172
274
  private readonly now: () => number
173
- /** `session kind` → when it was last sent and how long it cools down. */
174
- private readonly lastSent = new Map<string, { at: number; cooldown: number }>()
275
+ /** Cooldowns: `push:cool:<session kind>` / `push:cool:test <device>` → when it was sent, expiring with the cooldown. */
276
+ private readonly kv: Kv
277
+ /**
278
+ * `device session` → the activity digest being collected. Kept in this process: activity is reported by the
279
+ * pod holding the session's connection.
280
+ */
281
+ private readonly digests = new Map<string, Digest>()
282
+ private readonly timers: NonNullable<PushOptions["timers"]>
175
283
 
176
284
  constructor(options: PushOptions) {
177
285
  this.store = options.store
178
286
  this.subject = options.subject
179
287
  this.sender = options.sender ?? webPushSender
180
288
  this.now = options.now ?? Date.now
289
+ this.timers = options.timers ?? realTimers
290
+ this.kv = options.kv ?? new MemoryKv({ now: this.now })
291
+ }
292
+
293
+ /**
294
+ * Activity updates: collects a session's steps per device that wants them (pref `activity`, not muted, not in
295
+ * quiet hours) and sends one collapsing notification: at once when the device got none for this session within
296
+ * its interval, else when the interval ends, with everything that happened meanwhile. Short tools only count.
297
+ */
298
+ activity(alert: ActivityAlert): void {
299
+ const step = alert.step
300
+ if (!alert.owner || !step) return
301
+ const at = this.now()
302
+ for (const deviceID of this.activityDevices(alert.owner, alert.sessionID, at)) {
303
+ const key = `${deviceID}\n${alert.sessionID}`
304
+ let digest = this.digests.get(key)
305
+ if (!digest) this.digests.set(key, (digest = { title: alert.title, tools: 0, lines: [] }))
306
+ digest.title = alert.title
307
+ if (step.kind === "tool") {
308
+ digest.tools++
309
+ continue
310
+ }
311
+ if (digest.lines.at(-1) !== step.text) digest.lines.push(step.text)
312
+ if (digest.lines.length > 50) digest.lines.splice(0, digest.lines.length - 50)
313
+ if (digest.timer !== undefined) continue
314
+ const wait = digest.last === undefined ? 0 : digest.last + this.store.activityMinutes(deviceID) * 60_000 - at
315
+ if (wait <= 0) void this.flushDigest(deviceID, alert.sessionID, alert.owner)
316
+ else {
317
+ const owner = alert.owner
318
+ digest.timer = this.timers.set(() => void this.flushDigest(deviceID, alert.sessionID, owner), wait)
319
+ }
320
+ }
321
+ this.pruneDigests(at)
322
+ }
323
+
324
+ /** Devices of `owner` with a subscription that want activity updates for the session now. */
325
+ private activityDevices(owner: string, sessionID: string, at: number): string[] {
326
+ if (this.store.mutedSessions(owner).includes(sessionID)) return []
327
+ const mine = new Set(this.store.devices().filter((d) => d.ownerID === owner).map((d) => d.id))
328
+ const devices = new Set<string>()
329
+ for (const s of this.store.pushSubscriptions()) {
330
+ if (!mine.has(s.deviceID) || devices.has(s.deviceID) || !this.store.pushPrefs(s.deviceID).activity) continue
331
+ const quiet = this.store.quietHours(s.deviceID)
332
+ if (quiet && inQuietHours(quiet, at)) continue
333
+ devices.add(s.deviceID)
334
+ }
335
+ return [...devices]
336
+ }
337
+
338
+ private async flushDigest(deviceID: string, sessionID: string, owner: string): Promise<void> {
339
+ const key = `${deviceID}\n${sessionID}`
340
+ const digest = this.digests.get(key)
341
+ if (!digest) return
342
+ if (digest.timer !== undefined) this.timers.clear(digest.timer)
343
+ digest.timer = undefined
344
+ const body = digestBody(digest.tools, digest.lines)
345
+ digest.tools = 0
346
+ digest.lines = []
347
+ digest.last = this.now()
348
+ // Settings may have changed while it waited.
349
+ if (!body || !this.activityDevices(owner, sessionID, digest.last).includes(deviceID)) return
350
+ const targets = this.store.pushSubscriptions().filter((s) => s.deviceID === deviceID)
351
+ await this.send(targets, pushPayload("activity", sessionID, digest.title, body))
352
+ }
353
+
354
+ /** Forgets digests idle for an hour (nothing pending, interval long over). */
355
+ private pruneDigests(at: number): void {
356
+ if (this.digests.size < 1000) return
357
+ for (const [key, digest] of this.digests) {
358
+ if (digest.timer === undefined && !digest.lines.length && (digest.last ?? 0) < at - 3_600_000) this.digests.delete(key)
359
+ }
181
360
  }
182
361
 
183
362
  /** The VAPID public key (applicationServerKey); generated and persisted on first use. */
184
- publicKey(): string {
185
- return this.keys().publicKey
363
+ async publicKey(): Promise<string> {
364
+ return (await this.keys()).publicKey
186
365
  }
187
366
 
188
367
  /**
@@ -191,17 +370,10 @@ export class Push {
191
370
  * in its quiet hours (hand-offs, an explicit request from the terminal, go through both). Returns the
192
371
  * number of successful deliveries.
193
372
  */
194
- async notify(
195
- kind: PushKind,
196
- sessionID: string,
197
- title: string,
198
- detail?: PushDetail,
199
- body?: string,
200
- ownerID?: string,
201
- extra?: PushExtra,
202
- ): Promise<number> {
203
- const precise = pushKindFor(kind, detail)
204
- if (!this.cool(`${sessionID}\n${precise}`, precise === "say" ? SAY_COOLDOWN_MS : PUSH_COOLDOWN_MS)) return 0
373
+ async notify(kind: NotificationKind, sessionID: string, title: string, body?: string, ownerID?: string, extra?: PushExtra): Promise<number> {
374
+ const precise = kind
375
+ const key = `${sessionID}\n${precise}${extra?.key ? `\n${extra.key}` : ""}`
376
+ if (!(await this.cool(key, precise === "say" ? SAY_COOLDOWN_MS : PUSH_COOLDOWN_MS))) return 0
205
377
  const owners = new Map(this.store.devices().map((d) => [d.id, d.ownerID]))
206
378
  const at = this.now()
207
379
  const muted = new Map<string, boolean>()
@@ -219,21 +391,26 @@ export class Push {
219
391
  return !isMuted(owner) && !(quiet && inQuietHours(quiet, at))
220
392
  })
221
393
  if (targets.length === 0) return 0
222
- const payload = pushPayload(kind, sessionID, title, detail, body, extra)
394
+ const payload = pushPayload(kind, sessionID, title, body, extra)
223
395
  const { delivered } = await this.send(targets, payload)
224
396
  return delivered
225
397
  }
226
398
 
227
- /** Sends a test notification to `deviceID`'s own subscriptions, whatever its prefs; one per TEST_COOLDOWN_MS. */
228
- async test(deviceID: string): Promise<PushTestResult> {
399
+ /**
400
+ * Sends a test notification to `deviceID`'s own subscriptions, whatever its prefs; one per TEST_COOLDOWN_MS. With
401
+ * a kind, it looks like that kind (title, urgency) for a "Test session".
402
+ */
403
+ async test(deviceID: string, kind?: NotificationKind): Promise<PushTestResult> {
229
404
  const key = `test\n${deviceID}`
230
- const last = this.lastSent.get(key)
231
- if (last && this.now() - last.at < last.cooldown) {
232
- return { limited: true, retryAfter: Math.ceil((last.cooldown - (this.now() - last.at)) / 1000) }
405
+ if (!(await this.cool(key, TEST_COOLDOWN_MS))) {
406
+ const at = Number(await this.kv.get(COOL_KEY(key)))
407
+ const left = Number.isFinite(at) ? TEST_COOLDOWN_MS - (this.now() - at) : TEST_COOLDOWN_MS
408
+ return { limited: true, retryAfter: Math.max(1, Math.ceil(left / 1000)) }
233
409
  }
234
- this.cool(key, TEST_COOLDOWN_MS)
235
410
  const targets = this.store.pushSubscriptions().filter((s) => s.deviceID === deviceID)
236
- const payload: PushPayload = { kind: "test", sessionID: "", title: "Hub", body: "Test notification: notifications work on this device." }
411
+ const payload: PushPayload = kind
412
+ ? { ...pushPayload(kind, "", "Test session", `Test: this is how “${NOTIFICATION_LABELS[kind]}” looks on this device.`, { subject: TEST_SUBJECT[kind] }), test: true }
413
+ : { kind: "test", sessionID: "", title: "Hub", body: "Test notification: notifications work on this device." }
237
414
  const { delivered, failed } = await this.send(targets, payload)
238
415
  return { sent: delivered, failed }
239
416
  }
@@ -242,7 +419,7 @@ export class Push {
242
419
  targets: readonly { endpoint: string; keys: PushTarget["keys"] }[],
243
420
  payload: PushPayload,
244
421
  ): Promise<{ delivered: number; failed: Array<{ host: string; status?: number }> }> {
245
- const keys = this.keys()
422
+ const keys = await this.keys()
246
423
  const options: PushSendOptions = {
247
424
  ...sendOptionsFor(payload.kind),
248
425
  timeout: SEND_TIMEOUT_MS,
@@ -259,27 +436,24 @@ export class Push {
259
436
  const status = statusOf(result.reason)
260
437
  const endpoint = targets[index]!.endpoint
261
438
  failed.push({ host: hostOf(endpoint), ...(status !== undefined ? { status } : {}) })
262
- if (status === 404 || status === 410) this.store.dropPushSubscription(endpoint)
439
+ if (status === 404 || status === 410) {
440
+ void this.store.dropPushSubscription(endpoint).catch((cause: unknown) => console.error(`[hub] push subscription not dropped: ${(cause as Error)?.message ?? cause}`))
441
+ }
263
442
  else console.error(`[hub] push to ${hostOf(endpoint)} failed: ${status ?? (result.reason as Error)?.message}`)
264
443
  })
265
444
  return { delivered, failed }
266
445
  }
267
446
 
268
- /** True (and records the send) when (session, kind) is outside its cooldown. */
269
- private cool(key: string, cooldown: number): boolean {
270
- const t = this.now()
271
- for (const [k, sent] of this.lastSent) if (t - sent.at >= sent.cooldown) this.lastSent.delete(k)
272
- if (this.lastSent.has(key)) return false
273
- this.lastSent.set(key, { at: t, cooldown })
274
- return true
447
+ /** True (and records the send, SET NX PX: one pod wins) when `key` is outside its cooldown. */
448
+ private cool(key: string, cooldown: number): Promise<boolean> {
449
+ return this.kv.set(COOL_KEY(key), String(this.now()), { px: cooldown, nx: true })
275
450
  }
276
451
 
277
- private keys() {
452
+ /** The stored keys; the first call generates a pair (when pods race, every pod gets the pair stored first). */
453
+ private async keys(): Promise<VapidKeys> {
278
454
  const existing = this.store.vapid()
279
455
  if (existing) return existing
280
- const keys = webpush.generateVAPIDKeys()
281
- this.store.setVapid(keys)
282
- return keys
456
+ return this.store.setVapid(webpush.generateVAPIDKeys())
283
457
  }
284
458
  }
285
459