@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,263 @@
1
+ // HarnessSpoke: the base of a spoke for a harness other than OpenCode (design §1.1, §13.2). It owns the hub
2
+ // connection (SpokeConnection) and does what every such spoke must do the same way:
3
+ //
4
+ // - hello: harness, version, evidence, and exactly the ops it has handlers for (core ops included: a non-OpenCode
5
+ // hello implies none), so the hub sends only those;
6
+ // - op dispatch: an op (or an op option) it did not announce is refused with a clear error, as is an op for a
7
+ // session it does not report (a hub that predates announced core ops sends them anyway);
8
+ // - sessions: the sessions frame on every change and after every welcome; with `livenessMs`, a session that
9
+ // shows no sign of life (upsert, touch, emitted event) for that long is dropped by prune();
10
+ // - emit: only events of the harness subset with valid data (harnessEventProblem), never anything else;
11
+ // - tokens: the session and act tokens the hub sends, in memory, kept apart, for reported sessions only, dropped
12
+ // when the session ends; no token (nor the machine token) is ever logged, serialised or inspected;
13
+ // - deliver: each hub message admitted once per messageID (DeliveryLedger), whatever the harness does with a retry.
14
+ //
15
+ // Session IDs are hub IDs: map native ones with hubSessionID first.
16
+ import {
17
+ CORE_OPS,
18
+ encode,
19
+ featureForOp,
20
+ harnessEventProblem,
21
+ isHubSessionID,
22
+ parseSpokeMessage,
23
+ requestFeatures,
24
+ type HarnessEvent,
25
+ type ResultFor,
26
+ type ServerRequest,
27
+ type SessionInfo,
28
+ type SessionToken,
29
+ type SessionTokenSkipReason,
30
+ type SpokeFeature,
31
+ } from "../../protocol/src/index.ts"
32
+ import { SpokeConnection, type ConnectionOptions, type ConnectionState } from "./connection.ts"
33
+ import { DeliveryLedger, type Admission } from "./delivery.ts"
34
+ import { buildHarnessHello, type HarnessHelloOptions } from "./harness-hello.ts"
35
+ import { SessionTokenStore } from "./session-tokens.ts"
36
+
37
+ const INSPECT = Symbol.for("nodejs.util.inspect.custom")
38
+
39
+ type Op = ServerRequest["op"]
40
+ /** Ops the spoke itself serves for every harness (tokens and deliver's idempotency); the rest need a handler. */
41
+ export type HandledOp = Exclude<Op, "sessionTokens" | "actTokens" | "deliver">
42
+ export type OpHandler<O extends HandledOp> = (req: Extract<ServerRequest, { op: O }>) => Promise<ResultFor[O]> | ResultFor[O]
43
+ export type OpHandlers = { readonly [O in HandledOp]?: OpHandler<O> }
44
+ export type DeliverRequest = Extract<ServerRequest, { op: "deliver" }>
45
+ /**
46
+ * Features that change how an op is served rather than adding one; each needs the op it modifies, except
47
+ * "turnEvents" (the spoke reports every turn's end as the harness confirms it), which is about events, not an op.
48
+ */
49
+ export type ModifierFeature = "permissionMessage" | "launch" | "hubPrompt" | "turnEvents"
50
+
51
+ export interface HarnessSpokeOptions extends Omit<HarnessHelloOptions, "ops" | "features"> {
52
+ readonly server: string
53
+ /** The machine token. Sent only as the WebSocket's bearer; never logged or serialised. */
54
+ readonly token: string
55
+ readonly handlers?: OpHandlers
56
+ /** Admits a hub message into a session (feature "deliver"); run once per messageID (DeliveryLedger). */
57
+ readonly deliver?: (req: DeliverRequest) => Promise<Admission>
58
+ readonly features?: readonly ModifierFeature[]
59
+ /** Drop a session after this long without a sign of life (prune); 0 or absent: never. */
60
+ readonly livenessMs?: number
61
+ readonly now?: () => number
62
+ readonly socket?: ConnectionOptions["socket"]
63
+ readonly backoff?: ConnectionOptions["backoff"]
64
+ readonly requestTimeoutMs?: number
65
+ readonly onState?: ConnectionOptions["onState"]
66
+ }
67
+
68
+ const MODIFIER_NEEDS: Record<ModifierFeature, string | null> = { permissionMessage: "permission", launch: "create", hubPrompt: "deliver", turnEvents: null }
69
+ const ALWAYS: readonly SpokeFeature[] = ["sessionTokens", "actTokens"]
70
+ const CORE: readonly string[] = CORE_OPS
71
+
72
+ export class HarnessSpoke {
73
+ /** Every feature the hello announces (core ops first). */
74
+ readonly features: readonly SpokeFeature[]
75
+ readonly harness: string
76
+
77
+ readonly #handlers: OpHandlers
78
+ readonly #deliver?: HarnessSpokeOptions["deliver"]
79
+ readonly #announced: ReadonlySet<string>
80
+ readonly #connection: SpokeConnection
81
+ readonly #sessions = new Map<string, { info: SessionInfo; seen: number }>()
82
+ readonly #livenessMs: number
83
+ readonly #now: () => number
84
+ readonly #sessionTokens: SessionTokenStore
85
+ readonly #actTokens: SessionTokenStore
86
+ readonly #ledger = new DeliveryLedger()
87
+
88
+ constructor(options: HarnessSpokeOptions) {
89
+ this.harness = options.harness
90
+ this.#handlers = options.handlers ?? {}
91
+ this.#deliver = options.deliver
92
+ this.#livenessMs = options.livenessMs ?? 0
93
+ this.#now = options.now ?? Date.now
94
+ this.#sessionTokens = new SessionTokenStore(this.#now)
95
+ this.#actTokens = new SessionTokenStore(this.#now)
96
+ const handled = (Object.keys(this.#handlers) as HandledOp[]).filter((op) => typeof this.#handlers[op] === "function")
97
+ if (handled.includes("modelPrefs") !== handled.includes("setModelPrefs")) throw new Error("The modelPrefs feature needs both a modelPrefs and a setModelPrefs handler")
98
+ const ops = handled.filter((op) => CORE.includes(op)) as (typeof CORE_OPS)[number][]
99
+ const served = new Set<string>(handled)
100
+ if (this.#deliver) served.add("deliver")
101
+ const modifiers = [...new Set(options.features ?? [])]
102
+ for (const feature of modifiers) {
103
+ const needs = MODIFIER_NEEDS[feature]
104
+ if (needs === undefined || !Object.hasOwn(MODIFIER_NEEDS, feature)) throw new Error(`Unknown modifier feature ${JSON.stringify(feature)}`)
105
+ if (needs !== null && !served.has(needs)) throw new Error(`The ${feature} feature needs a ${needs} handler`)
106
+ }
107
+ const optional = [...new Set(handled.filter((op) => !CORE.includes(op)).map((op) => featureForOp(op)))]
108
+ const hello = buildHarnessHello({ ...options, ops, features: [...optional, ...(this.#deliver ? ["deliver" as const] : []), ...ALWAYS, ...modifiers] })
109
+ this.features = hello.features as SpokeFeature[]
110
+ this.#announced = new Set(this.features)
111
+ this.#connection = new SpokeConnection({
112
+ server: options.server,
113
+ token: options.token,
114
+ hello: () => hello,
115
+ handle: (req) => this.dispatch(req),
116
+ onWelcome: () => this.#sendSessions(),
117
+ ...(options.onState ? { onState: options.onState } : {}),
118
+ ...(options.backoff ? { backoff: options.backoff } : {}),
119
+ ...(options.requestTimeoutMs !== undefined ? { requestTimeoutMs: options.requestTimeoutMs } : {}),
120
+ ...(options.socket ? { socket: options.socket } : {}),
121
+ })
122
+ }
123
+
124
+ get state(): ConnectionState {
125
+ return this.#connection.state
126
+ }
127
+
128
+ start(): void {
129
+ this.#connection.start()
130
+ }
131
+
132
+ stop(): void {
133
+ this.#connection.stop()
134
+ }
135
+
136
+ retryNow(): void {
137
+ this.#connection.retryNow()
138
+ }
139
+
140
+ /** Whether the connected hub announced `feature` in its welcome. */
141
+ hubSupports(feature: string): boolean {
142
+ return this.#connection.hubSupports(feature)
143
+ }
144
+
145
+ /** Runs a hub request: refuses unannounced ops and unknown sessions, then calls the op's handler. */
146
+ async dispatch(req: ServerRequest): Promise<unknown> {
147
+ for (const feature of requestFeatures(req)) {
148
+ if (!this.#announced.has(feature)) throw new Error(`The ${this.harness} spoke does not serve "${feature}" (not announced in its hello)`)
149
+ }
150
+ if ("sessionID" in req && !this.#sessions.has(req.sessionID)) throw new Error(`Unknown session ${req.sessionID}: this ${this.harness} spoke does not report it`)
151
+ switch (req.op) {
152
+ case "sessionTokens":
153
+ if (req.hubURL !== undefined) this.#sessionTokens.hubURL = req.hubURL
154
+ return this.#storeTokens(this.#sessionTokens, req.tokens)
155
+ case "actTokens":
156
+ return this.#storeTokens(this.#actTokens, req.tokens)
157
+ case "deliver": {
158
+ const deliver = this.#deliver!
159
+ return this.#ledger.run(req.messageID, req.wake, () => deliver(req))
160
+ }
161
+ }
162
+ const handler = this.#handlers[req.op as HandledOp] as ((r: ServerRequest) => unknown) | undefined
163
+ if (!handler) throw new Error(`The ${this.harness} spoke has no handler for "${req.op}"`)
164
+ return await handler(req)
165
+ }
166
+
167
+ /** Reports a session (new or changed) and counts as a sign of life. Its IDs must be hub session IDs. */
168
+ upsertSession(info: SessionInfo): void {
169
+ if (!isHubSessionID(info?.id)) throw new Error(`Session id must be a hub session ID (ses_ + letters and digits; map native IDs with hubSessionID), got ${JSON.stringify(info?.id)}`)
170
+ if (info.parentID !== undefined && !isHubSessionID(info.parentID)) throw new Error(`Session parentID must be a hub session ID, got ${JSON.stringify(info.parentID)}`)
171
+ if (!parseSpokeMessage(encode({ t: "sessions", sessions: [info] }))) throw new Error(`Session ${info.id} is not a valid session for the hub (see the protocol's SessionInfo)`)
172
+ this.#sessions.set(info.id, { info, seen: this.#now() })
173
+ this.#sendSessions()
174
+ }
175
+
176
+ /** A sign of life from a session (keeps it from being pruned). */
177
+ touch(sessionID: string): void {
178
+ const entry = this.#sessions.get(sessionID)
179
+ if (entry) entry.seen = this.#now()
180
+ }
181
+
182
+ /** The session ended: it is no longer reported and its tokens are dropped. */
183
+ endSession(sessionID: string): void {
184
+ this.#dropTokens(sessionID)
185
+ if (this.#sessions.delete(sessionID)) this.#sendSessions()
186
+ }
187
+
188
+ /**
189
+ * The session's current session token (for its shells: `hub` there acts as the session) and the URL they reach
190
+ * the hub at. Hand it to the harness's process environment only, never to a log.
191
+ */
192
+ sessionToken(sessionID: string): { token: string; url?: string } | undefined {
193
+ const entry = this.#sessionTokens.get(sessionID)
194
+ if (!entry) return undefined
195
+ const url = this.#sessionTokens.hubURL ?? this.#connection.publicURL
196
+ return { token: entry.token, ...(url !== undefined ? { url } : {}) }
197
+ }
198
+
199
+ /** The session's act token (act verbs only; never in any environment) and the hub's URL. */
200
+ actToken(sessionID: string): { token: string; url: string } | undefined {
201
+ const entry = this.#actTokens.get(sessionID)
202
+ const url = this.#sessionTokens.hubURL ?? this.#connection.publicURL
203
+ return entry && url !== undefined ? { token: entry.token, url } : undefined
204
+ }
205
+
206
+ toJSON(): { harness: string; state: ConnectionState; sessions: number; features: readonly SpokeFeature[] } {
207
+ return { harness: this.harness, state: this.state, sessions: this.#sessions.size, features: this.features }
208
+ }
209
+
210
+ toString(): string {
211
+ return `HarnessSpoke(${this.harness}, ${this.state}, ${this.#sessions.size} sessions)`
212
+ }
213
+
214
+ [INSPECT](): string {
215
+ return this.toString()
216
+ }
217
+
218
+ sessions(): SessionInfo[] {
219
+ return [...this.#sessions.values()].map((e) => e.info)
220
+ }
221
+
222
+ /** Drops sessions without a sign of life for livenessMs (none when it is 0); returns their IDs. */
223
+ prune(): string[] {
224
+ if (this.#livenessMs <= 0) return []
225
+ const cutoff = this.#now() - this.#livenessMs
226
+ const dropped = [...this.#sessions].filter(([, e]) => e.seen < cutoff).map(([id]) => id)
227
+ for (const id of dropped) {
228
+ this.#sessions.delete(id)
229
+ this.#dropTokens(id)
230
+ }
231
+ if (dropped.length) this.#sendSessions()
232
+ return dropped
233
+ }
234
+
235
+ /** Sends a harness event (T1 subset, validated); throws on an invalid one. False when it could not be sent. */
236
+ emit(event: HarnessEvent): boolean {
237
+ const problem = harnessEventProblem(event)
238
+ if (problem) throw new Error(`Invalid harness event${typeof (event as { type?: unknown })?.type === "string" ? ` ${(event as { type: string }).type}` : ""}: ${problem}`)
239
+ const sessionID = (event.data as { sessionID?: unknown }).sessionID
240
+ if (typeof sessionID === "string") this.touch(sessionID)
241
+ return this.#connection.send({ t: "event", event })
242
+ }
243
+
244
+ #storeTokens(store: SessionTokenStore, tokens: readonly SessionToken[]): ResultFor["sessionTokens"] {
245
+ const applied: string[] = []
246
+ const skipped: { sessionID: string; reason: SessionTokenSkipReason }[] = []
247
+ for (const { sessionID, token, exp } of tokens) {
248
+ if (!this.#sessions.has(sessionID)) skipped.push({ sessionID, reason: "unknown" })
249
+ else if (store.set(sessionID, token, exp)) applied.push(sessionID)
250
+ else skipped.push({ sessionID, reason: "expired" })
251
+ }
252
+ return { applied, skipped }
253
+ }
254
+
255
+ #dropTokens(sessionID: string): void {
256
+ this.#sessionTokens.delete(sessionID)
257
+ this.#actTokens.delete(sessionID)
258
+ }
259
+
260
+ #sendSessions(): void {
261
+ this.#connection.send({ t: "sessions", sessions: this.sessions() })
262
+ }
263
+ }
@@ -0,0 +1,12 @@
1
+ // @huynle/hub-spoke-sdk: the harness-neutral parts of a hub spoke (design §13.3). Imports only the protocol and SDK
2
+ // packages, never the hub (src/central) or the OpenCode spoke (src/spoke).
3
+ export * from "./activity.ts"
4
+ export * from "./connection.ts"
5
+ export * from "./delivery.ts"
6
+ export * from "./enroll.ts"
7
+ export * from "./prompt-refs.ts"
8
+ export * from "./prompt-source.ts"
9
+ export * from "./session-tokens.ts"
10
+ export * from "./session-ids.ts"
11
+ export * from "./harness-hello.ts"
12
+ export * from "./harness-spoke.ts"
@@ -0,0 +1,154 @@
1
+ // Prompt references (design §2.4): hub addresses typed into a prompt get context, never routing. Finding the
2
+ // addresses, resolving them against what the hub reports, and rendering the context note are harness-neutral;
3
+ // how the note reaches the model (OpenCode: an inline text file on the prompt) is the spoke's.
4
+ import { parseAddress, sanitizeLabel, type AgentMessage, type DirectoryEntry, type Status } from "../../sdk/src/index.ts"
5
+
6
+ export interface Mention {
7
+ readonly start: number
8
+ readonly end: number
9
+ readonly text: string
10
+ }
11
+
12
+ export interface PromptLike {
13
+ readonly text: string
14
+ readonly files?: readonly { readonly uri?: string; readonly name?: string; readonly mention?: Mention }[]
15
+ readonly agents?: readonly { readonly name: string; readonly mention?: Mention }[]
16
+ readonly skills?: readonly { readonly name?: string; readonly mention?: Mention }[]
17
+ }
18
+
19
+ /**
20
+ * `@name`, `@name.project`, `@@role`, `@@role.project`: names and roles are 2–40 lowercase letters, digits and
21
+ * dashes (packages/sdk/src/address.ts); not part of a word, email or path, and not followed by one.
22
+ */
23
+ const ADDRESS = /(?<![\w@./\\-])(@@?)([a-z0-9-]{2,40})(?:\.([A-Za-z0-9_-]+))?(?![\w@/\\-]|\.[\w/\\-])/g
24
+
25
+ /** The user's own address (not a session). */
26
+ const SELF = "me"
27
+
28
+ /** The hub addresses a prompt mentions, once each, in order; those the harness already gave a meaning are skipped. */
29
+ export function findReferences(prompt: PromptLike): string[] {
30
+ const text = typeof prompt?.text === "string" ? prompt.text : ""
31
+ if (!text.includes("@")) return []
32
+ const mentions = [...(prompt.files ?? []), ...(prompt.agents ?? []), ...(prompt.skills ?? [])].map((m) => m?.mention).filter((m): m is Mention => !!m)
33
+ const agentNames = new Set((prompt.agents ?? []).map((a) => a?.name).filter(Boolean))
34
+ const found: string[] = []
35
+ for (const match of text.matchAll(ADDRESS)) {
36
+ const [token, ats, name] = match as unknown as [string, string, string]
37
+ const start = match.index ?? 0
38
+ const end = start + token.length
39
+ const role = ats === "@@"
40
+ if (!role && name === SELF) continue
41
+ if (mentions.some((m) => m.start < end && m.end > start)) continue
42
+ if (!role && (agentNames.has(name) || mentions.some((m) => m.text === token || m.text === `@${name}`))) continue
43
+ if (!found.includes(token)) found.push(token)
44
+ }
45
+ return found
46
+ }
47
+
48
+ /** What the hub told this session (who, status, inbox waiting), fetched once per lookup. */
49
+ export interface ReferenceSnapshot {
50
+ readonly who: readonly DirectoryEntry[]
51
+ readonly status?: Status
52
+ /** The session's own open asks (inbox waiting). */
53
+ readonly waiting?: readonly AgentMessage[]
54
+ }
55
+
56
+ export interface ReferencedSession {
57
+ readonly sessionID: string
58
+ readonly name?: string
59
+ readonly harness?: string
60
+ readonly machine?: string
61
+ readonly project?: string
62
+ readonly state: string
63
+ /** Open asks from the prompting session to this one. */
64
+ readonly openAsks: number
65
+ /** The prompting session itself. */
66
+ readonly self: boolean
67
+ }
68
+
69
+ export interface Reference {
70
+ readonly address: string
71
+ readonly sessions: readonly ReferencedSession[]
72
+ }
73
+
74
+ /** The same scope rule as the hub's directory: a claim's project, else the session's project. */
75
+ const inScope = (claim: string | undefined, session: string | undefined, wanted: string | undefined) =>
76
+ wanted === undefined || claim === wanted || (claim === undefined && session === wanted)
77
+
78
+ /** The sessions each address names in the snapshot (the caller's own world); unknown addresses are dropped. */
79
+ export function resolveReferences(addresses: readonly string[], snapshot: ReferenceSnapshot, self: string): Reference[] {
80
+ const live = new Map<string, { state: string; harness?: string }>()
81
+ const machines = new Map<string, string>()
82
+ for (const machine of snapshot.status?.machines ?? []) {
83
+ if (machine.name) machines.set(machine.id, machine.name)
84
+ for (const project of machine.projects) for (const s of project.sessions) live.set(s.id, { state: s.state, ...(s.harness ? { harness: s.harness } : {}) })
85
+ }
86
+ const asks = new Map<string, number>()
87
+ for (const m of snapshot.waiting ?? []) {
88
+ if (m.state !== "open" || m.recipient?.kind !== "session") continue
89
+ asks.set(m.recipient.sessionID, (asks.get(m.recipient.sessionID) ?? 0) + 1)
90
+ }
91
+ const view = (e: DirectoryEntry): ReferencedSession => {
92
+ const now = live.get(e.sessionID)
93
+ const machine = e.machineID ? machines.get(e.machineID) : undefined
94
+ return {
95
+ sessionID: e.sessionID,
96
+ ...(e.name ? { name: e.name } : {}),
97
+ ...(now?.harness ? { harness: now.harness } : {}),
98
+ ...(machine ? { machine } : {}),
99
+ ...(e.project ? { project: e.project } : {}),
100
+ state: now?.state ?? (e.online ? (e.state ?? "online") : "offline"),
101
+ openAsks: asks.get(e.sessionID) ?? 0,
102
+ self: e.sessionID === self,
103
+ }
104
+ }
105
+ const out: Reference[] = []
106
+ for (const address of addresses) {
107
+ const parsed = parseAddress(address)
108
+ if (!parsed || parsed.kind === "id") continue
109
+ const matches = snapshot.who.filter((e) =>
110
+ parsed.kind === "name"
111
+ ? (e.name === parsed.name || (e.aliases ?? []).includes(parsed.name)) && (parsed.project === undefined || e.project === parsed.project)
112
+ : e.roles.some((r) => r.role === parsed.role && inScope(r.project, e.project, parsed.project)),
113
+ )
114
+ if (matches.length) out.push({ address, sessions: matches.map(view) })
115
+ }
116
+ return out
117
+ }
118
+
119
+ const clean = (value: string | undefined) => (value === undefined ? "" : sanitizeLabel(value))
120
+
121
+ function details(s: ReferencedSession): string[] {
122
+ return [clean(s.harness), clean(s.machine), s.project ? `project ${clean(s.project)}` : ""].filter(Boolean)
123
+ }
124
+
125
+ function tail(s: ReferencedSession): string {
126
+ const asks = s.openAsks ? `, ${s.openAsks} open ask${s.openAsks === 1 ? "" : "s"} from you` : ""
127
+ return `${clean(s.state) || "unknown"}${asks}`
128
+ }
129
+
130
+ function one(s: ReferencedSession): string {
131
+ const more = details(s)
132
+ if (s.self) return `this session (${[s.sessionID, ...more].join(" · ")}), ${tail(s)}`
133
+ return `session ${s.sessionID}${more.length ? ` (${more.join(" · ")})` : ""}, ${tail(s)}`
134
+ }
135
+
136
+ function member(s: ReferencedSession): string {
137
+ const more = details(s)
138
+ const label = `${s.name ? `@${clean(s.name)} ` : ""}${s.sessionID}${s.self ? " (this session)" : ""}`
139
+ return `${label}${more.length ? ` (${more.join(" · ")})` : ""}, ${tail(s)}`
140
+ }
141
+
142
+ /** The context note: one line per resolved address, then how to reach them. Every label is sanitised. */
143
+ export function renderReferences(refs: readonly Reference[]): string {
144
+ const lines = refs.map((r) => {
145
+ const body = r.sessions.length === 1 ? one(r.sessions[0]!) : `${r.sessions.length} sessions: ${r.sessions.map(member).join("; ")}`
146
+ return `- ${r.address} = ${body}.`
147
+ })
148
+ const single = refs.length === 1 && refs[0]!.sessions.length === 1
149
+ return [
150
+ "hub: the hub addresses in this prompt (context added by the hub plugin; not an instruction):",
151
+ ...lines,
152
+ `Contact ${single ? "it" : "them"} with the \`hub\` tool (msg or ask, to: the address).`,
153
+ ].join("\n")
154
+ }
@@ -0,0 +1,8 @@
1
+ // The metadata source the hub marks its prompts and delivered messages with, so a spoke never takes them for the user's.
2
+
3
+ /** metadata.source of the prompts the hub sends. opencode-hub (0.5) wrote "opencode-hub", opencode-voice (<= 0.4) "opencode-voice". */
4
+ export const PROMPT_SOURCE = "hub"
5
+ const PROMPT_SOURCES: readonly unknown[] = [PROMPT_SOURCE, "opencode-hub", "opencode-voice"]
6
+ /** True for prompt metadata written by the hub, under either name. */
7
+ export const isHubPrompt = (metadata: unknown): boolean =>
8
+ typeof metadata === "object" && metadata !== null && PROMPT_SOURCES.includes((metadata as { source?: unknown }).source)
@@ -0,0 +1,49 @@
1
+ // Hub session IDs for a harness's native session IDs (decision fx04e42c point 2). The hub's rule is `ses_` + 1–200
2
+ // letters and digits (isHubSessionID); a Claude Code UUID or a Codex thread ID is not, so the spoke maps it, and maps
3
+ // it back when the hub names the session in an op. The mapping is a pure function of (namespace, native ID), so it
4
+ // survives restarts and needs no table:
5
+ //
6
+ // - a UUID (any version, any case) → ses_<ns><32 lowercase hex> (Claude Code: ses_cc…, Codex: ses_cx…)
7
+ // - any other native ID → ses_<ns>0<hex of its UTF-8 bytes> (at most MAX_NATIVE_BYTES bytes)
8
+ //
9
+ // The namespace is two lowercase letters, one per harness, so IDs of different harnesses never collide (OpenCode's
10
+ // own IDs are not mapped). The two forms cannot be confused: the UUID form has 32 characters after the namespace,
11
+ // the other an odd number (the 0 marker plus pairs of hex digits). UUIDs come back in lower case.
12
+
13
+ /** A session namespace: two lowercase letters (e.g. "cc" for Claude Code, "cx" for Codex). */
14
+ export const SESSION_NAMESPACE = /^[a-z]{2}$/
15
+
16
+ /** Longest native ID (UTF-8 bytes) that is not a UUID: ses_ + ns + 0 + 2 hex per byte stays within 200 characters. */
17
+ export const MAX_NATIVE_BYTES = 98
18
+
19
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
20
+ const UUID_HEX = /^[0-9a-f]{32}$/
21
+ const OTHER_HEX = /^0((?:[0-9a-f]{2})+)$/
22
+
23
+ function checkNamespace(ns: string): void {
24
+ if (typeof ns !== "string" || !SESSION_NAMESPACE.test(ns)) throw new Error(`Session namespace must be two lowercase letters (got ${JSON.stringify(ns)})`)
25
+ }
26
+
27
+ /** The hub session ID for a native session ID in namespace `ns`. Throws on a bad namespace or an unmappable ID. */
28
+ export function hubSessionID(ns: string, nativeID: string): string {
29
+ checkNamespace(ns)
30
+ if (typeof nativeID !== "string" || nativeID === "") throw new Error("Native session ID is empty")
31
+ if (UUID.test(nativeID)) return `ses_${ns}${nativeID.replace(/-/g, "").toLowerCase()}`
32
+ const bytes = Buffer.from(nativeID, "utf8")
33
+ if (bytes.length > MAX_NATIVE_BYTES) throw new Error(`Native session ID is too long (${bytes.length} bytes, at most ${MAX_NATIVE_BYTES})`)
34
+ return `ses_${ns}0${bytes.toString("hex")}`
35
+ }
36
+
37
+ /** The native session ID a hub session ID in namespace `ns` stands for; undefined when it is not one of them. */
38
+ export function nativeSessionID(ns: string, hubID: string): string | undefined {
39
+ if (typeof ns !== "string" || !SESSION_NAMESPACE.test(ns) || typeof hubID !== "string") return undefined
40
+ const prefix = `ses_${ns}`
41
+ if (!hubID.startsWith(prefix)) return undefined
42
+ const rest = hubID.slice(prefix.length)
43
+ if (UUID_HEX.test(rest)) return `${rest.slice(0, 8)}-${rest.slice(8, 12)}-${rest.slice(12, 16)}-${rest.slice(16, 20)}-${rest.slice(20)}`
44
+ const other = OTHER_HEX.exec(rest)
45
+ if (!other || other[1]!.length / 2 > MAX_NATIVE_BYTES) return undefined
46
+ const native = Buffer.from(other[1]!, "hex").toString("utf8")
47
+ // Only IDs hubSessionID makes: valid UTF-8 that round-trips, and not a UUID (those use the short form).
48
+ return native !== "" && !UUID.test(native) && hubSessionID(ns, native) === hubID ? native : undefined
49
+ }
@@ -0,0 +1,78 @@
1
+ // The session tokens the hub sent this spoke (sessionTokens op), one per session, kept in memory only. The spoke
2
+ // hands a session's current token to its shells (P1-T6, plugin hooks), so `hub` there acts as that session.
3
+ // A newer token replaces the older one; expired ones are dropped when read. Tokens never appear in JSON, inspect
4
+ // or string output, so logging the store (or an object holding it) cannot leak them.
5
+
6
+ export interface StoredSessionToken {
7
+ readonly token: string
8
+ /** Expiry, ms since the epoch. */
9
+ readonly exp: number
10
+ }
11
+
12
+ const INSPECT = Symbol.for("nodejs.util.inspect.custom")
13
+
14
+ export class SessionTokenStore {
15
+ /** The URL the sessions' shells reach the hub at, as the hub last sent it. */
16
+ hubURL?: string
17
+ readonly #tokens = new Map<string, StoredSessionToken>()
18
+ readonly #now: () => number
19
+
20
+ constructor(now: () => number = Date.now) {
21
+ this.#now = now
22
+ }
23
+
24
+ /** Stores the session's token, replacing its older one; false (nothing stored) when `exp` has passed. */
25
+ set(sessionID: string, token: string, exp: number): boolean {
26
+ if (exp <= this.#now()) return false
27
+ this.#tokens.set(sessionID, { token, exp })
28
+ return true
29
+ }
30
+
31
+ /** The session's current token, or undefined when it has none or it expired (then it is dropped). */
32
+ get(sessionID: string): StoredSessionToken | undefined {
33
+ const entry = this.#tokens.get(sessionID)
34
+ if (!entry) return undefined
35
+ if (entry.exp > this.#now()) return entry
36
+ this.#tokens.delete(sessionID)
37
+ return undefined
38
+ }
39
+
40
+ /** Sessions holding an unexpired token. */
41
+ sessions(): string[] {
42
+ this.prune()
43
+ return [...this.#tokens.keys()]
44
+ }
45
+
46
+ get size(): number {
47
+ this.prune()
48
+ return this.#tokens.size
49
+ }
50
+
51
+ /** Drops expired tokens. */
52
+ prune(): void {
53
+ const now = this.#now()
54
+ for (const [sessionID, entry] of this.#tokens) if (entry.exp <= now) this.#tokens.delete(sessionID)
55
+ }
56
+
57
+ delete(sessionID: string): void {
58
+ this.#tokens.delete(sessionID)
59
+ }
60
+
61
+ /** Forgets every token and the hub URL. */
62
+ clear(): void {
63
+ this.#tokens.clear()
64
+ this.hubURL = undefined
65
+ }
66
+
67
+ toJSON(): { sessions: number; hubURL?: string } {
68
+ return { sessions: this.size, ...(this.hubURL !== undefined ? { hubURL: this.hubURL } : {}) }
69
+ }
70
+
71
+ toString(): string {
72
+ return `SessionTokenStore(${this.size} sessions)`
73
+ }
74
+
75
+ [INSPECT](): string {
76
+ return this.toString()
77
+ }
78
+ }
@@ -0,0 +1,34 @@
1
+ # Template: keep the Claude Code spoke daemon (`hub claude-code daemon`) running on Linux (systemd user unit).
2
+ # The daemon follows every Claude Code session on this machine for the hub (the hub plugin's hooks reach it over a
3
+ # private Unix socket) and, with the Claude Code CLI found (HUB_CLAUDE_BIN, else claude on the PATH below), starts
4
+ # Claude Code for hub launches under node.json's workspaceRoots. `hub claude` starts it on demand; this unit keeps it
5
+ # running so launches can be placed here without a `hub claude` run first. It needs an enrolled machine (the plugin's
6
+ # /hub dialog writes the machine token to ~/.config/hub/config.json, which the daemon only reads).
7
+ # Install: `hub claude-code install` writes this unit to ~/.config/systemd/user with the real paths and prints the
8
+ # systemctl commands to enable it; it never runs them (--dry-run: only print).
9
+ # By hand (replace __RUNTIME__, __ENTRY__ and __HOME__ first):
10
+ # mkdir -p ~/.config/systemd/user
11
+ # sed -e "s|__RUNTIME__|$HOME/.bun/bin/bun|" -e "s|__ENTRY__|$PWD/packages/cli/src/main.ts|" -e "s|__HOME__|$HOME|g" \
12
+ # scripts/hub-claude-code.service > ~/.config/systemd/user/hub-claude-code.service
13
+ # systemctl --user daemon-reload && systemctl --user enable --now hub-claude-code
14
+ # Logs: journalctl --user -u hub-claude-code
15
+ # Remove: `hub claude-code uninstall`, or systemctl --user disable --now hub-claude-code && rm the unit file
16
+ # BUN_BE_BUN=1 lets an OpenCode binary act as bun; the CLI removes it from its own environment.
17
+ [Unit]
18
+ Description=Hub Claude Code spoke daemon (follows Claude Code sessions, starts hub launches)
19
+ After=network-online.target
20
+ Wants=network-online.target
21
+
22
+ [Service]
23
+ Type=simple
24
+ ExecStart=__RUNTIME__ __ENTRY__ claude-code daemon
25
+ Environment=PATH=__HOME__/.local/bin:__HOME__/.bun/bin:/usr/local/bin:/usr/bin:/bin
26
+ Environment=BUN_BE_BUN=1
27
+ Restart=on-failure
28
+ RestartSec=30
29
+ KillSignal=SIGTERM
30
+ TimeoutStopSec=20
31
+ NoNewPrivileges=true
32
+
33
+ [Install]
34
+ WantedBy=default.target
@@ -0,0 +1,34 @@
1
+ # Template: keep the Codex spoke (`hub codex-spoke serve`) running on Linux (systemd user unit).
2
+ # The spoke attaches to the shared Codex app-server daemon's threads (CODEX_HOME/app-server-control, read only) for the
3
+ # hub and starts its own `codex app-server` (HUB_CODEX_BIN, else codex on the PATH below) for hub launches. `hub codex`
4
+ # starts it on demand; this unit keeps it running so launches can be placed here without a `hub codex` run first. It
5
+ # needs an enrolled machine (the plugin's /hub dialog writes the machine token to ~/.config/hub/config.json, which the
6
+ # spoke only reads). HUB_CODEX_CONFIG is not carried in: add it below by hand.
7
+ # Install: `hub codex-spoke install` writes this unit to ~/.config/systemd/user with the real paths and prints the
8
+ # systemctl commands to enable it; it never runs them (--dry-run: only print).
9
+ # By hand (replace __RUNTIME__, __ENTRY__ and __HOME__ first):
10
+ # mkdir -p ~/.config/systemd/user
11
+ # sed -e "s|__RUNTIME__|$HOME/.bun/bin/bun|" -e "s|__ENTRY__|$PWD/packages/cli/src/main.ts|" -e "s|__HOME__|$HOME|g" \
12
+ # scripts/hub-codex-spoke.service > ~/.config/systemd/user/hub-codex-spoke.service
13
+ # systemctl --user daemon-reload && systemctl --user enable --now hub-codex-spoke
14
+ # Logs: journalctl --user -u hub-codex-spoke
15
+ # Remove: `hub codex-spoke uninstall`, or systemctl --user disable --now hub-codex-spoke && rm the unit file
16
+ # BUN_BE_BUN=1 lets an OpenCode binary act as bun; the CLI removes it from its own environment.
17
+ [Unit]
18
+ Description=Hub Codex spoke (follows Codex threads, starts hub launches)
19
+ After=network-online.target
20
+ Wants=network-online.target
21
+
22
+ [Service]
23
+ Type=simple
24
+ ExecStart=__RUNTIME__ __ENTRY__ codex-spoke serve
25
+ Environment=PATH=__HOME__/.local/bin:__HOME__/.bun/bin:/usr/local/bin:/usr/bin:/bin
26
+ Environment=BUN_BE_BUN=1
27
+ Restart=on-failure
28
+ RestartSec=30
29
+ KillSignal=SIGTERM
30
+ TimeoutStopSec=20
31
+ NoNewPrivileges=true
32
+
33
+ [Install]
34
+ WantedBy=default.target