@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,277 @@
1
+ // The canonical agent event journal (lifecycle design: Events; decision xwzersmm). One bounded journal per user of
2
+ // compact events that GET /api/agent/events, MCP `events` and /api/agent/watch read:
3
+ //
4
+ // {id: "evt_…", at, type, subject: {sessionID?, rootID?, messageID?, senderID?, launchID?, machineID?}, data}
5
+ //
6
+ // Payloads are built from allowlists here and nowhere else: never prompts, message bodies, titles, blocker
7
+ // summaries, launch reasons or labels, credentials, or connection/pod/spoke IDs. Each record also keeps, for the
8
+ // reader only, its scope family (session, manage, message, launch) and its audience (the parties of a message, who
9
+ // started a launch), so an app or session token sees only what its scopes and identity allow.
10
+ //
11
+ // Storage is an EventLog (a Valkey stream per user in a cluster, process memory otherwise) bounded to
12
+ // AGENT_EVENTS_MAX events and AGENT_EVENTS_AGE_MS. An event id is assigned once, before any cross-pod fan-out;
13
+ // append claims it in the Kv first (SET NX), so appending the same id twice stores it once.
14
+ import { randomBytes } from "node:crypto"
15
+ import type { Clock, EventLog, Kv } from "../backend/types"
16
+ import type { LiveStatus } from "../registry"
17
+ import { partyKey } from "./envelope"
18
+ import type { Launch } from "./launch-store"
19
+ import { addressOf, type Message } from "./messages"
20
+
21
+ export const AGENT_EVENTS_MAX = 10_000
22
+ export const AGENT_EVENTS_AGE_MS = 86_400_000
23
+ /** Record format version (readers reject others rather than skip them). */
24
+ export const AGENT_EVENT_VERSION = 1
25
+
26
+ /** The scope family a reader needs: sessions:read, sessions:manage, messages:read, sessions:launch. */
27
+ export type EventFamily = "session" | "manage" | "message" | "launch"
28
+
29
+ export type EventSubject = Readonly<Record<string, string>>
30
+ export type EventData = Readonly<Record<string, string | number | boolean>>
31
+
32
+ /** An event as readers receive it. */
33
+ export interface AgentEvent {
34
+ readonly id: string
35
+ readonly at: number
36
+ readonly type: string
37
+ readonly subject: EventSubject
38
+ readonly data: EventData
39
+ }
40
+
41
+ /** Who else may read an event beyond the user: the party keys of a message, who started a launch. */
42
+ export interface EventAudience {
43
+ readonly parties?: readonly string[]
44
+ readonly by?: string
45
+ }
46
+
47
+ /** What a writer appends. */
48
+ export interface EventInput {
49
+ /** A stable id (assigned before fan-out); a new one when absent. */
50
+ readonly id?: string
51
+ readonly type: string
52
+ readonly family: EventFamily
53
+ readonly subject: EventSubject
54
+ readonly data: EventData
55
+ readonly audience?: EventAudience
56
+ }
57
+
58
+ /** A journal record: the event, its reader-only scope fields, and its position in the user's log. */
59
+ export interface EventRecord {
60
+ readonly position: string
61
+ readonly event: AgentEvent
62
+ readonly family: EventFamily
63
+ readonly audience?: EventAudience
64
+ }
65
+
66
+ /** Records after a position, or `reset` when the journal no longer covers it (expired, trimmed, restarted). */
67
+ export type EventsSince = { readonly records: EventRecord[] } | { readonly reset: true }
68
+
69
+ const FAMILIES: ReadonlySet<string> = new Set<EventFamily>(["session", "manage", "message", "launch"])
70
+ const EVENT_ID = /^evt_[0-9a-f]{20}$/
71
+
72
+ export const newEventID = (): string => `evt_${randomBytes(10).toString("hex")}`
73
+
74
+ export interface AgentEventsOptions {
75
+ readonly log: EventLog
76
+ readonly kv: Kv
77
+ readonly now?: Clock
78
+ readonly onAppend?: (userID: string, record: Pick<EventRecord, "event" | "family" | "audience">) => void | Promise<void>
79
+ }
80
+
81
+ export class AgentEvents {
82
+ private readonly log: EventLog
83
+ private readonly kv: Kv
84
+ private readonly now: Clock
85
+ private readonly onAppend?: AgentEventsOptions["onAppend"]
86
+ /** Readers waiting for this process's next append, per user (long polling; other pods' appends are rechecked). */
87
+ private readonly waiters = new Map<string, Set<() => void>>()
88
+
89
+ constructor(options: AgentEventsOptions) {
90
+ this.log = options.log
91
+ this.kv = options.kv
92
+ this.now = options.now ?? Date.now
93
+ this.onAppend = options.onAppend
94
+ }
95
+
96
+ /** Stores an event once per id; returns its id. */
97
+ async append(userID: string, input: EventInput): Promise<string> {
98
+ const id = input.id ?? newEventID()
99
+ if (!EVENT_ID.test(id)) throw new Error("Invalid event id")
100
+ const claim = `aev:${userID}:${id}`
101
+ if (!(await this.kv.set(claim, "1", { nx: true, px: AGENT_EVENTS_AGE_MS }))) return id
102
+ const record = {
103
+ type: input.type,
104
+ v: AGENT_EVENT_VERSION,
105
+ id,
106
+ at: this.now(),
107
+ family: input.family,
108
+ subject: input.subject,
109
+ data: input.data,
110
+ ...(input.audience ? { audience: input.audience } : {}),
111
+ }
112
+ try {
113
+ await this.log.append(userID, record)
114
+ } catch (cause) {
115
+ // Not stored: a retry with the same id must be able to store it.
116
+ await this.kv.del(claim).catch(() => {})
117
+ throw cause
118
+ }
119
+ await this.onAppend?.(userID, {
120
+ event: { id, at: record.at, type: input.type, subject: input.subject, data: input.data },
121
+ family: input.family,
122
+ ...(input.audience ? { audience: input.audience } : {}),
123
+ })
124
+ const waiting = this.waiters.get(userID)
125
+ if (waiting) for (const wake of [...waiting]) wake()
126
+ return id
127
+ }
128
+
129
+ /** Resolves on this process's next append for the user, after `ms`, or when `signal` aborts. */
130
+ appended(userID: string, ms: number, signal?: AbortSignal): Promise<void> {
131
+ return new Promise((resolve) => {
132
+ let set = this.waiters.get(userID)
133
+ if (!set) this.waiters.set(userID, (set = new Set()))
134
+ const done = () => {
135
+ clearTimeout(timer)
136
+ signal?.removeEventListener("abort", done)
137
+ set!.delete(done)
138
+ if (set!.size === 0 && this.waiters.get(userID) === set) this.waiters.delete(userID)
139
+ resolve()
140
+ }
141
+ const timer = setTimeout(done, ms)
142
+ set.add(done)
143
+ if (signal?.aborted) done()
144
+ else signal?.addEventListener("abort", done, { once: true })
145
+ })
146
+ }
147
+
148
+ /** The position of the user's latest record; a reader holding it gets every later one. */
149
+ latest(userID: string): Promise<string> {
150
+ return this.log.latest(userID)
151
+ }
152
+
153
+ async since(userID: string, position: string): Promise<EventsSince> {
154
+ const result = await this.log.since(userID, position)
155
+ if ("resync" in result) return { reset: true }
156
+ const records: EventRecord[] = []
157
+ for (const logged of result.events) {
158
+ const m = logged.message as Record<string, unknown>
159
+ // A record this version cannot read is a reset, never a silent skip.
160
+ if (m.v !== AGENT_EVENT_VERSION || typeof m.id !== "string" || typeof m.at !== "number" || !FAMILIES.has(m.family as string)) return { reset: true }
161
+ records.push({
162
+ position: logged.id,
163
+ event: { id: m.id, at: m.at, type: String(m.type), subject: (m.subject ?? {}) as EventSubject, data: (m.data ?? {}) as EventData },
164
+ family: m.family as EventFamily,
165
+ ...(m.audience ? { audience: m.audience as EventAudience } : {}),
166
+ })
167
+ }
168
+ return { records }
169
+ }
170
+ }
171
+
172
+ // ---- Compact builders (allowlists) ------------------------------------------------------------------------
173
+
174
+ /** A stored agent message changed (sent, delivered, answered…): never its text. */
175
+ export function messageEvent(message: Message): EventInput {
176
+ const { id, kind, state, from, recipient, replyTo, delivery } = message
177
+ return {
178
+ type: "message.changed",
179
+ family: "message",
180
+ subject: {
181
+ messageID: id,
182
+ ...(recipient.kind === "session" ? { sessionID: recipient.sessionID } : {}),
183
+ ...(from.kind === "session" ? { senderID: from.sessionID } : {}),
184
+ },
185
+ data: { kind, state, from: addressOf(from), to: message.to, ...(replyTo ? { replyTo } : {}), ...(delivery ? { delivery } : {}) },
186
+ audience: { parties: [partyKey(from), partyKey(recipient)] },
187
+ }
188
+ }
189
+
190
+ /** A launch changed: never its prompt, reason or labels. */
191
+ export function launchEvent(launch: Launch): EventInput {
192
+ return {
193
+ type: "launch.changed",
194
+ family: "launch",
195
+ subject: { launchID: launch.id, ...(launch.sessionID !== undefined ? { sessionID: launch.sessionID } : {}) },
196
+ data: { state: launch.state, ...(launch.outcome !== undefined ? { outcome: launch.outcome } : {}), ...(launch.name !== undefined ? { name: launch.name } : {}) },
197
+ ...(launch.by !== undefined ? { audience: { by: launch.by } } : {}),
198
+ }
199
+ }
200
+
201
+ /** A session's archive state changed. */
202
+ export const archiveEvent = (sessionID: string, archived: boolean): EventInput => ({ type: "session.archived", family: "manage", subject: { sessionID }, data: { archived } })
203
+
204
+ export const pinnedEvent = (sessionID: string, pinned: boolean): EventInput => ({ type: "session.pinned", family: "manage", subject: { sessionID }, data: { pinned } })
205
+
206
+ /** A session was explicitly named (names are validated slugs: a-z, 0-9 and -). */
207
+ export const namedEvent = (sessionID: string, name: string): EventInput => ({ type: "session.named", family: "manage", subject: { sessionID }, data: { name } })
208
+
209
+ /** A session claimed (held) or released a role; role and project are validated slugs. */
210
+ export function roleEvent(sessionID: string, held: boolean, role: { role: string; project?: string; generation?: number; exclusive?: boolean }): EventInput {
211
+ return {
212
+ type: held ? "role.claimed" : "role.released",
213
+ family: "manage",
214
+ subject: { sessionID },
215
+ data: {
216
+ role: role.role,
217
+ ...(role.project !== undefined ? { project: role.project } : {}),
218
+ ...(held && role.generation !== undefined ? { generation: role.generation } : {}),
219
+ ...(held && role.exclusive ? { exclusive: true } : {}),
220
+ },
221
+ }
222
+ }
223
+
224
+ interface Known {
225
+ state: string
226
+ blockers: Set<string>
227
+ }
228
+
229
+ /**
230
+ * State and blocker changes of status trees, against what was seen before (one per writer). Bounded by the live
231
+ * trees: a tree's entry is replaced by each status of it and dropped by forget().
232
+ */
233
+ export class StatusDiff {
234
+ private readonly trees = new Map<string, Map<string, Known>>()
235
+
236
+ events(rootID: string, sessions: readonly LiveStatus[]): EventInput[] {
237
+ const before = this.trees.get(rootID) ?? new Map<string, Known>()
238
+ const after = new Map<string, Known>()
239
+ const out: EventInput[] = []
240
+ for (const entry of sessions) {
241
+ const known = before.get(entry.id)
242
+ const subject = { sessionID: entry.id, rootID }
243
+ const open = new Set(entry.blockers.map((b) => b.id))
244
+ for (const b of entry.blockers) if (!known?.blockers.has(b.id)) out.push({ type: "blocker.opened", family: "session", subject, data: { blockerID: b.id, kind: b.kind } })
245
+ for (const id of known?.blockers ?? []) if (!open.has(id)) out.push({ type: "blocker.closed", family: "session", subject, data: { blockerID: id } })
246
+ if (known?.state !== entry.state) out.push({ type: "session.changed", family: "session", subject, data: { state: entry.state, ...(known ? { prev: known.state } : {}) } })
247
+ after.set(entry.id, { state: entry.state, blockers: open })
248
+ }
249
+ this.trees.set(rootID, after)
250
+ return out
251
+ }
252
+
253
+ /** Drops a tree that is gone. */
254
+ forget(rootID: string): void {
255
+ this.trees.delete(rootID)
256
+ }
257
+
258
+ roots(): string[] {
259
+ return [...this.trees.keys()]
260
+ }
261
+ }
262
+
263
+ /** Presence per user (sessions and machines) diffed against the previous view (the catalog writer's). */
264
+ export interface PresenceView {
265
+ readonly sessions: ReadonlySet<string>
266
+ /** Machine id → name, for the machines with a connected process. */
267
+ readonly machines: ReadonlyMap<string, string>
268
+ }
269
+
270
+ export function presenceEvents(before: PresenceView, after: PresenceView): EventInput[] {
271
+ const out: EventInput[] = []
272
+ for (const id of after.sessions) if (!before.sessions.has(id)) out.push({ type: "session.added", family: "session", subject: { sessionID: id }, data: {} })
273
+ for (const id of before.sessions) if (!after.sessions.has(id)) out.push({ type: "session.removed", family: "session", subject: { sessionID: id }, data: {} })
274
+ for (const [id, name] of after.machines) if (!before.machines.has(id)) out.push({ type: "machine.changed", family: "session", subject: { machineID: id }, data: { name, online: true } })
275
+ for (const [id, name] of before.machines) if (!after.machines.has(id)) out.push({ type: "machine.changed", family: "session", subject: { machineID: id }, data: { name, online: false } })
276
+ return out
277
+ }
@@ -0,0 +1,221 @@
1
+ // Grant requests (design §5, §7; P3-T9): an agent asks the user for a grant. Grants are created only by users
2
+ // (grant-routes.ts); a session (any token) or an app token (scope messages:send) may only request one, and the
3
+ // user's tap in the console creates it.
4
+ //
5
+ // - The body is {grant, why}: the grant as POST /api/agent/grants takes it, validated the same way (validateGrant)
6
+ // when requested and again, as the user's, when approved (Grants.create). The holder defaults to the requester
7
+ // (`ses_…`, `app:<token id>`); it may name only the requester itself (`ses_…`, its `@name`: stored as the session
8
+ // id, so a name moving on never moves the grant) or a reserved `@@role` the requesting session holds now.
9
+ // Another session or app is forbidden; a session that is not the user's is not_found.
10
+ // - It opens a Decision to @me (tap tier, high urgency; options approve / deny, deny safe) whose question
11
+ // summarises holder, projects, verbs, permissions allow/never, always, the hourly cap, expiry and the why
12
+ // (sanitised); its action (kind "grant", the canonical grant body) is the hub's, never the asker's.
13
+ // - Approve: the grant is created AS THE USER (owner the user); the requester's reply carries the grant id. The
14
+ // holder gone by then (not_found) closes the Decision instead. Deny: nothing is created; the reply says so.
15
+ // No editing on approval (yet): the user may create an edited grant directly and deny the request.
16
+ // - Limits: one open request per requester (another is rate_limited with the open Decision's id; a version-checked
17
+ // reservation settles races) and GRANT_REQUESTS_PER_HOUR per requester an hour (Counters). Asks to @me also
18
+ // count against the message limits.
19
+ // - Audit: the request (grant.request, needs_user, escalated) and its outcome (grant.approve ok / grant.deny
20
+ // refused, as the user).
21
+ import type { Counters, StateStore } from "../backend/types"
22
+ import type { AgentActor, AppActor, SessionActor, UserActor } from "./actor"
23
+ import type { AgentAudit } from "./audit"
24
+ import { type ActOutcome, type Decision, type Decisions, WHY_MAX } from "./decisions"
25
+ import type { Directory } from "./directory"
26
+ import { type Party, partyFromActor, partyKey, partyLabel, sanitizeLabel } from "./envelope"
27
+ import { AgentError } from "./errors"
28
+ import type { Grants } from "./grants"
29
+ import type { Message } from "./messages"
30
+ import { parseAddress } from "./names"
31
+ import { type GrantSpec, validateGrant } from "./policy"
32
+
33
+ /** Grant requests each requester opens an hour. */
34
+ export const GRANT_REQUESTS_PER_HOUR = 5
35
+ /** Characters of the question (lists are cut to fit). */
36
+ const QUESTION_MAX = 4_000
37
+ /** A reservation older than this (its opener crashed) no longer blocks a new request. */
38
+ const RESERVATION_MS = 60_000
39
+ const HOUR_MS = 3_600_000
40
+ const COLLECTION = "grant-requests"
41
+ const BODY_FIELDS = ["grant", "why"]
42
+
43
+ /** requester → its latest request's Decision (without decisionID while it is being opened). */
44
+ interface RequestRecord {
45
+ readonly at: number
46
+ readonly decisionID?: string
47
+ }
48
+
49
+ export interface GrantRequestsDeps {
50
+ readonly state: StateStore
51
+ readonly counters: Counters
52
+ readonly decisions: Pick<Decisions, "ask" | "get">
53
+ readonly grants: Pick<Grants, "create">
54
+ readonly directory: Pick<Directory, "resolve">
55
+ readonly audit?: Pick<AgentAudit, "record">
56
+ /** The requester as envelopes name it; default: from the actor alone. */
57
+ readonly describe?: (userID: string, actor: AgentActor) => Party
58
+ /** The Decision's ask was stored (the console's message feed). */
59
+ readonly published?: (userID: string, message: Message) => void
60
+ readonly now?: () => number
61
+ }
62
+
63
+ const invalid = (message: string) => new AgentError("invalid", message)
64
+ const forbidden = (message: string) => new AgentError("forbidden", message, { hint: "Request a grant held by yourself (the default), or by a reserved role you hold" })
65
+ const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
66
+
67
+ export class GrantRequests {
68
+ private readonly now: () => number
69
+
70
+ constructor(private readonly deps: GrantRequestsDeps) {
71
+ this.now = deps.now ?? Date.now
72
+ this.act = this.act.bind(this)
73
+ }
74
+
75
+ /** Opens the Decision for the agent's request. Throws invalid, forbidden, not_found or rate_limited. */
76
+ async request(userID: string, actor: AgentActor, body: unknown): Promise<{ decision: Decision; message: Message }> {
77
+ if (actor.kind === "user") throw invalid("You are the user: create the grant directly (POST /api/agent/grants)")
78
+ if (!isObject(body)) throw invalid("A grant request is {grant, why}")
79
+ for (const key of Object.keys(body)) if (!BODY_FIELDS.includes(key)) throw invalid(`Unknown grant request field ${key}`)
80
+ if (!isObject(body.grant)) throw invalid("grant is the grant to request (as POST /api/agent/grants takes it)")
81
+ const why = typeof body.why === "string" && body.why.length <= WHY_MAX * 4 ? sanitizeLabel(body.why, WHY_MAX) : ""
82
+ if (!why) throw invalid(`why is the reason the user reads (1–${WHY_MAX} characters)`)
83
+ const own = ownHolder(actor)
84
+ const spec = validateGrant({ ...body.grant, holder: body.grant.holder ?? own })
85
+ const holder = this.holder(userID, actor, spec.holder, own)
86
+ const grant = canonical({ ...spec, holder })
87
+
88
+ const requester = this.describe(userID, actor)
89
+ const key = partyKey(requester)
90
+ const { state, counters, decisions } = this.deps
91
+ const existing = await state.get<RequestRecord>(userID, COLLECTION, key)
92
+ if (existing?.value.decisionID) {
93
+ const open = await decisions.get(userID, existing.value.decisionID)
94
+ if (open?.state === "open") {
95
+ throw new AgentError("rate_limited", `Your grant request ${open.id} still waits for the user`, { extra: { retryAfter: 60, decisionID: open.id } })
96
+ }
97
+ } else if (existing && this.now() - existing.value.at < RESERVATION_MS) {
98
+ throw new AgentError("rate_limited", "Your grant request is being opened", { extra: { retryAfter: 1 } })
99
+ }
100
+ const at = this.now()
101
+ const count = await counters.incr(`grant-requests:${userID}:${key}`, HOUR_MS)
102
+ if (count > GRANT_REQUESTS_PER_HOUR) {
103
+ throw new AgentError("rate_limited", `At most ${GRANT_REQUESTS_PER_HOUR} grant requests an hour`, { extra: { retryAfter: HOUR_MS / 1000 } })
104
+ }
105
+ const reserved = await state.put(userID, COLLECTION, key, { at } satisfies RequestRecord, { ifVersion: existing?.version ?? 0 })
106
+ if (!reserved.ok) throw new AgentError("rate_limited", "Your grant request is being opened", { extra: { retryAfter: 1 } })
107
+ let opened: { decision: Decision; message: Message }
108
+ try {
109
+ opened = await decisions.ask(
110
+ userID,
111
+ actor,
112
+ {
113
+ question: question(partyLabel(requester), spec, holder, why),
114
+ options: [
115
+ { id: "approve", label: "Approve grant" },
116
+ { id: "deny", label: "Deny", safe: true },
117
+ ],
118
+ urgency: "high",
119
+ tier: "tap",
120
+ },
121
+ { action: { kind: "grant", requester: key, grant } },
122
+ )
123
+ } catch (cause) {
124
+ await state.delete(userID, COLLECTION, key, { ifVersion: reserved.version })
125
+ throw cause
126
+ }
127
+ await state.put(userID, COLLECTION, key, { at, decisionID: opened.decision.id } satisfies RequestRecord, { ifVersion: reserved.version })
128
+ this.deps.published?.(userID, opened.message)
129
+ await this.deps.audit?.record(userID, { actor: requester, verb: "grant.request", target: target(actor), decision: "needs_user", reason: why, outcome: "escalated" })
130
+ return opened
131
+ }
132
+
133
+ /** Decisions' action runner: approve creates the grant as the answering user; deny creates nothing. */
134
+ async act(userID: string, decision: Decision, choice: { readonly option?: string }, by: UserActor): Promise<ActOutcome> {
135
+ const action = decision.action
136
+ if (!action || action.kind !== "grant" || by.userID !== userID) return "gone"
137
+ const about = decision.from.kind === "session" ? { sessionID: decision.from.sessionID } : {}
138
+ const reason = `Decision ${decision.id}`
139
+ if (choice.option !== "approve") {
140
+ await this.deps.audit?.record(userID, { actor: { kind: "user" }, verb: "grant.deny", target: about, decision: "allow", reason, outcome: "refused" })
141
+ return "done"
142
+ }
143
+ let created
144
+ try {
145
+ created = await this.deps.grants.create(userID, action.grant)
146
+ } catch (cause) {
147
+ if (!(cause instanceof AgentError) || cause.code !== "not_found") throw cause
148
+ await this.deps.audit?.record(userID, { actor: { kind: "user" }, verb: "grant.approve", target: about, decision: "allow", reason, outcome: "gone" })
149
+ return { outcome: "gone", reason: "The requested holder is gone" }
150
+ }
151
+ await this.deps.audit?.record(userID, { actor: { kind: "user" }, verb: "grant.approve", target: about, grant: created.id, decision: "allow", reason, outcome: "ok" })
152
+ return { outcome: "done", note: `Grant ${created.id} created (holder ${created.holder}, until ${new Date(created.expiresAt).toISOString()})` }
153
+ }
154
+
155
+ // ---- internals ----------------------------------------------------------------------------------------
156
+
157
+ private describe(userID: string, actor: AgentActor): Party {
158
+ return this.deps.describe?.(userID, actor) ?? partyFromActor(actor)
159
+ }
160
+
161
+ /** The holder the request may name (see the header), as stored. */
162
+ private holder(userID: string, actor: SessionActor | AppActor, holder: string, own: string): string {
163
+ if (holder === own) return own
164
+ if (holder.startsWith("app:") || actor.kind === "app") throw forbidden("A grant request may name only the requester as its holder")
165
+ const holds = (sessions: readonly { readonly sessionID: string }[]) => sessions.some((s) => s.sessionID === actor.sessionID)
166
+ if (parseAddress(holder)?.kind === "role") {
167
+ let held = false
168
+ try {
169
+ held = holds(this.deps.directory.resolve(userID, holder).sessions)
170
+ } catch {
171
+ held = false
172
+ }
173
+ if (!held) throw forbidden(`You do not hold ${holder}`)
174
+ return holder
175
+ }
176
+ // A session or name that is not the user's is not_found (the resolver's tenancy).
177
+ const { sessions } = this.deps.directory.resolve(userID, holder)
178
+ if (sessions.length !== 1 || !holds(sessions)) throw forbidden("A grant request may name only the requester as its holder")
179
+ return actor.sessionID
180
+ }
181
+ }
182
+
183
+ /** The holder a request defaults to: the session's id, or `app:<token id>`. */
184
+ const ownHolder = (actor: SessionActor | AppActor): string => (actor.kind === "session" ? actor.sessionID : `app:${actor.tokenID}`)
185
+
186
+ const target = (actor: SessionActor | AppActor) => (actor.kind === "session" ? { sessionID: actor.sessionID } : {})
187
+
188
+ /** The spec as the body Grants.create takes (expires in seconds). */
189
+ const canonical = (spec: GrantSpec): Record<string, unknown> => ({
190
+ holder: spec.holder,
191
+ scope: { projects: [...spec.scope.projects] },
192
+ allow: [...spec.allow],
193
+ permissions: { allow: [...spec.permissions.allow], never: [...spec.permissions.never] },
194
+ allowAlways: spec.allowAlways,
195
+ maxActionsPerHour: spec.maxActionsPerHour,
196
+ expires: Math.round(spec.expiresInMs / 1000),
197
+ })
198
+
199
+ /** `90m`, `8h`, `2d`; seconds otherwise. */
200
+ function duration(ms: number): string {
201
+ for (const [unit, size] of [["d", 86_400_000], ["h", 3_600_000], ["m", 60_000]] as const) if (ms % size === 0) return `${ms / size}${unit}`
202
+ return `${Math.round(ms / 1000)}s`
203
+ }
204
+
205
+ /** "@orchestrator requests a grant: holder ses_o; projects /p; verbs approve, deny; …. Why: …" (bounded). */
206
+ function question(by: string, spec: GrantSpec, holder: string, why: string): string {
207
+ const list = (values: readonly string[]) => (values.length ? values.join(", ") : "none")
208
+ const text = [
209
+ `${by} requests a grant: holder ${holder}`,
210
+ `projects ${list(spec.scope.projects)}`,
211
+ `verbs ${list(spec.allow)}`,
212
+ `permissions allow ${list(spec.permissions.allow)}`,
213
+ `never ${list(spec.permissions.never)}`,
214
+ `always ${spec.allowAlways ? "yes" : "no"}`,
215
+ `up to ${spec.maxActionsPerHour} actions an hour`,
216
+ `expires ${duration(spec.expiresInMs)} after approval`,
217
+ ].join("; ")
218
+ const tail = `. Why: ${why}`
219
+ const head = text.length + tail.length > QUESTION_MAX ? `${text.slice(0, Math.max(0, QUESTION_MAX - tail.length - 1))}…` : text
220
+ return `${head}${tail}`
221
+ }
@@ -0,0 +1,70 @@
1
+ // /api/agent/grants (design §5, §7): the user's grants. User actors only (allow {}): the device cookie (same-origin
2
+ // writes, checked by the agent framework) or a device token. A session or app token never creates, edits, reads
3
+ // or revokes a grant (escalation guard). Answers {grant} / {grants}, never cached; the owner is not echoed.
4
+ //
5
+ // POST /api/agent/grants create (201) GET /api/agent/grants list, newest first
6
+ // GET /api/agent/grants/:id one grant PUT /api/agent/grants/:id replace (holder fixed)
7
+ // DELETE /api/agent/grants/:id revoke (gone when already ended)
8
+ //
9
+ // Agents only request one (grant-requests.ts): POST /api/agent/grants/requests {grant, why} from a session (any of
10
+ // its tokens) or an app token with messages:send → 201 {message, decision} (a tap Decision for the user); the user
11
+ // gets invalid (create it directly).
12
+ import type { GrantRequests } from "./grant-requests"
13
+ import { type Grant, type Grants, grantState } from "./grants"
14
+ import type { AgentRoute } from "./routes"
15
+
16
+ // A literal: routes.ts imports this module (AGENT_PREFIX is not initialised yet when it loads).
17
+ const PREFIX = "/api/agent/grants"
18
+ const NO_STORE = { "cache-control": "no-store" }
19
+
20
+ /** A grant as the API shows it: without the owner, with its state now. */
21
+ export function grantView(grant: Grant, at: number) {
22
+ const { owner: _owner, ...rest } = grant
23
+ return { ...rest, state: grantState(grant, at) }
24
+ }
25
+
26
+ export function grantRoutes(grants: Grants, now: () => number = Date.now, requests?: GrantRequests): AgentRoute[] {
27
+ const one = (grant: Grant, status = 200) => Response.json({ grant: grantView(grant, now()) }, { status, headers: NO_STORE })
28
+ return [
29
+ ...(requests
30
+ ? [
31
+ {
32
+ method: "POST",
33
+ path: `${PREFIX}/requests`,
34
+ allow: { session: true, scope: "messages:send" },
35
+ handle: async ({ request, actor, user }) => Response.json(await requests.request(user.id, actor, await request.json()), { status: 201, headers: NO_STORE }),
36
+ } satisfies AgentRoute,
37
+ ]
38
+ : []),
39
+ {
40
+ method: "POST",
41
+ path: PREFIX,
42
+ allow: {},
43
+ handle: async ({ request, user }) => one(await grants.create(user.id, await request.json()), 201),
44
+ },
45
+ {
46
+ method: "GET",
47
+ path: PREFIX,
48
+ allow: {},
49
+ handle: async ({ user }) => Response.json({ grants: (await grants.list(user.id)).map((g) => grantView(g, now())) }, { headers: NO_STORE }),
50
+ },
51
+ {
52
+ method: "GET",
53
+ path: `${PREFIX}/:id`,
54
+ allow: {},
55
+ handle: async ({ params, user }) => one(await grants.get(user.id, params.id!)),
56
+ },
57
+ {
58
+ method: "PUT",
59
+ path: `${PREFIX}/:id`,
60
+ allow: {},
61
+ handle: async ({ params, request, user }) => one(await grants.update(user.id, params.id!, await request.json())),
62
+ },
63
+ {
64
+ method: "DELETE",
65
+ path: `${PREFIX}/:id`,
66
+ allow: {},
67
+ handle: async ({ params, user }) => one(await grants.revoke(user.id, params.id!)),
68
+ },
69
+ ]
70
+ }