@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,165 @@
1
+ // HMAC signing keys for hub-minted session tokens, kept apart from state.json in `<stateDir>/token-keys.json`
2
+ // (mode 0600, written atomically): {active: kid, keys: [{kid, secret (base64 of 32 random bytes), created}]}.
3
+ // The file is created on first use. rotate() adds a new active key and keeps the older ones so tokens they
4
+ // signed still verify. HUB_TOKEN_KEYS (same JSON shape) overrides the file, e.g. a Kubernetes secret shared by
5
+ // several pods; the file is then never written and rotation happens where the secret is managed.
6
+ // Secrets never appear in errors, logs, JSON or inspect output.
7
+ import { randomBytes } from "node:crypto"
8
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
9
+ import path from "node:path"
10
+
11
+ export const TOKEN_KEYS_FILE = "token-keys.json"
12
+ export const TOKEN_KEYS_ENV = "HUB_TOKEN_KEYS"
13
+ /** Bytes of a signing secret. */
14
+ export const TOKEN_KEY_BYTES = 32
15
+
16
+ export interface StoredTokenKey {
17
+ kid: string
18
+ /** Base64 of TOKEN_KEY_BYTES random bytes. */
19
+ secret: string
20
+ created: number
21
+ }
22
+
23
+ export interface TokenKeyFile {
24
+ active: string
25
+ keys: StoredTokenKey[]
26
+ }
27
+
28
+ export interface TokenKeysOptions {
29
+ readonly now?: () => number
30
+ /** Environment for the HUB_TOKEN_KEYS override (default process.env). */
31
+ readonly env?: Readonly<Record<string, string | undefined>>
32
+ }
33
+
34
+ interface Key {
35
+ kid: string
36
+ secret: Buffer
37
+ created: number
38
+ }
39
+
40
+ const newKid = (): string => `k_${randomBytes(6).toString("base64url")}`
41
+
42
+ /** Parsed and validated key data; `source` names the origin in errors (never the content). */
43
+ function parseKeys(text: string, source: string): { active: string; keys: Key[] } {
44
+ const fail = (why: string): never => {
45
+ throw new Error(`Invalid ${source}: ${why}. Fix or move it away.`)
46
+ }
47
+ let raw: unknown
48
+ try {
49
+ raw = JSON.parse(text)
50
+ } catch {
51
+ return fail("not valid JSON")
52
+ }
53
+ if (typeof raw !== "object" || raw === null) return fail("not an object")
54
+ const { active, keys } = raw as Record<string, unknown>
55
+ if (typeof active !== "string" || !Array.isArray(keys)) return fail("expected {active, keys: [...]}")
56
+ const out: Key[] = []
57
+ for (const entry of keys as unknown[]) {
58
+ const { kid, secret, created } = (typeof entry === "object" && entry !== null ? entry : {}) as Record<string, unknown>
59
+ if (typeof kid !== "string" || !kid) return fail("a key has no kid")
60
+ if (out.some((key) => key.kid === kid)) return fail(`duplicate kid ${kid}`)
61
+ const bytes = typeof secret === "string" ? Buffer.from(secret, "base64") : Buffer.alloc(0)
62
+ if (typeof secret !== "string" || bytes.toString("base64") !== secret || bytes.length < TOKEN_KEY_BYTES) {
63
+ return fail(`key ${kid} needs a base64 secret of at least ${TOKEN_KEY_BYTES} bytes`)
64
+ }
65
+ out.push({ kid, secret: bytes, created: typeof created === "number" ? created : 0 })
66
+ }
67
+ if (!out.some((key) => key.kid === active)) return fail("the active kid is not among the keys")
68
+ return { active, keys: out }
69
+ }
70
+
71
+ export class TokenKeys {
72
+ #active = ""
73
+ #keys: Key[] = []
74
+ #loaded = false
75
+
76
+ private constructor(
77
+ private readonly file: string,
78
+ private readonly now: () => number,
79
+ private readonly override: string | undefined,
80
+ ) {}
81
+
82
+ /** Nothing is read or written until a key is needed. */
83
+ static open(dir: string, options: TokenKeysOptions = {}): TokenKeys {
84
+ const env = options.env ?? process.env
85
+ const override = env[TOKEN_KEYS_ENV]?.trim() || undefined
86
+ return new TokenKeys(path.join(dir, TOKEN_KEYS_FILE), options.now ?? Date.now, override)
87
+ }
88
+
89
+ /** The key new tokens are signed with (created on first use). */
90
+ active(): { kid: string; secret: Buffer } {
91
+ this.load()
92
+ const key = this.#keys.find((k) => k.kid === this.#active)!
93
+ return { kid: key.kid, secret: Buffer.from(key.secret) }
94
+ }
95
+
96
+ /** The secret for a kid, to verify a token; undefined when unknown. */
97
+ key(kid: string): Buffer | undefined {
98
+ this.load()
99
+ const key = this.#keys.find((k) => k.kid === kid)
100
+ return key ? Buffer.from(key.secret) : undefined
101
+ }
102
+
103
+ /** Every kid, oldest first. */
104
+ kids(): string[] {
105
+ this.load()
106
+ return this.#keys.map((k) => k.kid)
107
+ }
108
+
109
+ /** Adds a new active key, keeping the previous ones for verification. Returns its kid. */
110
+ rotate(): string {
111
+ if (this.override) throw new Error(`Signing keys come from ${TOKEN_KEYS_ENV}; rotate them where that is managed`)
112
+ this.load(false)
113
+ let kid = newKid()
114
+ while (this.#keys.some((k) => k.kid === kid)) kid = newKid()
115
+ this.#keys.push({ kid, secret: randomBytes(TOKEN_KEY_BYTES), created: this.now() })
116
+ this.#active = kid
117
+ this.save()
118
+ return kid
119
+ }
120
+
121
+ toJSON(): { file: string; active: string; kids: string[] } {
122
+ return { file: this.file, active: this.#active, kids: this.#keys.map((k) => k.kid) }
123
+ }
124
+
125
+ toString(): string {
126
+ return `TokenKeys(${this.override ? TOKEN_KEYS_ENV : this.file})`
127
+ }
128
+
129
+ [Symbol.for("nodejs.util.inspect.custom")](): string {
130
+ return this.toString()
131
+ }
132
+
133
+ /** Reads the keys once; with `create`, a missing file gets its first key. */
134
+ private load(create = true): void {
135
+ if (this.#loaded) return
136
+ if (this.override) {
137
+ this.set(parseKeys(this.override, TOKEN_KEYS_ENV))
138
+ return
139
+ }
140
+ if (existsSync(this.file)) {
141
+ this.set(parseKeys(readFileSync(this.file, "utf8"), this.file))
142
+ return
143
+ }
144
+ this.#loaded = true
145
+ if (create) this.rotate()
146
+ }
147
+
148
+ private set(data: { active: string; keys: Key[] }): void {
149
+ this.#active = data.active
150
+ this.#keys = data.keys
151
+ this.#loaded = true
152
+ }
153
+
154
+ private save(): void {
155
+ const data: TokenKeyFile = {
156
+ active: this.#active,
157
+ keys: this.#keys.map((k) => ({ kid: k.kid, secret: k.secret.toString("base64"), created: k.created })),
158
+ }
159
+ mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 })
160
+ const tmp = `${this.file}.${process.pid}.tmp`
161
+ writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 })
162
+ chmodSync(tmp, 0o600)
163
+ renameSync(tmp, this.file)
164
+ }
165
+ }
package/src/config.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  // Per-machine spoke configuration, shared by every OpenCode process on the machine and edited
2
2
  // from the /hub dialog. Holds the machine token, so it is written with mode 0600.
3
- import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
3
+ import { randomBytes } from "node:crypto"
4
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeSync } from "node:fs"
4
5
  import { homedir } from "node:os"
5
6
  import path from "node:path"
6
7
  import { hubEnv, type Env } from "./env"
7
8
  import { APP_DIR, configBase, legacyPaths, migrateFirst } from "./migrate"
9
+ import { isHubURL } from "./protocol"
8
10
 
9
11
  export type Mode = "off" | "always" | "session"
10
12
 
@@ -141,13 +143,75 @@ export function readConfig(file: string, machineName: string): VoiceConfig {
141
143
  }
142
144
  }
143
145
 
144
- /** Atomic write with 0600 permissions. */
145
- export function writeConfig(file: string, config: VoiceConfig): void {
146
+ /** The keys VoiceConfig owns in config.json; every other key belongs to someone else and is preserved. */
147
+ const CONFIG_KEYS = ["server", "mode", "sessions", "machineName", "subagent", "autoStartLocalHub", "herdr", "list", "voiceModel", "workModel", "machineID", "token"] as const satisfies readonly (keyof VoiceConfig)[]
148
+
149
+ type RawConfig = Record<string, unknown>
150
+
151
+ const isObject = (v: unknown): v is RawConfig => typeof v === "object" && v !== null && !Array.isArray(v)
152
+
153
+ /** The raw JSON object in `file` (every key, known or not); undefined when the file does not exist. */
154
+ export function readRawConfig(file: string): RawConfig | undefined {
155
+ let text: string
156
+ try {
157
+ text = readFileSync(file, "utf8")
158
+ } catch (cause) {
159
+ if ((cause as NodeJS.ErrnoException).code === "ENOENT") return undefined
160
+ throw cause
161
+ }
162
+ let parsed: unknown
163
+ try {
164
+ parsed = JSON.parse(text)
165
+ } catch {
166
+ parsed = undefined
167
+ }
168
+ if (!isObject(parsed)) throw new Error(`${file} is not a JSON object; fix or remove it first`)
169
+ return parsed
170
+ }
171
+
172
+ /** Writes `content` to `file` atomically (a private temp file in the same directory, fsync, rename), mode 0600. */
173
+ function writePrivate(file: string, content: string): void {
146
174
  mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
147
- const tmp = `${file}.${process.pid}.tmp`
148
- writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
149
- chmodSync(tmp, 0o600)
150
- renameSync(tmp, file)
175
+ const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`
176
+ let fd: number | undefined
177
+ try {
178
+ fd = openSync(tmp, "wx", 0o600)
179
+ writeSync(fd, content)
180
+ fsyncSync(fd)
181
+ closeSync(fd)
182
+ fd = undefined
183
+ chmodSync(tmp, 0o600)
184
+ renameSync(tmp, file)
185
+ } catch (cause) {
186
+ if (fd !== undefined) closeSync(fd)
187
+ rmSync(tmp, { force: true })
188
+ throw cause
189
+ }
190
+ }
191
+
192
+ const serialize = (raw: RawConfig) => `${JSON.stringify(raw, null, 2)}\n`
193
+
194
+ /**
195
+ * Lossless read-modify-write of config.json: `update` gets the raw object (every key, {} for a missing file) and
196
+ * returns the next one, written atomically with 0600. Refuses (throws, file untouched) when the file is not a JSON
197
+ * object, so a hand-edited config is never replaced.
198
+ */
199
+ export function updateRawConfig(file: string, update: (raw: RawConfig) => RawConfig): void {
200
+ writePrivate(file, serialize(update(readRawConfig(file) ?? {})))
201
+ }
202
+
203
+ /**
204
+ * Atomic write with 0600 permissions. Lossless: keys config.json holds that VoiceConfig does not know are kept; the
205
+ * known keys come from `config` (a known key it lacks is removed). A file that is not a JSON object is replaced.
206
+ */
207
+ export function writeConfig(file: string, config: VoiceConfig): void {
208
+ let previous: RawConfig = {}
209
+ try {
210
+ previous = readRawConfig(file) ?? {}
211
+ } catch {}
212
+ const next: RawConfig = { ...previous }
213
+ for (const key of CONFIG_KEYS) delete next[key]
214
+ writePrivate(file, serialize({ ...next, ...config }))
151
215
  }
152
216
 
153
217
  /** Changing the server URL drops the machine credentials, which belong to the old server. */
@@ -168,3 +232,50 @@ export function setSession(config: VoiceConfig, sessionID: string, on: boolean):
168
232
  const sessions = on ? [...new Set([...config.sessions, sessionID])] : config.sessions.filter((id) => id !== sessionID)
169
233
  return { ...config, sessions }
170
234
  }
235
+
236
+ // ---------- launch-bound mode ----------
237
+ // A spoke inside a launch's sandbox (OpenShell, design §6.4) connects with a credential bound to that launch. The node
238
+ // writes {hubURL, token, launchID} next to node.json before the harness starts; the spoke only reads it: no enrolment,
239
+ // no local hub, no config writes.
240
+
241
+ export interface LaunchConfig {
242
+ readonly hubURL: string
243
+ /** The launch-bound credential (hlt1…); never logged. */
244
+ readonly token: string
245
+ readonly launchID: string
246
+ }
247
+
248
+ /** Where the node puts the launch config inside a sandbox. */
249
+ export const LAUNCH_CONFIG_DEFAULT = "/sandbox/.hub/config.json"
250
+ const LAUNCH_ID = /^[A-Za-z0-9_.:@/+~-]{1,300}$/
251
+ const LAUNCH_TOKEN = /^[\x21-\x7e]{1,4096}$/
252
+
253
+ /** A launch config, or undefined unless every field is present and valid. */
254
+ export function parseLaunchConfig(raw: unknown): LaunchConfig | undefined {
255
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined
256
+ const r = raw as Record<string, unknown>
257
+ if (!isHubURL(r.hubURL)) return undefined
258
+ if (typeof r.token !== "string" || !LAUNCH_TOKEN.test(r.token)) return undefined
259
+ if (typeof r.launchID !== "string" || !LAUNCH_ID.test(r.launchID)) return undefined
260
+ return { hubURL: r.hubURL.replace(/\/+$/, ""), token: r.token, launchID: r.launchID }
261
+ }
262
+
263
+ /** $HUB_LAUNCH_CONFIG, else LAUNCH_CONFIG_DEFAULT when it exists; undefined: not launch-bound. */
264
+ export function launchConfigPath(env: Env = process.env, exists: (file: string) => boolean = existsSync): string | undefined {
265
+ const explicit = env.HUB_LAUNCH_CONFIG
266
+ if (explicit) return explicit
267
+ return exists(LAUNCH_CONFIG_DEFAULT) ? LAUNCH_CONFIG_DEFAULT : undefined
268
+ }
269
+
270
+ /** Reads a launch config; throws (without the token) when it is missing, unparsable or invalid. */
271
+ export function readLaunchConfig(file: string): LaunchConfig {
272
+ let raw: unknown
273
+ try {
274
+ raw = JSON.parse(readFileSync(file, "utf8"))
275
+ } catch (cause) {
276
+ throw new Error(`Cannot read the launch config ${file}: ${cause instanceof SyntaxError ? "not JSON" : (cause as NodeJS.ErrnoException).code ?? "unreadable"}`)
277
+ }
278
+ const config = parseLaunchConfig(raw)
279
+ if (!config) throw new Error(`Invalid launch config ${file}: expected {hubURL, token, launchID}`)
280
+ return config
281
+ }
package/src/index.ts CHANGED
@@ -1,16 +1,26 @@
1
1
  // Hub server plugin: a thin "spoke" that connects this OpenCode process to the hub. The web
2
2
  // console, phones and (optional) Voice speech live on the hub; this side only exposes sessions, forwards
3
3
  // their events, and runs remote / voice turns and permission replies on request.
4
- import { readFileSync } from "node:fs"
5
- import { hostname } from "node:os"
6
- import { Plugin } from "@opencode/plugin"
4
+ import { mkdirSync, readFileSync } from "node:fs"
5
+ import { homedir, hostname } from "node:os"
6
+ import { fileURLToPath } from "node:url"
7
+ // Types only: @opencode/plugin's runtime entry loads its whole schema graph (~1 s cold) for an identity define().
8
+ import type { Plugin } from "@opencode/plugin"
7
9
  import { DEFAULT_SUBAGENT, prepareConfigPath } from "./config"
8
10
  import { HubRpc, VoiceRpc } from "./rpc"
11
+ import { jobFromInfo, type JobInfo } from "./protocol"
9
12
  import { checkServer, parseServerInput } from "./setup"
10
- import { defineVoiceAgent, orchestratorParts } from "./spoke/conversation"
13
+ import { defineVoiceAgent, orchestratorParts, STOP_TOOL, STOP_TOOL_DESCRIPTION } from "./spoke/conversation"
14
+ import { envInjector, hubBinDir, ShellCorrelator, stripInheritedHubEnv } from "./spoke/env-inject"
11
15
  import { herdrRunner } from "./spoke/herdr"
16
+ import { launchSource } from "./spoke/launch-source"
17
+ import { HubRpcError, hubMsg, hubWho } from "./spoke/hub-rpc"
18
+ import { HUB_TOOL, HUB_TOOL_DESCRIPTION, HUB_TOOL_INPUT, runHubTool } from "./spoke/hub-tool"
19
+ import { cliEntry, installLaunchers, linkUserBin, standaloneHubctl } from "./spoke/launchers"
12
20
  import { listServiceSessions } from "./spoke/listing"
13
21
  import { ensureLocalHub } from "./spoke/localhub"
22
+ import { nodeConfigPath, readWorkspaceRoots } from "./spoke/node-roots"
23
+ import { PromptReferences } from "./spoke/prompt-refs"
14
24
  import { replyPermission, type PermissionLocation } from "./spoke/locations"
15
25
  import { foregroundEndpoint, formOutcome } from "./spoke/own-server"
16
26
  import { joinProcess, type Membership } from "./spoke/singleton"
@@ -18,6 +28,18 @@ import { detectKind, Spoke, SpokeError, type FormAction, type SpokeHost } from "
18
28
 
19
29
  type Context = Parameters<Parameters<typeof Plugin.define>[0]["setup"]>[0]
20
30
 
31
+ /** Plugin.define without importing it (it returns its argument). */
32
+ const definePlugin = (plugin: Plugin.Plugin): Plugin.Plugin => plugin
33
+
34
+ /** One registration with OpenCode; a failure is logged and never stops the others. */
35
+ async function attempt(what: string, register: () => Promise<unknown> | undefined): Promise<void> {
36
+ try {
37
+ await register()
38
+ } catch (cause) {
39
+ console.error(`[hub] could not ${what}`, cause)
40
+ }
41
+ }
42
+
21
43
  const VOICE_STYLE =
22
44
  "This message came from a voice conversation and your reply will be read aloud. " +
23
45
  "Answer in at most three short sentences of plain speech: no markdown, code blocks, tables, or lists. " +
@@ -70,6 +92,45 @@ async function replyForm(kind: "service" | "standalone", action: FormAction): Pr
70
92
  }
71
93
  }
72
94
 
95
+ /**
96
+ * Has OpenCode regenerate a session's title with its title agent: session.update with an empty title over
97
+ * the own server's HTTP API (the handler runs SessionTitle.generate and returns when it is done). The plugin
98
+ * API's session.update would rename the session to "" instead. "unavailable" when the server cannot be reached.
99
+ */
100
+ async function generateTitle(kind: "service" | "standalone", sessionID: string): Promise<"done" | "unavailable"> {
101
+ const client = await ownClient(kind).catch(() => undefined)
102
+ if (!client) return "unavailable"
103
+ await client.session.update({ sessionID, title: "" })
104
+ return "done"
105
+ }
106
+
107
+ /**
108
+ * What runs now on this process's own server: session.active (sessions with a run in progress) and the running
109
+ * shells of the given directories (shell.list, per location) that belong to a session. Empty when the server
110
+ * cannot be reached.
111
+ */
112
+ async function activity(kind: "service" | "standalone", directories: readonly string[]) {
113
+ const client = await ownClient(kind).catch(() => undefined)
114
+ if (!client) return { active: [], jobs: [] }
115
+ const active = unwrap<Record<string, unknown>>(await client.session.active(), (v) => v && typeof v === "object" && !("data" in v))
116
+ const jobs: Array<JobInfo & { sessionID: string }> = []
117
+ const seen = new Set<string>()
118
+ for (const directory of directories) {
119
+ try {
120
+ const list = unwrap<unknown[]>(await client.shell.list({ location: { directory } }), Array.isArray)
121
+ for (const raw of Array.isArray(list) ? list : []) {
122
+ const job = jobFromInfo(raw, "shell")
123
+ if (!job?.sessionID || job.status !== "running" || seen.has(job.id)) continue
124
+ seen.add(job.id)
125
+ jobs.push(job as JobInfo & { sessionID: string })
126
+ }
127
+ } catch {
128
+ // a location this server does not serve
129
+ }
130
+ }
131
+ return { active: Object.keys(active ?? {}), jobs }
132
+ }
133
+
73
134
  /** Plugin API results may arrive bare or wrapped as { location, data }. */
74
135
  function unwrap<T>(value: unknown, isBare: (v: any) => boolean): T {
75
136
  const v = value as any
@@ -97,12 +158,18 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
97
158
  opencodeVersion: ctx.app?.version ?? "unknown",
98
159
  events: (signal) => ctx.event.subscribe({ signal }) as AsyncIterable<any>,
99
160
  prompt: (input) => session.prompt(input),
161
+ // OpenCode's native synthetic messages (hub deliveries); without them the spoke does not announce "deliver".
162
+ ...(typeof session.synthetic === "function"
163
+ ? { synthetic: async (input: Parameters<NonNullable<SpokeHost["synthetic"]>>[0]) => unwrap<{ id?: string }>(await session.synthetic(input), (v) => typeof v.id === "string") }
164
+ : {}),
100
165
  context: (sessionID) => session.context({ sessionID }),
101
166
  listModels: async () => unwrap<ReadonlyArray<unknown>>(await (ctx.model as any).list({}), Array.isArray),
102
167
  getSession: async (sessionID) => unwrap(await session.get({ sessionID }), (v) => typeof v.id === "string"),
103
168
  replyPermission: ({ directory, ...input }) => replyPermission(locations(), input, directory),
104
169
  replyForm: (action) => replyForm(kind, action),
105
170
  interrupt: (sessionID) => session.interrupt({ sessionID }),
171
+ updateSession: (sessionID, input) => session.update({ sessionID, ...input }),
172
+ generateTitle: (sessionID) => generateTitle(kind, sessionID),
106
173
  switchAgent: (sessionID, id) => session.switchAgent({ sessionID, agent: id }),
107
174
  switchModel: (sessionID, model) => session.switchModel({ sessionID, model }),
108
175
  hasAgent: async (id) => {
@@ -118,6 +185,7 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
118
185
  return list?.find((a) => a.mode !== "subagent" && !a.hidden)?.id
119
186
  },
120
187
  listRecent,
188
+ activity: (directories) => activity(kind, directories),
121
189
  createSession: async ({ directory, title }) =>
122
190
  unwrap<{ id: string }>(await session.create({ location: { directory }, ...(title ? { title } : {}) }), (v) => typeof v.id === "string"),
123
191
  ...(mcp?.list ? { mcpList: async (directory: string) => unwrap<ReadonlyArray<unknown>>(await mcp.list({ location: { directory } }), Array.isArray) } : {}),
@@ -133,49 +201,134 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
133
201
  }
134
202
  }
135
203
 
136
- export default Plugin.define({
204
+ /**
205
+ * Gives every shell the agent runs through the shell tool its session's HUB_URL/HUB_SESSION_TOKEN and puts the hub
206
+ * bin dir first on PATH (src/spoke/env-inject.ts). Known gap: user `!` commands have no tool call, so get no token.
207
+ */
208
+ async function registerShellEnv(ctx: Context, identity: (sessionID: string) => { token: string; url?: string } | undefined) {
209
+ const binDir = hubBinDir()
210
+ try {
211
+ mkdirSync(binDir, { recursive: true, mode: 0o700 })
212
+ // The `hub`/`hubctl` launchers run the CLI with this runtime (OpenCode's binary as bun; src/spoke/launchers.ts).
213
+ const bun = typeof process.versions.bun === "string"
214
+ const entry = cliEntry(fileURLToPath(new URL("..", import.meta.url)), bun)
215
+ if (entry) {
216
+ installLaunchers({ binDir, execPath: process.execPath, bun, entry, standalone: standaloneHubctl(homedir()) })
217
+ // `hub` in the user's own terminals too (~/.local/bin/hub, never over anything of theirs).
218
+ const { hint } = linkUserBin({ home: homedir(), binDir, path: process.env.PATH })
219
+ if (hint) console.error(`[hub] ${hint}`)
220
+ } else console.error("[hub] no built hub CLI (packages/cli/dist/hub-cli.js): `hub` is not installed")
221
+ } catch (cause) {
222
+ console.error("[hub] could not install the hub CLI launchers", cause)
223
+ }
224
+ const injector = envInjector({ correlator: new ShellCorrelator(), binDir, identity })
225
+ const tool = (ctx as any).tool
226
+ const shell = (ctx as any).shell
227
+ await attempt("register the shell environment hooks", async () => {
228
+ if (!tool?.hook || !shell?.hook) throw new Error("this OpenCode has no tool/shell hooks")
229
+ await Promise.all([
230
+ tool.hook("execute.before", (event: any) => injector.toolBefore(event)),
231
+ tool.hook("execute.after", (event: any) => injector.toolAfter(event)),
232
+ shell.hook("create.before", (invocation: any) => injector.shellBefore(invocation)),
233
+ ])
234
+ })
235
+ }
236
+
237
+ export default definePlugin({
137
238
  id: "hub",
138
239
  async setup(ctx) {
240
+ // An OpenCode started from a session's shell must not act as that session (nor hand its token to its shells).
241
+ stripInheritedHubEnv(process.env)
139
242
  const kind = detectKind(process.argv)
243
+ // Inside a launch's sandbox the spoke is launch-bound: the node's read-only launch config and node.json, no
244
+ // machine config, enrolment, local hub or Herdr (src/spoke/launch-source.ts). A broken one leaves no spoke.
245
+ const source = launchSource()
140
246
  const membership: Membership<Spoke> = joinProcess(
141
- (processID) =>
142
- new Spoke(hostFrom(ctx, () => membership.locals<PermissionLocation>(), kind), {
247
+ (processID) => {
248
+ if (source.kind === "invalid") throw new Error(`${source.error}; the hub spoke is off in this process`)
249
+ const host = hostFrom(ctx, () => membership.locals<PermissionLocation>(), kind)
250
+ const common = { machineName: hostname(), kind, processID, pid: process.pid, pluginVersion: pluginVersion() }
251
+ if (source.kind === "launch") {
252
+ const nodeConfig = source.nodeConfig
253
+ return new Spoke(host, { ...common, configFile: source.file, launch: source.launch, workspaceRoots: () => readWorkspaceRoots(nodeConfig) })
254
+ }
255
+ return new Spoke(host, {
256
+ ...common,
143
257
  configFile: prepareConfigPath(),
144
- machineName: hostname(),
145
- kind,
146
- processID,
147
- pid: process.pid,
148
- pluginVersion: pluginVersion(),
149
258
  // Loopback servers only, and only when autoStartLocalHub is on (see src/spoke/localhub.ts).
150
259
  localHub: (server) => ensureLocalHub(server),
151
- // One Herdr reader per machine: the background service's spoke (see src/spoke/herdr.ts).
152
- ...(kind === "service" ? { herdr: herdrRunner() } : {}),
153
- }),
260
+ // One Herdr reader per machine: the background service's spoke (see src/spoke/herdr.ts). Launch creates
261
+ // (service only) need the node workspace roots, read from node.json each time (src/spoke/node-roots.ts).
262
+ ...(kind === "service" ? { herdr: herdrRunner(), workspaceRoots: () => readWorkspaceRoots(nodeConfigPath()) } : {}),
263
+ })
264
+ },
154
265
  undefined,
155
266
  permissionLocation(ctx),
156
267
  )
157
268
 
158
- try {
159
- await ctx.agent.transform((editor) => defineVoiceAgent(editor as any))
160
- } catch (cause) {
161
- console.error("[hub] could not register the voice agent", cause)
162
- }
163
-
164
- try {
165
- await ctx.session.hook("context", (event) => {
166
- const spoke = membership.active()
167
- if (spoke?.isVoiceTurn(event.sessionID)) event.system.push({ type: "text", text: VOICE_STYLE })
168
- const parts = orchestratorParts({
169
- agent: event.agent,
170
- conversation: spoke?.inConversation(event.sessionID) ?? false,
171
- subagent: spoke?.subagent() ?? DEFAULT_SUBAGENT,
172
- workModel: spoke?.workModelFor(event.sessionID),
173
- })
174
- for (const text of parts) event.system.push({ type: "text", text })
175
- })
176
- } catch (cause) {
177
- console.error("[hub] could not register the context hook", cause)
178
- }
269
+ // Every registration is independent: they run together, so OpenCode's plugin activation (bounded at ~5 s on a
270
+ // cold start) waits for about one of them, not their sum.
271
+ const registrations: Promise<void>[] = [
272
+ attempt("register the voice agent", () => ctx.agent.transform((editor) => defineVoiceAgent(editor as any))),
273
+ attempt("register the context hook", () =>
274
+ ctx.session.hook("context", (event) => {
275
+ const spoke = membership.active()
276
+ if (spoke?.isVoiceTurn(event.sessionID)) event.system.push({ type: "text", text: VOICE_STYLE })
277
+ const parts = orchestratorParts({
278
+ agent: event.agent,
279
+ conversation: spoke?.inConversation(event.sessionID) ?? false,
280
+ subagent: spoke?.subagent() ?? DEFAULT_SUBAGENT,
281
+ workModel: spoke?.workModelFor(event.sessionID),
282
+ taskBoard: spoke?.taskBoard(event.sessionID),
283
+ })
284
+ for (const text of parts) event.system.push({ type: "text", text })
285
+ }),
286
+ ),
287
+ // The voice agent stops one worker ("cancel the deploy one") without stopping itself or the others.
288
+ attempt("register the task stop tool", () =>
289
+ (ctx as any).tool?.transform?.((editor: any) =>
290
+ editor.add({
291
+ name: STOP_TOOL,
292
+ description: STOP_TOOL_DESCRIPTION,
293
+ input: {
294
+ type: "object",
295
+ properties: {
296
+ sessionID: { type: "string", description: "The worker's sessionID, from your task board or the subagent tool's result." },
297
+ all: { type: "boolean", description: "true: stop every worker of yours that is still running." },
298
+ },
299
+ additionalProperties: false,
300
+ },
301
+ options: { codemode: false },
302
+ execute: async (value: unknown, context: { sessionID: string }) => {
303
+ const spoke = membership.active()
304
+ if (!spoke) return { content: "The hub is not running in this OpenCode process; nothing was stopped." }
305
+ const { stopped, text } = await spoke.stopWorkers(context.sessionID, (value ?? {}) as Record<string, unknown>)
306
+ return { content: text, metadata: { stopped } }
307
+ },
308
+ }),
309
+ ),
310
+ ),
311
+ // Agents message and ask other sessions as themselves (the session's own hub token; src/spoke/hub-tool.ts).
312
+ attempt("register the hub tool", () =>
313
+ (ctx as any).tool?.transform?.((editor: any) =>
314
+ editor.add({
315
+ name: HUB_TOOL,
316
+ description: HUB_TOOL_DESCRIPTION,
317
+ input: HUB_TOOL_INPUT,
318
+ options: { codemode: false },
319
+ execute: (value: unknown, context: { sessionID: string; signal?: AbortSignal }) =>
320
+ runHubTool(() => membership.active(), value, { sessionID: context.sessionID, ...(context.signal ? { signal: context.signal } : {}) }),
321
+ }),
322
+ ),
323
+ ),
324
+ // @name / @@role in a prompt get context from the hub as an attached text file; the text is never changed
325
+ // and the harness's own @ meanings win (src/spoke/prompt-refs.ts, design §2.4).
326
+ attempt("register the prompt hook", () => {
327
+ const references = new PromptReferences(() => membership.active())
328
+ return ctx.session.hook("prompt", (event) => references.onPrompt(event))
329
+ }),
330
+ registerShellEnv(ctx, (sessionID) => membership.active()?.shellIdentity(sessionID)),
331
+ ]
179
332
 
180
333
  const run = async <T>(rpc: { error: (...args: any[]) => any }, action: (spoke: Spoke) => T | Promise<T>) => {
181
334
  const spoke = membership.active()
@@ -184,6 +337,7 @@ export default Plugin.define({
184
337
  return await action(spoke)
185
338
  } catch (cause) {
186
339
  if (cause instanceof SpokeError) return rpc.error(cause.name, cause.message, {})
340
+ if (cause instanceof HubRpcError) return rpc.error(cause.code, cause.message, {})
187
341
  return rpc.error("server_error", cause instanceof Error ? cause.message : String(cause), {})
188
342
  }
189
343
  }
@@ -221,15 +375,13 @@ export default Plugin.define({
221
375
  s.presence(sessionIDs.filter((id): id is string => typeof id === "string").slice(0, 200), typeof reporter === "string" ? reporter.slice(0, 100) : undefined)
222
376
  return {}
223
377
  }),
378
+ // The TUI's /who and /msg, as its current session (src/spoke/hub-rpc.ts).
379
+ hubWho: (value, rpc) => run(rpc, (s) => hubWho(s, value)),
380
+ hubMsg: (value, rpc) => run(rpc, (s) => hubMsg(s, value)),
224
381
  } satisfies Parameters<typeof ctx.rpc.register<typeof HubRpc>>[1]
225
382
  // "hub", plus the opencode-voice id "voice" for clients built against the old name (see src/rpc.ts).
226
- for (const definition of [HubRpc, VoiceRpc]) {
227
- try {
228
- await ctx.rpc.register(definition, handlers)
229
- } catch (cause) {
230
- console.error(`[hub] could not register RPC methods (${definition.id})`, cause)
231
- }
232
- }
383
+ for (const definition of [HubRpc, VoiceRpc]) registrations.push(attempt(`register RPC methods (${definition.id})`, () => ctx.rpc.register(definition, handlers)))
384
+ await Promise.all(registrations)
233
385
 
234
386
  return () => membership.leave()
235
387
  },