@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,26 +1,70 @@
1
1
  // Hub: HTTP routes (web UI, JSON API, SSE, enrollment) and the spoke WebSocket.
2
- // Phones authenticate with the `voice_device` cookie; spokes with machine bearer tokens. Source IPs
2
+ // Phones authenticate with the `voice_device` cookie (or, in native apps, the same secret as a bearer device
3
+ // token; see ./agent/actor.ts); spokes with machine bearer tokens. Source IPs
3
4
  // (including loopback) are never trusted.
4
5
  import type { Server } from "bun"
5
6
  import { diskAssets, type WebAssets } from "./assets"
6
7
  import path from "node:path"
7
- import { formAnswerProblem, PROMPT_IMAGE_MIMES, promptImagesProblem, type AvailableModel, type EnrollStart, type FormAnswer, type ModelSelection, type PermissionDecision, type PromptImage, type TurnResult } from "../protocol"
8
+ import { promisify } from "node:util"
9
+ import { brotliCompress, constants as zlib, gzip } from "node:zlib"
10
+ import { formAnswerProblem, PROMPT_IMAGE_MIMES, promptImagesProblem, type AvailableModel, type CoreOp, type EnrollStart, type FormAnswer, type ModelSelection, type PermissionDecision, type PromptImage } from "../protocol"
8
11
  import { isLoopbackAddress, isLoopbackHost, isLoopbackURL, loopbackOrigins } from "../loopback"
9
12
  import { speakable } from "../speakable"
10
13
  import { Auth, RateLimitError, ValidationError } from "./auth"
11
14
  import { emailAllowed, type CentralConfig } from "./config"
12
15
  import { authPage, cookieValue, deviceName, OIDC_COOKIE, OidcClient, OidcError } from "./oidc"
13
- import { Push, type PushSender } from "./push"
16
+ import { isNotificationKind, Push, type PushSender } from "./push"
14
17
  import { pushEndpointProblem, resolveHost as systemResolveHost, type ResolveHost } from "./push-target"
18
+ import { Webhooks } from "./agent/webhooks"
19
+ import { issueAppToken } from "./agent/app-tokens"
20
+ import { AGENT_EVENTS_AGE_MS, AGENT_EVENTS_MAX, AgentEvents, archiveEvent } from "./agent/events"
21
+ import { EventWriters } from "./agent/event-writers"
22
+ import { MemoryEventLog } from "./backend/memory-events"
23
+ import { WEBHOOK_SCOPE } from "./agent/webhook-routes"
24
+ import { hubBackends, type Backends } from "./backend"
25
+ import { createResolver, type AgentActor, type BearerVerifier, type CredentialMode } from "./agent/actor"
26
+ import { ActingSwitch } from "./agent/acting-switch"
27
+ import { AgentAudit } from "./agent/audit"
28
+ import { Grants } from "./agent/grants"
29
+ import { Decisions, type Decision } from "./agent/decisions"
30
+ import type { Escalate } from "./agent/acting"
31
+ import { Escalations } from "./agent/escalation"
32
+ import { GrantRequests } from "./agent/grant-requests"
33
+ import { partyFromActor } from "./agent/envelope"
34
+ import { Delivery } from "./agent/delivery"
35
+ import { AgentError, type AgentErrorCode } from "./agent/errors"
36
+ import type { Launch } from "./agent/launch-store"
37
+ import { decisionStateQuery, MessageFeed } from "./agent/message-routes"
38
+ import { hubDirectory } from "./agent/directory-routes"
39
+ import { agentApi, agentRoutes, agentVerifiers, isAgentPath, wellKnown, type AgentRoute } from "./agent/routes"
40
+ import { MCP_PATH, mcpHttp } from "./mcp-http"
41
+ import { SessionTokens } from "./agent/session-tokens"
42
+ import { SessionTokenPush } from "./session-token-push"
43
+ import { TokenKeys } from "./token-keys"
44
+ import { EventLog } from "./events-log"
15
45
  import { BusyError, Registry } from "./registry"
46
+ import { createActions, type Unsupported, unsupportedMessage } from "./actions"
16
47
  import { audioFormat, SpeechError } from "./speech/provider"
17
48
  import { SpeechService } from "./speech/service"
18
- import { RequestTimeoutError, RequestTooLargeError, SpokeError, SpokeHub, type Notifier, type SpokeSocketData } from "./spoke-ws"
19
- import { LOCAL_USER, PUSH_PREF_KEYS, quietHoursInput, Store, VIEW_PREF_KEYS, type DeviceRecord, type LegacyPushPrefs, type PushPrefs, type UserRecord, type ViewPrefs } from "./store"
49
+ import { NodeRegistry } from "./nodes"
50
+ import { ConnDirectory } from "./cluster/directory"
51
+ import { PodRpc } from "./cluster/rpc"
52
+ import { ClusterView } from "./cluster/view"
53
+ import { LeaderLoop, NoLeaderError } from "./cluster/leader"
54
+ import { ClusterRecommendations } from "./cluster/recommend"
55
+ import { LiveFanout, type LiveEvent } from "./cluster/live"
56
+ import { randomBytes, randomUUID } from "node:crypto"
57
+ import { LaunchEngine } from "./agent/launch-engine"
58
+ import { LaunchTokens } from "./agent/launch-tokens"
59
+ import { LaunchFeed, launchInfo } from "./agent/launch-routes"
60
+ import { DRAIN_RETRY_AFTER_MS, DRAIN_TIMEOUT_MS, RequestTimeoutError, RequestTooLargeError, SpokeError, SpokeHub, type DrainOptions, type Notifier, type SpokeHubOptions, type SpokeSocketData } from "./spoke-ws"
61
+ import { ACTIVITY_INTERVAL_MAX, ACTIVITY_INTERVAL_MIN, LOCAL_USER, PUSH_PREF_KEYS, quietHoursInput, Store, VIEW_PREF_KEYS, VIEW_PREF_MAX_ARCHIVED, type DeviceRecord, type LegacyPushPrefs, type PushPrefs, type UserRecord, type ViewPrefs } from "./store"
20
62
 
21
63
  export interface Speech {
22
64
  transcribe(audio: Uint8Array, format: string): Promise<string>
23
65
  speak(text: string): Promise<Uint8Array<ArrayBuffer>>
66
+ /** Streams the audio as it is synthesized (the built-in providers do; an override may only buffer). */
67
+ speakStream?(text: string, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>>
24
68
  }
25
69
 
26
70
  export interface CentralOptions {
@@ -37,16 +81,36 @@ export interface CentralOptions {
37
81
  readonly notifier?: Notifier
38
82
  /** Overrides web-push delivery (tests; web-push itself needs https push services). */
39
83
  readonly pushSender?: PushSender
40
- /** Resolves push endpoint hosts at subscribe time (SSRF check); the system resolver by default. */
84
+ /** Resolves push endpoint and webhook hosts (SSRF checks); the system resolver by default. */
41
85
  readonly resolveHost?: ResolveHost
42
- /** Clock for sign-in expiry and the store (tests). */
86
+ /** Clock for sign-in expiry, the store, the registry and the backends (tests). */
43
87
  readonly now?: () => number
88
+ /** Shared state for new code (default: in memory, see ./backend). */
89
+ readonly backends?: Backends
90
+ /** More bearer verifiers and /api/agent routes, after the built-in ones (tests). */
91
+ readonly agent?: { readonly verifiers?: readonly BearerVerifier[]; readonly routes?: readonly AgentRoute[] }
44
92
  readonly timing?: {
45
93
  readonly helloTimeoutMs?: number
46
94
  readonly requestTimeoutMs?: number
47
95
  readonly turnGraceMs?: number
96
+ /** `Prefer: respond-async` turns: how long the hub waits for a quick result before answering 202. */
97
+ readonly turnAcceptMs?: number
48
98
  readonly heartbeatMs?: number
49
99
  readonly staleMs?: number
100
+ /** Per node op request timeouts (see NODE_TIMEOUTS_MS). */
101
+ readonly nodeTimeoutsMs?: SpokeHubOptions["nodeTimeoutsMs"]
102
+ /** Cluster mode: how often each pod re-checks its sockets' machines and owners (see RECHECK_MS). */
103
+ readonly recheckMs?: number
104
+ /** Cluster mode: the cluster view's heartbeat and slice TTL (see VIEW_HEARTBEAT_MS, VIEW_TTL_MS). */
105
+ readonly viewHeartbeatMs?: number
106
+ readonly viewTtlMs?: number
107
+ /** Leader leases of the timed jobs (see LEADER_TTL_MS). */
108
+ readonly leaderTtlMs?: number
109
+ /** How often the decision deadline and message re-wake loops run (see DECISION_TICK_MS, REWAKE_TICK_MS). */
110
+ readonly loopTickMs?: number
111
+ /** drain(): how long it waits for requests in flight, and the bye's retryAfterMs (see DRAIN_TIMEOUT_MS, DRAIN_RETRY_AFTER_MS). */
112
+ readonly drainTimeoutMs?: number
113
+ readonly drainRetryAfterMs?: number
50
114
  }
51
115
  }
52
116
 
@@ -56,11 +120,40 @@ export interface Central {
56
120
  readonly store: Store
57
121
  readonly auth: Auth
58
122
  readonly registry: Registry
123
+ /** Connected `hub node` daemons (never in the registry). */
124
+ readonly nodes: NodeRegistry
59
125
  readonly spokes: SpokeHub
60
126
  readonly push: Push
61
127
  readonly speechSettings: SpeechService
128
+ /** Backends for new code (state, bus, event log, counters, locks, catalog, kv). */
129
+ readonly backends: Backends
130
+ /** Agent messages: store and delivery to sessions (the message routes use it). */
131
+ readonly delivery: Delivery
132
+ /** Decisions (ask @me): answered by the user on /api/decisions. */
133
+ readonly decisions: Decisions
134
+ /** Kill switches (per user and hub-wide) and hub.json agents.never, for the acting verbs. */
135
+ readonly acting: ActingSwitch
136
+ /** Launches (design §6): accept, list, stop; placement onto nodes and each launch's run to its end. */
137
+ readonly launches: LaunchEngine
138
+ /** Every launch change, per user (SSE 'launch' events and the agent watch subscribe; webhooks hang off it). */
139
+ readonly launchFeed: LaunchFeed
140
+ /** Launch-bound (hlt1) tokens: what a sandboxed harness connects back to /spoke/ws with (phase 5). */
141
+ readonly launchTokens: LaunchTokens
142
+ /** Webhooks (P4-T10): registrations and the outbox sender; launch changes are published to it. */
143
+ readonly webhooks: Webhooks
144
+ /** The canonical agent event journal (GET /api/agent/events, MCP events): compact events per user, 24 h / 10,000. */
145
+ readonly agentEvents: AgentEvents
146
+ /** The journal's writers (state, blockers, presence, messages, launches); settled() waits for queued appends. */
147
+ readonly agentEventWriters: EventWriters
62
148
  /** The JSON API's route table (method, path, access), for audits and tests. */
63
149
  readonly routes: readonly RouteInfo[]
150
+ /**
151
+ * Draining before a shutdown (design §10; SIGTERM): /readyz fails, new /spoke/ws upgrades are refused, spokes and
152
+ * nodes get bye{reason:"draining", retryAfterMs} (once their requests in flight are answered), the leader leases
153
+ * are released, cluster view slices withdrawn, SSE streams end with a `retry:` hint, and it resolves when requests
154
+ * in flight are done (at most timeoutMs; the rest fail as offline). Call stop() after it. Repeated calls share one.
155
+ */
156
+ drain(options?: DrainOptions): Promise<void>
64
157
  stop(): Promise<void>
65
158
  }
66
159
 
@@ -69,12 +162,25 @@ type Message = { type: string; [key: string]: unknown }
69
162
  interface Listener {
70
163
  readonly userID: string
71
164
  readonly deviceID: string
72
- send(message: Message): void
165
+ /** Leaves out streamed text and reasoning (except of `session`'s tree). */
166
+ readonly lite: boolean
167
+ /** The session the page shows: its tree's text and reasoning stream even when lite. */
168
+ readonly session?: string
169
+ /** Sends a message; `id` is its event id (EventSource's lastEventId). */
170
+ send(message: Message, id?: string): void
73
171
  close(): void
172
+ /** Draining: tells the browser to reconnect after retryMs (`retry:`), then ends the stream. */
173
+ end(retryMs: number): void
74
174
  }
75
175
 
76
- /** Who may call a route: anyone, any signed-in user, admins, or the owner of the :session / :machine in the path. */
77
- export type Access = "public" | "user" | "admin" | "session" | "machine"
176
+ /** Messages a lite stream leaves out (unless they are of the listener's session tree). */
177
+ const STREAMED = new Set(["delta", "reasoning"])
178
+
179
+ /**
180
+ * Who may call a route: anyone, any signed-in user, admins, or the owner of the :session / :machine in the path;
181
+ * "agent": an /api/agent route (src/central/agent), checked per credential kind there.
182
+ */
183
+ export type Access = "public" | "user" | "admin" | "session" | "machine" | "agent"
78
184
 
79
185
  export interface RouteInfo {
80
186
  readonly method: string
@@ -107,7 +213,23 @@ const NO_STORE = { "cache-control": "no-store" }
107
213
  export const COOKIE = "voice_device"
108
214
  const COOKIE_MAX_AGE = 400 * 24 * 60 * 60
109
215
  const SSE_HEARTBEAT_MS = 20_000
216
+ /** Draining: the `retry:` an ending SSE stream sends (the browser reconnects, through the load balancer, after it). */
217
+ const SSE_RETRY_MS = 2_000
218
+ const SSE_RETRY = `retry: ${SSE_RETRY_MS}\n\n`
219
+ /** /readyz in cluster mode: how long the database and Valkey get to answer. */
220
+ const READY_CHECK_MS = 2_000
221
+ /** Cluster mode: how much longer than the turn timeout a turn's cluster-wide lease lasts (it is released at its end). */
222
+ const TURN_LEASE_SLACK_MS = 120_000
223
+ /** Cluster mode: how long a stop sent to the launches leader may take (it waits for the node's collect and cleanup). */
224
+ const LAUNCH_STOP_TIMEOUT_MS = 180_000
225
+ /**
226
+ * `POST …/turn` with `Prefer: respond-async`: a turn that has not settled after this long is answered 202 and
227
+ * finishes in the background (result as an SSE `turn` event). Long enough for the quick refusals (busy, unknown
228
+ * session, spoke error), short enough that no proxy cuts the request (Traefik's default readTimeout is 60 s).
229
+ */
230
+ export const TURN_ACCEPT_MS = 1500
110
231
  const NO_OWNER = "No OpenCode process for this session is connected"
232
+ const AUTO_NAME_OUTDATED = "Update the Hub plugin to use Auto-name"
111
233
  const NOT_EXPOSED = "Voice is not enabled for this session"
112
234
  /** A permission or question that is not pending any more: someone answered it elsewhere, or it expired. */
113
235
  const PERMISSION_GONE = "This request was already answered or expired"
@@ -149,6 +271,29 @@ const CONTENT_TYPES: Record<string, string> = {
149
271
  ".svg": "image/svg+xml",
150
272
  ".png": "image/png",
151
273
  ".ico": "image/x-icon",
274
+ // Voice mode's on-device speech detection (Silero VAD through onnxruntime-web)
275
+ ".wasm": "application/wasm",
276
+ ".onnx": "application/octet-stream",
277
+ }
278
+ /** Vite's content-hashed build output: a changed file gets a new name, so it can be cached for good. */
279
+ const HASHED_ASSET = /^assets\//
280
+ const IMMUTABLE = "public, max-age=31536000, immutable"
281
+ /** Hashed assets sent compressed when the browser accepts it (onnxruntime's wasm: 12 MB → 2.3 MB with Brotli). */
282
+ const COMPRESSIBLE = new Set([".js", ".mjs", ".css", ".wasm", ".json", ".svg"])
283
+ const MIN_COMPRESS_BYTES = 1024
284
+ const brotli = promisify(brotliCompress)
285
+ const gzipAsync = promisify(gzip)
286
+
287
+ /** "br" or "gzip" when the Accept-Encoding header allows it (q=0 refuses), else undefined. */
288
+ function pickEncoding(header: string | null): "br" | "gzip" | undefined {
289
+ const accepted = new Map<string, number>()
290
+ for (const part of (header ?? "").toLowerCase().split(",")) {
291
+ const [name, ...params] = part.trim().split(";")
292
+ const q = params.map((p) => /^\s*q=([\d.]+)\s*$/.exec(p)?.[1]).find(Boolean)
293
+ if (name) accepted.set(name.trim(), q === undefined ? 1 : Number(q))
294
+ }
295
+ for (const encoding of ["br", "gzip"] as const) if ((accepted.get(encoding) ?? 0) > 0) return encoding
296
+ return undefined
152
297
  }
153
298
 
154
299
  class HttpError extends Error {
@@ -162,38 +307,111 @@ class HttpError extends Error {
162
307
  }
163
308
  }
164
309
 
310
+ /** A core op the session's spoke does not serve (another harness's spoke): 409 code unsupported, like plugin_outdated. */
311
+ const unsupportedError = (lacking: Pick<Unsupported, "feature" | "harness">) => new HttpError(409, unsupportedMessage(lacking), "unsupported")
312
+
165
313
  const error = (status: number, message: string, code?: string) =>
166
314
  Response.json({ error: message, ...(code ? { code } : {}) }, { status })
167
315
 
168
316
  export function startCentral(options: CentralOptions): Central {
169
317
  const { config } = options
318
+ const heartbeatMs = options.timing?.heartbeatMs ?? SSE_HEARTBEAT_MS
170
319
  const web: WebAssets = options.web ?? diskAssets(options.webDir ?? path.join(import.meta.dir, "..", "..", "web", "dist"))
171
320
  const now = options.now ?? Date.now
172
- const store = Store.open(config.stateDir, now, { mode: config.auth.mode })
321
+ /** The bundle for the config (or the one given). Cluster mode never touches the state dir. */
322
+ const ownBackends = !options.backends
323
+ const backends = options.backends ?? hubBackends(config, { now })
324
+ // Cluster mode: the records are rows in the shared StateStore, cached per pod (loaded on first use; no data
325
+ // migration runs on start). Otherwise state.json in the state dir.
326
+ const store = config.cluster
327
+ ? Store.shared({ state: backends.state, bus: backends.bus, now, instanceID: `${config.cluster.podID}-${randomUUID().slice(0, 8)}` })
328
+ : Store.open(config.stateDir, now, { mode: config.auth.mode })
173
329
  // A machine's tenant is its owner (read live: ownership moves on migration).
174
- const registry = new Registry(Date.now, undefined, (machineID) => store.machines().find((m) => m.id === machineID)?.ownerID)
175
- const push = new Push({ store, subject: config.vapidSubject, sender: options.pushSender })
330
+ const registry = new Registry(now, undefined, (machineID) => store.machines().find((m) => m.id === machineID)?.ownerID)
331
+ const nodes = new NodeRegistry((machineID) => store.machines().find((m) => m.id === machineID)?.ownerID, now)
332
+ const push = new Push({ store, subject: config.vapidSubject, sender: options.pushSender, kv: backends.kv })
176
333
  const notifier = options.notifier ?? push
177
334
  /** SSE subscribers, one user each. */
178
335
  const listeners = new Set<Listener>()
336
+ /** Recent messages per user, replayed to a stream that reconnects with Last-Event-ID. */
337
+ const eventLog = new EventLog()
338
+ /** Whether a listener wants a message (lite streams skip other sessions' streamed text). */
339
+ const wants = (listener: Listener, message: Message): boolean => {
340
+ if (!listener.lite || !STREAMED.has(message.type)) return true
341
+ if (!listener.session) return false
342
+ const root = registry.status(listener.session)?.[0]?.id ?? listener.session
343
+ return message.sessionID === listener.session || message.rootID === root || message.sessionID === root
344
+ }
345
+ /**
346
+ * Cluster mode (design §9): a user's events go to every pod over the Bus (user.<id>.live), logged in the shared
347
+ * EventLog first (except streamed text), so consoles on any pod get them and resume by Last-Event-ID on any pod.
348
+ */
349
+ const live = config.cluster ? new LiveFanout({ bus: backends.bus, events: backends.events, podID: config.cluster.podID }) : undefined
350
+ /** Sends a message to this process's streams of a user (except `exceptDevice`'s); a catalog is built per user. */
351
+ const deliver = (userID: string, message: Message, id?: string, exceptDevice?: string) => {
352
+ for (const listener of [...listeners]) {
353
+ if (listener.userID !== userID || listener.deviceID === exceptDevice) continue
354
+ if (message.type === "catalog") listener.send({ type: "catalog", machines: machines(userID) }, id)
355
+ else if (wants(listener, message)) listener.send(message, id)
356
+ }
357
+ }
358
+ /** Logs a message for one user and sends it to their streams (except `exceptDevice`'s). */
359
+ const toUser = (userID: string, message: Message, exceptDevice?: string) => {
360
+ if (live) {
361
+ // A catalog is this pod's own view (its registry covers the cluster): never logged, never sent to other pods.
362
+ if (message.type === "catalog") deliver(userID, message)
363
+ // Failures are logged by the fan-out.
364
+ else live.send(userID, message, exceptDevice).catch(() => {})
365
+ return
366
+ }
367
+ // A catalog is logged as a marker: a replay sends one fresh catalog for any number of them.
368
+ const id = eventLog.append(userID, message.type === "catalog" ? { type: "catalog" } : message, exceptDevice)
369
+ deliver(userID, message, id, exceptDevice)
370
+ }
371
+ /** Closes the streams of users who were disabled or removed. */
372
+ const closeDisabled = () => {
373
+ for (const listener of [...listeners]) {
374
+ const user = store.user(listener.userID)
375
+ if (!user || user.disabled) listener.close()
376
+ }
377
+ }
378
+ /** Users that may receive an `audience` message (cluster mode: whether or not they stream on this pod). */
379
+ const audienceUsers = (audience: { tenant?: string; admins?: true }): string[] => {
380
+ if (audience.admins) return store.users().filter((u) => u.role === "admin" && !u.disabled).map((u) => u.id)
381
+ if (audience.tenant === undefined) return []
382
+ const user = store.user(audience.tenant)
383
+ return user && !user.disabled ? [user.id] : []
384
+ }
179
385
  /**
180
386
  * Sends to SSE subscribers: catalog to everyone (each gets their own machines), `admins` messages to
181
387
  * admins only, anything else only to `tenant` (the session's owner). Streams of users who were
182
388
  * disabled are closed.
183
389
  */
184
390
  const broadcast = (message: Message, audience: { tenant?: string; admins?: true } = {}) => {
185
- for (const listener of [...listeners]) {
186
- const user = store.user(listener.userID)
187
- if (!user || user.disabled) {
188
- listener.close()
189
- continue
190
- }
191
- if (message.type === "catalog") listener.send({ type: "catalog", machines: machines(user.id) })
391
+ closeDisabled()
392
+ if (live) {
393
+ // Stored machines changed: every pod sends its streams a fresh catalog (catalogHere).
394
+ if (message.type === "catalog") live.publishCatalog().catch((cause) => console.error(`[hub] catalog announcement failed: ${(cause as Error).message}`))
395
+ else for (const userID of audienceUsers(audience)) toUser(userID, message)
396
+ return
397
+ }
398
+ const users = new Set([...eventLog.users(), ...[...listeners].map((l) => l.userID)])
399
+ for (const userID of users) {
400
+ const user = store.user(userID)
401
+ if (!user || user.disabled) continue
402
+ if (message.type === "catalog") toUser(userID, message)
192
403
  else if (audience.admins) {
193
- if (user.role === "admin") listener.send(message)
194
- } else if (audience.tenant !== undefined && audience.tenant === user.id) listener.send(message)
404
+ if (user.role === "admin") toUser(userID, message)
405
+ } else if (audience.tenant !== undefined && audience.tenant === userID) toUser(userID, message)
195
406
  }
196
407
  }
408
+ /** Cluster mode: a fresh catalog to this pod's streams (the registry's cluster-wide view changed). */
409
+ const catalogHere = () => {
410
+ closeDisabled()
411
+ for (const userID of new Set([...listeners].map((l) => l.userID))) toUser(userID, { type: "catalog" })
412
+ }
413
+ live?.onCatalog(catalogHere)
414
+ live?.start().catch((cause) => console.error(`[hub] live fan-out did not start: ${(cause as Error).message}`))
197
415
  const speechSettings = new SpeechService({
198
416
  store,
199
417
  defaults: {
@@ -210,6 +428,10 @@ export function startCentral(options: CentralOptions): Central {
210
428
  let publicURL = config.publicURL
211
429
  let auth!: Auth
212
430
  let spokes!: SpokeHub
431
+ /** Set by drain() (design §10): /readyz fails and new SSE streams are sent away. */
432
+ let draining: Promise<void> | undefined
433
+ /** Aborted by drain(): the agent watch streams end with a `retry:` hint. */
434
+ const drainSignal = new AbortController()
213
435
  /** At most one machine per hub process is approved without confirmation (see localAutoApprove). */
214
436
  let autoApproved = false
215
437
 
@@ -233,22 +455,62 @@ export function startCentral(options: CentralOptions): Central {
233
455
  const origins = loopbackOrigins(publicURL)
234
456
  const secure = publicURL.startsWith("https:")
235
457
 
236
- auth = new Auth({ store, publicURL, now, mode: config.auth.mode })
458
+ auth = new Auth({ store, publicURL, now, mode: config.auth.mode, kv: backends.kv, counters: backends.counters })
237
459
  // options.fetch is for speech providers only; sign-in always talks to the real issuer.
238
- const oidc = config.auth.mode === "oidc" ? new OidcClient({ config: config.auth, publicURL, now }) : undefined
460
+ const oidc = config.auth.mode === "oidc" ? new OidcClient({ config: config.auth, publicURL, now, kv: backends.kv, counters: backends.counters }) : undefined
239
461
  /** Speech settings scope of a user: "local" (pairing mode) uses the server default directly. */
240
462
  const speechScope = (userID: string) => (userID === LOCAL_USER ? undefined : userID)
241
463
  const speechFor = (userID: string): Speech => options.speech ?? speechSettings.forUser(speechScope(userID))
464
+ const tokenKeys = TokenKeys.open(config.stateDir)
465
+ // Launch-bound tokens (phase 5): checked against the launch engine's store (created below; read only at use).
466
+ const launchTokens: LaunchTokens = new LaunchTokens({ keys: tokenKeys, store, launches: { launch: (userID, launchID) => launches.store.get(userID, launchID) }, publicURL, now })
242
467
  spokes = new SpokeHub({
243
468
  store,
244
469
  auth,
245
470
  registry,
471
+ nodes,
246
472
  publicURL,
247
473
  notifier,
248
474
  broadcast: (message, tenant) => broadcast(message, { tenant }),
249
475
  turnTimeoutMs: config.turnTimeoutMs,
476
+ launchTokens,
477
+ // Cluster mode: connections are found through the shared directory and reached over pod RPC (design §9).
478
+ ...(config.cluster
479
+ ? { cluster: { directory: new ConnDirectory({ kv: backends.kv, podID: config.cluster.podID }), rpc: new PodRpc({ bus: backends.bus, podID: config.cluster.podID }) } }
480
+ : {}),
250
481
  ...options.timing,
251
482
  })
483
+ spokes.ready().catch((cause) => console.error(`[hub] cluster routing did not start: ${(cause as Error).message}`))
484
+ // Cluster mode: this pod publishes its connections (spokes and nodes) per user and mirrors the other pods', so the
485
+ // registries' reads cover the whole cluster (design §9).
486
+ const view = config.cluster
487
+ ? new ClusterView({
488
+ catalog: backends.catalog,
489
+ bus: backends.bus,
490
+ podID: config.cluster.podID,
491
+ registry,
492
+ nodes,
493
+ users: async () => {
494
+ await store.ready()
495
+ return store.users().map((u) => u.id)
496
+ },
497
+ ...(options.timing?.viewHeartbeatMs !== undefined ? { heartbeatMs: options.timing.viewHeartbeatMs } : {}),
498
+ ...(options.timing?.viewTtlMs !== undefined ? { ttlMs: options.timing.viewTtlMs } : {}),
499
+ })
500
+ : undefined
501
+ view?.start().catch((cause) => console.error(`[hub] cluster view did not start: ${(cause as Error).message}`))
502
+ // Session tokens (design §1.3): minted for the sessions spokes report, pushed on their sessions frames.
503
+ const sessionTokens = new SessionTokens({ keys: tokenKeys, store, sessions: registry, publicURL, now })
504
+ const tokenPush = new SessionTokenPush({
505
+ registry,
506
+ tokens: sessionTokens,
507
+ send: (connID, req) => spokes.request(connID, req),
508
+ hubURL: publicURL,
509
+ now,
510
+ log: (message) => console.error(message),
511
+ })
512
+ registry.onSessions((connID) => void tokenPush.sessions(connID))
513
+ tokenPush.start()
252
514
 
253
515
  /** The user's machines with their live processes and sessions. */
254
516
  const machines = (userID: string) => {
@@ -267,12 +529,20 @@ export function startCentral(options: CentralOptions): Central {
267
529
  }))
268
530
  }
269
531
 
270
- registry.subscribe((message, tenant) => broadcast({ ...message }, tenant !== undefined ? { tenant } : {}))
532
+ // Cluster mode: a catalog change stays on this pod (each pod's registry emits its own for remote slices); the rest
533
+ // goes to the tenant's streams on every pod.
534
+ registry.subscribe((message, tenant) => (live && message.type === "catalog" ? catalogHere() : broadcast({ ...message }, tenant !== undefined ? { tenant } : {})))
271
535
  registry.subscribeAlerts((alert) => {
272
536
  // Push goes to the session owner's devices only; an alert without an owner goes nowhere.
273
537
  if (!alert.owner) return
274
- const extra = alert.requestID ? { requestID: alert.requestID } : undefined
275
- notifier.notify(alert.kind, alert.sessionID, alert.title, alert.detail, alert.text, alert.owner, extra).catch((cause) => {
538
+ // Activity updates are collected and throttled per device by the push layer.
539
+ if (alert.kind === "activity") return push.activity(alert)
540
+ const extra = {
541
+ ...(alert.requestID ? { requestID: alert.requestID } : {}),
542
+ ...(alert.subject ? { subject: alert.subject } : {}),
543
+ ...(alert.key ? { key: alert.key } : {}),
544
+ }
545
+ notifier.notify(alert.kind, alert.sessionID, alert.title, alert.text, alert.owner, extra).catch((cause) => {
276
546
  console.error("[hub] push notification failed:", cause instanceof Error ? cause.message : cause)
277
547
  })
278
548
  })
@@ -284,6 +554,7 @@ export function startCentral(options: CentralOptions): Central {
284
554
  {
285
555
  prefs: store.pushPrefs(deviceID),
286
556
  quietHours: store.quietHours(deviceID) ?? null,
557
+ activityMinutes: store.activityMinutes(deviceID),
287
558
  muted: store.mutedSessions(userID),
288
559
  subscriptions: mine.length,
289
560
  ...(endpoint ? { subscribed: mine.some((s) => s.endpoint === endpoint) } : {}),
@@ -296,12 +567,19 @@ export function startCentral(options: CentralOptions): Central {
296
567
  `${COOKIE}=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}${secure ? "; Secure" : ""}`
297
568
 
298
569
  /** The device of the voice_device cookie and its user; undefined when unknown or the user is disabled. */
299
- const deviceFor = (request: Request): { device: DeviceRecord; user: UserRecord } | undefined => {
570
+ /** The device of a token (cookie value or device token), its owner re-read too (a shared store's security re-check). */
571
+ const deviceOfToken = async (token: string): Promise<DeviceRecord | undefined> => {
572
+ const device = await store.findDevice(token)
573
+ if (device) await store.recheck({ users: [device.ownerID] })
574
+ return device
575
+ }
576
+
577
+ const deviceFor = async (request: Request): Promise<{ device: DeviceRecord; user: UserRecord } | undefined> => {
300
578
  const header = request.headers.get("cookie") ?? ""
301
579
  for (const part of header.split(";")) {
302
580
  const [name, ...rest] = part.trim().split("=")
303
581
  if (name !== COOKIE) continue
304
- const device = store.findDevice(rest.join("="))
582
+ const device = await deviceOfToken(rest.join("="))
305
583
  if (!device) continue
306
584
  const user = store.user(device.ownerID)
307
585
  return user && !user.disabled ? { device, user } : undefined
@@ -329,34 +607,34 @@ export function startCentral(options: CentralOptions): Central {
329
607
  result = await oidc!.callback(url.searchParams, cookieValue(request.headers.get("cookie"), OIDC_COOKIE))
330
608
  } catch (cause) {
331
609
  if (!(cause instanceof OidcError)) throw cause
332
- if (cause.audited) store.audit({ actor: "system", action: "signin.denied", detail: cause.reason })
610
+ if (cause.audited) await store.audit({ actor: "system", action: "signin.denied", detail: cause.reason })
333
611
  else console.warn(`[hub] sign-in refused: ${cause.reason}`)
334
612
  return deny(cause.status, "Sign-in failed", cause.message)
335
613
  }
336
614
  const { identity, next } = result
337
615
  if (!emailAllowed(settings, identity.email)) {
338
- store.audit({ actor: "system", action: "signin.denied", target: identity.email, detail: "not allowed" })
616
+ await store.audit({ actor: "system", action: "signin.denied", target: identity.email, detail: "not allowed" })
339
617
  return deny(403, "Not allowed", `${identity.email} is not allowed to use this hub. Ask its admin to add you.`)
340
618
  }
341
619
  const role = settings.admins.includes(identity.email) ? "admin" : "user"
342
620
  const existing = store.userByEmail(identity.email)
343
621
  if (existing?.disabled) {
344
- store.audit({ actor: "system", action: "signin.denied", target: identity.email, detail: "disabled" })
622
+ await store.audit({ actor: "system", action: "signin.denied", target: identity.email, detail: "disabled" })
345
623
  return deny(403, "Account disabled", "Your account on this hub is disabled. Ask its admin.")
346
624
  }
347
- const user = store.upsertUser({ email: identity.email, ...(identity.name ? { name: identity.name } : {}), role })
348
- if (user.role === "admin" && store.migrateLocal(user.id)) broadcast({ type: "catalog" })
625
+ const user = await store.upsertUser({ email: identity.email, ...(identity.name ? { name: identity.name } : {}), role })
626
+ if (user.role === "admin" && await store.migrateLocal(user.id)) broadcast({ type: "catalog" })
349
627
  const headers = new Headers({ "cache-control": "no-store" })
350
628
  headers.append("set-cookie", cleared["set-cookie"])
351
- const current = deviceFor(request)
629
+ const current = await deviceFor(request)
352
630
  let deviceID: string
353
631
  if (current && current.user.id === user.id) deviceID = current.device.id
354
632
  else {
355
- const { device, token } = store.addDevice(deviceName(request.headers.get("user-agent")), user.id)
633
+ const { device, token } = await store.addDevice(deviceName(request.headers.get("user-agent")), user.id)
356
634
  deviceID = device.id
357
635
  headers.append("set-cookie", cookie(token))
358
636
  }
359
- store.audit({ actor: user.id, action: "signin", target: deviceID })
637
+ await store.audit({ actor: user.id, action: "signin", target: deviceID })
360
638
  // Speech not usable yet (e.g. no key): land on the page's speech setup (the console opens Settings → Speech for #speech).
361
639
  const setup = !next.includes("#") && speechSettings.view(speechScope(user.id)).ready.reason ? "#speech" : ""
362
640
  headers.set("location", `${next}${setup}`)
@@ -376,17 +654,303 @@ export function startCentral(options: CentralOptions): Central {
376
654
  }
377
655
  }
378
656
 
657
+ /** A push to the user about one session (titled by it; "" opens the console), whatever device shows it. */
658
+ const pushUser = (kind: "decision" | "message", userID: string, sessionID: string, key: string, decisionID?: string) => {
659
+ const title = (sessionID && registry.status(sessionID)?.find((s) => s.id === sessionID)?.title) || "Hub"
660
+ notifier.notify(kind, sessionID, title, undefined, userID, { key, ...(decisionID ? { decisionID } : {}) }).catch((cause) => {
661
+ console.error("[hub] push notification failed:", cause instanceof Error ? cause.message : cause)
662
+ })
663
+ }
664
+
665
+ const directory = hubDirectory({ registry, store, now })
666
+ /** This process as owner of leases (leader loops, flushes, turns): `<pod>:<pid>-<random>`. */
667
+ const leaseOwner = `${config.cluster?.podID ?? "hub"}:${process.pid}-${randomBytes(4).toString("hex")}`
668
+ /** Every stored user (the leader loops look at all of them). */
669
+ const allUsers = async () => {
670
+ await store.ready()
671
+ return store.users().map((u) => u.id)
672
+ }
673
+ const loopTickMs = options.timing?.loopTickMs
674
+ // Timed jobs run on one pod at a time, under a fenced leader lease (design §9; a single hub always leads).
675
+ const leaders: LeaderLoop[] = []
676
+ const leader = (name: string, handlers: Pick<ConstructorParameters<typeof LeaderLoop>[0], "onElected" | "onDeposed" | "onKick" | "onRequest">) => {
677
+ const loop = new LeaderLoop({
678
+ locks: backends.locks,
679
+ bus: backends.bus,
680
+ name,
681
+ owner: leaseOwner,
682
+ ...(options.timing?.leaderTtlMs !== undefined ? { ttlMs: options.timing.leaderTtlMs } : {}),
683
+ ...handlers,
684
+ })
685
+ leaders.push(loop)
686
+ return loop
687
+ }
688
+ // Agent messages (design §3): delivered through spokes that announce "deliver"; pending ones flush on every
689
+ // sessions frame; human turns (console turn/utterance, prompts typed in the harness) reset hop chains.
690
+ const delivery = new Delivery({
691
+ state: backends.state,
692
+ counters: backends.counters,
693
+ registry,
694
+ directory,
695
+ send: (connID, req) => spokes.request(connID, req),
696
+ now,
697
+ describe: (userID, sessionID) => {
698
+ try {
699
+ const entry = directory.resolve(userID, sessionID).sessions[0]
700
+ const machine = entry?.machineID ? store.machines().find((m) => m.id === entry.machineID)?.name : undefined
701
+ return { ...(entry?.name ? { name: entry.name } : {}), ...(machine ? { machine } : {}) }
702
+ } catch {
703
+ return {}
704
+ }
705
+ },
706
+ // Push kind "message": a msg to @me, an ask left unanswered, a message loop (sent even while watched).
707
+ alert: (userID, alert) => pushUser("message", userID, alert.sessionID, `${alert.kind}:${alert.messageID}`),
708
+ ...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
709
+ // Cluster mode: the re-wake loop (leader) looks at every user; each pending flush holds a (user, session) lease.
710
+ ...(config.cluster ? { users: allUsers, locks: backends.locks, owner: leaseOwner } : {}),
711
+ })
712
+ /** Message events (watch, waiters), shared by the agent routes and the console's Decision answers. */
713
+ const feed = new MessageFeed()
714
+ // Cluster mode: messages stored on any pod reach this pod's listeners (ask waiters, agent watches).
715
+ if (config.cluster) feed.attach({ bus: backends.bus, podID: config.cluster.podID }).catch((cause) => console.error(`[hub] message feed did not attach: ${(cause as Error).message}`))
716
+ // Decisions (design §12.1): asks to @me; deadlines run over every user's Decisions (survive restarts).
717
+ const decisions = new Decisions({
718
+ state: backends.state,
719
+ delivery,
720
+ now,
721
+ users: () => store.users().map((u) => u.id),
722
+ notify: (userID, decision, reason) => pushUser("decision", userID, decision.sessionID ?? "", `${decision.id}:${reason}`, decision.id),
723
+ // The console's live Decision cards: opened, answered (here, by timeout), escalated, expired.
724
+ changed: (userID, decision) => broadcast({ type: "decision", decision }, { tenant: userID }),
725
+ // Escalations' Decisions reply to the permission as the user; grant requests' create the grant as the user
726
+ // (both are set up below).
727
+ act: (userID, decision, choice, by) => (decision.action?.kind === "grant" ? grantRequests.act(userID, decision, choice, by) : escalations.act(userID, decision, choice, by)),
728
+ ...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
729
+ })
730
+ leader("decisions", { onElected: () => decisions.start(), onDeposed: () => decisions.stop() })
731
+ /** The user prompted the session: its message hop chain starts over (never fails the prompt). */
732
+ const humanTurn = async (sessionID: string): Promise<void> => {
733
+ const tenant = registry.sessionTenant(sessionID)
734
+ if (tenant === undefined) return
735
+ await delivery.humanTurn(tenant, sessionID).catch((cause) => console.error(`[hub] hop reset failed: ${(cause as Error).message}`))
736
+ }
737
+ registry.onSessions((connID) => void delivery.onSessions(connID).catch((cause) => console.error(`[hub] message flush failed: ${(cause as Error).message}`)))
738
+ registry.onHumanTurn((sessionID) => void humanTurn(sessionID))
739
+ /** Permission, form, prompt and stop execution, shared by the console routes and the agent act verbs. */
740
+ const actions = createActions({
741
+ registry,
742
+ spokes,
743
+ humanTurn,
744
+ kv: backends.kv,
745
+ // Cluster mode: a turn also holds the session's lease, so two pods never start turns in it at once.
746
+ ...(config.cluster ? { turns: { locks: backends.locks, owner: leaseOwner, ttlMs: config.turnTimeoutMs + TURN_LEASE_SLACK_MS } } : {}),
747
+ })
748
+ leader("delivery", { onElected: () => delivery.start(), onDeposed: () => delivery.stop() })
749
+ // Launches (design §6): placement onto the user's nodes, prepare on the node, the session through the machine's
750
+ // service spoke, then its end; restart recovery runs on start. Every change goes to launchFeed: the owner's
751
+ // console streams get it as a 'launch' event.
752
+ const launchFeed = new LaunchFeed()
753
+ // Cluster mode: launch changes made on any pod reach this pod's agent watches. The console broadcast, closing a
754
+ // launch's connections and webhooks run only on the publishing pod (origin): they reach every pod themselves.
755
+ if (config.cluster) launchFeed.attach({ bus: backends.bus, podID: config.cluster.podID }).catch((cause) => console.error(`[hub] launch feed did not attach: ${(cause as Error).message}`))
756
+ launchFeed.subscribe((userID, launch) => broadcast({ type: "launch", launch: launchInfo(launch) }, { tenant: userID }), { origin: true })
757
+ const launches = new LaunchEngine({
758
+ state: backends.state,
759
+ counters: backends.counters,
760
+ locks: backends.locks,
761
+ owner: `hub-${process.pid}-${randomBytes(4).toString("hex")}`,
762
+ ...(config.launchCaps ? { caps: config.launchCaps } : {}),
763
+ nodes,
764
+ nodeOps: spokes,
765
+ registry,
766
+ spokes,
767
+ directory,
768
+ delivery,
769
+ decisions,
770
+ actions,
771
+ // openshell launches: the node starts the sandbox's harness with an hlt1 token and the hub's public URL.
772
+ launchTokens,
773
+ hubURL: publicURL,
774
+ users: () => store.users().map((u) => u.id),
775
+ now,
776
+ onLaunchChanged: (launch) => launchFeed.publish(launch.owner, launch),
777
+ // Cluster mode: the supervisor runs on the launches leader; other pods kick it and send it their stops.
778
+ ...(config.cluster
779
+ ? {
780
+ supervisor: {
781
+ kick: (userID: string) => void launchLeader.kick({ userID }).catch((cause) => console.error(`[hub] launch kick failed: ${(cause as Error).message}`)),
782
+ stop: async (userID: string, id: string, reason: string) => {
783
+ let reply: { launch?: Launch; error?: { code: AgentErrorCode; message: string } }
784
+ try {
785
+ reply = await launchLeader.request({ op: "stop", userID, id, reason }, { timeoutMs: LAUNCH_STOP_TIMEOUT_MS })
786
+ } catch (cause) {
787
+ if (cause instanceof NoLeaderError) return undefined
788
+ throw cause
789
+ }
790
+ if (reply.error) throw new AgentError(reply.error.code, reply.error.message)
791
+ return reply.launch
792
+ },
793
+ },
794
+ }
795
+ : {}),
796
+ })
797
+ // A launch-bound connection lives only while its launch is provisioning or running: it is closed (and refused from
798
+ // then on) when the launch ends. Not on queued: that would refuse the sandbox's spoke before it ever connects.
799
+ launchFeed.subscribe((_userID, launch) => {
800
+ if (launch.state === "ended") spokes.closeLaunch(launch.id)
801
+ }, { origin: true })
802
+ const launchLeader = leader("launches", {
803
+ onElected: () => launches.start().catch((cause) => console.error(`[hub] launches did not start: ${(cause as Error).message}`)),
804
+ onDeposed: () => launches.shutdown(),
805
+ onKick: (message) => {
806
+ const userID = (message as { userID?: unknown } | undefined)?.userID
807
+ if (typeof userID === "string") launches.kick(userID)
808
+ },
809
+ onRequest: async (message) => {
810
+ const { op, userID, id, reason } = (message ?? {}) as { op?: unknown; userID?: unknown; id?: unknown; reason?: unknown }
811
+ if (op !== "stop" || typeof userID !== "string" || typeof id !== "string" || typeof reason !== "string") throw new Error("Invalid launch supervisor request")
812
+ try {
813
+ return { launch: await launches.stop(userID, id, reason) }
814
+ } catch (cause) {
815
+ if (cause instanceof AgentError) return { error: { code: cause.code, message: cause.message } }
816
+ throw cause
817
+ }
818
+ },
819
+ })
820
+ // Webhooks (P4-T10): launch changes go to the owner's matching registrations through the durable outbox; URLs
821
+ // are checked again at every send; private targets only with hub.json agents.webhooks.allowPrivate.
822
+ const webhooks = new Webhooks({
823
+ state: backends.state,
824
+ locks: backends.locks,
825
+ now,
826
+ source: publicURL,
827
+ users: () => store.users().map((u) => u.id),
828
+ resolve: options.resolveHost ?? systemResolveHost,
829
+ ...(config.webhooksAllowPrivate ? { allowPrivate: true } : {}),
830
+ appAlive: (userID, tokenID) => store.agentData(userID).appTokens?.some((t) => t.id === tokenID && Array.isArray(t.scopes) && t.scopes.includes(WEBHOOK_SCOPE)) === true,
831
+ appScopes: (userID, tokenID) => store.agentData(userID).appTokens?.find((t) => t.id === tokenID)?.scopes,
832
+ userAgent: `hub/${options.version ?? "0.0.0"}`,
833
+ })
834
+ const webhookLeader = leader("webhooks", {
835
+ onElected: () => webhooks.start(),
836
+ onDeposed: () => webhooks.stop(),
837
+ onKick: () => void webhooks.drain().catch((cause) => console.error(`[hub] webhook delivery failed: ${(cause as Error).message}`)),
838
+ })
839
+ // The canonical agent event journal (lifecycle design, Events): each change appended once across the cluster;
840
+ // presence by the agent-events leader, everything else where it happens (event-writers.ts).
841
+ const agentEvents = new AgentEvents({
842
+ log: backends.agentEvents ?? new MemoryEventLog({ now, max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS }),
843
+ kv: backends.kv,
844
+ now,
845
+ onAppend: (userID, record) => webhooks.publishCanonical(userID, record.event, record.family, record.audience).then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined)),
846
+ })
847
+ const agentEventWriters = new EventWriters({
848
+ events: agentEvents,
849
+ kv: backends.kv,
850
+ registry,
851
+ machineOwner: (machineID) => store.machines().find((m) => m.id === machineID)?.ownerID,
852
+ users: allUsers,
853
+ messages: feed,
854
+ launches: launchFeed,
855
+ })
856
+ agentEventWriters.start()
857
+ leader("agent-events", { onElected: () => agentEventWriters.elected(), onDeposed: () => agentEventWriters.deposed() })
858
+ for (const loop of leaders) void loop.start()
859
+
860
+ // One credential resolver for every /api request: device cookie, device token, session and app tokens.
861
+ // Every stored agent message also goes to the user's console streams (live transcript rows).
862
+ feed.subscribe((userID, message) => broadcast({ type: "message", message }, { tenant: userID }), { origin: true })
863
+ const audit = new AgentAudit({ state: backends.state, now, admin: store, recorded: (userID, entry) => broadcast({ type: "agent", entry }, { tenant: userID }) })
864
+ const grants = new Grants({ state: backends.state, counters: backends.counters, directory, store, admin: store, now })
865
+ const acting = new ActingSwitch({ store, never: config.agentsNever, audit })
866
+ /** An agent as envelopes name it (a session by its directory name). */
867
+ const describeActor = (userID: string, actor: AgentActor) => {
868
+ if (actor.kind !== "session") return partyFromActor(actor)
869
+ try {
870
+ const name = directory.resolve(userID, actor.sessionID).sessions[0]?.name
871
+ return partyFromActor(actor, name ? { name } : {})
872
+ } catch {
873
+ return partyFromActor(actor)
874
+ }
875
+ }
876
+ // needs_user escalations (P3-T8): one Decision per (holder, request); answered as the user; closed when the
877
+ // request goes elsewhere.
878
+ // Cluster mode: a recommendation on a request another pod holds goes to that pod.
879
+ const recommendations = config.cluster ? new ClusterRecommendations({ bus: backends.bus, podID: config.cluster.podID, registry }) : undefined
880
+ recommendations?.start().catch((cause) => console.error(`[hub] cluster recommendations did not start: ${(cause as Error).message}`))
881
+ const escalations: Escalations = new Escalations({
882
+ state: backends.state,
883
+ counters: backends.counters,
884
+ decisions,
885
+ registry: {
886
+ sessionTenant: (sessionID) => registry.sessionTenant(sessionID),
887
+ recommend: (requestID, recommendation) => (recommendations ?? registry).recommend(requestID, recommendation),
888
+ },
889
+ actions,
890
+ audit,
891
+ now,
892
+ describe: describeActor,
893
+ published: (userID, message) => feed.publish(userID, message),
894
+ })
895
+ // Grant requests (P3-T9): an agent asks; the user's tap creates the grant as the user.
896
+ const grantRequests: GrantRequests = new GrantRequests({
897
+ state: backends.state,
898
+ counters: backends.counters,
899
+ decisions,
900
+ grants,
901
+ directory,
902
+ audit,
903
+ now,
904
+ describe: describeActor,
905
+ published: (userID, message) => feed.publish(userID, message),
906
+ })
907
+ registry.onPermissionClosed((requestID, _sessionID, tenant) => {
908
+ void escalations.closed(tenant, requestID).catch((cause) => console.error(`[hub] escalation close failed: ${(cause as Error).message}`))
909
+ })
910
+ const escalate: Escalate = (escalation) => escalations.escalate(escalation)
911
+ const agentDeps = { draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, agentEvents, eventCursorKeys: tokenKeys, maxPromptChars: MAX_PROMPT_CHARS }
912
+ const resolveActor = createResolver({
913
+ cookieName: COOKIE,
914
+ findDevice: (token) => deviceOfToken(token),
915
+ user: (id) => store.user(id),
916
+ verifiers: [...agentVerifiers(agentDeps), ...(options.agent?.verifiers ?? [])],
917
+ })
918
+ const agent = agentApi({
919
+ resolve: resolveActor,
920
+ cookieName: COOKIE,
921
+ sameOrigin,
922
+ touch: (user, device) => {
923
+ // Never rejects (lastSeen only).
924
+ if (device) void store.touchDevice(device.id)
925
+ void store.touchUser(user.id)
926
+ },
927
+ routes: [...agentRoutes(agentDeps), ...(options.agent?.routes ?? [])],
928
+ })
929
+
930
+ // POST /mcp (MCP Streamable HTTP): `hub mcp`'s tools as the bearer's caller; their /api/agent calls are dispatched
931
+ // to this handler in process.
932
+ const mcp = mcpHttp({
933
+ publicURL: () => publicURL,
934
+ isOwnOrigin: (origin) => origins.has(origin),
935
+ verify: async (token) => (await resolveActor(new Request(`${url}${MCP_PATH}`, { headers: { authorization: `Bearer ${token}` } }))).ok,
936
+ dispatch: async (request) => (await handle(request, server)) ?? error(500, "No response"),
937
+ version: options.version ?? "0.0.0",
938
+ })
939
+
379
940
  async function handle(request: Request, server: Server<SpokeSocketData>): Promise<Response | undefined> {
380
941
  const url = new URL(request.url)
381
942
  const { pathname } = url
382
943
  const method = request.method
383
944
  try {
945
+ if (method === "GET" && pathname === "/readyz") return readyz()
384
946
  if (pathname === "/spoke/ws") return spokes.upgrade(request, server)
385
947
  if (method === "GET" && pathname === "/healthz") {
386
- return Response.json({ ok: true, version: options.version ?? "0.0.0", spokes: registry.connections() })
948
+ return Response.json({ ok: true, version: options.version ?? "0.0.0", spokes: registry.connections(), nodes: nodes.count() })
387
949
  }
950
+ // A shared (cluster) store loads its records on first use; the file store is ready at once.
951
+ await store.ready()
388
952
  if (method === "POST" && pathname === "/spoke/enroll") {
389
- const start = auth.startEnrollment((await body(request)).machineName)
953
+ const start = await auth.startEnrollment((await body(request)).machineName)
390
954
  const auto = config.auth.mode === "pairing" && localAutoApprove({
391
955
  host: config.host,
392
956
  publicURL,
@@ -396,7 +960,7 @@ export function startCentral(options: CentralOptions): Central {
396
960
  devices: store.devices().length,
397
961
  used: autoApproved,
398
962
  })
399
- if (auto && auth.approveEnrollment(start.userCode)) {
963
+ if (auto && (await auth.approveEnrollment(start.userCode))) {
400
964
  autoApproved = true
401
965
  console.log(`[hub] local hub: approved the first machine (code ${start.userCode}) without confirmation; later machines need approval`)
402
966
  return Response.json({ ...start, autoApproved: true } satisfies EnrollStart)
@@ -405,25 +969,62 @@ export function startCentral(options: CentralOptions): Central {
405
969
  return Response.json(start)
406
970
  }
407
971
  if (method === "POST" && pathname === "/spoke/enroll/poll") {
408
- const poll = auth.pollEnrollment((await body(request)).deviceCode)
972
+ const poll = await auth.pollEnrollment((await body(request)).deviceCode)
409
973
  if (poll.status === "approved") broadcast({ type: "catalog" })
410
974
  return Response.json(poll)
411
975
  }
976
+ // `hub login` without a token (CLI login device code): no credential; the device code is the poller's secret.
977
+ if (method === "POST" && pathname === "/cli/login") {
978
+ const input = await body(request)
979
+ return Response.json(await auth.startCliLogin(input.clientName, input.scopes), { headers: NO_STORE })
980
+ }
981
+ if (method === "POST" && pathname === "/cli/login/poll") return await cliLoginPoll((await body(request)).deviceCode)
982
+ if (method === "GET" && pathname === "/spoke/presence") return await spokePresence(request)
983
+ if (method === "GET" && pathname === "/.well-known/hub") {
984
+ return wellKnown({ publicURL, version: options.version ?? "0.0.0", auth: config.auth.mode === "oidc" ? { mode: "oidc", issuer: config.auth.issuer } : { mode: "pairing" } })
985
+ }
412
986
  if (method === "GET" && pathname === "/login") return login(request, url)
413
987
  if (oidc && method === "GET" && pathname === "/auth/login") return await authLogin(url)
414
988
  if (oidc && method === "GET" && pathname === "/auth/callback") return await authCallback(request, url)
989
+ if (pathname === MCP_PATH) return await mcp(request)
990
+ if (isAgentPath(pathname)) return await agent.handle(request, url)
415
991
  if (pathname.startsWith("/api/")) return await api(request, server, url)
416
- if (method === "GET") return staticFile(pathname)
992
+ if (method === "GET") return await staticFile(pathname, request.headers.get("accept-encoding"))
417
993
  return error(404, "Not found")
418
994
  } catch (cause) {
419
995
  return failure(cause)
420
996
  }
421
997
  }
422
998
 
423
- function login(request: Request, url: URL): Response {
999
+ /**
1000
+ * GET /spoke/presence: which harness spokes of the caller's own machine are connected (machine token only). The
1001
+ * one read-only query a machine credential can make about itself, for `hubctl setup verify` (spoke-connected
1002
+ * readiness): harness, evidence level and features only, never sessions, directories, pids or other machines.
1003
+ */
1004
+ async function spokePresence(request: Request): Promise<Response> {
1005
+ const refused = () => Response.json({ error: "Invalid or revoked machine token" }, { status: 401 })
1006
+ const token = /^Bearer\s+(\S+)$/i.exec(request.headers.get("authorization") ?? "")?.[1]
1007
+ if (!token || token.startsWith("hlt1.")) return refused()
1008
+ const machine = await store.findMachine(token)
1009
+ if (machine) await store.recheck({ users: [machine.ownerID] })
1010
+ const owner = machine && store.user(machine.ownerID)
1011
+ if (!machine || machine.revoked || !owner || owner.disabled) return refused()
1012
+ const processes = registry.catalog().find((m) => m.id === machine.id)?.processes ?? []
1013
+ const seen = new Set<string>()
1014
+ const spokes = processes.flatMap((p) => {
1015
+ const entry = { harness: p.harness, evidence: p.evidence, features: [...p.features] }
1016
+ const key = JSON.stringify(entry)
1017
+ if (seen.has(key)) return []
1018
+ seen.add(key)
1019
+ return [entry]
1020
+ })
1021
+ return Response.json({ machineID: machine.id, spokes })
1022
+ }
1023
+
1024
+ async function login(request: Request, url: URL): Promise<Response> {
424
1025
  const next = url.searchParams.get("next") ?? "/"
425
1026
  if (!LOGIN_NEXT.test(next)) return error(400, "Invalid next path")
426
- const ownerID = auth.redeemLogin(url.searchParams.get("code"))
1027
+ const ownerID = await auth.redeemLogin(url.searchParams.get("code"))
427
1028
  if (!ownerID) {
428
1029
  return new Response("This sign-in link has expired or was already used. Run /hub again.", {
429
1030
  status: 401,
@@ -434,9 +1035,9 @@ export function startCentral(options: CentralOptions): Central {
434
1035
  const setup = speechSettings.view(speechScope(ownerID)).ready.reason ? "#speech" : ""
435
1036
  const headers = new Headers({ location: `${next}${setup}`, "cache-control": "no-store" })
436
1037
  // A link signs in as the machine's owner: a browser signed in as someone else gets a new device.
437
- if (deviceFor(request)?.user.id !== ownerID) {
438
- const { device, token } = store.addDevice("Browser (sign-in link)", ownerID)
439
- store.audit({ actor: ownerID, action: "device.pair", target: device.id, detail: "login link" })
1038
+ if ((await deviceFor(request))?.user.id !== ownerID) {
1039
+ const { device, token } = await store.addDevice("Browser (sign-in link)", ownerID)
1040
+ await store.audit({ actor: ownerID, action: "device.pair", target: device.id, detail: "login link" })
440
1041
  headers.set("set-cookie", cookie(token))
441
1042
  }
442
1043
  return new Response(null, { status: 302, headers })
@@ -447,6 +1048,11 @@ export function startCentral(options: CentralOptions): Central {
447
1048
  const ownMachine = (userID: string, machineID: string) =>
448
1049
  store.machines().find((m) => m.id === machineID && !m.revoked && m.ownerID === userID)
449
1050
 
1051
+ /** Session routes that also answer for the user's offline sessions the directory knows (hub-side data only). */
1052
+ const OFFLINE_SESSION_ROUTES = new Set(["/api/sessions/:session/messages"])
1053
+ /** No spoke reports the session (for anyone) and the user's directory has a record of it (not deleted). */
1054
+ const knownOffline = (userID: string, sessionID: string) =>
1055
+ registry.sessionTenant(sessionID) === undefined && (store.agentData(userID).sessions ?? []).some((s) => s.sessionID === sessionID && s.deletedAt === undefined)
450
1056
  const route = (method: string, path: string, access: Access, handle: Handler, exposed = false): Route => {
451
1057
  const keys: string[] = []
452
1058
  const pattern = new RegExp(`^${path.replace(/:([a-z]+)/g, (_, key: string) => (keys.push(key), "([^/]+)"))}$`)
@@ -460,11 +1066,19 @@ export function startCentral(options: CentralOptions): Central {
460
1066
  ),
461
1067
  route("POST", "/api/pair", "public", async ({ request }) => {
462
1068
  const input = await body(request)
463
- const paired = auth.pair(input.code, input.deviceName)
1069
+ const paired = await auth.pair(input.code, input.deviceName)
464
1070
  if (!paired) return error(401, "Invalid or expired pairing code")
465
- store.audit({ actor: paired.device.ownerID, action: "device.pair", target: paired.device.id })
1071
+ await store.audit({ actor: paired.device.ownerID, action: "device.pair", target: paired.device.id })
466
1072
  return Response.json({ device: deviceView(paired.device) }, { headers: { "set-cookie": cookie(paired.token) } })
467
1073
  }),
1074
+ // A native app trades a link code for a device token (kept by the app, sent as Authorization: Bearer): no cookie.
1075
+ route("POST", "/api/devices/redeem", "public", async ({ request }) => {
1076
+ const input = await body(request)
1077
+ const linked = await auth.redeemLink(input.code, input.deviceName, input.publicKey)
1078
+ if (!linked) return error(401, "Invalid or expired link code")
1079
+ await store.audit({ actor: linked.device.ownerID, action: "device.pair", target: linked.device.id, detail: "app link" })
1080
+ return Response.json({ device: deviceView(linked.device), token: linked.token }, { headers: NO_STORE })
1081
+ }),
468
1082
 
469
1083
  // any signed-in user, own data only
470
1084
  route("GET", "/api/me", "user", ({ device, user }) =>
@@ -477,21 +1091,36 @@ export function startCentral(options: CentralOptions): Central {
477
1091
  }),
478
1092
  ),
479
1093
  route("GET", "/api/machines", "user", ({ user }) => Response.json({ machines: machines(user.id) })),
480
- route("GET", "/api/events", "user", ({ request, server, user, device }) => {
1094
+ route("GET", "/api/events", "user", ({ request, server, user, device, url }) => {
481
1095
  server.timeout(request, 0)
482
- return events(request.signal, user.id, device.id)
1096
+ const session = url.searchParams.get("session")
1097
+ return events(request.signal, user.id, device.id, {
1098
+ lastID: request.headers.get("last-event-id") ?? url.searchParams.get("last") ?? undefined,
1099
+ lite: url.searchParams.get("lite") === "1",
1100
+ ...(session && session.length <= 300 && registry.owns(user.id, session) ? { session } : {}),
1101
+ })
483
1102
  }),
484
1103
  // The phone's session view (hidden/pinned sessions and workspaces), per user, synced to their other devices.
485
1104
  route("GET", "/api/prefs/view", "user", ({ user }) => Response.json({ prefs: store.viewPrefs(user.id) }, { headers: NO_STORE })),
486
1105
  route("PUT", "/api/prefs/view", "user", async ({ request, user, device }) => {
487
- const prefs = store.setViewPrefs(user.id, viewPrefsInput(await request.json()))
1106
+ const before = new Set(store.viewPrefs(user.id).hiddenSessions)
1107
+ const prefs = await store.setViewPrefs(user.id, viewPrefsInput(await request.json()))
488
1108
  // The user's other devices only: never another user, and not the device that made the change.
489
- for (const listener of [...listeners]) {
490
- if (listener.userID === user.id && listener.deviceID !== device.id) listener.send({ type: "prefs", prefs })
491
- }
1109
+ toUser(user.id, { type: "prefs", prefs }, device.id)
1110
+ // Archive changes made in the console go to the agent event journal like the agent API's.
1111
+ const after = new Set(prefs.hiddenSessions)
1112
+ const changes = [...[...after].filter((id) => !before.has(id)).map((id) => archiveEvent(id, true)), ...[...before].filter((id) => !after.has(id)).map((id) => archiveEvent(id, false))]
1113
+ for (const change of changes) await agentEvents.append(user.id, change).catch((cause) => console.error(`[hub] archive event failed: ${(cause as Error).message}`))
492
1114
  return Response.json({ prefs }, { headers: NO_STORE })
493
1115
  }),
494
1116
  route("GET", "/api/reads", "user", ({ user }) => Response.json({ reads: store.reads(user.id) }, { headers: NO_STORE })),
1117
+ // A CLI login (`hub login`): the user approves it as themselves, with the scopes they tick, or denies it.
1118
+ route("GET", "/api/cli-logins/:code", "user", async ({ params }) => {
1119
+ const login = await auth.cliLogin(params.code!)
1120
+ return login ? Response.json({ login }, { headers: NO_STORE }) : error(404, "No pending CLI login with that code")
1121
+ }),
1122
+ route("POST", "/api/cli-logins/:code/approve", "user", async ({ request, params, user }) => decideCliLogin(params.code!, "approved", user, (await body(request)).scopes)),
1123
+ route("POST", "/api/cli-logins/:code/deny", "user", ({ params, user }) => decideCliLogin(params.code!, "denied", user)),
495
1124
  route("POST", "/api/enrollments/:code/approve", "user", ({ params, user }) => decideEnrollment(params.code!, "approved", user)),
496
1125
  route("POST", "/api/enrollments/:code/deny", "user", ({ params, user }) => decideEnrollment(params.code!, "denied", user)),
497
1126
  route("GET", "/api/devices", "user", ({ device, user }) =>
@@ -502,23 +1131,33 @@ export function startCentral(options: CentralOptions): Central {
502
1131
  .map((d) => ({ ...deviceView(d), current: d.id === device.id })),
503
1132
  }),
504
1133
  ),
505
- route("DELETE", "/api/devices/:id", "user", ({ params, user }) => {
1134
+ // A link code for a native app, shown as a QR code / deep link in Settings → Devices → Add app.
1135
+ route("POST", "/api/devices/link", "user", async ({ user }) => {
1136
+ const { code, expiresAt } = auth.issueLinkCode(user.id)
1137
+ await store.audit({ actor: user.id, action: "device.link" })
1138
+ const url = `${APP_SCHEME}://link?hub=${encodeURIComponent(publicURL)}&code=${code}`
1139
+ return Response.json({ code, expiresAt, hub: publicURL, url }, { headers: NO_STORE })
1140
+ }),
1141
+ route("DELETE", "/api/devices/:id", "user", async ({ params, user }) => {
506
1142
  const target = store.devices().find((d) => d.id === params.id && d.ownerID === user.id)
507
- if (!target || !store.removeDevice(target.id)) return error(404, "No such device")
508
- store.audit({ actor: user.id, action: "device.revoke", target: target.id })
1143
+ if (!target || !await store.removeDevice(target.id)) return error(404, "No such device")
1144
+ await store.audit({ actor: user.id, action: "device.revoke", target: target.id })
509
1145
  return new Response(null, { status: 204 })
510
1146
  }),
511
- route("POST", "/api/logout", "user", ({ device }) => {
512
- store.removeDevice(device.id)
1147
+ route("POST", "/api/logout", "user", async ({ device }) => {
1148
+ await store.removeDevice(device.id)
513
1149
  return new Response(null, { status: 204, headers: { "set-cookie": cookie("", 0) } })
514
1150
  }),
515
1151
  route("POST", "/api/transcribe", "user", ({ request, user }) => transcribe(request, speechFor(user.id))),
516
1152
  route("POST", "/api/speak", "user", async ({ request, user }) => {
517
1153
  const text = speakable(String((await body(request)).text ?? ""), config.maxSpeechChars)
518
1154
  if (!text) return error(400, "Nothing to speak")
519
- return new Response(await speechFor(user.id).speak(text), { headers: { "content-type": "audio/mpeg" } })
1155
+ const speech = speechFor(user.id)
1156
+ // Passed through as the provider sends it, so the phone can start playing before synthesis ends.
1157
+ const audio = speech.speakStream ? await speech.speakStream(text, request.signal) : await speech.speak(text)
1158
+ return new Response(audio, { headers: { "content-type": "audio/mpeg" } })
520
1159
  }),
521
- route("GET", "/api/push/key", "user", () => Response.json({ publicKey: push.publicKey() })),
1160
+ route("GET", "/api/push/key", "user", async () => Response.json({ publicKey: await push.publicKey() })),
522
1161
  route("POST", "/api/push/subscribe", "user", async ({ request, device }) => {
523
1162
  const input = await body(request)
524
1163
  const target = pushTarget(input.subscription)
@@ -526,14 +1165,14 @@ export function startCentral(options: CentralOptions): Central {
526
1165
  const problem = await pushEndpointProblem(target.endpoint, options.resolveHost ?? systemResolveHost)
527
1166
  if (problem) return error(400, `Invalid push subscription: ${problem}`)
528
1167
  // pushsubscriptionchange: the service worker sends the endpoint it replaces.
529
- if (typeof input.oldEndpoint === "string" && input.oldEndpoint !== target.endpoint) store.removePushSubscription(device.id, input.oldEndpoint)
530
- store.addPushSubscription(device.id, target)
1168
+ if (typeof input.oldEndpoint === "string" && input.oldEndpoint !== target.endpoint) await store.removePushSubscription(device.id, input.oldEndpoint)
1169
+ await store.addPushSubscription(device.id, target)
531
1170
  const prefs = prefsInput(input.prefs)
532
- return Response.json({ prefs: prefs ? store.setPushPrefs(device.id, prefs) : store.pushPrefs(device.id) })
1171
+ return Response.json({ prefs: prefs ? await store.setPushPrefs(device.id, prefs) : store.pushPrefs(device.id) })
533
1172
  }),
534
1173
  route("POST", "/api/push/unsubscribe", "user", async ({ request, device }) => {
535
1174
  const endpoint = (await body(request)).endpoint
536
- if (typeof endpoint !== "string" || !store.removePushSubscription(device.id, endpoint)) {
1175
+ if (typeof endpoint !== "string" || !await store.removePushSubscription(device.id, endpoint)) {
537
1176
  return error(404, "No such subscription on this device")
538
1177
  }
539
1178
  return new Response(null, { status: 204 })
@@ -544,10 +1183,17 @@ export function startCentral(options: CentralOptions): Central {
544
1183
  if ("quietHours" in input) {
545
1184
  const quiet = input.quietHours === null ? null : quietHoursInput(input.quietHours)
546
1185
  if (quiet === undefined) return error(400, "quietHours must be {start: \"HH:MM\", end: \"HH:MM\", timeZone} or null")
547
- store.setQuietHours(device.id, quiet)
1186
+ await store.setQuietHours(device.id, quiet)
1187
+ }
1188
+ if ("activityMinutes" in input) {
1189
+ const minutes = input.activityMinutes
1190
+ if (typeof minutes !== "number" || !Number.isInteger(minutes) || minutes < ACTIVITY_INTERVAL_MIN || minutes > ACTIVITY_INTERVAL_MAX) {
1191
+ return error(400, `activityMinutes must be a whole number of minutes from ${ACTIVITY_INTERVAL_MIN} to ${ACTIVITY_INTERVAL_MAX}`)
1192
+ }
1193
+ await store.setActivityMinutes(device.id, minutes)
548
1194
  }
549
1195
  const prefs = prefsInput(input)
550
- if (prefs) store.setPushPrefs(device.id, prefs)
1196
+ if (prefs) await store.setPushPrefs(device.id, prefs)
551
1197
  return pushPrefsView(device.id, user.id)
552
1198
  }),
553
1199
  route("PUT", "/api/push/mute", "user", async ({ request, user }) => {
@@ -556,12 +1202,15 @@ export function startCentral(options: CentralOptions): Central {
556
1202
  if (typeof sessionID !== "string" || !sessionID || sessionID.length > 200 || typeof input.muted !== "boolean") {
557
1203
  return error(400, "Expected {sessionID, muted}")
558
1204
  }
559
- return Response.json({ muted: store.setSessionMuted(user.id, sessionID, input.muted) })
1205
+ return Response.json({ muted: await store.setSessionMuted(user.id, sessionID, input.muted) })
560
1206
  }),
561
- route("POST", "/api/push/test", "user", async ({ device }) => {
1207
+ route("POST", "/api/push/test", "user", async ({ request, device }) => {
1208
+ // The body is optional (older clients send none): {kind?} picks what the test looks like.
1209
+ const kind = (await body(request).catch(() => ({}) as Record<string, unknown>)).kind
1210
+ if (kind !== undefined && !isNotificationKind(kind)) return error(400, `kind must be one of ${PUSH_PREF_KEYS.join(", ")}`)
562
1211
  const subscriptions = store.pushSubscriptions().filter((s) => s.deviceID === device.id).length
563
1212
  if (subscriptions === 0) return Response.json({ sent: 0, failed: [], subscriptions })
564
- const result = await push.test(device.id)
1213
+ const result = await push.test(device.id, kind)
565
1214
  if ("limited" in result) {
566
1215
  return Response.json({ error: "Wait a few seconds before sending another test" }, { status: 429, headers: { "retry-after": String(result.retryAfter) } })
567
1216
  }
@@ -571,7 +1220,7 @@ export function startCentral(options: CentralOptions): Central {
571
1220
  route("GET", "/api/settings/speech", "user", ({ user }) => Response.json(speechSettings.view(speechScope(user.id)), { headers: NO_STORE })),
572
1221
  // Raw JSON: anything but an object is refused by the validator.
573
1222
  route("PUT", "/api/settings/speech", "user", async ({ request, user }) =>
574
- Response.json(speechSettings.update(await request.json(), speechScope(user.id)), { headers: NO_STORE }),
1223
+ Response.json(await speechSettings.update(await request.json(), speechScope(user.id)), { headers: NO_STORE }),
575
1224
  ),
576
1225
  route("GET", "/api/settings/speech/models", "user", async ({ url, user }) => {
577
1226
  const kind = url.searchParams.get("kind")
@@ -605,16 +1254,18 @@ export function startCentral(options: CentralOptions): Central {
605
1254
  "/api/sessions/:session/interrupt",
606
1255
  "session",
607
1256
  async ({ params }) => {
608
- await spokes.request(ownerOf(params.session!), { op: "interrupt", sessionID: params.session! })
1257
+ await spokes.request(ownerOf(params.session!, "interrupt"), { op: "interrupt", sessionID: params.session! })
609
1258
  registry.cancelTurns(params.session!)
610
1259
  return new Response(null, { status: 204 })
611
1260
  },
612
1261
  ),
613
1262
  route("POST", "/api/sessions/:session/stop", "session", async ({ params }) => {
614
- const result = await spokes.request(ownerOf(params.session!), { op: "stop", sessionID: params.session! })
615
- registry.cancelTurns(params.session!)
616
- return Response.json(result, { headers: NO_STORE })
1263
+ const stopped = await actions.stop(params.session!)
1264
+ if (stopped.outcome === "offline") throw new HttpError(409, NO_OWNER)
1265
+ if (stopped.outcome === "unsupported") throw unsupportedError(stopped)
1266
+ return Response.json(stopped.result, { headers: NO_STORE })
617
1267
  }),
1268
+ route("POST", "/api/sessions/:session/rename", "session", ({ request, params }) => rename(request, params.session!)),
618
1269
  route("POST", "/api/sessions/:session/conversation", "session", ({ request, params }) => conversation(request, params.session!), true),
619
1270
  route("GET", "/api/sessions/:session/models", "session", async ({ params }) =>
620
1271
  Response.json(await spokes.request(ownerOf(params.session!), { op: "models", sessionID: params.session! }), { headers: NO_STORE })),
@@ -632,7 +1283,7 @@ export function startCentral(options: CentralOptions): Central {
632
1283
  const change = modelPrefsInput(await request.json())
633
1284
  await checkModelSelections(ownerOf(params.session!), params.session!, change)
634
1285
  const reply = await spokes.request(ownerOf(params.session!), { op: "setModelPrefs", sessionID: params.session!, ...change })
635
- const prefs = store.setModelPrefs(user.id, params.session!, change)
1286
+ const prefs = await store.setModelPrefs(user.id, params.session!, change)
636
1287
  return Response.json({ prefs, defaults: reply.defaults }, { headers: NO_STORE })
637
1288
  }),
638
1289
  route("GET", "/api/sessions/:session/timeline", "session", async ({ params, url }) => {
@@ -641,7 +1292,35 @@ export function startCentral(options: CentralOptions): Central {
641
1292
  if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200)) return error(400, "limit must be 1–200")
642
1293
  const cursor = url.searchParams.get("cursor") ?? undefined
643
1294
  if (cursor && cursor.length > 4096) return error(400, "cursor is too long")
644
- return Response.json(await spokes.request(ownerOf(params.session!), { op: "timeline", sessionID: params.session!, ...(limit ? { limit } : {}), ...(cursor ? { cursor } : {}) }), { headers: NO_STORE })
1295
+ // Newest-first mode: order=desc for the newest page, before=<older cursor> for the page before it.
1296
+ const order = url.searchParams.get("order") ?? undefined
1297
+ if (order !== undefined && order !== "asc" && order !== "desc") return error(400, "order must be asc or desc")
1298
+ const before = url.searchParams.get("before") || undefined
1299
+ if (before && before.length > 4096) return error(400, "before is too long")
1300
+ if (cursor && (before || order === "desc")) return error(400, "cursor cannot be combined with before or order=desc")
1301
+ try {
1302
+ const page = await spokes.request(ownerOf(params.session!), {
1303
+ op: "timeline",
1304
+ sessionID: params.session!,
1305
+ ...(limit ? { limit } : {}),
1306
+ ...(cursor ? { cursor } : {}),
1307
+ ...(order ? { order } : {}),
1308
+ ...(before ? { before } : {}),
1309
+ })
1310
+ return Response.json(page, { headers: NO_STORE })
1311
+ } catch (cause) {
1312
+ // The row the cursor names is gone (history reverted or compacted): the console starts over.
1313
+ if (cause instanceof SpokeError && cause.message.includes("Invalid timeline cursor")) return error(400, "Invalid timeline cursor")
1314
+ throw cause
1315
+ }
1316
+ }),
1317
+ // Agent messages the session sent or received (newest first), merged into the timeline by `at` (+ watch events).
1318
+ route("GET", "/api/sessions/:session/messages", "session", async ({ params, url, user }) => {
1319
+ const raw = url.searchParams.get("limit")
1320
+ const limit = raw === null ? undefined : Number(raw)
1321
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200)) return error(400, "limit must be 1–200")
1322
+ const messages = await delivery.messages.list(user.id, { kind: "session", sessionID: params.session! }, limit === undefined ? {} : { limit })
1323
+ return Response.json({ messages }, { headers: NO_STORE })
645
1324
  }),
646
1325
  route("GET", "/api/sessions/:session/timeline/messages/:message/parts/:part", "session", async ({ params }) =>
647
1326
  Response.json(await spokes.request(ownerOf(params.session!), { op: "timelinePart", sessionID: params.session!, messageID: params.message!, partID: params.part! }), { headers: NO_STORE })),
@@ -662,11 +1341,9 @@ export function startCentral(options: CentralOptions): Central {
662
1341
  const { patch } = await spokes.request(ownerOf(params.session!), { op: "diff", sessionID: params.session!, file })
663
1342
  return patch ? Response.json(patch, { headers: NO_STORE }) : error(404, "No changes for that file")
664
1343
  }),
665
- route("POST", "/api/sessions/:session/read", "session", ({ params, user, device }) => {
666
- const reads = store.markRead(user.id, params.session!, Date.now())
667
- for (const listener of [...listeners]) {
668
- if (listener.userID === user.id && listener.deviceID !== device.id) listener.send({ type: "reads", reads })
669
- }
1344
+ route("POST", "/api/sessions/:session/read", "session", async ({ params, user, device }) => {
1345
+ const reads = await store.markRead(user.id, params.session!, Date.now())
1346
+ toUser(user.id, { type: "reads", reads }, device.id)
670
1347
  return Response.json({ reads }, { headers: NO_STORE })
671
1348
  }),
672
1349
 
@@ -675,7 +1352,7 @@ export function startCentral(options: CentralOptions): Central {
675
1352
  route("DELETE", "/api/machines/:machine", "machine", ({ params, user }) => revokeMachine(params.machine!, user)),
676
1353
 
677
1354
  // admins: users, machines, devices, audit, enrollments, the server speech default
678
- route("GET", "/api/enrollments", "admin", () => Response.json({ enrollments: auth.pendingEnrollments() })),
1355
+ route("GET", "/api/enrollments", "admin", async () => Response.json({ enrollments: await auth.pendingEnrollments() })),
679
1356
  route("GET", "/api/admin/users", "admin", () => Response.json(store.users().map(userRow))),
680
1357
  route("PUT", "/api/admin/users/:id", "admin", async ({ request, params, user }) => updateUser(params.id!, await body(request), user)),
681
1358
  route("GET", "/api/admin/machines", "admin", () => {
@@ -697,16 +1374,49 @@ export function startCentral(options: CentralOptions): Central {
697
1374
  )
698
1375
  }),
699
1376
  route("DELETE", "/api/admin/machines/:id", "admin", ({ params, user }) => revokeMachine(params.id!, user)),
700
- route("DELETE", "/api/admin/devices/:id", "admin", ({ params, user }) => {
701
- if (!store.removeDevice(params.id!)) return error(404, "No such device")
702
- store.audit({ actor: user.id, action: "device.revoke", target: params.id! })
1377
+ route("DELETE", "/api/admin/devices/:id", "admin", async ({ params, user }) => {
1378
+ if (!await store.removeDevice(params.id!)) return error(404, "No such device")
1379
+ await store.audit({ actor: user.id, action: "device.revoke", target: params.id! })
703
1380
  return new Response(null, { status: 204 })
704
1381
  }),
705
1382
  route("GET", "/api/admin/audit", "admin", () => Response.json(store.auditLog())),
1383
+ // The hub-wide agent kill switch: pauses every agent action for every user (reading and messaging still work).
1384
+ route("GET", "/api/admin/agents/acting", "admin", () => Response.json({ enabled: !store.actingPaused() }, { headers: NO_STORE })),
1385
+ route("PUT", "/api/admin/agents/acting", "admin", async ({ request, user }) => {
1386
+ const enabled = (await body(request)).enabled
1387
+ if (typeof enabled !== "boolean") return error(400, "enabled must be true or false")
1388
+ await store.setActingPaused(!enabled)
1389
+ await store.audit({ actor: user.id, action: enabled ? "agents.acting.resume" : "agents.acting.pause" })
1390
+ return Response.json({ enabled }, { headers: NO_STORE })
1391
+ }),
1392
+ // Decisions (asks to @me): the user's own; answered only here (tap: the console cookie, not a device token).
1393
+ route("GET", "/api/decisions", "user", async ({ url, user }) => {
1394
+ try {
1395
+ return Response.json({ decisions: await decisions.list(user.id, { ...optionalState(url) }) }, { headers: NO_STORE })
1396
+ } catch (cause) {
1397
+ return decisionFailure(cause)
1398
+ }
1399
+ }),
1400
+ route("GET", "/api/decisions/:id", "user", async ({ params, user }) => {
1401
+ const decision = await decisions.get(user.id, params.id!)
1402
+ return decision ? Response.json({ decision }, { headers: NO_STORE }) : error(404, "No such decision", "not_found")
1403
+ }),
1404
+ route("POST", "/api/decisions/:id/answer", "user", async ({ request, params, user, device }) => {
1405
+ const input = await body(request)
1406
+ const via = (await resolveActor.mode(request)) === "bearer" ? "device-token" : "cookie"
1407
+ try {
1408
+ const answered = await decisions.answer(user.id, params.id!, decisionAnswer(input), { kind: "user", userID: user.id, deviceID: device.id, via })
1409
+ if (answered.reply) feed.publish(user.id, answered.reply)
1410
+ await store.audit({ actor: user.id, action: "decision.answer", target: params.id!, detail: answered.decision.answer?.option ?? "text" })
1411
+ return Response.json(answered, { headers: NO_STORE })
1412
+ } catch (cause) {
1413
+ return decisionFailure(cause)
1414
+ }
1415
+ }),
706
1416
  route("GET", "/api/settings/speech/default", "admin", () => Response.json(speechSettings.view(), { headers: NO_STORE })),
707
1417
  route("PUT", "/api/settings/speech/default", "admin", async ({ request, user }) => {
708
- const view = speechSettings.update(await request.json())
709
- store.audit({ actor: user.id, action: "speech.default" })
1418
+ const view = await speechSettings.update(await request.json())
1419
+ await store.audit({ actor: user.id, action: "speech.default" })
710
1420
  return Response.json(view, { headers: NO_STORE })
711
1421
  }),
712
1422
  ]
@@ -725,7 +1435,11 @@ export function startCentral(options: CentralOptions): Central {
725
1435
 
726
1436
  async function api(request: Request, server: Server<SpokeSocketData>, url: URL): Promise<Response> {
727
1437
  const method = request.method
728
- if (method !== "GET" && method !== "HEAD" && !sameOrigin(request)) {
1438
+ const mode = await resolveActor.mode(request)
1439
+ if (mode === "mixed") return error(400, "Send the device cookie or a bearer token, not both", "invalid")
1440
+ // Bearer requests (device tokens of native apps) are not CSRF-prone; cookie writes must be same-origin (a
1441
+ // foreign bearer next to the cookie, e.g. from an auth proxy, is ignored: still a cookie request).
1442
+ if (mode !== "bearer" && method !== "GET" && method !== "HEAD" && !sameOrigin(request) && !credentialFree(request, mode, url)) {
729
1443
  return error(403, "Cross-origin request refused")
730
1444
  }
731
1445
  const found = findRoute(method, url.pathname)
@@ -734,11 +1448,16 @@ export function startCentral(options: CentralOptions): Central {
734
1448
  return await found.route.handle({ request, server, url, params: found.params } as Context)
735
1449
  }
736
1450
 
737
- const resolved = deviceFor(request)
738
- if (!resolved) return error(401, "This device is not paired")
1451
+ const resolved = await resolveActor(request)
1452
+ if (!resolved.ok) {
1453
+ if (resolved.reason === "expired") return error(401, "Token expired", "token_expired")
1454
+ return resolved.bearer ? error(401, "Invalid or revoked token", "unauthorized") : error(401, "This device is not paired")
1455
+ }
1456
+ // Console routes are for the user's devices (cookie or device token) only, never session or app tokens.
1457
+ if (resolved.actor.kind !== "user" || !resolved.device) return error(403, "This token cannot be used here", "forbidden")
739
1458
  const { device, user } = resolved
740
- store.touchDevice(device.id)
741
- store.touchUser(user.id)
1459
+ void store.touchDevice(device.id)
1460
+ void store.touchUser(user.id)
742
1461
  if (!found) return error(404, "Not found")
743
1462
 
744
1463
  const { route: matched, params } = found
@@ -748,7 +1467,7 @@ export function startCentral(options: CentralOptions): Central {
748
1467
  break
749
1468
  case "session":
750
1469
  // Foreign and unknown sessions answer alike, so IDs don't leak.
751
- if (!registry.owns(user.id, params.session!)) return error(404, "Not found")
1470
+ if (!registry.owns(user.id, params.session!) && !(OFFLINE_SESSION_ROUTES.has(matched.path) && knownOffline(user.id, params.session!))) return error(404, "Not found")
752
1471
  // Sessions listed for discovery only: nothing but enabling voice (and presence) reaches them.
753
1472
  if (matched.exposed && registry.exposed(params.session!) === false) return error(403, NOT_EXPOSED, "not_exposed")
754
1473
  break
@@ -759,17 +1478,43 @@ export function startCentral(options: CentralOptions): Central {
759
1478
  return await matched.handle({ request, server, url, params, device, user })
760
1479
  }
761
1480
 
762
- function decideEnrollment(code: string, status: "approved" | "denied", user: UserRecord): Response {
763
- const machineName = auth.decide(code, status, user.id)
1481
+ async function decideEnrollment(code: string, status: "approved" | "denied", user: UserRecord): Promise<Response> {
1482
+ const machineName = await auth.decide(code, status, user.id)
764
1483
  if (machineName === undefined) return error(404, "No pending enrollment with that code")
765
- store.audit({ actor: user.id, action: status === "approved" ? "machine.approve" : "machine.deny", detail: machineName })
1484
+ await store.audit({ actor: user.id, action: status === "approved" ? "machine.approve" : "machine.deny", detail: machineName })
766
1485
  broadcast({ type: "enrollment" }, { admins: true })
767
1486
  return new Response(null, { status: 204 })
768
1487
  }
769
1488
 
770
- function revokeMachine(id: string, user: UserRecord): Response {
771
- if (!store.revokeMachine(id)) return error(404, "No such machine")
772
- store.audit({ actor: user.id, action: "machine.revoke", target: id })
1489
+ async function decideCliLogin(code: string, status: "approved" | "denied", user: UserRecord, scopes?: unknown): Promise<Response> {
1490
+ const clientName = await auth.decideCliLogin(code, status, user.id, scopes)
1491
+ if (clientName === undefined) return error(404, "No pending CLI login with that code")
1492
+ await store.audit({ actor: user.id, action: status === "approved" ? "cli-login.approve" : "cli-login.deny", detail: clientName })
1493
+ return new Response(null, { status: 204 })
1494
+ }
1495
+
1496
+ /**
1497
+ * The decision on a CLI login, once. Approved: a new app token of the approver (still active) with the scopes
1498
+ * they chose, in this response only (no-store, never logged).
1499
+ */
1500
+ async function cliLoginPoll(deviceCode: unknown): Promise<Response> {
1501
+ const poll = await auth.pollCliLogin(deviceCode)
1502
+ if (poll.status !== "approved") return Response.json({ status: poll.status }, { headers: NO_STORE })
1503
+ const owner = store.user(poll.ownerID)
1504
+ if (!owner || owner.disabled) return Response.json({ status: "denied" }, { headers: NO_STORE })
1505
+ try {
1506
+ const { record, token } = await issueAppToken(store, owner.id, { label: `CLI login: ${poll.clientName}`, scopes: poll.scopes })
1507
+ return Response.json({ status: "approved", token, tokenID: record.id, scopes: record.scopes }, { headers: NO_STORE })
1508
+ } catch (cause) {
1509
+ if (cause instanceof AgentError && cause.code === "rate_limited") return error(429, cause.message, "rate_limited")
1510
+ throw cause
1511
+ }
1512
+ }
1513
+
1514
+ async function revokeMachine(id: string, user: UserRecord): Promise<Response> {
1515
+ if (!await store.revokeMachine(id)) return error(404, "No such machine")
1516
+ await store.bumpMachineEpoch(id)
1517
+ await store.audit({ actor: user.id, action: "machine.revoke", target: id })
773
1518
  spokes.closeMachine(id, "revoked")
774
1519
  broadcast({ type: "catalog" })
775
1520
  return new Response(null, { status: 204 })
@@ -790,7 +1535,7 @@ export function startCentral(options: CentralOptions): Central {
790
1535
  }
791
1536
 
792
1537
  /** PUT /api/admin/users/:id {role?, disabled?}: the last active admin can be neither demoted nor disabled. */
793
- function updateUser(id: string, input: Record<string, unknown>, actor: UserRecord): Response {
1538
+ async function updateUser(id: string, input: Record<string, unknown>, actor: UserRecord): Promise<Response> {
794
1539
  const target = store.user(id)
795
1540
  if (!target) return error(404, "No such user")
796
1541
  const { role, disabled } = input
@@ -802,12 +1547,13 @@ export function startCentral(options: CentralOptions): Central {
802
1547
  if (activeAdmin(target) && !activeAdmin(after) && store.users().filter(activeAdmin).length <= 1) {
803
1548
  return error(409, "Cannot demote or disable the last active admin. Make someone else an admin first.")
804
1549
  }
805
- if (role && role !== target.role) store.audit({ actor: actor.id, action: "user.role", target: id, detail: role })
1550
+ if (role && role !== target.role) await store.audit({ actor: actor.id, action: "user.role", target: id, detail: role })
806
1551
  if (disabled !== undefined && disabled !== (target.disabled === true)) {
807
- store.audit({ actor: actor.id, action: disabled ? "user.disable" : "user.enable", target: id })
1552
+ await store.audit({ actor: actor.id, action: disabled ? "user.disable" : "user.enable", target: id })
808
1553
  }
809
- store.setUser(id, { ...(role ? { role: role as "admin" | "user" } : {}), ...(disabled !== undefined ? { disabled } : {}) })
1554
+ await store.setUser(id, { ...(role ? { role: role as "admin" | "user" } : {}), ...(disabled !== undefined ? { disabled } : {}) })
810
1555
  if (disabled === true) {
1556
+ await store.bumpUserEpoch(id)
811
1557
  // Their machines go offline at once; their browsers get 401 from now on and lose their event streams.
812
1558
  for (const machine of store.machines()) if (machine.ownerID === id) spokes.closeMachine(machine.id, "revoked")
813
1559
  broadcast({ type: "catalog" })
@@ -829,26 +1575,30 @@ export function startCentral(options: CentralOptions): Central {
829
1575
  : undefined
830
1576
  if (!text && !images) return error(400, "Empty prompt")
831
1577
  if (text.length > MAX_PROMPT_CHARS) return error(400, `Prompt longer than ${MAX_PROMPT_CHARS} characters`)
832
- const attached = images ? { images } : {}
833
- const connID = ownerOf(sessionID)
834
- // While anything in the tree runs, typed input redirects the current work instead of queueing.
835
- if (registry.running(sessionID)) {
836
- const { inboxID } = await spokes.request(connID, { op: "steer", sessionID, text, ...attached })
837
- return Response.json({ mode: "steer", inboxID }, { status: 202 })
838
- }
839
- // A stop or interrupt releases the lock and answers this request at once, even if the spoke never does.
840
- let cancel = () => {}
841
- const stopped = new Promise<TurnResult>((resolve) => {
842
- cancel = () => resolve({ outcome: "interrupted", message: "Stopped", reply: "" })
843
- })
844
- const release = registry.beginTurn(sessionID, () => cancel())
1578
+ // A turn may run for minutes: this request never times out (a stop or interrupt still answers it at once).
845
1579
  server.timeout(request, 0)
846
- try {
847
- const voiceStyle = registry.exposed(sessionID) === true
848
- return Response.json(await Promise.race([spokes.request(connID, { op: "turn", sessionID, text, voiceStyle, ...attached }), stopped]))
849
- } finally {
850
- release()
1580
+ const started = await actions.prompt({ sessionID, text, ...(images ? { images } : {}), resetHops: true })
1581
+ if (started.outcome === "offline") throw new HttpError(409, NO_OWNER)
1582
+ if (started.outcome === "unsupported") throw unsupportedError(started)
1583
+ if (started.mode === "steer") return Response.json({ mode: "steer", inboxID: started.inboxID }, { status: 202 })
1584
+ const result = started.result
1585
+ if (!/(^|[\s,])respond-async\b/i.test(request.headers.get("prefer") ?? "")) return Response.json(await result)
1586
+ // Asynchronous: answer quick results (and refusals) as usual; a turn still running is accepted (202) and its
1587
+ // result goes to the owner's consoles as an SSE `turn` event (the reply itself streams as deltas meanwhile).
1588
+ const early = await Promise.race([result.then((value) => ({ value }), (cause: unknown) => ({ cause })), sleep(options.timing?.turnAcceptMs ?? TURN_ACCEPT_MS)])
1589
+ if (early) {
1590
+ if ("cause" in early) throw early.cause
1591
+ return Response.json(early.value)
851
1592
  }
1593
+ const tenant = registry.sessionTenant(sessionID)
1594
+ void result.then(
1595
+ (value) => broadcast({ type: "turn", sessionID, ...value }, { tenant }),
1596
+ (cause: unknown) => {
1597
+ const message = cause instanceof Error ? cause.message : String(cause)
1598
+ broadcast({ type: "turn", sessionID, ...(cause instanceof RequestTimeoutError ? { outcome: "timeout" } : { outcome: "failed", message }), reply: "" }, { tenant })
1599
+ },
1600
+ )
1601
+ return Response.json({ mode: "turn" }, { status: 202, headers: { "preference-applied": "respond-async" } })
852
1602
  }
853
1603
 
854
1604
  /** GET …/timeline/messages/:message/files/:index: one image of a user message, as bytes. */
@@ -887,10 +1637,11 @@ export function startCentral(options: CentralOptions): Central {
887
1637
  }
888
1638
  }
889
1639
 
890
- /** Connection that owns the session, or a 409. */
891
- function ownerOf(sessionID: string): string {
1640
+ /** Connection that owns the session, or a 409; also a 409 (code unsupported) when it lacks the core op `feature`. */
1641
+ function ownerOf(sessionID: string, feature?: CoreOp): string {
892
1642
  const connID = registry.owner(sessionID)
893
1643
  if (!connID) throw new HttpError(409, NO_OWNER)
1644
+ if (feature && !registry.supports(connID, feature)) throw unsupportedError({ feature, harness: registry.harnessOf(connID) ?? "unknown" })
894
1645
  return connID
895
1646
  }
896
1647
 
@@ -900,11 +1651,61 @@ export function startCentral(options: CentralOptions): Central {
900
1651
  const text = typeof raw === "string" ? raw.trim() : ""
901
1652
  if (!text) return error(400, "Empty utterance")
902
1653
  if (text.length > MAX_UTTERANCE_CHARS) return error(400, `Utterance longer than ${MAX_UTTERANCE_CHARS} characters`)
903
- const { inboxID } = await spokes.request(ownerOf(sessionID), { op: "steer", sessionID, text })
1654
+ const connID = ownerOf(sessionID, "steer")
1655
+ await humanTurn(sessionID)
1656
+ const { inboxID } = await spokes.request(connID, { op: "steer", sessionID, text })
904
1657
  registry.trackUtterance(sessionID, inboxID)
905
1658
  return Response.json({ inboxID }, { status: 202 })
906
1659
  }
907
1660
 
1661
+ /** Renames a session in OpenCode through its owner process, falling back to other processes that report it. */
1662
+ async function rename(request: Request, sessionID: string): Promise<Response> {
1663
+ const json = await body(request)
1664
+ if (json.auto !== undefined) {
1665
+ if (json.auto !== true) return error(400, "Expected {auto: true} or {title}")
1666
+ return autoName(sessionID)
1667
+ }
1668
+ const raw = json.title
1669
+ const title = typeof raw === "string" ? raw.trim() : ""
1670
+ if (!title) return error(400, "Expected {title}: a non-empty title")
1671
+ if (title.length > MAX_TITLE_CHARS) return error(400, `Title must be at most ${MAX_TITLE_CHARS} characters`)
1672
+ const targets = registry.sessionTargets(sessionID)
1673
+ if (!targets.length) throw new HttpError(409, NO_OWNER)
1674
+ let failure: unknown
1675
+ for (const connID of targets) {
1676
+ try {
1677
+ const result = await spokes.request(connID, { op: "rename", sessionID, title })
1678
+ return Response.json({ title: typeof result?.title === "string" ? result.title : title }, { headers: NO_STORE })
1679
+ } catch (cause) {
1680
+ failure ??= cause
1681
+ }
1682
+ }
1683
+ throw failure
1684
+ }
1685
+
1686
+ /**
1687
+ * Has OpenCode regenerate the title with its title agent, through the processes reporting the session that
1688
+ * support it (older spokes drop the op): 200 {title} or {title, unchanged}, 202 {pending} while generating.
1689
+ */
1690
+ async function autoName(sessionID: string): Promise<Response> {
1691
+ const reporters = registry.sessionTargets(sessionID)
1692
+ if (!reporters.length) throw new HttpError(409, NO_OWNER)
1693
+ const targets = reporters.filter((connID) => registry.supports(connID, "autoName"))
1694
+ if (!targets.length) throw new HttpError(409, AUTO_NAME_OUTDATED, "plugin_outdated")
1695
+ let failure: unknown
1696
+ for (const connID of targets) {
1697
+ try {
1698
+ const result = await spokes.request(connID, { op: "autoName", sessionID })
1699
+ if (result?.pending) return Response.json({ pending: true }, { status: 202, headers: NO_STORE })
1700
+ if (typeof result?.title !== "string") throw new SpokeError("OpenCode did not report a title")
1701
+ return Response.json({ title: result.title, ...(result.unchanged ? { unchanged: true } : {}) }, { headers: NO_STORE })
1702
+ } catch (cause) {
1703
+ failure ??= cause
1704
+ }
1705
+ }
1706
+ throw failure
1707
+ }
1708
+
908
1709
  async function conversation(request: Request, sessionID: string): Promise<Response> {
909
1710
  const on = (await body(request)).on
910
1711
  if (typeof on !== "boolean") return error(400, "Expected {on: boolean}")
@@ -968,22 +1769,9 @@ export function startCentral(options: CentralOptions): Central {
968
1769
  const input = await body(request)
969
1770
  const decision = input.decision as PermissionDecision
970
1771
  if (typeof input.requestID !== "string" || !DECISIONS.has(decision)) return error(400, "Invalid decision")
971
- const targets = registry.permissionTargets(rootID, input.requestID)
972
- if (!targets.length) return error(409, PERMISSION_GONE, "gone")
973
- // Try every process that saw the request: only the one (and the location) running the session holds it.
974
- let failure: unknown
975
- for (const target of targets) {
976
- try {
977
- const result = await spokes.request(target.connID, { op: "permission", sessionID: target.sessionID, requestID: input.requestID, decision })
978
- if (!result?.gone) return new Response(null, { status: 204 })
979
- } catch (cause) {
980
- // Older spokes pass OpenCode's "Permission request not found" through as an error.
981
- if (!/not found/i.test(cause instanceof Error ? cause.message : String(cause))) failure ??= cause
982
- }
983
- }
984
- if (failure) throw failure
985
- registry.dropPermission(input.requestID)
986
- return error(409, PERMISSION_GONE, "gone")
1772
+ const answered = await actions.answerPermission({ rootID, requestID: input.requestID, decision })
1773
+ if (answered.outcome === "unsupported") throw unsupportedError(answered)
1774
+ return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
987
1775
  }
988
1776
 
989
1777
  /** Answers or cancels a pending form (question) in the session's tree, through a process that holds it. */
@@ -996,23 +1784,11 @@ export function startCentral(options: CentralOptions): Central {
996
1784
  const problem = formAnswerProblem(input.answer)
997
1785
  if (problem) return error(400, problem)
998
1786
  }
999
- const targets = registry.formTargets(rootID, input.formID)
1000
- if (!targets.length) return error(409, PERMISSION_GONE, "gone")
1001
- let failure: unknown
1002
- for (const target of targets) {
1003
- const req = cancel
1004
- ? { op: "form" as const, sessionID: target.sessionID, formID: input.formID, cancel: true as const }
1005
- : { op: "form" as const, sessionID: target.sessionID, formID: input.formID, answer: input.answer as FormAnswer }
1006
- try {
1007
- const result = await spokes.request(target.connID, req)
1008
- if (!result?.gone) return new Response(null, { status: 204 })
1009
- } catch (cause) {
1010
- failure ??= cause
1011
- }
1012
- }
1013
- if (failure) throw failure
1014
- registry.dropForm(input.formID)
1015
- return error(409, PERMISSION_GONE, "gone")
1787
+ const answered = await actions.answerForm(
1788
+ cancel ? { rootID, formID: input.formID, cancel: true } : { rootID, formID: input.formID, answer: input.answer as FormAnswer },
1789
+ )
1790
+ if (answered.outcome === "unsupported") throw unsupportedError(answered)
1791
+ return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
1016
1792
  }
1017
1793
 
1018
1794
  async function transcribe(request: Request, speech: Speech): Promise<Response> {
@@ -1027,7 +1803,14 @@ export function startCentral(options: CentralOptions): Central {
1027
1803
  return Response.json({ text: await speech.transcribe(audio, format) })
1028
1804
  }
1029
1805
 
1030
- function events(signal: AbortSignal, userID: string, deviceID: string): Response {
1806
+ /**
1807
+ * The live stream. Every message has an id; with `lastID` (Last-Event-ID or ?last=) the hub replays what came
1808
+ * after it when its log still covers the gap (one fresh catalog if catalogs changed meanwhile), else it starts
1809
+ * over: the catalog, then a `resync` marker so the page refetches what it shows. Heartbeats are `ping` events.
1810
+ */
1811
+ function events(signal: AbortSignal, userID: string, deviceID: string, options: { lastID?: string; lite: boolean; session?: string }): Response {
1812
+ // Draining: come back (to another pod) shortly.
1813
+ if (draining) return new Response(SSE_RETRY, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache" } })
1031
1814
  const encoder = new TextEncoder()
1032
1815
  let cleanup = () => {}
1033
1816
  const stream = new ReadableStream<Uint8Array>({
@@ -1042,17 +1825,38 @@ export function startCentral(options: CentralOptions): Central {
1042
1825
  const listener: Listener = {
1043
1826
  userID,
1044
1827
  deviceID,
1045
- send: (message) => write(`data: ${JSON.stringify(message)}\n\n`),
1828
+ lite: options.lite,
1829
+ ...(options.session ? { session: options.session } : {}),
1830
+ send: (message, id) => write(`${id ? `id: ${id}\n` : ""}data: ${JSON.stringify(message)}\n\n`),
1046
1831
  close: () => {
1047
1832
  cleanup()
1048
1833
  try {
1049
1834
  controller.close()
1050
1835
  } catch {}
1051
1836
  },
1837
+ end: (retryMs) => {
1838
+ write(`retry: ${retryMs}\n\n`)
1839
+ listener.close()
1840
+ },
1841
+ }
1842
+ if (live) return clusterStream(listener, write)
1843
+ eventLog.open(userID)
1844
+ const missed = options.lastID ? eventLog.since(userID, options.lastID) : undefined
1845
+ if (missed) {
1846
+ let catalog = false
1847
+ for (const { id, message, exceptDevice } of missed) {
1848
+ if (exceptDevice === deviceID) continue
1849
+ if (message.type === "catalog") catalog = true
1850
+ else if (wants(listener, message)) listener.send(message, id)
1851
+ }
1852
+ if (catalog) listener.send({ type: "catalog", machines: machines(userID) }, eventLog.latest(userID))
1853
+ } else {
1854
+ listener.send({ type: "catalog", machines: machines(userID) }, eventLog.latest(userID))
1855
+ if (options.lastID) listener.send({ type: "resync" }, eventLog.latest(userID))
1052
1856
  }
1053
- listener.send({ type: "catalog", machines: machines(userID) })
1054
1857
  listeners.add(listener)
1055
- const heartbeat = setInterval(() => write(": ping\n\n"), SSE_HEARTBEAT_MS)
1858
+ // A named event (not a comment) so the page sees it and can tell a live stream from a dead one.
1859
+ const heartbeat = setInterval(() => write("event: ping\ndata: 0\n\n"), heartbeatMs)
1056
1860
  cleanup = () => {
1057
1861
  clearInterval(heartbeat)
1058
1862
  listeners.delete(listener)
@@ -1063,12 +1867,104 @@ export function startCentral(options: CentralOptions): Central {
1063
1867
  cleanup()
1064
1868
  },
1065
1869
  })
1870
+ /**
1871
+ * Cluster mode: the user's live events from every pod; the replay comes from the shared EventLog. Events that
1872
+ * arrive while it is read wait (in order) and then go out, except those the replay already sent.
1873
+ */
1874
+ async function clusterStream(listener: Listener, write: (chunk: string) => void): Promise<void> {
1875
+ let pending: LiveEvent[] | undefined = []
1876
+ const replayed = new Set<string>()
1877
+ const forward = (event: LiveEvent) => {
1878
+ if (event.exceptDevice === deviceID || (event.id !== undefined && replayed.has(event.id))) return
1879
+ if (wants(listener, event.message)) listener.send(event.message, event.id)
1880
+ }
1881
+ const subscription = live!.subscribe(userID, (event) => (pending ? pending.push(event) : forward(event)))
1882
+ listeners.add(listener)
1883
+ const heartbeat = setInterval(() => write("event: ping\ndata: 0\n\n"), heartbeatMs)
1884
+ cleanup = () => {
1885
+ clearInterval(heartbeat)
1886
+ listeners.delete(listener)
1887
+ subscription.close()
1888
+ }
1889
+ signal.addEventListener("abort", () => cleanup(), { once: true })
1890
+ try {
1891
+ await subscription.ready
1892
+ const missed = options.lastID ? await backends.events.since(userID, options.lastID) : undefined
1893
+ if (missed && "events" in missed) {
1894
+ for (const { id, message, exceptDevice } of missed.events) {
1895
+ replayed.add(id)
1896
+ if (exceptDevice !== deviceID && wants(listener, message)) listener.send(message, id)
1897
+ }
1898
+ // Catalogs are not logged in a cluster: a resumed stream always gets the current one.
1899
+ listener.send({ type: "catalog", machines: machines(userID) })
1900
+ } else {
1901
+ const latest = await backends.events.latest(userID)
1902
+ listener.send({ type: "catalog", machines: machines(userID) }, latest)
1903
+ if (options.lastID) listener.send({ type: "resync" }, latest)
1904
+ }
1905
+ } catch (cause) {
1906
+ console.error(`[hub] live stream did not start: ${(cause as Error).message}`)
1907
+ listener.close()
1908
+ return
1909
+ }
1910
+ const waiting = pending
1911
+ pending = undefined
1912
+ for (const event of waiting) forward(event)
1913
+ }
1066
1914
  return new Response(stream, {
1067
1915
  headers: { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" },
1068
1916
  })
1069
1917
  }
1070
1918
 
1071
- function staticFile(pathname: string): Response {
1919
+ /** /readyz (design §10): 503 while draining or (cluster mode) when the database or Valkey does not answer in time. */
1920
+ async function readyz(): Promise<Response> {
1921
+ if (draining) return Response.json({ ok: false, draining: true }, { status: 503, headers: NO_STORE })
1922
+ if (config.cluster) {
1923
+ let timer: ReturnType<typeof setTimeout> | undefined
1924
+ const late = new Promise<never>((_, reject) => (timer = setTimeout(() => reject(new Error("timed out")), READY_CHECK_MS)))
1925
+ try {
1926
+ await Promise.race([Promise.all([backends.state.ping?.(), backends.kv.ping?.()]), late])
1927
+ } catch {
1928
+ // A public endpoint: no backend detail (hosts, users) in the answer.
1929
+ return Response.json({ ok: false, error: "database or Valkey not reachable" }, { status: 503, headers: NO_STORE })
1930
+ } finally {
1931
+ clearTimeout(timer)
1932
+ }
1933
+ }
1934
+ return Response.json({ ok: true }, { headers: NO_STORE })
1935
+ }
1936
+
1937
+ async function runDrain(drainOptions: DrainOptions): Promise<void> {
1938
+ const timeoutMs = drainOptions.timeoutMs ?? options.timing?.drainTimeoutMs ?? DRAIN_TIMEOUT_MS
1939
+ const retryAfterMs = drainOptions.retryAfterMs ?? options.timing?.drainRetryAfterMs ?? DRAIN_RETRY_AFTER_MS
1940
+ // Refuse new WebSockets and requests; bye each connection once its requests in flight are answered.
1941
+ const connections = spokes.drain({ timeoutMs, retryAfterMs })
1942
+ // Step down at once (releasing the leases), so another pod takes the timed jobs over.
1943
+ await Promise.allSettled(leaders.map((loop) => loop.stop()))
1944
+ // Withdraw this pod's slices: the other pods stop showing its connections (the new pod republishes them).
1945
+ await view?.stop().catch((cause) => console.error(`[hub] cluster view not withdrawn: ${(cause as Error).message}`))
1946
+ for (const listener of [...listeners]) listener.end(SSE_RETRY_MS)
1947
+ drainSignal.abort()
1948
+ await connections
1949
+ }
1950
+
1951
+ /** Compressed copies of hashed assets, made once per file and encoding (they never change). */
1952
+ const compressed = new Map<string, Promise<Uint8Array>>()
1953
+ function compress(name: string, bytes: Uint8Array, encoding: "br" | "gzip"): Promise<Uint8Array> {
1954
+ const key = `${encoding} ${name}`
1955
+ let done = compressed.get(key)
1956
+ if (!done) {
1957
+ done =
1958
+ encoding === "br"
1959
+ ? brotli(bytes, { params: { [zlib.BROTLI_PARAM_QUALITY]: 6, [zlib.BROTLI_PARAM_SIZE_HINT]: bytes.length } })
1960
+ : gzipAsync(bytes, { level: 9 })
1961
+ done.catch(() => compressed.delete(key))
1962
+ compressed.set(key, done)
1963
+ }
1964
+ return done
1965
+ }
1966
+
1967
+ async function staticFile(pathname: string, acceptEncoding: string | null): Promise<Response> {
1072
1968
  const name = APP_ROUTES.test(pathname) ? "index.html" : STATIC_PATH.test(pathname) ? pathname.slice(1) : undefined
1073
1969
  const type = name && CONTENT_TYPES[path.extname(name).toLowerCase()]
1074
1970
  const bytes = name && type ? web.read(name) : undefined
@@ -1082,6 +1978,16 @@ export function startCentral(options: CentralOptions): Central {
1082
1978
  headers.set("service-worker-allowed", "/")
1083
1979
  } else if (type.startsWith("text/html")) {
1084
1980
  headers.set("cache-control", "no-cache")
1981
+ } else if (HASHED_ASSET.test(name)) {
1982
+ headers.set("cache-control", IMMUTABLE)
1983
+ if (COMPRESSIBLE.has(path.extname(name).toLowerCase()) && bytes.length >= MIN_COMPRESS_BYTES) {
1984
+ headers.set("vary", "Accept-Encoding")
1985
+ const encoding = pickEncoding(acceptEncoding)
1986
+ if (encoding) {
1987
+ headers.set("content-encoding", encoding)
1988
+ return new Response(new Uint8Array(await compress(name, bytes, encoding)), { headers })
1989
+ }
1990
+ }
1085
1991
  }
1086
1992
  return new Response(bytes, { headers })
1087
1993
  }
@@ -1092,20 +1998,88 @@ export function startCentral(options: CentralOptions): Central {
1092
1998
  store,
1093
1999
  auth,
1094
2000
  registry,
2001
+ nodes,
1095
2002
  spokes,
1096
2003
  push,
1097
2004
  speechSettings,
1098
- routes: routes.map(({ method, path, access }) => ({ method, path, access })),
2005
+ backends,
2006
+ delivery,
2007
+ decisions,
2008
+ acting,
2009
+ launches,
2010
+ launchFeed,
2011
+ launchTokens,
2012
+ webhooks,
2013
+ agentEvents,
2014
+ agentEventWriters,
2015
+ routes: [...routes.map(({ method, path, access }) => ({ method, path, access })), ...agent.routes.map(({ method, path }) => ({ method, path, access: "agent" as const }))],
2016
+ drain(drainOptions: DrainOptions = {}) {
2017
+ draining ??= runDrain(drainOptions)
2018
+ return draining
2019
+ },
1099
2020
  async stop() {
1100
- spokes.stop()
2021
+ // Step down first (releasing the leases), so another pod takes the timed jobs over at once.
2022
+ await Promise.allSettled([...leaders.map((loop) => loop.stop()), recommendations?.stop()])
2023
+ launches.shutdown()
2024
+ webhooks.stop()
2025
+ agentEventWriters.stop()
2026
+ tokenPush.stop()
2027
+ delivery.stop()
2028
+ decisions.stop()
2029
+ await spokes.stop()
2030
+ await view?.stop().catch((cause) => console.error(`[hub] cluster view not stopped: ${(cause as Error).message}`))
1101
2031
  for (const listener of [...listeners]) listeners.delete(listener)
2032
+ await Promise.all([live?.stop(), feed.detach(), launchFeed.detach()]).catch((cause) => console.error(`[hub] live fan-out not stopped: ${(cause as Error).message}`))
1102
2033
  // Bun 1.3.5 quirk: once the server has closed a WebSocket itself, stop(true)'s promise never
1103
2034
  // resolves even though the listener is closed. Don't wait on it forever.
1104
2035
  await Promise.race([server.stop(true), Bun.sleep(250)])
2036
+ await backends.state.flush?.().catch((cause) => console.error(`[hub] agent state not saved: ${(cause as Error).message}`))
2037
+ await store.close()
2038
+ if (ownBackends) await backends.close?.().catch((cause) => console.error(`[hub] backends not closed: ${(cause as Error).message}`))
1105
2039
  },
1106
2040
  }
1107
2041
  }
1108
2042
 
2043
+ /** The state filter of GET /api/decisions (throws invalid). */
2044
+ const optionalState = (url: URL): { state?: Decision["state"] } => {
2045
+ const state = decisionStateQuery(url.searchParams.get("state"))
2046
+ return state ? { state } : {}
2047
+ }
2048
+
2049
+ /** {option?, text?} of a Decision answer (types only; Decisions validates). */
2050
+ function decisionAnswer(input: Record<string, unknown>): { option?: string; text?: string } {
2051
+ for (const key of ["option", "text"] as const) {
2052
+ if (input[key] !== undefined && typeof input[key] !== "string") throw new AgentError("invalid", `${key} must be a string`)
2053
+ }
2054
+ return { ...(typeof input.option === "string" ? { option: input.option } : {}), ...(typeof input.text === "string" ? { text: input.text } : {}) }
2055
+ }
2056
+
2057
+ /** Console envelope for a Decision failure: gone → 409 (the web app's toast), the rest by code. */
2058
+ function decisionFailure(cause: unknown): Response {
2059
+ if (!(cause instanceof AgentError)) throw cause
2060
+ const status: Partial<Record<string, number>> = { gone: 409, not_found: 404, forbidden: 403, invalid: 400, rate_limited: 429, paused: 423 }
2061
+ return Response.json({ error: cause.message, code: cause.code, ...(cause.options.hint ? { hint: cause.options.hint } : {}) }, { status: status[cause.code] ?? 500 })
2062
+ }
2063
+
2064
+ /** Deep-link scheme of the native apps: `hubapp://link?hub=<public URL>&code=<link code>`. */
2065
+ export const APP_SCHEME = "hubapp"
2066
+
2067
+ /**
2068
+ * Public writes a native app makes with no ambient credential at all (no cookie, no bearer, no Origin or
2069
+ * Referer): not CSRF-prone, the single-use code in the body is the credential. Every other write keeps the
2070
+ * same-origin check, and so does any request carrying a cookie or a browser Origin.
2071
+ */
2072
+ const CREDENTIAL_FREE = new Set(["POST /api/devices/redeem"])
2073
+
2074
+ function credentialFree(request: Request, mode: CredentialMode, url: URL): boolean {
2075
+ return (
2076
+ mode === "none" &&
2077
+ !request.headers.has("origin") &&
2078
+ !request.headers.has("referer") &&
2079
+ CREDENTIAL_FREE.has(`${request.method} ${url.pathname}`)
2080
+ )
2081
+ }
2082
+
1109
2083
  function userView(user: UserRecord) {
1110
2084
  return { id: user.id, ...(user.email ? { email: user.email } : {}), ...(user.name ? { name: user.name } : {}), role: user.role }
1111
2085
  }
@@ -1134,11 +2108,11 @@ function pushTarget(value: unknown): { endpoint: string; keys: { p256dh: string;
1134
2108
  return { endpoint, keys: { p256dh, auth } }
1135
2109
  }
1136
2110
 
1137
- /** Boolean pref fields (and 0.5.0's `blocked`) from untrusted input; undefined when nothing applies. */
2111
+ /** Boolean pref fields (and the legacy `blocked` and `finished`) from untrusted input; undefined when nothing applies. */
1138
2112
  function prefsInput(value: unknown): (Partial<PushPrefs> & LegacyPushPrefs) | undefined {
1139
2113
  if (typeof value !== "object" || value === null) return undefined
1140
2114
  const prefs: Partial<PushPrefs> & LegacyPushPrefs = {}
1141
- for (const key of [...PUSH_PREF_KEYS, "blocked"] as const) {
2115
+ for (const key of [...PUSH_PREF_KEYS, "blocked", "finished"] as const) {
1142
2116
  const v = (value as Record<string, unknown>)[key]
1143
2117
  if (typeof v === "boolean") prefs[key] = v
1144
2118
  }
@@ -1148,7 +2122,10 @@ function prefsInput(value: unknown): (Partial<PushPrefs> & LegacyPushPrefs) | un
1148
2122
  /** PUT /api/prefs/view body: any of the four lists, each replacing the stored one. */
1149
2123
  function viewPrefsInput(value: unknown): Partial<ViewPrefs> {
1150
2124
  const invalid = () =>
1151
- new HttpError(400, `Expected {${VIEW_PREF_KEYS.join("?, ")}?}: lists of at most ${VIEW_PREF_MAX_ENTRIES} strings of at most ${VIEW_PREF_MAX_CHARS} characters`)
2125
+ new HttpError(
2126
+ 400,
2127
+ `Expected {${VIEW_PREF_KEYS.join("?, ")}?}: lists of at most ${VIEW_PREF_MAX_ENTRIES} (hiddenSessions: ${VIEW_PREF_MAX_ARCHIVED}) strings of at most ${VIEW_PREF_MAX_CHARS} characters`,
2128
+ )
1152
2129
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalid()
1153
2130
  const record = value as Record<string, unknown>
1154
2131
  if (Object.keys(record).some((key) => !(VIEW_PREF_KEYS as readonly string[]).includes(key))) throw invalid()
@@ -1156,7 +2133,7 @@ function viewPrefsInput(value: unknown): Partial<ViewPrefs> {
1156
2133
  for (const key of VIEW_PREF_KEYS) {
1157
2134
  const list = record[key]
1158
2135
  if (list === undefined) continue
1159
- if (!Array.isArray(list) || list.length > VIEW_PREF_MAX_ENTRIES) throw invalid()
2136
+ if (!Array.isArray(list) || list.length > (key === "hiddenSessions" ? VIEW_PREF_MAX_ARCHIVED : VIEW_PREF_MAX_ENTRIES)) throw invalid()
1160
2137
  if (!list.every((entry) => typeof entry === "string" && entry.length > 0 && entry.length <= VIEW_PREF_MAX_CHARS)) throw invalid()
1161
2138
  out[key] = [...new Set(list as string[])]
1162
2139
  }
@@ -1192,6 +2169,8 @@ function param(raw: string): string {
1192
2169
  }
1193
2170
  }
1194
2171
 
2172
+ const sleep = (ms: number) => new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), ms))
2173
+
1195
2174
  async function body(request: Request): Promise<Record<string, unknown>> {
1196
2175
  const value = (await request.json()) as unknown
1197
2176
  return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {}