@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,328 @@
1
+ // The directory on /api/agent (design §2.2) and its wiring to the hub: the Registry is the SessionSource
2
+ // (sessions reported now, per tenant), the console's Archive (viewPrefs.hiddenSessions) the archive source,
3
+ // and every catalog change syncs the affected users (records, lastSeen, derived names).
4
+ //
5
+ // Routes (user actors always; session tokens may read, and write for their OWN session only):
6
+ // GET /api/agent/who?project=&role=
7
+ // POST /api/agent/name {name, session?}
8
+ // POST /api/agent/roles/claim {role, exclusive?, project?, session?}
9
+ // POST /api/agent/roles/release {role, project?, session?}
10
+ // PUT /api/agent/sessions/:addr/archive {archived}
11
+ // `session` is an address (ses_…, ID prefix, @name, …) resolved for the caller; a session token defaults to
12
+ // its own session, a user or app must name one. Foreign and unknown sessions are both not_found.
13
+ //
14
+ // App tokens (lifecycle design, decision xwzersmm): naming and archiving need sessions:manage; a role claim or
15
+ // release also needs a grant allowing `roles` whose projects cover the (live) session's directory, in the acting
16
+ // order (kill switch → grant → hourly cap → kill switch), with every outcome in the agent audit (verb roles or
17
+ // roles.release). Reserved roles stay the user's to assign. Each change is in the admin audit and the event
18
+ // journal once (session.named, role.claimed, role.released, session.archived); a no-op is neither.
19
+ import type { ActingSwitch } from "./acting-switch"
20
+ import { auditOf } from "./acting"
21
+ import type { AgentAudit, AuditDecision, AuditOutcome } from "./audit"
22
+ import { partyFromActor } from "./envelope"
23
+ import { type AgentEvents, archiveEvent, type EventInput, namedEvent, pinnedEvent, roleEvent } from "./events"
24
+ import type { Grant, Grants } from "./grants"
25
+ import path from "node:path"
26
+ import type { CatalogMachine, Registry, RegistryMessage } from "../registry"
27
+ import type { Store } from "../store"
28
+ import { Directory, type ReportedSession, type SessionSource } from "./directory"
29
+ import { AgentError } from "./errors"
30
+ import { ArchiveInactive, parseInactiveFor } from "./archive-inactive"
31
+ import { slugify } from "./names"
32
+ import { AGENT_PREFIX, type AgentContext, type AgentRoute } from "./routes"
33
+ import type { CursorKeys } from "./event-cursor"
34
+
35
+ /** What the directory reads from the Registry. */
36
+ export interface DirectoryRegistry {
37
+ catalog(): CatalogMachine[]
38
+ sessionTenant(sessionID: string): string | undefined
39
+ subscribe(listener: (message: RegistryMessage, tenant?: string) => void): () => void
40
+ }
41
+
42
+ type CatalogSession = CatalogMachine["processes"][number]["sessions"][number]
43
+
44
+ /** The `.project` scope of a session: its project name (else folder name), as a slug. */
45
+ function projectOf(s: Pick<CatalogSession, "projectName" | "project" | "directory">): string | undefined {
46
+ const name = s.projectName || path.basename(s.project || s.directory || "")
47
+ return name ? (slugify(name) ?? name.toLowerCase()) : undefined
48
+ }
49
+
50
+ /** Sessions the Registry reports for a user (subagents from the status trees too), and their Archive. */
51
+ export function registrySource(registry: Pick<DirectoryRegistry, "catalog" | "sessionTenant">, store: Pick<Store, "viewPrefs">): SessionSource {
52
+ return {
53
+ reported(userID) {
54
+ const out = new Map<string, ReportedSession>()
55
+ for (const machine of registry.catalog()) {
56
+ for (const process of machine.processes) {
57
+ for (const s of process.sessions) {
58
+ if (out.has(s.id) || registry.sessionTenant(s.id) !== userID) continue
59
+ const project = projectOf(s)
60
+ const scope = project ? { project } : {}
61
+ out.set(s.id, { sessionID: s.id, title: s.title, machineID: s.machineID, ...(s.parentID ? { parentID: s.parentID } : {}), ...scope, state: s.state })
62
+ for (const child of s.status) {
63
+ if (child.id === s.id || out.has(child.id)) continue
64
+ out.set(child.id, { sessionID: child.id, title: child.title, machineID: s.machineID, parentID: child.parentID ?? s.id, ...scope, state: child.state })
65
+ }
66
+ }
67
+ }
68
+ }
69
+ return [...out.values()]
70
+ },
71
+ archived: (userID) => new Set(store.viewPrefs(userID).hiddenSessions),
72
+ }
73
+ }
74
+
75
+ /** The hub's directory: fed by the Registry, synced for the affected users on every catalog change. */
76
+ export function hubDirectory(options: { registry: DirectoryRegistry; store: Pick<Store, "viewPrefs" | "updateAgentData" | "agentData" | "user">; now?: () => number }): Directory {
77
+ const { registry, store } = options
78
+ const directory = new Directory({ store, source: registrySource(registry, store), ...(options.now ? { now: options.now } : {}) })
79
+ /** Users with reported sessions at the last catalog change (they need a sync when theirs go away). */
80
+ let online = new Set<string>()
81
+ registry.subscribe((message) => {
82
+ if (message.type !== "catalog") return
83
+ const now = new Set<string>()
84
+ for (const machine of registry.catalog()) {
85
+ for (const process of machine.processes) {
86
+ for (const s of process.sessions) {
87
+ const tenant = registry.sessionTenant(s.id)
88
+ if (tenant !== undefined) now.add(tenant)
89
+ }
90
+ }
91
+ }
92
+ for (const userID of new Set([...online, ...now])) {
93
+ directory.sync(userID).catch((cause: unknown) => {
94
+ if (!(cause instanceof AgentError)) console.error("[hub] directory sync failed:", cause)
95
+ })
96
+ }
97
+ online = now
98
+ })
99
+ return directory
100
+ }
101
+
102
+ // ---------- routes ----------
103
+
104
+ type Body = Record<string, unknown>
105
+
106
+ async function readBody(request: Request): Promise<Body> {
107
+ const body = await request.json()
108
+ if (typeof body !== "object" || body === null || Array.isArray(body)) throw new AgentError("invalid", "Expected a JSON object")
109
+ return body as Body
110
+ }
111
+
112
+ function text(body: Body, field: string, required: true): string
113
+ function text(body: Body, field: string, required?: false): string | undefined
114
+ function text(body: Body, field: string, required = false): string | undefined {
115
+ const value = body[field]
116
+ if (value === undefined && !required) return undefined
117
+ if (typeof value !== "string" || !value || value.length > 300) throw new AgentError("invalid", `${field} must be a string`)
118
+ return value
119
+ }
120
+
121
+ function flag(body: Body, field: string): boolean | undefined {
122
+ const value = body[field]
123
+ if (value !== undefined && typeof value !== "boolean") throw new AgentError("invalid", `${field} must be true or false`)
124
+ return value
125
+ }
126
+
127
+ /**
128
+ * The session a write targets: a session token acts on its own session only (forbidden otherwise); a user
129
+ * names any of their sessions by address.
130
+ */
131
+ function target(directory: Directory, { actor, user }: AgentContext, addr: string | undefined): string {
132
+ if (actor.kind === "session") {
133
+ if (addr === undefined) return actor.sessionID
134
+ const sessionID = directory.resolveOne(user.id, addr).sessionID
135
+ if (sessionID !== actor.sessionID) throw new AgentError("forbidden", "A session token can only act for its own session")
136
+ return sessionID
137
+ }
138
+ if (addr === undefined) throw new AgentError("invalid", "session is required (ses_…, @name)")
139
+ return directory.resolveOne(user.id, addr).sessionID
140
+ }
141
+
142
+ const NO_STORE = { "cache-control": "no-store" }
143
+ const ok = (body: unknown) => Response.json(body, { headers: NO_STORE })
144
+
145
+ export interface DirectoryRoutesDeps {
146
+ readonly directory: Directory
147
+ readonly store: Pick<Store, "setSessionArchived" | "setSessionPinned" | "archiveSessionIfUnpinned" | "viewPrefs" | "agentData" | "audit">
148
+ readonly events?: Pick<AgentEvents, "append">
149
+ /** App role writes: the live session (its directory, for the grant's projects). */
150
+ readonly registry?: Pick<Registry, "sessionTenant" | "session">
151
+ readonly grants?: Pick<Grants, "usable" | "consume">
152
+ readonly acting?: Pick<ActingSwitch, "ensureActing">
153
+ readonly audit?: Pick<AgentAudit, "record">
154
+ readonly previewKeys?: CursorKeys
155
+ }
156
+
157
+ const MANAGE = { session: true, scope: "sessions:manage" } as const
158
+
159
+ export function directoryRoutes(deps: DirectoryRoutesDeps): AgentRoute[] {
160
+ const { directory, store, events } = deps
161
+
162
+ /** The admin audit and the journal, once per change. */
163
+ async function changed(context: AgentContext, action: string, sessionID: string, event: EventInput): Promise<void> {
164
+ await store.audit({ actor: context.user.id, action, target: sessionID, detail: context.actor.kind })
165
+ await events?.append(context.user.id, event).catch((cause) => console.error(`[hub] ${event.type} event failed: ${(cause as Error).message}`))
166
+ }
167
+
168
+ /** A role write: users and sessions as before; an app under a `roles` grant on the session's project, audited. */
169
+ async function roleWrite<T>(context: AgentContext, verb: "roles" | "roles.release", sessionID: string, run: () => Promise<T>): Promise<T> {
170
+ const { actor, user } = context
171
+ if (actor.kind !== "app") return run()
172
+ const { registry, grants, acting, audit } = deps
173
+ if (!registry || !grants || !acting || !audit) throw new AgentError("forbidden", "This hub does not let app tokens change roles")
174
+ let grant: Grant | undefined
175
+ const record = (decision: AuditDecision, outcome: AuditOutcome, reason?: string) =>
176
+ audit.record(user.id, { actor: partyFromActor(actor), verb, target: { sessionID }, ...(grant ? { grant: grant.id } : {}), decision, ...(reason ? { reason } : {}), outcome })
177
+ try {
178
+ acting.ensureActing(user.id)
179
+ const live = registry.sessionTenant(sessionID) === user.id ? registry.session(sessionID) : undefined
180
+ if (!live) throw new AgentError("offline", "No spoke reports this session now, so its project cannot be checked against a grant")
181
+ const usable = await grants.usable(actor, "roles", { directory: live.directory })
182
+ if (!usable.length) throw new AgentError("forbidden", "No grant lets this app change roles in this session's project")
183
+ grant = usable[0]!
184
+ await grants.consume(grant)
185
+ acting.ensureActing(user.id)
186
+ const result = await run()
187
+ await record("allow", "ok")
188
+ return result
189
+ } catch (cause) {
190
+ const [decision, outcome] = auditOf(cause)
191
+ await record(decision, outcome, cause instanceof Error ? cause.message : undefined)
192
+ throw cause
193
+ }
194
+ }
195
+
196
+ return [
197
+ ...(deps.previewKeys ? [{
198
+ method: "POST" as const,
199
+ path: `${AGENT_PREFIX}/sessions/archive-inactive`,
200
+ allow: { scope: "sessions:manage" },
201
+ async handle(context: AgentContext) {
202
+ const inactive = new ArchiveInactive({ secret: deps.previewKeys!.active().secret })
203
+ const body = await readBody(context.request)
204
+ const prefs = store.viewPrefs(context.user.id)
205
+ const entries = (store.agentData(context.user.id).sessions ?? []).map((s) => ({ sessionID: s.sessionID, online: !!deps.registry?.session(s.sessionID), ...(s.lastSeen !== undefined ? { lastSeen: s.lastSeen } : {}), roles: [] }))
206
+ if (body.preview === true) {
207
+ const limit = body.limit
208
+ if (!Number.isInteger(limit) || Number(limit) < 1 || Number(limit) > 200) throw new AgentError("invalid", "limit must be a whole number from 1 to 200")
209
+ return ok(inactive.preview(context.user.id, entries, { pinned: new Set(prefs.pinnedSessions), archived: new Set(prefs.hiddenSessions), inactiveForMs: parseInactiveFor(body.inactiveFor), limit: Number(limit) }))
210
+ }
211
+ if (body.confirm !== true || typeof body.previewToken !== "string") throw new AgentError("invalid", "Use preview:true first, then confirm:true with its previewToken")
212
+ const open = new Set(entries.filter((s) => deps.registry?.session(s.sessionID)?.open).map((s) => s.sessionID))
213
+ const active = new Set(entries.filter((s) => { const v = deps.registry?.session(s.sessionID); return !!v && (v.busy || v.status.some((x) => x.state === "working" || x.state === "blocked")) }).map((s) => s.sessionID))
214
+ const checked = inactive.revalidate(context.user.id, body.previewToken, entries, { pinned: new Set(prefs.pinnedSessions), archived: new Set(prefs.hiddenSessions), open, active })
215
+ const changedIDs: string[] = []
216
+ const skipped = [...checked.skipped]
217
+ for (const sessionID of checked.candidates) {
218
+ const result = await store.archiveSessionIfUnpinned(context.user.id, sessionID)
219
+ if (result === "changed") { changedIDs.push(sessionID); await changed(context, "session.archive", sessionID, archiveEvent(sessionID, true)) }
220
+ else skipped.push({ sessionID, reason: result === "pinned" ? "pinned" : result === "not_found" ? "not_found" : "archived" })
221
+ }
222
+ if (changedIDs.length) await directory.sync(context.user.id)
223
+ return ok({ changed: changedIDs, skipped })
224
+ },
225
+ }] : []),
226
+ {
227
+ method: "PUT",
228
+ path: `${AGENT_PREFIX}/sessions/:addr/pin`,
229
+ allow: { scope: "sessions:manage" },
230
+ async handle(context) {
231
+ const body = await readBody(context.request)
232
+ if (flag(body, "pinned") === undefined) throw new AgentError("invalid", "pinned must be true or false")
233
+ const sessionID = target(directory, context, context.params.addr)
234
+ const pinned = body.pinned === true
235
+ const result = await store.setSessionPinned(context.user.id, sessionID, pinned)
236
+ if (!result) throw new AgentError("not_found", `No session ${context.params.addr}`)
237
+ if (result.changed) await changed(context, pinned ? "session.pin" : "session.unpin", sessionID, pinnedEvent(sessionID, pinned))
238
+ return ok({ sessionID, pinned, changed: result.changed })
239
+ },
240
+ },
241
+ {
242
+ method: "PUT",
243
+ path: `${AGENT_PREFIX}/sessions/:addr/archive`,
244
+ allow: { scope: "sessions:manage" },
245
+ async handle(context) {
246
+ const body = await readBody(context.request)
247
+ if (flag(body, "archived") === undefined) throw new AgentError("invalid", "archived must be true or false")
248
+ const sessionID = target(directory, context, context.params.addr)
249
+ const archived = body.archived === true
250
+ const result = await store.setSessionArchived(context.user.id, sessionID, archived)
251
+ if (!result) throw new AgentError("invalid", "The archive is full; unarchive a session first")
252
+ if (result.changed) {
253
+ await directory.sync(context.user.id)
254
+ await changed(context, archived ? "session.archive" : "session.unarchive", sessionID, archiveEvent(sessionID, archived))
255
+ }
256
+ return ok({ sessionID, archived, changed: result.changed })
257
+ },
258
+ },
259
+ {
260
+ method: "GET",
261
+ path: `${AGENT_PREFIX}/who`,
262
+ // Reading the directory: sessions, and app tokens holding sessions:read; writes stay users and sessions.
263
+ allow: { session: true, scope: "sessions:read" },
264
+ handle: ({ url, user }) => {
265
+ const project = url.searchParams.get("project") || undefined
266
+ const role = url.searchParams.get("role") || undefined
267
+ return ok({ sessions: directory.who(user.id, { ...(project ? { project } : {}), ...(role ? { role } : {}) }) })
268
+ },
269
+ },
270
+ {
271
+ method: "POST",
272
+ path: `${AGENT_PREFIX}/name`,
273
+ allow: MANAGE,
274
+ handle: async (context) => {
275
+ const body = await readBody(context.request)
276
+ const name = text(body, "name", true)
277
+ const sessionID = target(directory, context, text(body, "session"))
278
+ const before = directory.resolveOne(context.user.id, sessionID).name
279
+ const named = await directory.setName(context.user.id, sessionID, name)
280
+ if (named.name !== before) await changed(context, "session.name", sessionID, namedEvent(sessionID, named.name))
281
+ return ok(named)
282
+ },
283
+ },
284
+ {
285
+ method: "POST",
286
+ path: `${AGENT_PREFIX}/roles/claim`,
287
+ allow: MANAGE,
288
+ handle: async (context) => {
289
+ const body = await readBody(context.request)
290
+ const role = text(body, "role", true)
291
+ const exclusive = flag(body, "exclusive")
292
+ const project = text(body, "project")
293
+ const sessionID = target(directory, context, text(body, "session"))
294
+ const claim = await roleWrite(context, "roles", sessionID, async () => {
295
+ const before = directory.resolveOne(context.user.id, sessionID).roles
296
+ const claim = await directory.claimRole(context.user.id, sessionID, role, {
297
+ ...(exclusive !== undefined ? { exclusive } : {}),
298
+ ...(project !== undefined ? { project } : {}),
299
+ byUser: context.actor.kind === "user",
300
+ })
301
+ const prev = before.find((r) => r.role === claim.role && r.project === claim.project)
302
+ if (!prev || prev.generation !== claim.generation || !!prev.exclusive !== !!claim.exclusive) {
303
+ await changed(context, "role.claim", sessionID, roleEvent(sessionID, true, claim))
304
+ }
305
+ return claim
306
+ })
307
+ return ok({ sessionID, ...claim })
308
+ },
309
+ },
310
+ {
311
+ method: "POST",
312
+ path: `${AGENT_PREFIX}/roles/release`,
313
+ allow: MANAGE,
314
+ handle: async (context) => {
315
+ const body = await readBody(context.request)
316
+ const role = text(body, "role", true)
317
+ const project = text(body, "project")
318
+ const sessionID = target(directory, context, text(body, "session"))
319
+ const released = await roleWrite(context, "roles.release", sessionID, async () => {
320
+ const released = await directory.releaseRole(context.user.id, sessionID, role, project)
321
+ await changed(context, "role.release", sessionID, roleEvent(sessionID, false, released))
322
+ return released
323
+ })
324
+ return ok({ sessionID, ...released, released: true })
325
+ },
326
+ },
327
+ ]
328
+ }