@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,553 @@
1
+ // The hub client: bearer requests to the hub's /api/agent with JSON bodies and query strings; failures become
2
+ // HubError (the envelope) or HubTransportError (errors.ts). Typed verbs are added on top of call()/request().
3
+ import { envelopeOf, HubError, HubTransportError } from "./errors.ts"
4
+ import { sseEvents } from "./sse.ts"
5
+ import type {
6
+ Approved,
7
+ AppTokenView,
8
+ Archived,
9
+ ArchiveInactivePreview,
10
+ ArchiveInactiveResult,
11
+ Asked,
12
+ CreatedAppToken,
13
+ Decision,
14
+ DecisionAnswered,
15
+ DecisionOption,
16
+ DecisionState,
17
+ DecisionTier,
18
+ DecisionUrgency,
19
+ Denied,
20
+ DirectoryEntry,
21
+ FormAnswer,
22
+ FormAnswered,
23
+ Grant,
24
+ GrantRequested,
25
+ GrantRequestSpec,
26
+ GrantSpec,
27
+ Inbox,
28
+ LaunchCreated,
29
+ LaunchSpec,
30
+ LaunchState,
31
+ LaunchView,
32
+ LogRow,
33
+ Named,
34
+ PromptDelivery,
35
+ Prompted,
36
+ Pinned,
37
+ Replied,
38
+ RoleClaim,
39
+ RoleRelease,
40
+ Sent,
41
+ SentAll,
42
+ SessionDetail,
43
+ SessionView,
44
+ Status,
45
+ Stopped,
46
+ WatchEvent,
47
+ EventsPage,
48
+ Whoami,
49
+ WebhookDelivery,
50
+ WebhookView,
51
+ } from "./types.ts"
52
+
53
+ const AGENT = "/api/agent"
54
+
55
+ /** The longest wait the hub serves in one request (seconds); longer waits loop. */
56
+ export const WAIT_POLL_MAX_S = 60
57
+
58
+ export type QueryValue = string | number | boolean | readonly string[] | undefined
59
+
60
+ export interface RequestOptions {
61
+ /** Query parameters; undefined values are skipped, arrays repeat the key. */
62
+ readonly query?: Readonly<Record<string, QueryValue>>
63
+ /** Sent as JSON. */
64
+ readonly body?: unknown
65
+ readonly signal?: AbortSignal
66
+ }
67
+
68
+ /** The part of fetch the client uses. */
69
+ export type Fetch = (input: string, init: RequestInit) => Promise<Response>
70
+
71
+ export interface HubClientOptions {
72
+ /** The hub's base URL, e.g. https://hub.example.com. */
73
+ readonly url: string
74
+ /** A session token (HUB_SESSION_TOKEN), an app token or a device token. */
75
+ readonly token: string
76
+ /** Defaults to the global fetch. */
77
+ readonly fetch?: Fetch
78
+ }
79
+
80
+ /** `?a=1&b=2` (repeated keys for arrays); empty when nothing is set. */
81
+ export function queryString(query: Readonly<Record<string, QueryValue>> = {}): string {
82
+ const params = new URLSearchParams()
83
+ for (const [key, value] of Object.entries(query)) {
84
+ if (value === undefined) continue
85
+ for (const v of Array.isArray(value) ? value : [value]) params.append(key, String(v))
86
+ }
87
+ const text = params.toString()
88
+ return text ? `?${text}` : ""
89
+ }
90
+
91
+ /** A path segment (an address or ID); `@` stays readable (it is valid in a path). */
92
+ export const segment = (value: string): string => encodeURIComponent(value).replace(/%40/g, "@")
93
+
94
+ /** `@@reviewer` → `reviewer` (the role routes take the bare role). */
95
+ const bareRole = (role: string) => role.replace(/^@@/, "")
96
+
97
+ /** Only an abort signal. */
98
+ export interface CallOptions {
99
+ readonly signal?: AbortSignal
100
+ }
101
+
102
+ export interface SessionsFilter extends CallOptions {
103
+ readonly project?: string
104
+ /** One state or several (sent comma-separated). */
105
+ readonly state?: string | readonly string[]
106
+ readonly machine?: string
107
+ /** `key=value` labels, all of which must match. */
108
+ readonly label?: readonly string[]
109
+ }
110
+
111
+ export interface LaunchesFilter extends CallOptions {
112
+ /** One state or several (sent comma-separated). */
113
+ readonly state?: LaunchState | readonly LaunchState[]
114
+ /** `key=value` launch labels, all of which must match. */
115
+ readonly label?: readonly string[]
116
+ }
117
+
118
+ /** hub.launches: start sessions (users; app tokens with sessions:launch and a launch grant; sessions by act token). */
119
+ export interface Launches {
120
+ /** Starts a launch; the same key and body again answers the same launch (created false). */
121
+ create(spec: LaunchSpec, options?: CallOptions): Promise<LaunchCreated>
122
+ /** The caller's launches, oldest first (apps and sessions: only those they started). */
123
+ list(filter?: LaunchesFilter): Promise<{ launches: LaunchView[] }>
124
+ get(id: string, options?: CallOptions): Promise<{ launch: LaunchView }>
125
+ /** Ends a launch (outcome stopped); one that already ended is gone (HubError). */
126
+ stop(id: string, options?: CallOptions): Promise<{ launch: LaunchView }>
127
+ }
128
+
129
+ export interface TargetOptions extends CallOptions {
130
+ /** Act on this session (address) instead of the caller's own. */
131
+ readonly session?: string
132
+ }
133
+
134
+ export interface RoleOptions extends TargetOptions {
135
+ readonly project?: string
136
+ }
137
+
138
+ export interface MsgOptions extends CallOptions {
139
+ /** false: admit without starting a turn. */
140
+ readonly wake?: boolean
141
+ /** Steer into a running turn. */
142
+ readonly urgent?: boolean
143
+ }
144
+
145
+ export interface AskOptions extends MsgOptions {
146
+ /** Seconds until the ask expires (the hub's default when unset). */
147
+ readonly expires?: number
148
+ /** Seconds to wait for the answer; the hub serves at most WAIT_POLL_MAX_S per request, so longer waits loop. */
149
+ readonly wait?: number
150
+ // Asks to @me open a Decision (design §12.1) with these:
151
+ readonly options?: readonly DecisionOption[]
152
+ readonly recommendation?: { readonly option: string; readonly why?: string }
153
+ readonly urgency?: DecisionUrgency
154
+ readonly tier?: DecisionTier
155
+ /** Seconds until the deadline. */
156
+ readonly deadline?: number
157
+ /** A safe option taken at the deadline. */
158
+ readonly onTimeout?: string
159
+ /** Addresses of the sessions it concerns. */
160
+ readonly about?: readonly string[]
161
+ }
162
+
163
+ /** The Decision fields of AskOptions, when given. */
164
+ const decisionFields = (options: AskOptions) => {
165
+ const out: Record<string, unknown> = {}
166
+ for (const key of ["options", "recommendation", "urgency", "tier", "deadline", "onTimeout", "about"] as const) if (options[key] !== undefined) out[key] = options[key]
167
+ return out
168
+ }
169
+
170
+ /** Options of the act verbs: the reason, recorded in the agent audit (deny passes it to the harness). */
171
+ export interface ActOptions extends CallOptions {
172
+ readonly why?: string
173
+ }
174
+
175
+ export interface ApproveOptions extends ActOptions {
176
+ /** Approve for good (OpenCode saves the rule); agents need a grant with allowAlways. */
177
+ readonly always?: boolean
178
+ }
179
+
180
+ export interface PromptOptions extends ActOptions {
181
+ readonly delivery?: PromptDelivery
182
+ /** Agents' prompts always wake the session (false is refused). */
183
+ readonly wake?: boolean
184
+ /** Users only. */
185
+ readonly images?: readonly { readonly mime: string; readonly data: string; readonly name?: string }[]
186
+ }
187
+
188
+ /** The why of an act verb, when given. */
189
+ const whyField = (options: ActOptions) => (options.why !== undefined ? { why: options.why } : {})
190
+
191
+ const signalOf = (options: CallOptions) => (options.signal ? { signal: options.signal } : {})
192
+ /** The wake and urgent fields of a send, when given. */
193
+ const sendFields = (options: MsgOptions) => ({ ...(options.wake !== undefined ? { wake: options.wake } : {}), ...(options.urgent !== undefined ? { urgent: options.urgent } : {}) })
194
+ /** The session and project fields of a role request, when given. */
195
+ const roleTarget = (options: RoleOptions) => ({ ...(options.session ? { session: options.session } : {}), ...(options.project !== undefined ? { project: options.project } : {}) })
196
+
197
+ export class HubClient {
198
+ /** The URL as given (used in messages). */
199
+ readonly url: string
200
+ readonly #base: string
201
+ readonly #token: string
202
+ readonly #fetch: Fetch
203
+
204
+ /** Launches (design §6.1): create, list, get, stop. */
205
+ readonly launches: Launches = {
206
+ create: async (spec, options = {}) => {
207
+ const response = await this.request("POST", `${AGENT}/launches`, { body: spec, ...signalOf(options) })
208
+ const body = (await this.#json(response, `${AGENT}/launches`)) as { launch: LaunchView }
209
+ return { launch: body.launch, created: response.status === 201 }
210
+ },
211
+ list: (filter = {}) => {
212
+ const state = typeof filter.state === "string" ? filter.state : filter.state?.join(",")
213
+ return this.call("GET", `${AGENT}/launches`, { query: { state, label: filter.label }, ...signalOf(filter) })
214
+ },
215
+ get: (id, options = {}) => this.call("GET", `${AGENT}/launches/${segment(id)}`, signalOf(options)),
216
+ stop: (id, options = {}) => this.call("DELETE", `${AGENT}/launches/${segment(id)}`, signalOf(options)),
217
+ }
218
+
219
+ constructor(options: HubClientOptions) {
220
+ this.url = options.url
221
+ this.#base = options.url.replace(/\/+$/, "")
222
+ this.#token = options.token
223
+ this.#fetch = options.fetch ?? ((input, init) => fetch(input, init))
224
+ }
225
+
226
+ /** Sends a request; resolves the response when OK, else throws HubError / HubTransportError. */
227
+ async request(method: string, pathname: string, options: RequestOptions = {}): Promise<Response> {
228
+ const target = `${pathname}${queryString(options.query)}`
229
+ const headers: Record<string, string> = { authorization: `Bearer ${this.#token}`, accept: "application/json" }
230
+ if (options.body !== undefined) headers["content-type"] = "application/json"
231
+ let response: Response
232
+ try {
233
+ response = await this.#fetch(`${this.#base}${target}`, {
234
+ method,
235
+ headers,
236
+ ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}),
237
+ ...(options.signal ? { signal: options.signal } : {}),
238
+ redirect: "manual",
239
+ })
240
+ } catch (cause) {
241
+ if (options.signal?.aborted) throw cause
242
+ throw new HubTransportError("unreachable", `cannot reach ${this.url} (${cause instanceof Error ? cause.message : String(cause)})`, { cause })
243
+ }
244
+ if (response.ok) return response
245
+ const text = await response.text().catch(() => "")
246
+ let body: unknown
247
+ try {
248
+ body = JSON.parse(text)
249
+ } catch {
250
+ body = undefined
251
+ }
252
+ const envelope = envelopeOf(body)
253
+ if (envelope) throw new HubError(response.status, envelope)
254
+ throw new HubTransportError("http", `HTTP ${response.status} from ${this.url}${target}`, { status: response.status })
255
+ }
256
+
257
+ /** request() answering JSON (undefined for 204). */
258
+ async call<T = unknown>(method: string, pathname: string, options: RequestOptions = {}): Promise<T> {
259
+ const response = await this.request(method, pathname, options)
260
+ if (response.status === 204) return undefined as T
261
+ return (await this.#json(response, `${pathname}${queryString(options.query)}`)) as T
262
+ }
263
+
264
+ async #json(response: Response, target: string): Promise<unknown> {
265
+ try {
266
+ return await response.json()
267
+ } catch (cause) {
268
+ throw new HubTransportError("bad_response", `the hub at ${this.url} did not answer JSON for ${target}`, { cause })
269
+ }
270
+ }
271
+
272
+ // Reading (sessions:read).
273
+
274
+ whoami(options: CallOptions = {}): Promise<Whoami> {
275
+ return this.call("GET", `${AGENT}/whoami`, signalOf(options))
276
+ }
277
+
278
+ status(options: CallOptions = {}): Promise<Status> {
279
+ return this.call("GET", `${AGENT}/status`, signalOf(options))
280
+ }
281
+
282
+ sessions(filter: SessionsFilter = {}): Promise<{ sessions: SessionView[] }> {
283
+ const state = typeof filter.state === "string" ? filter.state : filter.state?.join(",")
284
+ return this.call("GET", `${AGENT}/sessions`, {
285
+ query: { project: filter.project, state, machine: filter.machine, label: filter.label },
286
+ ...signalOf(filter),
287
+ })
288
+ }
289
+
290
+ /** One session by address (ses_… or a prefix, @name, @@role, @name.project). */
291
+ show(addr: string, options: CallOptions = {}): Promise<SessionDetail> {
292
+ return this.call("GET", `${AGENT}/sessions/${segment(addr)}`, signalOf(options))
293
+ }
294
+
295
+ /** The newest timeline rows (n: 1–200, the hub's default when unset). */
296
+ log(addr: string, options: CallOptions & { readonly n?: number | string } = {}): Promise<{ rows: LogRow[] }> {
297
+ return this.call("GET", `${AGENT}/sessions/${segment(addr)}/log`, { query: { n: options.n }, ...signalOf(options) })
298
+ }
299
+
300
+ /** Named and role-holding sessions. */
301
+ who(options: CallOptions & { readonly project?: string; readonly role?: string } = {}): Promise<{ sessions: DirectoryEntry[] }> {
302
+ return this.call("GET", `${AGENT}/who`, { query: { project: options.project, role: options.role === undefined ? undefined : bareRole(options.role) }, ...signalOf(options) })
303
+ }
304
+
305
+ /**
306
+ * GET /api/agent/watch: the journal's compact events (the same as events()) as they happen, until the hub closes
307
+ * the stream or `signal` aborts (which ends the iteration quietly). blocked: only blocker events; session: one
308
+ * address; cursor: resume after an event's `id` (or an events() cursor).
309
+ */
310
+ async *watch(options: CallOptions & { readonly blocked?: boolean; readonly session?: string; readonly cursor?: string } = {}): AsyncGenerator<WatchEvent, void, undefined> {
311
+ const { signal } = options
312
+ if (signal?.aborted) return
313
+ let response: Response
314
+ try {
315
+ response = await this.request("GET", `${AGENT}/watch`, { query: { blocked: options.blocked ? "1" : undefined, session: options.session, cursor: options.cursor }, ...signalOf(options) })
316
+ } catch (cause) {
317
+ if (signal?.aborted) return
318
+ throw cause
319
+ }
320
+ yield* sseEvents(response, signal)
321
+ }
322
+
323
+ /**
324
+ * GET /api/agent/events: the compact events after `cursor` (none: start at the latest). wait: seconds (0–60) to
325
+ * hold while nothing matches; limit 1–200; types (exact, or `launch.*`) and sessions filter but still advance the
326
+ * cursor. App tokens need events:subscribe plus each family's scope.
327
+ */
328
+ events(options: CallOptions & { readonly cursor?: string; readonly wait?: number; readonly limit?: number; readonly types?: readonly string[]; readonly sessions?: readonly string[] } = {}): Promise<EventsPage> {
329
+ return this.call("GET", `${AGENT}/events`, { query: { cursor: options.cursor, wait: options.wait, limit: options.limit, type: options.types, session: options.sessions }, ...signalOf(options) })
330
+ }
331
+
332
+ webhookCreate(input: { readonly url: string; readonly events?: readonly string[]; readonly label?: string }, options: CallOptions = {}): Promise<{ webhook: WebhookView; secret: string }> {
333
+ return this.call("POST", `${AGENT}/webhooks`, { body: input, ...signalOf(options) })
334
+ }
335
+
336
+ webhooks(options: CallOptions = {}): Promise<{ webhooks: WebhookView[] }> {
337
+ return this.call("GET", `${AGENT}/webhooks`, signalOf(options))
338
+ }
339
+
340
+ webhook(id: string, options: CallOptions = {}): Promise<{ webhook: WebhookView; deliveries: WebhookDelivery[] }> {
341
+ return this.call("GET", `${AGENT}/webhooks/${segment(id)}`, signalOf(options))
342
+ }
343
+
344
+ webhookDelete(id: string, options: CallOptions = {}): Promise<void> {
345
+ return this.call("DELETE", `${AGENT}/webhooks/${segment(id)}`, signalOf(options))
346
+ }
347
+
348
+ // Organization (sessions:manage).
349
+
350
+ setArchived(addr: string, archived: boolean, options: CallOptions = {}): Promise<Archived> {
351
+ return this.call("PUT", `${AGENT}/sessions/${segment(addr)}/archive`, { body: { archived }, ...signalOf(options) })
352
+ }
353
+
354
+ setPinned(addr: string, pinned: boolean, options: CallOptions = {}): Promise<Pinned> {
355
+ return this.call("PUT", `${AGENT}/sessions/${segment(addr)}/pin`, { body: { pinned }, ...signalOf(options) })
356
+ }
357
+
358
+ previewArchiveInactive(inactiveFor: string, limit: number, options: CallOptions = {}): Promise<ArchiveInactivePreview> {
359
+ return this.call("POST", `${AGENT}/sessions/archive-inactive`, { body: { preview: true, inactiveFor, limit }, ...signalOf(options) })
360
+ }
361
+
362
+ archiveInactive(previewToken: string, options: CallOptions = {}): Promise<ArchiveInactiveResult> {
363
+ return this.call("POST", `${AGENT}/sessions/archive-inactive`, { body: { previewToken, confirm: true }, ...signalOf(options) })
364
+ }
365
+
366
+ // Naming.
367
+
368
+ name(name: string, options: TargetOptions = {}): Promise<Named> {
369
+ return this.call("POST", `${AGENT}/name`, { body: { name, ...(options.session ? { session: options.session } : {}) }, ...signalOf(options) })
370
+ }
371
+
372
+ claimRole(role: string, options: RoleOptions & { readonly exclusive?: boolean } = {}): Promise<RoleClaim> {
373
+ return this.call("POST", `${AGENT}/roles/claim`, { body: { role: bareRole(role), ...(options.exclusive ? { exclusive: true } : {}), ...roleTarget(options) }, ...signalOf(options) })
374
+ }
375
+
376
+ releaseRole(role: string, options: RoleOptions = {}): Promise<RoleRelease> {
377
+ return this.call("POST", `${AGENT}/roles/release`, { body: { role: bareRole(role), ...roleTarget(options) }, ...signalOf(options) })
378
+ }
379
+
380
+ // Messaging (messages:send / messages:read).
381
+
382
+ /** Tells a session something (fire and forget). */
383
+ msg(to: string, text: string, options?: MsgOptions & { readonly all?: false }): Promise<Sent>
384
+ /** To every session the address names (each holder of a role). */
385
+ msg(to: string, text: string, options: MsgOptions & { readonly all: true }): Promise<SentAll>
386
+ msg(to: string, text: string, options: MsgOptions & { readonly all?: boolean } = {}): Promise<Sent | SentAll> {
387
+ return this.call("POST", `${AGENT}/msg`, { body: { to, text, ...sendFields(options), ...(options.all ? { all: true } : {}) }, ...signalOf(options) })
388
+ }
389
+
390
+ /** Asks a session; with wait, polls (≤WAIT_POLL_MAX_S each) until it is answered, declined or expired, or the wait is spent. */
391
+ ask(to: string, text: string, options?: AskOptions & { readonly all?: false }): Promise<Asked>
392
+ /** To every session the address names (no wait: TypeError). */
393
+ ask(to: string, text: string, options: AskOptions & { readonly all: true }): Promise<SentAll>
394
+ async ask(to: string, text: string, options: AskOptions & { readonly all?: boolean } = {}): Promise<Asked | SentAll> {
395
+ if (options.all && options.wait !== undefined) throw new TypeError("ask: wait cannot be combined with all")
396
+ let left = options.wait ?? 0
397
+ const first = Math.min(left, WAIT_POLL_MAX_S)
398
+ left -= first
399
+ const body = {
400
+ to,
401
+ text,
402
+ ...sendFields(options),
403
+ ...(options.all ? { all: true } : {}),
404
+ ...(options.expires !== undefined ? { expiresIn: options.expires } : {}),
405
+ ...(options.wait !== undefined ? { wait: first } : {}),
406
+ ...decisionFields(options),
407
+ }
408
+ const asked = await this.call<Asked | SentAll>("POST", `${AGENT}/ask`, { body, ...signalOf(options) })
409
+ if ("sent" in asked) return asked
410
+ let current: Asked = asked
411
+ while (current.message.state === "open" && left > 0 && !options.signal?.aborted) {
412
+ const wait = Math.min(left, WAIT_POLL_MAX_S)
413
+ left -= wait
414
+ current = { ...(asked.delivery ? { delivery: asked.delivery } : {}), ...(asked.decision ? { decision: asked.decision } : {}), ...(await this.message(current.message.id, { wait, ...signalOf(options) })) }
415
+ }
416
+ return current
417
+ }
418
+
419
+ /** One message the caller sent or received; wait (≤WAIT_POLL_MAX_S seconds) long-polls an open ask. */
420
+ message(id: string, options: CallOptions & { readonly wait?: number } = {}): Promise<Asked> {
421
+ return this.call("GET", `${AGENT}/messages/${segment(id)}`, { query: { wait: options.wait }, ...signalOf(options) })
422
+ }
423
+
424
+ /** Replies to a message (always to its real sender). */
425
+ reply(id: string, text: string, options: CallOptions & { readonly wake?: boolean } = {}): Promise<Replied> {
426
+ return this.call("POST", `${AGENT}/reply`, { body: { replyTo: id, text, ...(options.wake !== undefined ? { wake: options.wake } : {}) }, ...signalOf(options) })
427
+ }
428
+
429
+ /** Declines an ask, with a reason. */
430
+ decline(id: string, why: string, options: CallOptions = {}): Promise<Replied> {
431
+ return this.call("POST", `${AGENT}/decline`, { body: { replyTo: id, why }, ...signalOf(options) })
432
+ }
433
+
434
+ /** Open asks to the caller, its own waiting asks, recent messages and mutes. */
435
+ inbox(options: CallOptions = {}): Promise<Inbox> {
436
+ return this.call("GET", `${AGENT}/inbox`, signalOf(options))
437
+ }
438
+
439
+ /** Refuses messages from a session (an address). */
440
+ mute(session: string, options: CallOptions = {}): Promise<{ muted: string[] }> {
441
+ return this.call("POST", `${AGENT}/mute`, { body: { session }, ...signalOf(options) })
442
+ }
443
+
444
+ unmute(session: string, options: CallOptions = {}): Promise<{ muted: string[] }> {
445
+ return this.call("POST", `${AGENT}/unmute`, { body: { session }, ...signalOf(options) })
446
+ }
447
+
448
+ // Decisions (asks to @me).
449
+
450
+ /** Decisions the caller asked (the user: all of theirs), newest first. */
451
+ decisions(options: CallOptions & { readonly state?: DecisionState } = {}): Promise<{ decisions: Decision[] }> {
452
+ return this.call("GET", `${AGENT}/decisions`, { query: { state: options.state }, ...signalOf(options) })
453
+ }
454
+
455
+ decision(id: string, options: CallOptions = {}): Promise<{ decision: Decision }> {
456
+ return this.call("GET", `${AGENT}/decisions/${segment(id)}`, signalOf(options))
457
+ }
458
+
459
+ /** Answers a Decision as the user (a device token; tap Decisions need the console): first answer wins, later ones are gone. */
460
+ answerDecision(id: string, answer: CallOptions & { readonly option?: string; readonly text?: string }): Promise<DecisionAnswered> {
461
+ const { signal: _signal, ...body } = answer
462
+ return this.call("POST", `/api/decisions/${segment(id)}/answer`, { body, ...signalOf(answer) })
463
+ }
464
+
465
+ // Acting (users; sessions through their act token; app tokens with sessions:act). Outside the caller's grant the
466
+ // hub answers needs_user (HubError; needsUserOf reads its evaluation).
467
+
468
+ /** Approves a pending permission request in a session's tree. */
469
+ approve(addr: string, requestID: string, options: ApproveOptions = {}): Promise<Approved> {
470
+ const body = { requestID, ...(options.always ? { always: true } : {}), ...whyField(options) }
471
+ return this.call("POST", `${AGENT}/sessions/${segment(addr)}/approve`, { body, ...signalOf(options) })
472
+ }
473
+
474
+ /** Rejects a pending permission request; the why reaches the harness (with who denied it). */
475
+ deny(addr: string, requestID: string, options: ActOptions = {}): Promise<Denied> {
476
+ return this.call("POST", `${AGENT}/sessions/${segment(addr)}/deny`, { body: { requestID, ...whyField(options) }, ...signalOf(options) })
477
+ }
478
+
479
+ /** Answers a pending form (question). */
480
+ answer(addr: string, formID: string, answer: FormAnswer, options: ActOptions = {}): Promise<FormAnswered> {
481
+ return this.call("POST", `${AGENT}/sessions/${segment(addr)}/answer`, { body: { formID, answer, ...whyField(options) }, ...signalOf(options) })
482
+ }
483
+
484
+ /** Dismisses a pending form without an answer. */
485
+ cancelForm(addr: string, formID: string, options: ActOptions = {}): Promise<FormAnswered> {
486
+ return this.call("POST", `${AGENT}/sessions/${segment(addr)}/answer`, { body: { formID, cancel: true, ...whyField(options) }, ...signalOf(options) })
487
+ }
488
+
489
+ /** Prompts a session (agents' prompts carry the hub's provenance). */
490
+ prompt(addr: string, text: string, options: PromptOptions = {}): Promise<Prompted> {
491
+ const body = {
492
+ text,
493
+ ...(options.images ? { images: options.images } : {}),
494
+ ...(options.delivery ? { delivery: options.delivery } : {}),
495
+ ...(options.wake !== undefined ? { wake: options.wake } : {}),
496
+ ...whyField(options),
497
+ }
498
+ return this.call("POST", `${AGENT}/sessions/${segment(addr)}/prompt`, { body, ...signalOf(options) })
499
+ }
500
+
501
+ /** Stops a session's running turn. */
502
+ stop(addr: string, options: ActOptions = {}): Promise<Stopped> {
503
+ return this.call("POST", `${AGENT}/sessions/${segment(addr)}/stop`, { body: whyField(options), ...signalOf(options) })
504
+ }
505
+
506
+ // Grants (user credentials only: a session or app token is forbidden).
507
+
508
+ /** The user's grants, newest first. */
509
+ grants(options: CallOptions = {}): Promise<{ grants: Grant[] }> {
510
+ return this.call("GET", `${AGENT}/grants`, signalOf(options))
511
+ }
512
+
513
+ grant(id: string, options: CallOptions = {}): Promise<{ grant: Grant }> {
514
+ return this.call("GET", `${AGENT}/grants/${segment(id)}`, signalOf(options))
515
+ }
516
+
517
+ createGrant(spec: GrantSpec, options: CallOptions = {}): Promise<{ grant: Grant }> {
518
+ return this.call("POST", `${AGENT}/grants`, { body: spec, ...signalOf(options) })
519
+ }
520
+
521
+ /** Replaces a grant (its holder stays). */
522
+ updateGrant(id: string, spec: GrantSpec, options: CallOptions = {}): Promise<{ grant: Grant }> {
523
+ return this.call("PUT", `${AGENT}/grants/${segment(id)}`, { body: spec, ...signalOf(options) })
524
+ }
525
+
526
+ /** Revokes a grant (gone when it already ended). */
527
+ revokeGrant(id: string, options: CallOptions = {}): Promise<{ grant: Grant }> {
528
+ return this.call("DELETE", `${AGENT}/grants/${segment(id)}`, signalOf(options))
529
+ }
530
+
531
+ /**
532
+ * Asks the user for a grant (sessions; app tokens with messages:send): opens a tap Decision for the user, whose
533
+ * approval creates the grant; the answer comes back as a reply to the ask (with the grant id when approved).
534
+ */
535
+ requestGrant(grant: GrantRequestSpec, why: string, options: CallOptions = {}): Promise<GrantRequested> {
536
+ return this.call("POST", `${AGENT}/grants/requests`, { body: { grant, why }, ...signalOf(options) })
537
+ }
538
+
539
+ // App tokens (user credentials only).
540
+
541
+ createAppToken(input: CallOptions & { readonly label?: string; readonly scopes?: readonly string[] } = {}): Promise<CreatedAppToken> {
542
+ const { signal: _signal, ...body } = input
543
+ return this.call("POST", `${AGENT}/app-tokens`, { body, ...signalOf(input) })
544
+ }
545
+
546
+ appTokens(options: CallOptions = {}): Promise<{ tokens: AppTokenView[] }> {
547
+ return this.call("GET", `${AGENT}/app-tokens`, signalOf(options))
548
+ }
549
+
550
+ revokeAppToken(id: string, options: CallOptions = {}): Promise<void> {
551
+ return this.call("DELETE", `${AGENT}/app-tokens/${segment(id)}`, signalOf(options))
552
+ }
553
+ }
@@ -0,0 +1,108 @@
1
+ // The message envelope (design §3.3) and its sanitiser: pure functions shared by the hub (which renders what a
2
+ // recipient session sees), the OpenCode plugin's hub tool and `hub mcp` (which render messages they read).
3
+ //
4
+ // - Text and every interpolated label are sanitised: ANSI escape sequences, C0 (except tab and newline), DEL,
5
+ // C1, bidi embeddings/overrides/isolates, zero-width characters, BOM and line/paragraph separators go.
6
+ // - The body sits between two per-message boundary lines; body lines that look like a header, a boundary, a
7
+ // reply hint (or an escaped one) are escaped with a leading backslash, so a body cannot forge any of them.
8
+ // - The sender is the hub's to set (from the authenticated actor); nothing here trusts a request body for it.
9
+ import type { Party } from "./types.ts"
10
+
11
+ /** Interpolated labels (names, harness, machine, the address as written) are cut to this many characters. */
12
+ export const LABEL_MAX = 120
13
+
14
+ export type EnvelopeKind = "msg" | "ask" | "reply" | "decline"
15
+
16
+ export interface EnvelopeInput {
17
+ readonly id: string
18
+ readonly kind: EnvelopeKind
19
+ /** The sender, from partyFromActor. */
20
+ readonly from: Party
21
+ /** The address as the sender wrote it (`@@reviewer`, `@x`, `ses_…`). */
22
+ readonly to: string
23
+ readonly at: number
24
+ readonly text: string
25
+ /** What a reply or decline answers. */
26
+ readonly replyTo?: string
27
+ /** When an ask expires. */
28
+ readonly expiresAt?: number
29
+ }
30
+
31
+ /** ANSI escape sequences: CSI, OSC (BEL or ST), DCS/PM/APC strings, other ESC sequences, 8-bit CSI. */
32
+ const ANSI =
33
+ /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|\u001b[P^_][^\u001b]*(?:\u001b\\)?|\u001b[ -/]*[0-~]?|\u009b[0-?]*[ -/]*[@-~]/g
34
+
35
+ /**
36
+ * C0 except tab/newline, DEL, C1, Arabic letter mark, zero-width and directional marks (U+200B-200F), line and
37
+ * paragraph separators and bidi embeddings/overrides (U+2028-202E), word joiner, invisible operators and bidi
38
+ * isolates (U+2060-206F), BOM.
39
+ */
40
+ const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g
41
+
42
+ /** Message text: newlines normalised, escape sequences and invisible/control characters removed. */
43
+ export function sanitizeText(text: string): string {
44
+ return text.replace(/\r\n?/g, "\n").replace(ANSI, "").replace(CONTROL, "")
45
+ }
46
+
47
+ /** A label: sanitised, one line (whitespace runs become one space), trimmed, at most `max` characters. */
48
+ export function sanitizeLabel(text: string, max = LABEL_MAX): string {
49
+ return Array.from(sanitizeText(text).replace(/\s+/g, " ").trim()).slice(0, max).join("").trimEnd()
50
+ }
51
+
52
+ /** How a party reads in an envelope: `@name (harness · machine)`, `ses_…`, `the user`, `app <label>`. */
53
+ export function partyLabel(party: Party): string {
54
+ switch (party.kind) {
55
+ case "user":
56
+ return "the user"
57
+ case "app":
58
+ return `app ${sanitizeLabel(party.label ?? "") || party.tokenID}`
59
+ case "session": {
60
+ const name = sanitizeLabel(party.name ?? "")
61
+ const details = [party.harness, party.machine].map((value) => sanitizeLabel(value ?? "")).filter(Boolean)
62
+ return `${name ? `@${name}` : party.sessionID}${details.length ? ` (${details.join(" · ")})` : ""}`
63
+ }
64
+ }
65
+ }
66
+
67
+ /** `MM-DD HH:MMZ` in UTC. */
68
+ function stamp(at: number): string {
69
+ const iso = new Date(at).toISOString()
70
+ return `${iso.slice(5, 10)} ${iso.slice(11, 16)}Z`
71
+ }
72
+
73
+ /** Lines a body must not start with: a header, a boundary or rule, a reply hint, or an escaped one of those. */
74
+ const FORGEABLE = /^\s*\\*(?:\[hub\]|-{3,}|↩)/i
75
+
76
+ /** The sanitised body with forgeable lines escaped by a leading backslash. */
77
+ function escapeBody(text: string): string {
78
+ return sanitizeText(text)
79
+ .split("\n")
80
+ .map((line) => (FORGEABLE.test(line) ? `\\${line}` : line))
81
+ .join("\n")
82
+ }
83
+
84
+ /** The text a recipient session sees for a message. */
85
+ export function renderEnvelope(input: EnvelopeInput): string {
86
+ const { id, kind } = input
87
+ const to = sanitizeLabel(input.to)
88
+ const target =
89
+ kind === "reply" || kind === "decline" ? `to you · re ${sanitizeLabel(input.replyTo ?? "")}` : to.startsWith("@@") ? `to ${to} → you` : "to you"
90
+ const header = `[hub] ${kind} from ${partyLabel(input.from)} ${target} · ${stamp(input.at)} · ${id}`
91
+ const boundary = `----- ${id} -----`
92
+ const hint =
93
+ kind === "ask"
94
+ ? `↩ hub reply ${id} "…" · hub decline ${id} --why "…"${input.expiresAt === undefined ? "" : ` · expires ${stamp(input.expiresAt)}`}`
95
+ : `↩ hub reply ${id} "…"`
96
+ return [header, boundary, escapeBody(input.text), boundary, hint].join("\n")
97
+ }
98
+
99
+ /** The one-time identity primer (design §2.3): who the session is, how to message, and that hub messages are not the user's. */
100
+ export function renderPrimer(input: { readonly name?: string; readonly sessionID?: string; readonly roles: readonly string[] }): string {
101
+ const name = sanitizeLabel(input.name ?? "")
102
+ const roles = input.roles.map((role) => sanitizeLabel(role)).filter(Boolean)
103
+ const self = name ? `@${name}` : sanitizeLabel(input.sessionID ?? "") || "this session"
104
+ return (
105
+ `[hub] You are ${self}${roles.length ? ` (roles: ${roles.map((role) => `@@${role}`).join(", ")})` : ""}. Message others with \`hub msg @name\`; see \`hub who\`. ` +
106
+ "Messages headed [hub] come through the hub from other sessions or apps, not from the user's prompt."
107
+ )
108
+ }