@huynle/hub 0.6.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (355) hide show
  1. package/CHANGELOG.md +870 -0
  2. package/Dockerfile +33 -0
  3. package/README.md +1007 -66
  4. package/package.json +47 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +156 -0
  21. package/packages/cli/dist/hub-cli.js +21647 -0
  22. package/packages/cli/package.json +11 -0
  23. package/packages/cli/src/acting.ts +70 -0
  24. package/packages/cli/src/args.ts +91 -0
  25. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  26. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  27. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  28. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  29. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  30. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  31. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  32. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  33. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  34. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  35. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  36. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  37. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  38. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  39. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  40. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  41. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  42. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  43. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  44. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  45. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  46. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  47. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  48. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  49. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  50. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  51. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  52. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  53. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  54. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  55. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  56. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  57. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  58. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  59. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  60. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  61. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  62. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  63. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  64. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  65. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  66. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  67. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  68. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  69. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  70. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  71. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  72. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  73. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  74. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  75. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  76. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  77. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  78. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  79. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  80. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  81. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  82. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  83. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  84. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  85. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  86. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  87. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  88. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  89. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  90. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  91. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  92. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  93. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  94. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  95. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  96. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  97. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  98. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  99. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  100. package/packages/cli/src/bootstrap/release/README.md +115 -0
  101. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  102. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  103. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  104. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  105. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  106. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  107. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  108. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  109. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  110. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  111. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  112. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  113. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  114. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  115. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  116. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  117. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  118. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  119. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  120. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  121. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  122. package/packages/cli/src/cli.ts +814 -0
  123. package/packages/cli/src/credentials.ts +75 -0
  124. package/packages/cli/src/decisions.ts +106 -0
  125. package/packages/cli/src/format.ts +185 -0
  126. package/packages/cli/src/grants.ts +105 -0
  127. package/packages/cli/src/http.ts +86 -0
  128. package/packages/cli/src/launches.ts +157 -0
  129. package/packages/cli/src/login.ts +160 -0
  130. package/packages/cli/src/main.ts +63 -0
  131. package/packages/cli/src/mcp.ts +879 -0
  132. package/packages/cli/src/messaging.ts +108 -0
  133. package/packages/cli/src/node.ts +279 -0
  134. package/packages/cli/src/openshell.ts +166 -0
  135. package/packages/cli/src/setup/commands.ts +809 -0
  136. package/packages/cli/src/setup/contributors.ts +171 -0
  137. package/packages/cli/src/setup/dispatch.ts +74 -0
  138. package/packages/cli/src/setup/launch-driver.ts +109 -0
  139. package/packages/cli/src/setup/runtime.ts +456 -0
  140. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  141. package/packages/cli/src/setup/state.ts +244 -0
  142. package/packages/cli/src/setup/user-bin.ts +39 -0
  143. package/packages/cli/src/spoke-services.ts +109 -0
  144. package/packages/cli/src/watch.ts +28 -0
  145. package/packages/codex/package.json +12 -0
  146. package/packages/codex/src/app-server.ts +44 -0
  147. package/packages/codex/src/ids.ts +28 -0
  148. package/packages/codex/src/index.ts +11 -0
  149. package/packages/codex/src/jsonrpc.ts +168 -0
  150. package/packages/codex/src/main.ts +123 -0
  151. package/packages/codex/src/paths.ts +65 -0
  152. package/packages/codex/src/sessions.ts +578 -0
  153. package/packages/codex/src/spoke.ts +252 -0
  154. package/packages/codex/src/stdio.ts +101 -0
  155. package/packages/codex/src/websocket.ts +206 -0
  156. package/packages/codex/src/wrapper.ts +104 -0
  157. package/packages/node/package.json +12 -0
  158. package/packages/node/src/config.ts +184 -0
  159. package/packages/node/src/connection.ts +283 -0
  160. package/packages/node/src/daemon.ts +281 -0
  161. package/packages/node/src/git.ts +107 -0
  162. package/packages/node/src/harnesses.ts +95 -0
  163. package/packages/node/src/host.ts +319 -0
  164. package/packages/node/src/index.ts +17 -0
  165. package/packages/node/src/main.ts +11 -0
  166. package/packages/node/src/openshell.ts +73 -0
  167. package/packages/node/src/run.ts +106 -0
  168. package/packages/preset/package.json +12 -0
  169. package/packages/preset/presets/aider.json +8 -0
  170. package/packages/preset/presets/goose.json +8 -0
  171. package/packages/preset/src/index.ts +2 -0
  172. package/packages/preset/src/main.ts +195 -0
  173. package/packages/preset/src/presets.ts +87 -0
  174. package/packages/preset/src/spoke.ts +251 -0
  175. package/packages/preset/src/tmux.ts +160 -0
  176. package/packages/protocol/package.json +12 -0
  177. package/packages/protocol/src/harness-events.ts +207 -0
  178. package/packages/protocol/src/index.ts +1215 -0
  179. package/packages/protocol/src/launch.ts +172 -0
  180. package/packages/protocol/src/node.ts +409 -0
  181. package/packages/protocol/src/preset.ts +144 -0
  182. package/packages/protocol/src/webhook.ts +62 -0
  183. package/packages/protocol/src/wire.ts +54 -0
  184. package/packages/runtime-openshell/image/Dockerfile +36 -0
  185. package/packages/runtime-openshell/image/README.md +78 -0
  186. package/packages/runtime-openshell/image/build.ts +203 -0
  187. package/packages/runtime-openshell/image/entry.sh +163 -0
  188. package/packages/runtime-openshell/image/image.ts +119 -0
  189. package/packages/runtime-openshell/image/start.sh +24 -0
  190. package/packages/runtime-openshell/package.json +12 -0
  191. package/packages/runtime-openshell/src/cli.ts +584 -0
  192. package/packages/runtime-openshell/src/index.ts +6 -0
  193. package/packages/runtime-openshell/src/policy.ts +155 -0
  194. package/packages/runtime-openshell/src/profiles.ts +181 -0
  195. package/packages/runtime-openshell/src/runtime.ts +475 -0
  196. package/packages/runtime-sdk/package.json +12 -0
  197. package/packages/runtime-sdk/src/index.ts +87 -0
  198. package/packages/sdk/package.json +12 -0
  199. package/packages/sdk/src/address.ts +25 -0
  200. package/packages/sdk/src/client.ts +553 -0
  201. package/packages/sdk/src/envelope.ts +108 -0
  202. package/packages/sdk/src/errors.ts +107 -0
  203. package/packages/sdk/src/index.ts +10 -0
  204. package/packages/sdk/src/sse.ts +70 -0
  205. package/packages/sdk/src/types.ts +517 -0
  206. package/packages/sdk/src/webhook.ts +102 -0
  207. package/packages/spoke-sdk/package.json +12 -0
  208. package/packages/spoke-sdk/src/activity.ts +3 -0
  209. package/packages/spoke-sdk/src/connection.ts +295 -0
  210. package/packages/spoke-sdk/src/delivery.ts +119 -0
  211. package/packages/spoke-sdk/src/enroll.ts +101 -0
  212. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  213. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  214. package/packages/spoke-sdk/src/index.ts +12 -0
  215. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  216. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  217. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  218. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  219. package/scripts/hub-claude-code.service +34 -0
  220. package/scripts/hub-codex-spoke.service +34 -0
  221. package/scripts/hub-node.service +35 -0
  222. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  223. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  224. package/scripts/io.github.huynle.hub-node.plist +52 -0
  225. package/src/central/actions.ts +233 -0
  226. package/src/central/agent/acting-routes.ts +133 -0
  227. package/src/central/agent/acting-switch.ts +93 -0
  228. package/src/central/agent/acting.ts +599 -0
  229. package/src/central/agent/actor.ts +193 -0
  230. package/src/central/agent/app-tokens.ts +152 -0
  231. package/src/central/agent/archive-inactive.ts +66 -0
  232. package/src/central/agent/audit.ts +185 -0
  233. package/src/central/agent/decisions.ts +530 -0
  234. package/src/central/agent/delivery.ts +678 -0
  235. package/src/central/agent/directory-routes.ts +328 -0
  236. package/src/central/agent/directory.ts +473 -0
  237. package/src/central/agent/envelope.ts +51 -0
  238. package/src/central/agent/errors.ts +74 -0
  239. package/src/central/agent/escalation.ts +219 -0
  240. package/src/central/agent/event-cursor.ts +49 -0
  241. package/src/central/agent/event-writers.ts +181 -0
  242. package/src/central/agent/events-routes.ts +148 -0
  243. package/src/central/agent/events.ts +277 -0
  244. package/src/central/agent/grant-requests.ts +221 -0
  245. package/src/central/agent/grant-routes.ts +70 -0
  246. package/src/central/agent/grants.ts +235 -0
  247. package/src/central/agent/launch-engine.ts +1025 -0
  248. package/src/central/agent/launch-routes.ts +252 -0
  249. package/src/central/agent/launch-spec.ts +289 -0
  250. package/src/central/agent/launch-store.ts +264 -0
  251. package/src/central/agent/launch-tokens.ts +206 -0
  252. package/src/central/agent/message-limits.ts +123 -0
  253. package/src/central/agent/message-routes.ts +385 -0
  254. package/src/central/agent/messages.ts +442 -0
  255. package/src/central/agent/names.ts +86 -0
  256. package/src/central/agent/placement.ts +419 -0
  257. package/src/central/agent/policy.ts +378 -0
  258. package/src/central/agent/read-routes.ts +385 -0
  259. package/src/central/agent/routes.ts +281 -0
  260. package/src/central/agent/session-tokens.ts +227 -0
  261. package/src/central/agent/versioned.ts +26 -0
  262. package/src/central/agent/watch.ts +112 -0
  263. package/src/central/agent/webhook-routes.ts +86 -0
  264. package/src/central/agent/webhooks.ts +493 -0
  265. package/src/central/agent-data.ts +177 -0
  266. package/src/central/auth.ts +350 -121
  267. package/src/central/backend/file-state.ts +240 -0
  268. package/src/central/backend/index.ts +116 -0
  269. package/src/central/backend/memory-bus.ts +93 -0
  270. package/src/central/backend/memory-catalog.ts +90 -0
  271. package/src/central/backend/memory-counters.ts +102 -0
  272. package/src/central/backend/memory-events.ts +45 -0
  273. package/src/central/backend/memory-kv.ts +38 -0
  274. package/src/central/backend/memory-state.ts +77 -0
  275. package/src/central/backend/postgres-migrate.ts +100 -0
  276. package/src/central/backend/postgres-state.ts +197 -0
  277. package/src/central/backend/types.ts +246 -0
  278. package/src/central/backend/valkey-bus.ts +425 -0
  279. package/src/central/backend/valkey-catalog.ts +190 -0
  280. package/src/central/backend/valkey-client.ts +36 -0
  281. package/src/central/backend/valkey-counters.ts +38 -0
  282. package/src/central/backend/valkey-events.ts +168 -0
  283. package/src/central/backend/valkey-kv.ts +54 -0
  284. package/src/central/backend/valkey-locks.ts +92 -0
  285. package/src/central/cluster/directory.ts +141 -0
  286. package/src/central/cluster/feed.ts +84 -0
  287. package/src/central/cluster/leader.ts +232 -0
  288. package/src/central/cluster/live.ts +151 -0
  289. package/src/central/cluster/recommend.ts +59 -0
  290. package/src/central/cluster/rpc.ts +150 -0
  291. package/src/central/cluster/view.ts +232 -0
  292. package/src/central/config.ts +197 -3
  293. package/src/central/done.ts +96 -0
  294. package/src/central/events-log.ts +100 -0
  295. package/src/central/http.ts +1149 -170
  296. package/src/central/import.ts +318 -0
  297. package/src/central/main.ts +211 -12
  298. package/src/central/mcp-http.ts +116 -0
  299. package/src/central/nodes.ts +314 -0
  300. package/src/central/oidc.ts +34 -14
  301. package/src/central/push-target.ts +118 -18
  302. package/src/central/push.ts +256 -82
  303. package/src/central/registry.ts +902 -92
  304. package/src/central/say-stream.ts +128 -0
  305. package/src/central/session-token-push.ts +233 -0
  306. package/src/central/speech/openai.ts +21 -14
  307. package/src/central/speech/openrouter.ts +27 -10
  308. package/src/central/speech/provider.ts +54 -0
  309. package/src/central/speech/service.ts +22 -7
  310. package/src/central/spoke-ws.ts +522 -44
  311. package/src/central/store-shared.ts +518 -0
  312. package/src/central/store.ts +625 -189
  313. package/src/central/token-keys.ts +165 -0
  314. package/src/config.ts +118 -7
  315. package/src/index.ts +195 -43
  316. package/src/protocol.ts +3 -650
  317. package/src/rpc.ts +27 -2
  318. package/src/spoke/client.ts +3 -272
  319. package/src/spoke/conversation.ts +16 -12
  320. package/src/spoke/deliver.ts +75 -0
  321. package/src/spoke/enroll.ts +2 -79
  322. package/src/spoke/env-inject.ts +160 -0
  323. package/src/spoke/hub-rpc.ts +115 -0
  324. package/src/spoke/hub-tool.ts +409 -0
  325. package/src/spoke/launch-source.ts +22 -0
  326. package/src/spoke/launchers.ts +174 -0
  327. package/src/spoke/locations.ts +3 -2
  328. package/src/spoke/node-roots.ts +61 -0
  329. package/src/spoke/prompt-refs.ts +116 -0
  330. package/src/spoke/session-tokens.ts +2 -0
  331. package/src/spoke/spoke.ts +465 -28
  332. package/src/spoke/taskboard.ts +243 -0
  333. package/src/spoke/timeline.ts +184 -6
  334. package/src/spoke/tracker.ts +79 -4
  335. package/src/status.ts +203 -10
  336. package/src/tui-commands.ts +13 -6
  337. package/src/tui-messaging.ts +93 -0
  338. package/src/tui.ts +15 -0
  339. package/web/dist/apple-touch-icon.png +0 -0
  340. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  341. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  342. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  343. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  344. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  345. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  346. package/web/dist/favicon.ico +0 -0
  347. package/web/dist/icon-192.png +0 -0
  348. package/web/dist/icon-512.png +0 -0
  349. package/web/dist/icon-maskable-512.png +0 -0
  350. package/web/dist/icon.svg +5 -5
  351. package/web/dist/index.html +17 -4
  352. package/web/dist/manifest.webmanifest +2 -2
  353. package/web/dist/sw.js +79 -15
  354. package/web/dist/assets/index-DClBvfLL.css +0 -1
  355. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -0,0 +1,385 @@
1
+ // Reading on /api/agent (design §4): the snapshot and the flat session list, built only from the hub's catalog
2
+ // (Registry) and directory, never from a spoke.
3
+ //
4
+ // Routes (user actors always; session tokens and app tokens holding sessions:read):
5
+ // GET /api/agent/status
6
+ // GET /api/agent/sessions?project=&state=&machine=&label=k=v
7
+ // GET /api/agent/sessions/:addr show: the catalog view plus the spoke's summary (when it serves one)
8
+ // GET /api/agent/sessions/:addr/log?n=20 the newest timeline rows (spoke feature "timeline")
9
+ // GET /api/agent/watch?blocked=1&session=<addr> SSE of compact events (watch.ts)
10
+ // show and log call spokes; a spoke without the feature answers unsupported {feature, harness}.
11
+ // A session a launch started carries that launch's labels (the latest launch's, when several did); others have none.
12
+ import path from "node:path"
13
+ import { featureForOp, type ServerRequest, type SpokeFeature } from "../../protocol"
14
+ import type { CatalogMachine, CatalogSession, LiveStatus, NowActivity, RegistryMessage, SessionView } from "../registry"
15
+ import { unsupportedMessage } from "../actions"
16
+ import { SpokeError } from "../spoke-ws"
17
+ import type { Store } from "../store"
18
+ import type { Directory, DirectoryEntry, RoleView } from "./directory"
19
+ import type { AgentActor } from "./actor"
20
+ import { AgentError } from "./errors"
21
+ import { AGENT_PREFIX, type AgentRoute } from "./routes"
22
+ import { watchStream } from "./watch"
23
+ import type { EventsRouteDeps } from "./events-routes"
24
+
25
+ /** Labels per session ID. */
26
+ export type SessionLabels = ReadonlyMap<string, Readonly<Record<string, string>>>
27
+
28
+ /** What the read routes need from the Registry. */
29
+ export interface ReadRegistry {
30
+ catalog(): CatalogMachine[]
31
+ sessionTenant(sessionID: string): string | undefined
32
+ session(sessionID: string): SessionView | undefined
33
+ owner(sessionID: string): string | undefined
34
+ supports(connID: string, feature: SpokeFeature): boolean
35
+ subscribe(listener: (message: RegistryMessage, tenant?: string) => void): () => void
36
+ }
37
+
38
+ /** Hub → spoke requests (SpokeHub). */
39
+ export interface SpokeRequester {
40
+ request(connID: string, req: ServerRequest): Promise<unknown>
41
+ }
42
+
43
+ export interface ReadDeps {
44
+ readonly store: Pick<Store, "machines">
45
+ readonly directory: Directory
46
+ readonly registry: ReadRegistry
47
+ readonly spokes?: SpokeRequester
48
+ /** The labels of the user's launched sessions (none without it). */
49
+ readonly labels?: (userID: string) => Promise<SessionLabels>
50
+ /** The journal GET /api/agent/watch streams (no watch route without it). */
51
+ readonly events?: EventsRouteDeps
52
+ /** Watch stream heartbeat (default WATCH_HEARTBEAT_MS). */
53
+ readonly heartbeatMs?: number
54
+ /** Aborted when the hub drains: watch streams end with a `retry:` hint. */
55
+ readonly draining?: AbortSignal
56
+ readonly now?: () => number
57
+ }
58
+
59
+ /** One session in agent answers (status, sessions). */
60
+ export interface AgentSessionView {
61
+ readonly id: string
62
+ readonly name?: string
63
+ readonly roles: string[]
64
+ readonly title: string
65
+ readonly state: string
66
+ readonly harness: string
67
+ readonly parentID?: string
68
+ readonly busySince?: number
69
+ readonly now?: string
70
+ readonly blockers: { kind: string; id: string; summary: string; sessionID?: string }[]
71
+ readonly children: number
72
+ readonly labels: Record<string, string>
73
+ }
74
+
75
+ export interface AttentionItem {
76
+ readonly kind: string
77
+ readonly sessionID: string
78
+ readonly name?: string
79
+ readonly id?: string
80
+ readonly summary: string
81
+ }
82
+
83
+ /** A session the user owns, where it is reported. */
84
+ interface Row {
85
+ readonly machineID: string
86
+ readonly harness: string
87
+ readonly session: CatalogSession
88
+ }
89
+
90
+ const roleLabel = (r: RoleView) => (r.project !== undefined ? `${r.role}.${r.project}` : r.role)
91
+
92
+ /** "bash: bun test", from the session's latest running activity item. */
93
+ const nowText = (now: NowActivity) => [now.tool ?? (now.kind === "thinking" ? "thinking" : undefined), now.summary].filter(Boolean).join(": ") || now.kind
94
+
95
+ /** The user's sessions in the catalog, once each (the first process reporting one wins). */
96
+ function rows(registry: ReadRegistry, userID: string): Row[] {
97
+ const out = new Map<string, Row>()
98
+ for (const machine of registry.catalog()) {
99
+ for (const process of machine.processes) {
100
+ for (const session of process.sessions) {
101
+ if (out.has(session.id) || registry.sessionTenant(session.id) !== userID) continue
102
+ out.set(session.id, { machineID: machine.id, harness: process.harness, session })
103
+ }
104
+ }
105
+ }
106
+ return [...out.values()]
107
+ }
108
+
109
+ const NO_LABELS: SessionLabels = new Map()
110
+
111
+ function view({ session: s, harness }: Row, entry: DirectoryEntry | undefined, labels: SessionLabels): AgentSessionView {
112
+ const own = s.status.find((e) => e.id === s.id) as LiveStatus | undefined
113
+ return {
114
+ id: s.id,
115
+ ...(entry?.name ? { name: `@${entry.name}` } : {}),
116
+ roles: (entry?.roles ?? []).map(roleLabel),
117
+ title: own?.title ?? s.title,
118
+ state: s.state,
119
+ harness,
120
+ ...(s.parentID ? { parentID: s.parentID } : {}),
121
+ ...(own?.since !== undefined ? { busySince: own.since } : {}),
122
+ ...(s.now ? { now: nowText(s.now) } : {}),
123
+ blockers: s.blockers.map((b) => ({ kind: b.kind, id: b.id, summary: b.summary, ...(b.sessionID !== s.id ? { sessionID: b.sessionID } : {}) })),
124
+ children: s.status.filter((e) => e.parentID === s.id).length,
125
+ labels: { ...labels.get(s.id) },
126
+ }
127
+ }
128
+
129
+ const projectOf = (s: CatalogSession) => {
130
+ const root = s.project || s.directory
131
+ return { root, name: s.projectName || path.basename(root) }
132
+ }
133
+
134
+ function directoryOf(directory: Directory, userID: string): Map<string, DirectoryEntry> {
135
+ return new Map(directory.who(userID).map((e) => [e.sessionID, e]))
136
+ }
137
+
138
+ /** Blockers and errors across the user's sessions, oldest first (by the session's last update). */
139
+ function attention(list: readonly Row[], entries: Map<string, DirectoryEntry>): AttentionItem[] {
140
+ const out: AttentionItem[] = []
141
+ const seen = new Set<string>()
142
+ const named = (sessionID: string) => {
143
+ const name = entries.get(sessionID)?.name
144
+ return name ? { name: `@${name}` } : {}
145
+ }
146
+ for (const { session: s } of [...list].sort((a, b) => a.session.updated - b.session.updated)) {
147
+ for (const b of s.blockers) {
148
+ if (seen.has(b.id)) continue
149
+ seen.add(b.id)
150
+ out.push({ kind: b.kind, sessionID: b.sessionID, ...named(b.sessionID), id: b.id, summary: b.summary })
151
+ }
152
+ if (s.state === "error" && !seen.has(`error:${s.id}`)) {
153
+ seen.add(`error:${s.id}`)
154
+ const own = s.status.find((e) => e.id === s.id)
155
+ out.push({ kind: "error", sessionID: s.id, ...named(s.id), summary: own?.detail ?? "error" })
156
+ }
157
+ }
158
+ return out
159
+ }
160
+
161
+ function you(actor: AgentActor, entries: Map<string, DirectoryEntry>) {
162
+ if (actor.kind === "session") {
163
+ const name = entries.get(actor.sessionID)?.name
164
+ return { kind: "session", sessionID: actor.sessionID, ...(name ? { name: `@${name}` } : {}) }
165
+ }
166
+ if (actor.kind === "app") return { kind: "app", tokenID: actor.tokenID, ...(actor.label ? { label: actor.label } : {}) }
167
+ return { kind: "user" }
168
+ }
169
+
170
+ /** The status snapshot of one user (design §4): machines → projects → sessions, and what needs attention. */
171
+ export function snapshot(deps: ReadDeps, userID: string, actor: AgentActor, labels: SessionLabels = NO_LABELS) {
172
+ const list = rows(deps.registry, userID)
173
+ const entries = directoryOf(deps.directory, userID)
174
+ const online = new Map(deps.registry.catalog().map((m) => [m.id, m]))
175
+ const machines = deps.store
176
+ .machines()
177
+ .filter((m) => !m.revoked && m.ownerID === userID)
178
+ .map((m) => {
179
+ const live = online.get(m.id)
180
+ const projects = new Map<string, { root: string; name: string; sessions: AgentSessionView[] }>()
181
+ for (const row of list) {
182
+ if (row.machineID !== m.id) continue
183
+ const project = projectOf(row.session)
184
+ const group = projects.get(project.root) ?? { ...project, sessions: [] }
185
+ projects.set(project.root, group)
186
+ group.sessions.push(view(row, entries.get(row.session.id), labels))
187
+ }
188
+ return {
189
+ id: m.id,
190
+ name: m.name,
191
+ online: live !== undefined,
192
+ harnesses: [...new Set((live?.processes ?? []).map((p) => p.harness))],
193
+ projects: [...projects.values()],
194
+ }
195
+ })
196
+ return { at: (deps.now ?? Date.now)(), you: you(actor, entries), machines, attention: attention(list, entries) }
197
+ }
198
+
199
+ /** The session an address names for the user, if the user's spokes report it now (else offline). */
200
+ function located(deps: ReadDeps, userID: string, addr: string) {
201
+ const entry = deps.directory.resolveOne(userID, addr)
202
+ const live = deps.registry.sessionTenant(entry.sessionID) === userID ? deps.registry.session(entry.sessionID) : undefined
203
+ const list = rows(deps.registry, userID)
204
+ const row = list.find((r) => r.session.id === entry.sessionID) ?? (live && list.find((r) => r.session.id === live.status[0]?.id))
205
+ return { entry, live, row }
206
+ }
207
+
208
+ /** The connection to ask for `op` about a session: unsupported when it lacks the op's feature, offline when none. */
209
+ function spokeFor(deps: ReadDeps, sessionID: string, op: ServerRequest["op"], harness: string): string {
210
+ const connID = deps.registry.owner(sessionID)
211
+ if (!connID || !deps.spokes) throw new AgentError("offline", "No spoke reports this session now")
212
+ const feature = featureForOp(op)
213
+ if (feature && !deps.registry.supports(connID, feature)) {
214
+ throw new AgentError("unsupported", unsupportedMessage({ feature, harness }), { extra: { feature, harness } })
215
+ }
216
+ return connID
217
+ }
218
+
219
+ async function ask(deps: ReadDeps, connID: string, req: ServerRequest): Promise<unknown> {
220
+ try {
221
+ return await deps.spokes!.request(connID, req)
222
+ } catch (cause) {
223
+ if (cause instanceof SpokeError && cause.message.includes("disconnected")) throw new AgentError("offline", "The session's spoke disconnected")
224
+ throw cause
225
+ }
226
+ }
227
+
228
+ /** GET /sessions/:addr: the session's catalog view with full blockers, and its summary from the spoke. */
229
+ async function show(deps: ReadDeps, userID: string, addr: string) {
230
+ const { entry, live, row } = located(deps, userID, addr)
231
+ const labels = { ...(await labelsOf(deps, userID)).get(entry.sessionID) }
232
+ const name = entry.name ? { name: `@${entry.name}` } : {}
233
+ const roles = entry.roles.map(roleLabel)
234
+ if (!live) {
235
+ return {
236
+ id: entry.sessionID,
237
+ ...name,
238
+ roles,
239
+ ...(entry.title ? { title: entry.title } : {}),
240
+ state: "offline",
241
+ online: false,
242
+ ...(entry.parentID ? { parentID: entry.parentID } : {}),
243
+ ...(entry.project ? { project: entry.project } : {}),
244
+ ...(entry.machineID ? { machineID: entry.machineID } : {}),
245
+ ...(entry.lastSeen !== undefined ? { lastSeen: entry.lastSeen } : {}),
246
+ blockers: [],
247
+ labels,
248
+ }
249
+ }
250
+ const harness = row?.harness ?? "unknown"
251
+ const own = live.status.find((e) => e.id === live.id) as LiveStatus | undefined
252
+ const base = {
253
+ id: live.id,
254
+ ...name,
255
+ roles,
256
+ title: live.title,
257
+ state: live.state,
258
+ harness,
259
+ online: true,
260
+ ...(row ? { machineID: row.machineID } : {}),
261
+ project: live.projectName || path.basename(live.project || live.directory),
262
+ directory: live.directory,
263
+ ...(live.parentID ? { parentID: live.parentID } : {}),
264
+ ...(own?.since !== undefined ? { busySince: own.since } : {}),
265
+ ...(live.now ? { now: nowText(live.now), activity: live.now } : {}),
266
+ blockers: live.blockers.map(({ sessionTitle: _, ...b }) => b),
267
+ children: live.status.filter((e) => e.parentID === live.id).length,
268
+ labels,
269
+ }
270
+ try {
271
+ const connID = spokeFor(deps, live.id, "summary", harness)
272
+ return { ...base, summary: await ask(deps, connID, { op: "summary", sessionID: live.id }) }
273
+ } catch (cause) {
274
+ if (cause instanceof AgentError && cause.code === "unsupported") return { ...base, unsupported: [cause.options.extra] }
275
+ if (cause instanceof AgentError && cause.code === "offline") return base
276
+ console.error("[hub] agent show: summary failed:", cause)
277
+ return { ...base, failed: [{ feature: "summary" }] }
278
+ }
279
+ }
280
+
281
+ /** GET /sessions/:addr/log: the newest `n` timeline rows, newest first. */
282
+ async function log(deps: ReadDeps, userID: string, addr: string, raw: string | null) {
283
+ const n = raw === null ? 20 : Number(raw)
284
+ if (!Number.isInteger(n) || n < 1 || n > 200) throw new AgentError("invalid", "n must be 1–200")
285
+ const { entry, live, row } = located(deps, userID, addr)
286
+ if (!live) throw new AgentError("offline", "No spoke reports this session now")
287
+ const connID = spokeFor(deps, entry.sessionID, "timeline", row?.harness ?? "unknown")
288
+ const page = (await ask(deps, connID, { op: "timeline", sessionID: entry.sessionID, limit: n, order: "desc" })) as { items?: unknown[]; older?: string }
289
+ return { sessionID: entry.sessionID, rows: page.items ?? [], ...(page.older ? { older: page.older } : {}) }
290
+ }
291
+
292
+ const labelsOf = (deps: ReadDeps, userID: string): Promise<SessionLabels> => deps.labels?.(userID) ?? Promise.resolve(NO_LABELS)
293
+
294
+ const STATES = new Set(["idle", "working", "blocked", "error"])
295
+
296
+ /** The /sessions filters: project (name or root), state (comma list), machine (ID or name), label k=v. */
297
+ function sessionFilter(deps: ReadDeps, userID: string, params: URLSearchParams): (row: Row, view: AgentSessionView) => boolean {
298
+ const project = params.get("project") || undefined
299
+ const states = params.get("state")?.split(",").filter(Boolean)
300
+ if (states?.some((state) => !STATES.has(state))) throw new AgentError("invalid", "state must be idle, working, blocked or error (comma-separated)")
301
+ const machine = params.get("machine") || undefined
302
+ const machineIDs = machine && new Set(deps.store.machines().filter((m) => m.ownerID === userID && (m.id === machine || m.name === machine)).map((m) => m.id))
303
+ const labels = params.getAll("label").map((label) => {
304
+ const at = label.indexOf("=")
305
+ if (at < 1) throw new AgentError("invalid", "label must be key=value")
306
+ return [label.slice(0, at), label.slice(at + 1)] as const
307
+ })
308
+ return (row, view) => {
309
+ const { root, name } = projectOf(row.session)
310
+ if (project !== undefined && root !== project && name.toLowerCase() !== project.toLowerCase()) return false
311
+ if (states?.length && !states.includes(view.state)) return false
312
+ if (machineIDs && !machineIDs.has(row.machineID)) return false
313
+ return labels.every(([key, value]) => view.labels[key] === value)
314
+ }
315
+ }
316
+
317
+ const NO_STORE = { "cache-control": "no-store" }
318
+ const ok = (body: unknown) => Response.json(body, { headers: NO_STORE })
319
+ const READ = { session: true, scope: "sessions:read" } as const
320
+
321
+ export function readRoutes(deps: ReadDeps): AgentRoute[] {
322
+ return [
323
+ {
324
+ method: "GET",
325
+ path: `${AGENT_PREFIX}/status`,
326
+ allow: READ,
327
+ handle: async ({ user, actor }) => ok(snapshot(deps, user.id, actor, await labelsOf(deps, user.id))),
328
+ },
329
+ {
330
+ method: "GET",
331
+ path: `${AGENT_PREFIX}/sessions`,
332
+ allow: READ,
333
+ handle: async ({ user, url }) => {
334
+ const keep = sessionFilter(deps, user.id, url.searchParams)
335
+ const entries = directoryOf(deps.directory, user.id)
336
+ const labels = await labelsOf(deps, user.id)
337
+ const sessions = rows(deps.registry, user.id)
338
+ .map((row) => ({ row, view: view(row, entries.get(row.session.id), labels) }))
339
+ .filter(({ row, view }) => keep(row, view))
340
+ .map(({ row, view }) => ({ ...view, machineID: row.machineID, project: projectOf(row.session).name }))
341
+ return ok({ sessions })
342
+ },
343
+ },
344
+ {
345
+ method: "GET",
346
+ path: `${AGENT_PREFIX}/sessions/:addr`,
347
+ allow: READ,
348
+ handle: async ({ user, params }) => ok(await show(deps, user.id, params.addr!)),
349
+ },
350
+ {
351
+ method: "GET",
352
+ path: `${AGENT_PREFIX}/sessions/:addr/log`,
353
+ allow: READ,
354
+ handle: async ({ user, params, url }) => ok(await log(deps, user.id, params.addr!, url.searchParams.get("n"))),
355
+ },
356
+ ...(deps.events ? [watchRoute(deps, deps.events)] : []),
357
+ ]
358
+ }
359
+
360
+ /** GET /api/agent/watch: the journal's events live (watch.ts), with the events route's visibility and filters. */
361
+ function watchRoute(deps: ReadDeps, events: EventsRouteDeps): AgentRoute {
362
+ return {
363
+ method: "GET",
364
+ path: `${AGENT_PREFIX}/watch`,
365
+ allow: READ,
366
+ handle: ({ user, url, request, actor }) => {
367
+ const addr = url.searchParams.get("session") || undefined
368
+ const sessions = addr ? new Set(deps.directory.resolve(user.id, addr).sessions.map((s) => s.sessionID)) : undefined
369
+ const blocked = ["1", "true"].includes(url.searchParams.get("blocked") ?? "")
370
+ const cursor = url.searchParams.get("cursor") || request.headers.get("last-event-id") || undefined
371
+ return watchStream({
372
+ events,
373
+ userID: user.id,
374
+ actor,
375
+ signal: request.signal,
376
+ ...(blocked ? { types: ["blocker.*"] } : {}),
377
+ ...(sessions ? { sessions } : {}),
378
+ ...(cursor ? { cursor } : {}),
379
+ ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}),
380
+ ...(deps.now ? { now: deps.now } : {}),
381
+ ...(deps.draining ? { draining: deps.draining } : {}),
382
+ })
383
+ },
384
+ }
385
+ }
@@ -0,0 +1,281 @@
1
+ // The /api/agent framework: its own route table (access "agent" in the hub's table), credential checks and error
2
+ // envelope. http.ts registers it once (agentApi) and sends every /api/agent request here; later tasks add routes
3
+ // to agentRoutes() and verifiers to agentVerifiers() without touching http.ts.
4
+ //
5
+ // Order for each request: cookie + a hub bearer together → 400 invalid (a foreign bearer is ignored); cookie writes need the same origin (bearer
6
+ // requests skip it) → 403 forbidden; credential → 401 unauthorized / token_expired; route → 404 not_found;
7
+ // route class (agentAllows) → 403 forbidden; then the handler, whose throws become the envelope (agentFailure).
8
+ // Path parameters address sessions as `:addr` (resolved per caller), never `:session`.
9
+ import type { AgentEvents } from "./events"
10
+ import type { CursorKeys } from "./event-cursor"
11
+ import { eventsRoutes } from "./events-routes"
12
+ import type { DeviceRecord, Store, UserRecord } from "../store"
13
+ import type { Directory } from "./directory"
14
+ import { directoryRoutes } from "./directory-routes"
15
+ import { type ReadDeps, readRoutes, type ReadRegistry, type SpokeRequester } from "./read-routes"
16
+ import { agentAllows, type AgentActor, type AgentAllow, type BearerVerifier, type Credential, type Resolver } from "./actor"
17
+ import { appTokenRoutes, appTokenVerifier } from "./app-tokens"
18
+ import type { Decisions } from "./decisions"
19
+ import type { Delivery } from "./delivery"
20
+ import { MessageFeed, messageRoutes } from "./message-routes"
21
+ import type { SessionTokens } from "./session-tokens"
22
+ import { AgentError, agentFailure } from "./errors"
23
+ import { type AgentAudit, auditRoutes } from "./audit"
24
+ import { grantRoutes } from "./grant-routes"
25
+ import type { GrantRequests } from "./grant-requests"
26
+ import type { Grants } from "./grants"
27
+ import { type ActingSwitch, actingSwitchRoutes } from "./acting-switch"
28
+ import { Acting, type ActingRegistry, type Escalate } from "./acting"
29
+ import { actingRoutes } from "./acting-routes"
30
+ import type { Actions } from "../actions"
31
+ import { launchRoutes, type RouteLaunches } from "./launch-routes"
32
+ import { type RouteWebhooks, webhookRoutes } from "./webhook-routes"
33
+
34
+ export const AGENT_PREFIX = "/api/agent"
35
+
36
+ export const isAgentPath = (pathname: string): boolean => pathname === AGENT_PREFIX || pathname.startsWith(`${AGENT_PREFIX}/`)
37
+
38
+ export interface AgentContext {
39
+ readonly request: Request
40
+ readonly url: URL
41
+ readonly params: Record<string, string>
42
+ readonly actor: AgentActor
43
+ readonly credential: Credential
44
+ readonly user: UserRecord
45
+ /** The caller's device (user actors only). */
46
+ readonly device?: DeviceRecord
47
+ }
48
+
49
+ export interface AgentRoute {
50
+ readonly method: string
51
+ /** Express-style path under /api/agent, e.g. /api/agent/sessions/:addr. */
52
+ readonly path: string
53
+ /** Who besides user actors may call it (default: users only, e.g. token management). */
54
+ readonly allow: AgentAllow
55
+ handle(context: AgentContext): Response | Promise<Response>
56
+ }
57
+
58
+ /** What the agent API needs from the hub. */
59
+ export interface AgentDeps {
60
+ readonly store: Store
61
+ /** Hub-minted session tokens (P1-T5). */
62
+ readonly sessionTokens?: SessionTokens
63
+ /** Names, roles and address resolution (P1-T8); its routes are added when given. */
64
+ readonly directory?: Directory
65
+ /** The hub's catalog (P1-T9): the read routes are added when given with the directory. */
66
+ readonly registry?: ReadRegistry & ActingRegistry
67
+ /** Hub → spoke requests (show, log). */
68
+ readonly spokes?: SpokeRequester
69
+ /** Agent messages (P2-T5): the message routes are added when given with the directory. */
70
+ readonly delivery?: Delivery
71
+ /** Decisions (ask @me, P2-T9): with the message routes. */
72
+ readonly decisions?: Decisions
73
+ /** The agent audit (P3-T5): GET /api/agent/audit when given. */
74
+ readonly audit?: AgentAudit
75
+ /** Grants (P3-T2): the /api/agent/grants routes when given. */
76
+ readonly grants?: Grants
77
+ /** Grant requests (P3-T9): POST /api/agent/grants/requests with the grant routes. */
78
+ readonly grantRequests?: GrantRequests
79
+ /** Kill switches (P3-T4): GET/PUT /api/agent/acting when given; the acting verbs check it. */
80
+ readonly acting?: ActingSwitch
81
+ /**
82
+ * The shared action executor (P3-T6): with acting, grants, audit, registry, directory and spokes, the act verbs
83
+ * POST /api/agent/sessions/:addr/{approve,deny,answer,prompt,stop} (P3-T7).
84
+ */
85
+ readonly actions?: Actions
86
+ /**
87
+ * Launches (P4-T8): with grants, acting and audit, the launch routes /api/agent/launches (P4-T9); the read
88
+ * routes show the labels of launched sessions.
89
+ */
90
+ readonly launches?: RouteLaunches
91
+ /** Webhooks (P4-T10): the /api/agent/webhooks routes when given. */
92
+ readonly webhooks?: RouteWebhooks
93
+ /** The canonical agent event journal: archive changes are appended to it. */
94
+ readonly agentEvents?: Pick<AgentEvents, "append" | "latest" | "since" | "appended">
95
+ /** Signing keys of GET /api/agent/events cursors; the events route is served when given with agentEvents. */
96
+ readonly eventCursorKeys?: CursorKeys
97
+ /** needs_user escalations of the act verbs (P3-T8 opens a Decision); without it needs_user is only answered. */
98
+ readonly escalate?: Escalate
99
+ /** The console's prompt limit, for the user's prompts on the act route. */
100
+ readonly maxPromptChars?: number
101
+ /** Stored messages as they are sent (the hub also streams them to the console); a new feed when absent. */
102
+ readonly feed?: MessageFeed
103
+ /** Heartbeat of GET /api/agent/watch. */
104
+ readonly heartbeatMs?: number
105
+ /** Aborted when the hub drains: watch streams end with a `retry:` hint. */
106
+ readonly draining?: AbortSignal
107
+ readonly now?: () => number
108
+ }
109
+
110
+ /**
111
+ * Session and app token verifiers (P1-T5 adds the session token verifier, P1-T7 the app token one). Tried in
112
+ * order after the device lookup; see BearerVerifier.
113
+ */
114
+ export function agentVerifiers(deps: AgentDeps): BearerVerifier[] {
115
+ return [...(deps.sessionTokens ? [deps.sessionTokens.verifier] : []), appTokenVerifier(deps.store)]
116
+ }
117
+
118
+ const userView = (user: UserRecord) => ({
119
+ id: user.id,
120
+ ...(user.name ? { name: user.name } : {}),
121
+ ...(user.email ? { email: user.email } : {}),
122
+ role: user.role,
123
+ })
124
+
125
+ /** The built-in /api/agent routes. */
126
+ export function agentRoutes(deps: AgentDeps): AgentRoute[] {
127
+ const feed = deps.feed ?? new MessageFeed()
128
+ return [
129
+ ...(deps.directory ? directoryRoutes({ directory: deps.directory, store: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.grants ? { grants: deps.grants } : {}), ...(deps.acting ? { acting: deps.acting } : {}), ...(deps.audit ? { audit: deps.audit } : {}), ...(deps.eventCursorKeys ? { previewKeys: deps.eventCursorKeys } : {}) }) : []),
130
+ ...(deps.directory && deps.agentEvents && deps.eventCursorKeys ? eventsRoutes({ events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory }) : []),
131
+ ...(deps.directory && deps.delivery ? messageRoutes({ delivery: deps.delivery, directory: deps.directory, store: deps.store, feed, ...(deps.decisions ? { decisions: deps.decisions } : {}) }) : []),
132
+ ...(deps.directory && deps.registry ? readRoutes({ store: deps.store, directory: deps.directory, registry: deps.registry, ...(deps.spokes ? { spokes: deps.spokes } : {}), ...launchReads(deps), ...(deps.agentEvents && deps.eventCursorKeys ? { events: { events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory } } : {}), ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}), ...(deps.now ? { now: deps.now } : {}), ...(deps.draining ? { draining: deps.draining } : {}) }) : []),
133
+ {
134
+ method: "GET",
135
+ path: `${AGENT_PREFIX}/whoami`,
136
+ allow: { session: true, app: true },
137
+ handle: ({ actor, user }) => Response.json({ actor, user: userView(user) }, { headers: { "cache-control": "no-store" } }),
138
+ },
139
+ ...appTokenRoutes(deps.store),
140
+ ...(deps.audit ? auditRoutes(deps.audit) : []),
141
+ ...(deps.grants ? grantRoutes(deps.grants, deps.now, deps.grantRequests) : []),
142
+ ...(deps.acting ? actingSwitchRoutes(deps.acting) : []),
143
+ ...actVerbRoutes(deps),
144
+ ...(deps.launches && deps.grants && deps.acting && deps.audit
145
+ ? launchRoutes({ launches: deps.launches, grants: deps.grants, acting: deps.acting, audit: deps.audit, ...(deps.directory ? { directory: deps.directory } : {}) })
146
+ : []),
147
+ ...(deps.webhooks ? webhookRoutes(deps.webhooks, deps.store) : []),
148
+ ]
149
+ }
150
+
151
+ /** What the read routes show of launches: launched sessions' labels (the latest launch wins). */
152
+ function launchReads(deps: AgentDeps): Pick<ReadDeps, "labels"> {
153
+ const { launches } = deps
154
+ return {
155
+ ...(launches
156
+ ? {
157
+ labels: async (userID: string) => {
158
+ const out = new Map<string, Readonly<Record<string, string>>>()
159
+ // Oldest first: a later launch of the same session overrides.
160
+ for (const launch of await launches.list(userID)) if (launch.sessionID !== undefined) out.set(launch.sessionID, launch.spec.labels)
161
+ return out
162
+ },
163
+ }
164
+ : {}),
165
+ }
166
+ }
167
+
168
+ /** The act verbs, when every part they need is given. */
169
+ function actVerbRoutes(deps: AgentDeps): AgentRoute[] {
170
+ const { actions, acting, grants, audit, registry, directory, spokes } = deps
171
+ if (!actions || !acting || !grants || !audit || !registry || !directory || !spokes) return []
172
+ return actingRoutes(
173
+ new Acting({
174
+ registry,
175
+ directory,
176
+ actions,
177
+ spokes,
178
+ grants,
179
+ acting,
180
+ audit,
181
+ ...(deps.escalate ? { escalate: deps.escalate } : {}),
182
+ ...(deps.maxPromptChars !== undefined ? { maxPromptChars: deps.maxPromptChars } : {}),
183
+ }),
184
+ )
185
+ }
186
+
187
+ export interface AgentApiOptions {
188
+ readonly resolve: Resolver
189
+ readonly cookieName: string
190
+ /** CSRF guard for cookie writes (the hub's same-origin check). */
191
+ sameOrigin(request: Request): boolean
192
+ /** Records activity of a resolved caller (device and user lastSeen). */
193
+ touch?(user: UserRecord, device?: DeviceRecord): void
194
+ readonly routes: readonly AgentRoute[]
195
+ }
196
+
197
+ export interface AgentApi {
198
+ /** The route table (method, path) for the hub's audit table, as access "agent". */
199
+ readonly routes: readonly { readonly method: string; readonly path: string }[]
200
+ handle(request: Request, url: URL): Promise<Response>
201
+ }
202
+
203
+ interface Compiled {
204
+ readonly route: AgentRoute
205
+ readonly pattern: RegExp
206
+ readonly keys: string[]
207
+ }
208
+
209
+ const MIXED = "Send the device cookie or a bearer token, not both"
210
+
211
+ export function agentApi(options: AgentApiOptions): AgentApi {
212
+ const compiled: Compiled[] = options.routes.map((route) => {
213
+ if (!isAgentPath(route.path) || route.path.includes(":session")) throw new Error(`agent route ${route.path}: must be under ${AGENT_PREFIX} and use :addr, not :session`)
214
+ const keys: string[] = []
215
+ const pattern = new RegExp(`^${route.path.replace(/:([a-z]+)/g, (_, key: string) => (keys.push(key), "([^/]+)"))}$`)
216
+ return { route, pattern, keys }
217
+ })
218
+
219
+ const find = (method: string, pathname: string) => {
220
+ for (const { route, pattern, keys } of compiled) {
221
+ if (route.method !== method) continue
222
+ const match = pattern.exec(pathname)
223
+ if (!match) continue
224
+ const params: Record<string, string> = {}
225
+ keys.forEach((key, i) => {
226
+ try {
227
+ params[key] = decodeURIComponent(match[i + 1]!)
228
+ } catch {
229
+ throw new AgentError("invalid", "Bad path")
230
+ }
231
+ })
232
+ return { route, params }
233
+ }
234
+ return undefined
235
+ }
236
+
237
+ async function handle(request: Request, url: URL): Promise<Response> {
238
+ try {
239
+ const mode = await options.resolve.mode(request)
240
+ if (mode === "mixed") throw new AgentError("invalid", MIXED)
241
+ const method = request.method
242
+ if (mode !== "bearer" && method !== "GET" && method !== "HEAD" && !options.sameOrigin(request)) {
243
+ throw new AgentError("forbidden", "Cross-origin request refused")
244
+ }
245
+ const resolved = await options.resolve(request)
246
+ if (!resolved.ok) {
247
+ if (resolved.reason === "expired") throw new AgentError("token_expired", "Token expired", { hint: "Get a new token" })
248
+ throw new AgentError("unauthorized", resolved.bearer ? "Invalid or revoked token" : "Sign in or send a bearer token")
249
+ }
250
+ options.touch?.(resolved.user, resolved.device)
251
+ const found = find(method, url.pathname)
252
+ if (!found) throw new AgentError("not_found", "Not found")
253
+ if (!agentAllows(found.route.allow, resolved.actor)) throw new AgentError("forbidden", "This credential cannot use this route")
254
+ const { actor, credential, user, device } = resolved
255
+ return await found.route.handle({ request, url, params: found.params, actor, credential, user, ...(device ? { device } : {}) })
256
+ } catch (cause) {
257
+ return agentFailure(cause)
258
+ }
259
+ }
260
+
261
+ return { routes: options.routes.map(({ method, path }) => ({ method, path })), handle }
262
+ }
263
+
264
+ /** Oldest client (native app, CLI) this hub still serves; raised when the API breaks old clients. */
265
+ export const MIN_CLIENT_VERSION = "0.0.0"
266
+
267
+ export interface WellKnownInfo {
268
+ readonly publicURL: string
269
+ readonly version: string
270
+ readonly auth: { readonly mode: "pairing" } | { readonly mode: "oidc"; readonly issuer: string }
271
+ }
272
+
273
+ /** GET /.well-known/hub: public discovery and version gating for apps and CLIs (design §14.5). */
274
+ export function wellKnown(info: WellKnownInfo): Response {
275
+ // Pairing codes also work on an OIDC hub (a signed-in device issues them).
276
+ const auth = info.auth.mode === "oidc" ? { modes: ["oidc", "pairing"], oidc: { issuer: info.auth.issuer } } : { modes: ["pairing"] }
277
+ return Response.json(
278
+ { publicURL: info.publicURL, version: info.version, minClientVersion: MIN_CLIENT_VERSION, auth, features: [] },
279
+ { headers: { "cache-control": "public, max-age=60" } },
280
+ )
281
+ }