@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,35 @@
1
+ # Template: keep `hub node` running on Linux (systemd user unit).
2
+ # The node prepares launch workspaces for the hub. It needs an enrolled machine (the plugin's /hub dialog writes the
3
+ # machine token to ~/.config/hub/config.json, which the node only reads) and ~/.config/hub/node.json with at least
4
+ # {"workspaceRoots": ["/home/you/projects"]} (optional: "labels", "slots", "memoryMB").
5
+ # Install: `hub node install` writes this unit to ~/.config/systemd/user with the real paths (the runtime running the
6
+ # CLI and the CLI's entry) and prints the systemctl commands to enable it; it never runs them (--dry-run: only print).
7
+ # By hand (replace __RUNTIME__, __ENTRY__ and __HOME__ first):
8
+ # mkdir -p ~/.config/systemd/user
9
+ # sed -e "s|__RUNTIME__|$HOME/.bun/bin/bun|" -e "s|__ENTRY__|$PWD/packages/cli/src/main.ts|" -e "s|__HOME__|$HOME|g" \
10
+ # scripts/hub-node.service > ~/.config/systemd/user/hub-node.service
11
+ # systemctl --user daemon-reload && systemctl --user enable --now hub-node
12
+ # loginctl enable-linger "$USER" # keep it running while you are logged out
13
+ # Logs: journalctl --user -u hub-node
14
+ # Remove: `hub node uninstall`, or systemctl --user disable --now hub-node && rm ~/.config/systemd/user/hub-node.service
15
+ # Exit 78 means node.json gives the node nothing to do (no workspaceRoots): fix it, then restart the unit.
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 node (prepares launch workspaces for the hub)
19
+ After=network-online.target
20
+ Wants=network-online.target
21
+
22
+ [Service]
23
+ Type=simple
24
+ ExecStart=__RUNTIME__ __ENTRY__ node run
25
+ Environment=PATH=__HOME__/.bun/bin:/usr/local/bin:/usr/bin:/bin
26
+ Environment=BUN_BE_BUN=1
27
+ Restart=on-failure
28
+ RestartSec=30
29
+ RestartPreventExitStatus=78
30
+ KillSignal=SIGTERM
31
+ TimeoutStopSec=20
32
+ NoNewPrivileges=true
33
+
34
+ [Install]
35
+ WantedBy=default.target
@@ -0,0 +1,53 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <!--
4
+ Template: keep the Claude Code spoke daemon (`hub claude-code daemon`) running on macOS (launchd).
5
+ The daemon follows every Claude Code session on this machine for the hub (the hub plugin's hooks reach it over a
6
+ private Unix socket) and, with the Claude Code CLI found (HUB_CLAUDE_BIN, else claude on the PATH below), starts
7
+ Claude Code for hub launches under node.json's workspaceRoots. `hub claude` starts it on demand; this agent keeps it
8
+ running so launches can be placed here without a `hub claude` run first. It needs an enrolled machine (the plugin's
9
+ /hub dialog writes the machine token to ~/.config/hub/config.json, which the daemon only reads).
10
+ Install: `hub claude-code install` writes this file to ~/Library/LaunchAgents with the real paths and prints the
11
+ launchctl commands to load it; it never runs them (add the dry-run flag to only print). By hand (replace
12
+ __RUNTIME__, __ENTRY__ and __HOME__ first):
13
+ sed -e "s|__RUNTIME__|$HOME/.bun/bin/bun|" -e "s|__ENTRY__|$PWD/packages/cli/src/main.ts|" -e "s|__HOME__|$HOME|g" \
14
+ scripts/io.github.huynle.hub-claude-code.plist > ~/Library/LaunchAgents/io.github.huynle.hub-claude-code.plist
15
+ mkdir -p ~/.local/state/hub
16
+ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/io.github.huynle.hub-claude-code.plist
17
+ Remove: `hub claude-code uninstall`, or
18
+ launchctl bootout gui/$(id -u)/io.github.huynle.hub-claude-code && rm ~/Library/LaunchAgents/io.github.huynle.hub-claude-code.plist
19
+ BUN_BE_BUN=1 lets an OpenCode binary act as bun; the CLI removes it from its own environment.
20
+ -->
21
+ <plist version="1.0">
22
+ <dict>
23
+ <key>Label</key>
24
+ <string>io.github.huynle.hub-claude-code</string>
25
+ <key>ProgramArguments</key>
26
+ <array>
27
+ <string>__RUNTIME__</string>
28
+ <string>__ENTRY__</string>
29
+ <string>claude-code</string>
30
+ <string>daemon</string>
31
+ </array>
32
+ <key>EnvironmentVariables</key>
33
+ <dict>
34
+ <key>PATH</key>
35
+ <string>__HOME__/.local/bin:__HOME__/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
36
+ <key>BUN_BE_BUN</key>
37
+ <string>1</string>
38
+ </dict>
39
+ <key>RunAtLoad</key>
40
+ <true/>
41
+ <key>KeepAlive</key>
42
+ <dict>
43
+ <key>SuccessfulExit</key>
44
+ <false/>
45
+ </dict>
46
+ <key>ThrottleInterval</key>
47
+ <integer>30</integer>
48
+ <key>StandardOutPath</key>
49
+ <string>__HOME__/.local/state/hub/claude-code.log</string>
50
+ <key>StandardErrorPath</key>
51
+ <string>__HOME__/.local/state/hub/claude-code.log</string>
52
+ </dict>
53
+ </plist>
@@ -0,0 +1,53 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <!--
4
+ Template: keep the Codex spoke (`hub codex-spoke serve`) running on macOS (launchd).
5
+ The spoke attaches to the shared Codex app-server daemon's threads (CODEX_HOME/app-server-control, read only) for
6
+ the hub and starts its own `codex app-server` (HUB_CODEX_BIN, else codex on the PATH below) for hub launches. `hub
7
+ codex` starts it on demand; this agent keeps it running so launches can be placed here without a `hub codex` run
8
+ first. It needs an enrolled machine (the plugin's /hub dialog writes the machine token to
9
+ ~/.config/hub/config.json, which the spoke only reads). HUB_CODEX_CONFIG is not carried in: add it below by hand.
10
+ Install: `hub codex-spoke install` writes this file to ~/Library/LaunchAgents with the real paths and prints the
11
+ launchctl commands to load it; it never runs them (add the dry-run flag to only print). By hand (replace
12
+ __RUNTIME__, __ENTRY__ and __HOME__ first):
13
+ sed -e "s|__RUNTIME__|$HOME/.bun/bin/bun|" -e "s|__ENTRY__|$PWD/packages/cli/src/main.ts|" -e "s|__HOME__|$HOME|g" \
14
+ scripts/io.github.huynle.hub-codex-spoke.plist > ~/Library/LaunchAgents/io.github.huynle.hub-codex-spoke.plist
15
+ mkdir -p ~/.local/state/hub
16
+ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/io.github.huynle.hub-codex-spoke.plist
17
+ Remove: `hub codex-spoke uninstall`, or
18
+ launchctl bootout gui/$(id -u)/io.github.huynle.hub-codex-spoke && rm ~/Library/LaunchAgents/io.github.huynle.hub-codex-spoke.plist
19
+ BUN_BE_BUN=1 lets an OpenCode binary act as bun; the CLI removes it from its own environment.
20
+ -->
21
+ <plist version="1.0">
22
+ <dict>
23
+ <key>Label</key>
24
+ <string>io.github.huynle.hub-codex-spoke</string>
25
+ <key>ProgramArguments</key>
26
+ <array>
27
+ <string>__RUNTIME__</string>
28
+ <string>__ENTRY__</string>
29
+ <string>codex-spoke</string>
30
+ <string>serve</string>
31
+ </array>
32
+ <key>EnvironmentVariables</key>
33
+ <dict>
34
+ <key>PATH</key>
35
+ <string>__HOME__/.local/bin:__HOME__/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
36
+ <key>BUN_BE_BUN</key>
37
+ <string>1</string>
38
+ </dict>
39
+ <key>RunAtLoad</key>
40
+ <true/>
41
+ <key>KeepAlive</key>
42
+ <dict>
43
+ <key>SuccessfulExit</key>
44
+ <false/>
45
+ </dict>
46
+ <key>ThrottleInterval</key>
47
+ <integer>30</integer>
48
+ <key>StandardOutPath</key>
49
+ <string>__HOME__/.local/state/hub/codex-spoke.log</string>
50
+ <key>StandardErrorPath</key>
51
+ <string>__HOME__/.local/state/hub/codex-spoke.log</string>
52
+ </dict>
53
+ </plist>
@@ -0,0 +1,52 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <!--
4
+ Template: keep `hub node` running on macOS (launchd).
5
+ The node prepares launch workspaces for the hub. It needs an enrolled machine (the plugin's /hub dialog writes
6
+ the machine token to ~/.config/hub/config.json, which the node only reads) and ~/.config/hub/node.json with at
7
+ least {"workspaceRoots": ["/Users/you/projects"]} (optional: "labels", "slots", "memoryMB").
8
+ Install: `hub node install` writes this file to ~/Library/LaunchAgents with the real paths (the runtime running
9
+ the CLI and the CLI's entry) and prints the launchctl commands to load it; it never runs them. Add the dry-run
10
+ flag to only print. By hand (replace __RUNTIME__, __ENTRY__ and __HOME__ first):
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/io.github.huynle.hub-node.plist > ~/Library/LaunchAgents/io.github.huynle.hub-node.plist
13
+ mkdir -p ~/.local/state/hub
14
+ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/io.github.huynle.hub-node.plist
15
+ Remove: `hub node uninstall`, or
16
+ launchctl bootout gui/$(id -u)/io.github.huynle.hub-node && rm ~/Library/LaunchAgents/io.github.huynle.hub-node.plist
17
+ Exit 78 means node.json gives the node nothing to do (no workspaceRoots): fix it, then kickstart the agent.
18
+ BUN_BE_BUN=1 lets an OpenCode binary act as bun; the CLI removes it from its own environment.
19
+ -->
20
+ <plist version="1.0">
21
+ <dict>
22
+ <key>Label</key>
23
+ <string>io.github.huynle.hub-node</string>
24
+ <key>ProgramArguments</key>
25
+ <array>
26
+ <string>__RUNTIME__</string>
27
+ <string>__ENTRY__</string>
28
+ <string>node</string>
29
+ <string>run</string>
30
+ </array>
31
+ <key>EnvironmentVariables</key>
32
+ <dict>
33
+ <key>PATH</key>
34
+ <string>__HOME__/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
35
+ <key>BUN_BE_BUN</key>
36
+ <string>1</string>
37
+ </dict>
38
+ <key>RunAtLoad</key>
39
+ <true/>
40
+ <key>KeepAlive</key>
41
+ <dict>
42
+ <key>SuccessfulExit</key>
43
+ <false/>
44
+ </dict>
45
+ <key>ThrottleInterval</key>
46
+ <integer>30</integer>
47
+ <key>StandardOutPath</key>
48
+ <string>__HOME__/.local/state/hub/node.log</string>
49
+ <key>StandardErrorPath</key>
50
+ <string>__HOME__/.local/state/hub/node.log</string>
51
+ </dict>
52
+ </plist>
@@ -0,0 +1,233 @@
1
+ // The shared action executor (design §5, §11.4): answer a permission or form, prompt a session (steer while its
2
+ // tree runs, else a locked turn) and stop it. The console routes and the agent act verbs both call these; each
3
+ // returns an outcome instead of an HTTP response, so every caller maps it to its own errors (the console answers
4
+ // gone with 409, the agent API with 410) and adds its own checks (grants, audit) around the same execution.
5
+ // Core ops are features (CORE_OPS): an OpenCode spoke serves them all, a spoke of another harness only those it
6
+ // announced. Each action checks the feature it sends (stop; turn or steer; permission; form) and answers
7
+ // unsupported {feature, harness} instead of sending an op the spoke would not serve.
8
+ import type { CoreOp, FormAnswer, PermissionDecision, PromptImage, ResultFor, TurnResult } from "../protocol"
9
+ import { randomUUID } from "node:crypto"
10
+ import { MemoryKv } from "./backend/memory-kv"
11
+ import type { Kv, Locks } from "./backend/types"
12
+ import { BusyError, type Registry } from "./registry"
13
+ import type { SpokeHub } from "./spoke-ws"
14
+
15
+ /** The registry calls the executor needs (a fake in tests). */
16
+ export type ActionRegistry = Pick<
17
+ Registry,
18
+ "permissionTargets" | "dropPermission" | "formTargets" | "dropForm" | "owner" | "running" | "exposed" | "beginTurn" | "cancelTurns" | "supports" | "harnessOf"
19
+ >
20
+ export type ActionSpokes = Pick<SpokeHub, "request">
21
+
22
+ export interface ActionDeps {
23
+ readonly registry: ActionRegistry
24
+ readonly spokes: ActionSpokes
25
+ /** The user prompted the session: its message hop chain starts over (never fails). */
26
+ readonly humanTurn: (sessionID: string) => Promise<void>
27
+ /** Where requests answered through the hub are recorded (default: this process; Valkey in a cluster). */
28
+ readonly kv?: Kv
29
+ /**
30
+ * Cluster mode: a turn also holds the session's lease in the shared Locks (turn:<sessionID>, for ttlMs at most),
31
+ * so two pods never start turns in the same session within the cluster view's publish window.
32
+ */
33
+ readonly turns?: { readonly locks: Locks; readonly owner: string; readonly ttlMs: number }
34
+ }
35
+
36
+ /** The action ran. */
37
+ export interface Done {
38
+ readonly outcome: "done"
39
+ }
40
+ /** The request or form is not pending any more: answered elsewhere, expired or ended. */
41
+ export interface Gone {
42
+ readonly outcome: "gone"
43
+ }
44
+ /** No connected process owns the session. */
45
+ export interface Offline {
46
+ readonly outcome: "offline"
47
+ }
48
+ /** The process that would run the action does not serve its core op (`feature`); `harness` is that process's. */
49
+ export interface Unsupported {
50
+ readonly outcome: "unsupported"
51
+ readonly feature: CoreOp
52
+ readonly harness: string
53
+ }
54
+
55
+ export interface PermissionInput {
56
+ /** Root of the session tree whose status holds the pending request. */
57
+ readonly rootID: string
58
+ readonly requestID: string
59
+ readonly decision: PermissionDecision
60
+ /** Why (a reject's reason, for spokes announcing "permissionMessage"; the caller checks the feature). */
61
+ readonly message?: string
62
+ }
63
+
64
+ export type FormInput =
65
+ | { readonly rootID: string; readonly formID: string; readonly answer: FormAnswer; readonly cancel?: undefined }
66
+ | { readonly rootID: string; readonly formID: string; readonly cancel: true; readonly answer?: undefined }
67
+
68
+ export interface PromptInput {
69
+ readonly sessionID: string
70
+ /** Validated and trimmed by the caller. */
71
+ readonly text: string
72
+ /** Validated by the caller; omit (or empty) for none. */
73
+ readonly images?: readonly PromptImage[]
74
+ /** True for the user's own prompts; agent prompts never reset the hop chain (decision wvqeuis0 §8). */
75
+ readonly resetHops: boolean
76
+ }
77
+
78
+ /** Typed input steered into the running tree. */
79
+ export interface Steered extends Done {
80
+ readonly mode: "steer"
81
+ readonly inboxID: string
82
+ }
83
+ /** A new turn holds the session's lock until `result` settles (a stop ends it at once as interrupted). */
84
+ export interface TurnStarted extends Done {
85
+ readonly mode: "turn"
86
+ readonly result: Promise<TurnResult>
87
+ }
88
+
89
+ export interface Stopped extends Done {
90
+ /** The spoke's answer, as it gave it. */
91
+ readonly result: ResultFor["stop"]
92
+ }
93
+
94
+ export interface Actions {
95
+ /** Unsupported when no process holding the request serves "permission". */
96
+ answerPermission(input: PermissionInput): Promise<Done | Gone | Unsupported>
97
+ /** Unsupported when no process holding the form serves "form". */
98
+ answerForm(input: FormInput): Promise<Done | Gone | Unsupported>
99
+ /** Throws BusyError when another turn holds the lock. Unsupported when the owner lacks steer (running) or turn (idle). */
100
+ prompt(input: PromptInput): Promise<Offline | Steered | TurnStarted | Unsupported>
101
+ stop(sessionID: string): Promise<Offline | Stopped | Unsupported>
102
+ }
103
+
104
+ /** How callers word an Unsupported outcome (as the read routes do). */
105
+ export const unsupportedMessage = ({ feature, harness }: { readonly feature: string; readonly harness: string }): string => `The ${harness} spoke does not support ${feature}`
106
+
107
+ const DONE: Done = { outcome: "done" }
108
+ const GONE: Gone = { outcome: "gone" }
109
+ const OFFLINE: Offline = { outcome: "offline" }
110
+ const STOPPED: TurnResult = { outcome: "interrupted", message: "Stopped", reply: "" }
111
+
112
+ /** How long a request answered through the hub stays answered when its harness never reports the reply. */
113
+ const ANSWERED_MS = 10 * 60_000
114
+ const answeredKey = (requestID: string) => `action:answered:${requestID}`
115
+
116
+ export function createActions({ registry, spokes, humanTurn, kv = new MemoryKv(), turns }: ActionDeps): Actions {
117
+ const unsupported = (connID: string, feature: CoreOp): Unsupported | undefined =>
118
+ registry.supports(connID, feature) ? undefined : { outcome: "unsupported", feature, harness: registry.harnessOf(connID) ?? "unknown" }
119
+ /** The targets serving `feature`, or unsupported when there are targets but none serves it. */
120
+ const serving = <T extends { readonly connID: string }>(all: readonly T[], feature: CoreOp): T[] | Unsupported => {
121
+ const targets = all.filter((t) => registry.supports(t.connID, feature))
122
+ return targets.length || !all.length ? targets : unsupported(all[0]!.connID, feature)!
123
+ }
124
+ /** Takes the session's cluster-wide turn lease (BusyError when another pod's turn holds it); resolves its release. */
125
+ const shareTurn = async (sessionID: string): Promise<() => void> => {
126
+ if (!turns) return () => {}
127
+ const key = `turn:${sessionID}`
128
+ const holder = `${turns.owner}:${randomUUID()}`
129
+ if (!(await turns.locks.acquire(key, holder, turns.ttlMs))) throw new BusyError()
130
+ return () => void turns.locks.release(key, holder).catch(() => false)
131
+ }
132
+ return {
133
+ async answerPermission({ rootID, requestID, decision, message }) {
134
+ if ((await kv.get(answeredKey(requestID))) !== undefined) return GONE
135
+ const targets = serving(registry.permissionTargets(rootID, requestID), "permission")
136
+ if (!Array.isArray(targets)) return targets
137
+ if (!targets.length) return GONE
138
+ // Requests answered through the hub (on any pod) are gone for a second answer, even before (or without)
139
+ // permission.replied; claimed before sending, so two pods racing never both send it.
140
+ if (!(await kv.set(answeredKey(requestID), "1", { px: ANSWERED_MS, nx: true }))) return GONE
141
+ // Try every process that saw the request: only the one (and the location) running the session holds it.
142
+ let failure: unknown
143
+ for (const target of targets) {
144
+ try {
145
+ const result = await spokes.request(target.connID, { op: "permission", sessionID: target.sessionID, requestID, decision, ...(message !== undefined ? { message } : {}) })
146
+ if (!result?.gone) return DONE
147
+ } catch (cause) {
148
+ // Older spokes pass OpenCode's "Permission request not found" through as an error.
149
+ if (!/not found/i.test(cause instanceof Error ? cause.message : String(cause))) failure ??= cause
150
+ }
151
+ }
152
+ // Nobody took it: the claim goes, so a later answer (here or on another pod) is tried again.
153
+ await kv.del(answeredKey(requestID))
154
+ if (failure) throw failure
155
+ registry.dropPermission(requestID)
156
+ return GONE
157
+ },
158
+
159
+ async answerForm(input) {
160
+ const targets = serving(registry.formTargets(input.rootID, input.formID), "form")
161
+ if (!Array.isArray(targets)) return targets
162
+ if (!targets.length) return GONE
163
+ let failure: unknown
164
+ for (const target of targets) {
165
+ const req = input.cancel
166
+ ? { op: "form" as const, sessionID: target.sessionID, formID: input.formID, cancel: true as const }
167
+ : { op: "form" as const, sessionID: target.sessionID, formID: input.formID, answer: input.answer }
168
+ try {
169
+ const result = await spokes.request(target.connID, req)
170
+ if (!result?.gone) return DONE
171
+ } catch (cause) {
172
+ failure ??= cause
173
+ }
174
+ }
175
+ if (failure) throw failure
176
+ registry.dropForm(input.formID)
177
+ return GONE
178
+ },
179
+
180
+ async prompt({ sessionID, text, images, resetHops }) {
181
+ const connID = registry.owner(sessionID)
182
+ if (!connID) return OFFLINE
183
+ const steer = registry.running(sessionID)
184
+ const lacking = unsupported(connID, steer ? "steer" : "turn")
185
+ if (lacking) return lacking
186
+ const attached = images?.length ? { images: [...images] } : {}
187
+ if (resetHops) await humanTurn(sessionID)
188
+ // While anything in the tree runs, typed input redirects the current work instead of queueing.
189
+ if (steer) {
190
+ const { inboxID } = await spokes.request(connID, { op: "steer", sessionID, text, ...attached })
191
+ return { outcome: "done", mode: "steer", inboxID }
192
+ }
193
+ // A stop or interrupt releases the lock and ends the turn at once, even if the spoke never answers.
194
+ let cancel = () => {}
195
+ const stopped = new Promise<TurnResult>((resolve) => {
196
+ cancel = () => resolve(STOPPED)
197
+ })
198
+ const releaseLocal = registry.beginTurn(sessionID, () => cancel())
199
+ let releaseShared: () => void
200
+ try {
201
+ releaseShared = await shareTurn(sessionID)
202
+ } catch (cause) {
203
+ releaseLocal()
204
+ throw cause
205
+ }
206
+ const release = () => {
207
+ releaseLocal()
208
+ releaseShared()
209
+ }
210
+ const voiceStyle = registry.exposed(sessionID) === true
211
+ let result: Promise<TurnResult>
212
+ try {
213
+ result = Promise.race([spokes.request(connID, { op: "turn", sessionID, text, voiceStyle, ...attached }), stopped])
214
+ } catch (cause) {
215
+ release()
216
+ throw cause
217
+ }
218
+ // The lock is held until the turn ends, whether anyone waits for it or not.
219
+ void result.then(release, release)
220
+ return { outcome: "done", mode: "turn", result }
221
+ },
222
+
223
+ async stop(sessionID) {
224
+ const connID = registry.owner(sessionID)
225
+ if (!connID) return OFFLINE
226
+ const lacking = unsupported(connID, "stop")
227
+ if (lacking) return lacking
228
+ const result = await spokes.request(connID, { op: "stop", sessionID })
229
+ registry.cancelTurns(sessionID)
230
+ return { outcome: "done", result }
231
+ },
232
+ }
233
+ }
@@ -0,0 +1,133 @@
1
+ // The act verbs on /api/agent (design §5, §8; decision wvqeuis0): thin HTTP over the acting engine (acting.ts).
2
+ //
3
+ // POST /api/agent/sessions/:addr/approve {requestID, always?, why?}
4
+ // POST /api/agent/sessions/:addr/deny {requestID, why?} why → the harness, with who denied it
5
+ // POST /api/agent/sessions/:addr/answer {formID, answer | cancel: true, why?}
6
+ // POST /api/agent/sessions/:addr/prompt {text, images? (users), delivery? "queue"|"steer", wake?, why?}
7
+ // POST /api/agent/sessions/:addr/stop {why?} (body optional)
8
+ //
9
+ // Users always; sessions only through their act token (allow.act; a shell token never acts); app tokens with
10
+ // scope sessions:act. Answers 200 with the engine's result, never cached. Errors: invalid 400, needs_user /
11
+ // forbidden 403 (needs_user carries `evaluation` and, once P3-T8 escalates, `decisionID`), not_found 404, gone 410,
12
+ // unsupported 422, paused 423, rate_limited 429, offline 503.
13
+ import { DELIVER_ID_MAX, DELIVERY_MODES, type DeliveryMode, type FormAnswer, formAnswerProblem, type PromptImage, promptImagesProblem } from "../../protocol"
14
+ import type { Acting } from "./acting"
15
+ import { AgentError } from "./errors"
16
+ import type { AgentRoute } from "./routes"
17
+
18
+ // A literal: routes.ts imports this module (AGENT_PREFIX is not initialised yet when it loads).
19
+ const PREFIX = "/api/agent/sessions/:addr"
20
+ const NO_STORE = { "cache-control": "no-store" }
21
+ const ACT = { act: true, scope: "sessions:act" } as const
22
+ /** Longest `why`. */
23
+ export const WHY_MAX = 1000
24
+
25
+ const invalid = (message: string) => new AgentError("invalid", message)
26
+
27
+ type Body = Record<string, unknown>
28
+
29
+ async function bodyOf(request: Request, optional = false): Promise<Body> {
30
+ const raw = await request.text()
31
+ if (!raw.trim() && optional) return {}
32
+ const value: unknown = JSON.parse(raw)
33
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalid("Body must be a JSON object")
34
+ return value as Body
35
+ }
36
+
37
+ function id(body: Body, key: string): string {
38
+ const value = body[key]
39
+ if (typeof value !== "string" || !value || value.length > DELIVER_ID_MAX) throw invalid(`${key} must be 1–${DELIVER_ID_MAX} characters`)
40
+ return value
41
+ }
42
+
43
+ function why(body: Body): { why?: string } {
44
+ const value = body.why
45
+ if (value === undefined) return {}
46
+ if (typeof value !== "string" || value.length > WHY_MAX) throw invalid(`why must be a string of at most ${WHY_MAX} characters`)
47
+ return value.trim() ? { why: value.trim() } : {}
48
+ }
49
+
50
+ function flag(body: Body, key: string): boolean | undefined {
51
+ const value = body[key]
52
+ if (value !== undefined && typeof value !== "boolean") throw invalid(`${key} must be true or false`)
53
+ return value
54
+ }
55
+
56
+ const ok = (result: unknown) => Response.json(result, { headers: NO_STORE })
57
+
58
+ export function actingRoutes(acting: Acting): AgentRoute[] {
59
+ return [
60
+ {
61
+ method: "POST",
62
+ path: `${PREFIX}/approve`,
63
+ allow: ACT,
64
+ async handle({ request, params, actor, user }) {
65
+ const body = await bodyOf(request)
66
+ const always = flag(body, "always")
67
+ return ok(await acting.approve({ actor, userID: user.id }, params.addr!, { requestID: id(body, "requestID"), ...(always ? { always } : {}), ...why(body) }))
68
+ },
69
+ },
70
+ {
71
+ method: "POST",
72
+ path: `${PREFIX}/deny`,
73
+ allow: ACT,
74
+ async handle({ request, params, actor, user }) {
75
+ const body = await bodyOf(request)
76
+ return ok(await acting.deny({ actor, userID: user.id }, params.addr!, { requestID: id(body, "requestID"), ...why(body) }))
77
+ },
78
+ },
79
+ {
80
+ method: "POST",
81
+ path: `${PREFIX}/answer`,
82
+ allow: ACT,
83
+ async handle({ request, params, actor, user }) {
84
+ const body = await bodyOf(request)
85
+ const formID = id(body, "formID")
86
+ const cancel = flag(body, "cancel") === true
87
+ if (cancel ? body.answer !== undefined : body.cancel !== undefined) throw invalid("Send either an answer or cancel: true")
88
+ const caller = { actor, userID: user.id }
89
+ if (cancel) return ok(await acting.answer(caller, params.addr!, { formID, cancel: true, ...why(body) }))
90
+ const problem = formAnswerProblem(body.answer)
91
+ if (problem) throw invalid(problem)
92
+ return ok(await acting.answer(caller, params.addr!, { formID, answer: body.answer as FormAnswer, ...why(body) }))
93
+ },
94
+ },
95
+ {
96
+ method: "POST",
97
+ path: `${PREFIX}/prompt`,
98
+ allow: ACT,
99
+ async handle({ request, params, actor, user }) {
100
+ const body = await bodyOf(request)
101
+ if (typeof body.text !== "string") throw invalid("text must be a string")
102
+ const delivery = body.delivery
103
+ if (delivery !== undefined && !(DELIVERY_MODES as readonly unknown[]).includes(delivery)) throw invalid(`delivery must be one of ${DELIVERY_MODES.join(", ")}`)
104
+ const wake = flag(body, "wake")
105
+ let images: PromptImage[] | undefined
106
+ if (body.images !== undefined) {
107
+ const problem = promptImagesProblem(body.images)
108
+ if (problem) throw invalid(problem)
109
+ // Only the validated fields go on.
110
+ images = (body.images as PromptImage[]).map(({ mime, data, name }) => ({ mime, data, ...(name ? { name } : {}) }))
111
+ }
112
+ return ok(
113
+ await acting.prompt({ actor, userID: user.id }, params.addr!, {
114
+ text: body.text,
115
+ ...(images ? { images } : {}),
116
+ ...(delivery ? { delivery: delivery as DeliveryMode } : {}),
117
+ ...(wake !== undefined ? { wake } : {}),
118
+ ...why(body),
119
+ }),
120
+ )
121
+ },
122
+ },
123
+ {
124
+ method: "POST",
125
+ path: `${PREFIX}/stop`,
126
+ allow: ACT,
127
+ async handle({ request, params, actor, user }) {
128
+ const body = await bodyOf(request, true)
129
+ return ok(await acting.stop({ actor, userID: user.id }, params.addr!, why(body)))
130
+ },
131
+ },
132
+ ]
133
+ }
@@ -0,0 +1,93 @@
1
+ // Kill switches and the hub-wide never rules for agent actions (design §5, §7; decision wvqeuis0).
2
+ // - Per user: AgentData.actingPaused, set by the user through GET/PUT /api/agent/acting (users only; sessions and
3
+ // apps never turn it on or off). Each change is in the user's agent audit (acting.pause / acting.resume).
4
+ // - Hub-wide: the admin's switch in the Store (PUT /api/admin/agents/acting in http.ts); it applies to everyone.
5
+ // - Either one pauses every agent action (approve, deny, answer, prompt, stop, launch, roles): the acting code
6
+ // (agent/acting.ts) calls ensureActing() first and again before the spoke request; it answers 423 paused.
7
+ // Reading and messaging keep working (messaging has its own switch, AgentData.messagingPaused).
8
+ // - hubNever(): hub.json `agents.never` (already validated by config.ts), checked before every grant.
9
+ import type { Store } from "../store"
10
+ import type { AgentActor } from "./actor"
11
+ import type { AgentAudit } from "./audit"
12
+ import { partyFromActor } from "./envelope"
13
+ import { AgentError } from "./errors"
14
+ import { parsePattern, type Pattern } from "./policy"
15
+ import { AGENT_PREFIX, type AgentRoute } from "./routes"
16
+
17
+ export interface ActingSwitchDeps {
18
+ readonly store: Pick<Store, "agentData" | "updateAgentData" | "actingPaused" | "audit">
19
+ /** hub.json `agents.never` patterns (CentralConfig.agentsNever). */
20
+ readonly never?: readonly string[]
21
+ /** The agent audit (P3-T5): records each per-user change; the hub audit when absent. */
22
+ readonly audit?: AgentAudit
23
+ }
24
+
25
+ export class ActingSwitch {
26
+ private readonly never: readonly Pattern[]
27
+
28
+ constructor(private readonly deps: ActingSwitchDeps) {
29
+ this.never = (deps.never ?? []).flatMap((text) => parsePattern(text) ?? [])
30
+ }
31
+
32
+ /** The user turned agent actions off. */
33
+ userPaused(userID: string): boolean {
34
+ return this.deps.store.agentData(userID).actingPaused === true
35
+ }
36
+
37
+ /** The admin turned agent actions off for the whole hub. */
38
+ hubPaused(): boolean {
39
+ return this.deps.store.actingPaused()
40
+ }
41
+
42
+ /** Agent actions are off for this user: their own switch or the hub-wide one. */
43
+ isActingPaused(userID: string): boolean {
44
+ return this.hubPaused() || this.userPaused(userID)
45
+ }
46
+
47
+ /** Throws paused (423) while a kill switch is on for this user. */
48
+ ensureActing(userID: string): void {
49
+ if (this.hubPaused()) throw new AgentError("paused", "Agent actions are paused by the hub admin", { hint: "Ask the hub admin to turn them back on (Settings → Agents → Agent actions on this hub)" })
50
+ if (this.userPaused(userID)) {
51
+ throw new AgentError("paused", "Agent actions are off for this user", { hint: "Turn them back on in the console: Settings → Agents → Agent actions" })
52
+ }
53
+ }
54
+
55
+ /** The hub-wide never rules (hub.json agents.never), for every user; they beat any grant. */
56
+ hubNever(): readonly Pattern[] {
57
+ return this.never
58
+ }
59
+
60
+ /** Turns the user's agent actions on or off, and audits it. */
61
+ async setUserActing(userID: string, actor: AgentActor, enabled: boolean): Promise<void> {
62
+ await this.deps.store.updateAgentData(userID, (data) => {
63
+ if (enabled) delete data.actingPaused
64
+ else data.actingPaused = true
65
+ })
66
+ const verb = enabled ? "acting.resume" : "acting.pause"
67
+ if (this.deps.audit) {
68
+ await this.deps.audit.record(userID, { actor: partyFromActor(actor), verb, target: {}, decision: "allow", outcome: "ok" })
69
+ } else await this.deps.store.audit({ actor: userID, action: verb })
70
+ }
71
+ }
72
+
73
+ const NO_STORE = { "cache-control": "no-store" }
74
+
75
+ /** GET/PUT /api/agent/acting {enabled}: the caller's own switch (users only); hubEnabled shows the admin's. */
76
+ export function actingSwitchRoutes(acting: ActingSwitch): AgentRoute[] {
77
+ const view = (userID: string) => Response.json({ enabled: !acting.userPaused(userID), hubEnabled: !acting.hubPaused() }, { headers: NO_STORE })
78
+ return [
79
+ { method: "GET", path: `${AGENT_PREFIX}/acting`, allow: {}, handle: ({ user }) => view(user.id) },
80
+ {
81
+ method: "PUT",
82
+ path: `${AGENT_PREFIX}/acting`,
83
+ allow: {},
84
+ async handle({ request, user, actor }) {
85
+ const body: unknown = await request.json()
86
+ const enabled = typeof body === "object" && body !== null && !Array.isArray(body) ? (body as Record<string, unknown>).enabled : undefined
87
+ if (typeof enabled !== "boolean") throw new AgentError("invalid", "enabled must be true or false")
88
+ await acting.setUserActing(user.id, actor, enabled)
89
+ return view(user.id)
90
+ },
91
+ },
92
+ ]
93
+ }