@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,252 @@
1
+ // The launch API on /api/agent (design §6.1, §7, §8; decision znbg8xa5): thin HTTP over the launch engine
2
+ // (launch-engine.ts, Central.launches).
3
+ //
4
+ // POST /api/agent/launches {launch spec} → 201 {launch}; an idempotent repeat (same key and
5
+ // body) → 200 {launch}; same key, other body → 400
6
+ // GET /api/agent/launches?state=a,b&label=k=v → {launches} (oldest first; every label must match)
7
+ // GET /api/agent/launches/:id → {launch}
8
+ // DELETE /api/agent/launches/:id → 200 {launch} (ended stopped); already ended → 410 gone
9
+ //
10
+ // Who: users always (their own launches; tenancy only). App tokens with scope sessions:launch and sessions through
11
+ // their act token (never a shell token) under a grant of their user with verb `launch` covering the repo, in the
12
+ // acting order: kill switch → grant usable → grant action consumed (hourly cap; an idempotent repeat spends none)
13
+ // → kill switch again → the engine. Apps and sessions see and stop only the launches they started (by); any other
14
+ // launch, like another user's, is 404 not_found. The engine applies the per-user launch caps (rate_limited).
15
+ // Every outcome once the body is valid is in the agent audit (verb launch, or launch.stop for DELETE).
16
+ import type { LaunchInfo, LaunchSpec as WireSpec } from "../../protocol"
17
+ import { Feed } from "../cluster/feed"
18
+ import type { ActingSwitch } from "./acting-switch"
19
+ import { auditOf } from "./acting"
20
+ import type { AgentActor } from "./actor"
21
+ import type { AgentAudit, AuditDecision, AuditOutcome, AuditTarget } from "./audit"
22
+ import type { Directory } from "./directory"
23
+ import { partyFromActor, type Party } from "./envelope"
24
+ import { AgentError } from "./errors"
25
+ import type { Grant, Grants } from "./grants"
26
+ import type { LaunchEngine, LaunchView } from "./launch-engine"
27
+ import { type LaunchSpec, validateLaunchSpec } from "./launch-spec"
28
+ import { LAUNCH_STATES, type Launch, type LaunchFilter, type LaunchState, type LaunchStep } from "./launch-store"
29
+ import type { AgentRoute } from "./routes"
30
+
31
+ // A literal: routes.ts imports this module (AGENT_PREFIX is not initialised yet when it loads).
32
+ const PATH = "/api/agent/launches"
33
+ const NO_STORE = { "cache-control": "no-store" }
34
+ /** Users; sessions by their act token; apps with sessions:launch. */
35
+ const LAUNCH = { act: true, scope: "sessions:launch" } as const
36
+
37
+ export type RouteLaunches = Pick<LaunchEngine, "accept" | "view" | "list" | "stop">
38
+
39
+ export interface LaunchRoutesDeps {
40
+ readonly launches: RouteLaunches
41
+ readonly grants: Pick<Grants, "usable" | "consume">
42
+ readonly acting: Pick<ActingSwitch, "ensureActing">
43
+ readonly audit: Pick<AgentAudit, "record">
44
+ /** Names a session caller in the audit. */
45
+ readonly directory?: Pick<Directory, "resolveOne">
46
+ }
47
+
48
+ /** A launch change for its user (the engine's onLaunchChanged). */
49
+ export type LaunchListener = (userID: string, launch: Launch) => void
50
+
51
+ /** Launch changes' Bus topic (cluster mode). */
52
+ export const LAUNCHES_TOPIC = "feed.launches"
53
+
54
+ /**
55
+ * Every launch change, per user: the hub's SSE 'launch' events, GET /api/agent/watch and webhooks (P4-T10)
56
+ * subscribe. A throwing listener is logged and never stops the others. Shared with the other pods once attached
57
+ * (see ../cluster/feed).
58
+ */
59
+ export class LaunchFeed extends Feed<Launch> {
60
+ constructor() {
61
+ super(LAUNCHES_TOPIC, (cause) => console.error(`[hub] launch listener failed: ${cause instanceof Error ? cause.message : String(cause)}`))
62
+ }
63
+ }
64
+
65
+ /** A launch as the API shows it: LaunchInfo, plus who launched it and its steps. */
66
+ export type LaunchApiView = LaunchInfo & { readonly by?: string; readonly history: readonly LaunchStep[] }
67
+
68
+ const UNITS: readonly [string, number][] = [
69
+ ["d", 86_400_000],
70
+ ["h", 3_600_000],
71
+ ["m", 60_000],
72
+ ["s", 1000],
73
+ ]
74
+
75
+ /** A duration as the launch API writes it (`4h`, `90m`, `45s`). */
76
+ function duration(ms: number): string {
77
+ for (const [unit, size] of UNITS) if (ms % size === 0) return `${ms / size}${unit}`
78
+ return `${Math.ceil(ms / 1000)}s`
79
+ }
80
+
81
+ /** The validated spec as a request body (end.timeout as a duration). */
82
+ function wireSpec(spec: LaunchSpec): WireSpec {
83
+ const { timeoutMs, ...end } = spec.end
84
+ return { ...spec, end: { ...end, ...(timeoutMs !== undefined ? { timeout: duration(timeoutMs) } : {}) } }
85
+ }
86
+
87
+ /** The API view of a launch: never its owner; `updated` is when it entered its state. */
88
+ export function launchInfo(launch: LaunchView): LaunchApiView {
89
+ return {
90
+ id: launch.id,
91
+ state: launch.state,
92
+ reason: launch.reason,
93
+ spec: wireSpec(launch.spec),
94
+ created: launch.created,
95
+ updated: launch.at,
96
+ ...(launch.by !== undefined ? { by: launch.by } : {}),
97
+ ...(launch.machineID !== undefined ? { machineID: launch.machineID } : {}),
98
+ ...(launch.directory !== undefined ? { directory: launch.directory } : {}),
99
+ ...(launch.sessionID !== undefined ? { sessionID: launch.sessionID } : {}),
100
+ ...(launch.name !== undefined ? { name: launch.name } : {}),
101
+ ...(launch.outcome !== undefined ? { outcome: launch.outcome } : {}),
102
+ ...(launch.artifacts !== undefined ? { artifacts: launch.artifacts as LaunchInfo["artifacts"] } : {}),
103
+ history: launch.history,
104
+ }
105
+ }
106
+
107
+ /** Who launched, as the launch records it: `user`, `app:<token id>` or the session ID. */
108
+ export function launchedBy(actor: AgentActor): string {
109
+ switch (actor.kind) {
110
+ case "user":
111
+ return "user"
112
+ case "app":
113
+ return `app:${actor.tokenID}`
114
+ case "session":
115
+ return actor.sessionID
116
+ }
117
+ }
118
+
119
+ const invalid = (message: string) => new AgentError("invalid", message)
120
+ const notFound = (id: string) => new AgentError("not_found", `No launch ${id}`)
121
+
122
+ async function bodyOf(request: Request): Promise<unknown> {
123
+ return JSON.parse(await request.text())
124
+ }
125
+
126
+ /** ?state=a,b and ?label=k=v (repeated). */
127
+ function filterOf(params: URLSearchParams): LaunchFilter {
128
+ const raw = params.get("state")
129
+ const states = raw === null ? undefined : raw.split(",").filter(Boolean)
130
+ if (states?.some((s) => !(LAUNCH_STATES as readonly string[]).includes(s))) throw invalid(`state must be ${LAUNCH_STATES.join(", ")} (comma-separated)`)
131
+ const labels: Record<string, string> = {}
132
+ for (const label of params.getAll("label")) {
133
+ const at = label.indexOf("=")
134
+ if (at < 1) throw invalid("label must be key=value")
135
+ labels[label.slice(0, at)] = label.slice(at + 1)
136
+ }
137
+ return { ...(states?.length ? { states: states as LaunchState[] } : {}), ...(Object.keys(labels).length ? { labels } : {}) }
138
+ }
139
+
140
+ export function launchRoutes(deps: LaunchRoutesDeps): AgentRoute[] {
141
+ const { launches, grants, acting, audit } = deps
142
+
143
+ const party = (userID: string, actor: AgentActor): Party => {
144
+ if (actor.kind !== "session" || !deps.directory) return partyFromActor(actor)
145
+ let name: string | undefined
146
+ try {
147
+ name = deps.directory.resolveOne(userID, actor.sessionID).name
148
+ } catch {}
149
+ return partyFromActor(actor, name ? { name } : {})
150
+ }
151
+
152
+ /** The caller's launch: not_found when unknown, another user's, or (apps, sessions) started by someone else. */
153
+ const own = async (userID: string, actor: AgentActor, id: string): Promise<LaunchView> => {
154
+ const launch = await launches.view(userID, id)
155
+ if (!launch || (actor.kind !== "user" && launch.by !== launchedBy(actor))) throw notFound(id)
156
+ return launch
157
+ }
158
+
159
+ /**
160
+ * Runs `run` for the caller in the acting order (agents: kill switch, launch grant on the repo, its action
161
+ * spent unless `spend` is false, kill switch again) and audits the outcome.
162
+ */
163
+ async function act<T>(
164
+ userID: string,
165
+ actor: AgentActor,
166
+ verb: string,
167
+ repo: string,
168
+ plan: { readonly target: () => AuditTarget; readonly spend: () => Promise<boolean>; readonly run: () => Promise<T> },
169
+ ): Promise<T> {
170
+ let grant: Grant | undefined
171
+ const record = (decision: AuditDecision, outcome: AuditOutcome, reason?: string) =>
172
+ audit.record(userID, { actor: party(userID, actor), verb, target: plan.target(), ...(grant ? { grant: grant.id } : {}), decision, ...(reason ? { reason } : {}), outcome })
173
+ try {
174
+ if (actor.kind !== "user") {
175
+ acting.ensureActing(userID)
176
+ const usable = await grants.usable(actor, "launch", { directory: repo })
177
+ if (!usable.length) throw new AgentError("forbidden", `No grant lets this ${actor.kind} launch in ${repo}`)
178
+ grant = usable[0]!
179
+ if (await plan.spend()) await grants.consume(grant)
180
+ acting.ensureActing(userID)
181
+ }
182
+ const result = await plan.run()
183
+ await record("allow", "ok")
184
+ return result
185
+ } catch (cause) {
186
+ const [decision, outcome] = auditOf(cause)
187
+ await record(decision, outcome, cause instanceof Error ? cause.message : undefined)
188
+ throw cause
189
+ }
190
+ }
191
+
192
+ return [
193
+ {
194
+ method: "POST",
195
+ path: PATH,
196
+ allow: LAUNCH,
197
+ async handle({ request, actor, user }) {
198
+ const raw = await bodyOf(request)
199
+ const spec = validateLaunchSpec(raw)
200
+ const by = launchedBy(actor)
201
+ const held = spec.key === undefined ? undefined : (await launches.list(user.id)).find((l) => l.spec.key === spec.key)
202
+ let launchID: string | undefined
203
+ const result = await act(user.id, actor, "launch", spec.workspace.repo, {
204
+ target: () => (launchID !== undefined ? { launchID } : {}),
205
+ // An idempotent repeat spends no grant action.
206
+ spend: async () => held === undefined,
207
+ run: async () => {
208
+ // An agent never gets another launcher's launch through its key.
209
+ if (held && actor.kind !== "user" && held.by !== by) throw new AgentError("invalid", "This key was used by another launcher", { hint: "Use a key of your own" })
210
+ const accepted = await launches.accept(user.id, raw, { by })
211
+ launchID = accepted.launch.id
212
+ return accepted
213
+ },
214
+ })
215
+ return Response.json({ launch: launchInfo(result.launch) }, { status: result.created ? 201 : 200, headers: NO_STORE })
216
+ },
217
+ },
218
+ {
219
+ method: "GET",
220
+ path: PATH,
221
+ allow: LAUNCH,
222
+ async handle({ url, actor, user }) {
223
+ const filter = filterOf(url.searchParams)
224
+ const by = actor.kind === "user" ? undefined : launchedBy(actor)
225
+ const list = (await launches.list(user.id, filter)).filter((l) => by === undefined || l.by === by)
226
+ return Response.json({ launches: list.map(launchInfo) }, { headers: NO_STORE })
227
+ },
228
+ },
229
+ {
230
+ method: "GET",
231
+ path: `${PATH}/:id`,
232
+ allow: LAUNCH,
233
+ async handle({ params, actor, user }) {
234
+ return Response.json({ launch: launchInfo(await own(user.id, actor, params.id!)) }, { headers: NO_STORE })
235
+ },
236
+ },
237
+ {
238
+ method: "DELETE",
239
+ path: `${PATH}/:id`,
240
+ allow: LAUNCH,
241
+ async handle({ params, actor, user }) {
242
+ const launch = await own(user.id, actor, params.id!)
243
+ const stopped = await act(user.id, actor, "launch.stop", launch.spec.workspace.repo, {
244
+ target: () => ({ launchID: launch.id, ...(launch.sessionID !== undefined ? { sessionID: launch.sessionID } : {}) }),
245
+ spend: async () => true,
246
+ run: () => launches.stop(user.id, launch.id),
247
+ })
248
+ return Response.json({ launch: launchInfo(stopped) }, { headers: NO_STORE })
249
+ },
250
+ },
251
+ ]
252
+ }
@@ -0,0 +1,289 @@
1
+ // The launch spec (design §6.1, §6.4; decision znbg8xa5): validates a POST /api/agent/launches body into a
2
+ // LaunchSpec with the defaults filled in. Pure: no state, no I/O; tenancy, grants, caps and placement are the
3
+ // launch engine's. Malformed input is AgentError invalid.
4
+ // - Git-facing fields are strict because the node hands them to git (argv only, never a shell): branch and base
5
+ // follow `git check-ref-format --branch` with no option-like (`-`) component; remotes are https or ssh only
6
+ // (no credentials in the URL, no `ext::`, `file://`, local paths or option-like hosts); repo is an absolute path.
7
+ // - `grant` is not supported in phase 4 (a launched session gets no powers beyond its launcher's).
8
+ // - labels are free-form and never interpreted by the hub, but bounded (LABELS_MAX entries, short keys and values).
9
+ // - Defaults: harness opencode, workspace mode worktree, runtime host, placement mode require, end when idle,
10
+ // cleanup keep, no timeout.
11
+ // LaunchSpec is defined here until packages/protocol carries it (P4-T1); keep the two in step.
12
+
13
+ import { AgentError } from "./errors"
14
+ import { isReservedName, parseAddress, validName } from "./names"
15
+ import { canonicalProject, parseDuration } from "./policy"
16
+
17
+ export type WorkspaceMode = "worktree" | "checkout" | "clone"
18
+ export type RuntimeKind = "host" | "openshell"
19
+ export type PlacementMode = "require" | "prefer"
20
+ export type EndWhen = "idle" | "asked" | "never"
21
+ export type CleanupPolicy = "keep" | "remove"
22
+
23
+ export interface LaunchWorkspace {
24
+ /** Canonical absolute path of the repository on the node. */
25
+ readonly repo: string
26
+ readonly mode: WorkspaceMode
27
+ /** The branch to create or check out (required for worktree). */
28
+ readonly branch?: string
29
+ /** What a new branch starts from. */
30
+ readonly base?: string
31
+ /** https or ssh remote (required for clone). */
32
+ readonly remote?: string
33
+ }
34
+
35
+ export interface LaunchRuntime {
36
+ readonly kind: RuntimeKind
37
+ /** An openshell sandbox profile of the user's. */
38
+ readonly profile?: string
39
+ }
40
+
41
+ export interface LaunchPlacement {
42
+ /** Node labels to match. */
43
+ readonly labels: Readonly<Record<string, string>>
44
+ /** A machine ID or name. */
45
+ readonly machine?: string
46
+ readonly mode: PlacementMode
47
+ }
48
+
49
+ export interface LaunchEnd {
50
+ readonly when: EndWhen
51
+ readonly timeoutMs?: number
52
+ readonly cleanup: CleanupPolicy
53
+ }
54
+
55
+ export interface LaunchSpec {
56
+ /** App-chosen idempotency key: the same key (and body) returns the same launch. */
57
+ readonly key?: string
58
+ /** The session's @name (without the @). */
59
+ readonly name?: string
60
+ readonly roles: readonly string[]
61
+ readonly harness: string
62
+ readonly agent?: string
63
+ /** `provider/model`. */
64
+ readonly model?: string
65
+ readonly workspace: LaunchWorkspace
66
+ readonly runtime: LaunchRuntime
67
+ readonly placement: LaunchPlacement
68
+ readonly prompt?: string
69
+ /** Free-form app labels (never interpreted by the hub). */
70
+ readonly labels: Readonly<Record<string, string>>
71
+ readonly end: LaunchEnd
72
+ }
73
+
74
+ export const KEY_MAX = 200
75
+ export const ROLES_MAX = 10
76
+ export const PROMPT_MAX = 10_000
77
+ export const LABELS_MAX = 32
78
+ export const LABEL_KEY_MAX = 63
79
+ export const LABEL_VALUE_MAX = 256
80
+ export const BRANCH_MAX = 200
81
+ export const REMOTE_MAX = 500
82
+ export const MODEL_MAX = 200
83
+ export const LAUNCH_TIMEOUT_MIN_MS = 60_000
84
+ export const LAUNCH_TIMEOUT_MAX_MS = 30 * 86_400_000
85
+
86
+ const FIELDS = ["key", "name", "roles", "harness", "agent", "model", "workspace", "runtime", "placement", "prompt", "labels", "end"]
87
+ const WORKSPACE_FIELDS = ["repo", "mode", "branch", "base", "remote"]
88
+ const RUNTIME_FIELDS = ["kind", "profile"]
89
+ const PLACEMENT_FIELDS = ["labels", "machine", "mode"]
90
+ const END_FIELDS = ["when", "timeout", "cleanup"]
91
+
92
+ const KEY = /^[!-~]{1,200}$/
93
+ const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/
94
+ const AGENT = /^[A-Za-z0-9_.-]{1,80}$/
95
+ const MODEL = /^[A-Za-z0-9._-]{1,80}\/[!-~]+$/
96
+ const MACHINE = /^[A-Za-z0-9._-]{1,80}$/
97
+ const LABEL_KEY = /^[A-Za-z0-9](?:[A-Za-z0-9._/-]*[A-Za-z0-9])?$/
98
+ /** Control characters (C0, DEL, C1). */
99
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/
100
+ /** Control characters other than tab, newline and carriage return. */
101
+ const PROMPT_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/
102
+
103
+ const invalid = (message: string, hint?: string) => new AgentError("invalid", message, hint ? { hint } : {})
104
+ const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
105
+
106
+ function onlyFields(value: Record<string, unknown>, allowed: readonly string[], what: string): void {
107
+ for (const key of Object.keys(value)) if (!allowed.includes(key)) throw invalid(`Unknown ${what} field ${key}`)
108
+ }
109
+
110
+ function oneOf<T extends string>(value: unknown, options: readonly T[], fallback: T, what: string): T {
111
+ if (value === undefined) return fallback
112
+ if (!options.includes(value as T)) throw invalid(`${what} is ${options.join(", ")}`)
113
+ return value as T
114
+ }
115
+
116
+ function matching(value: unknown, pattern: RegExp, what: string, max = Infinity): string | undefined {
117
+ if (value === undefined) return undefined
118
+ if (typeof value !== "string" || value.length > max || !pattern.test(value)) throw invalid(`${what} is malformed`)
119
+ return value
120
+ }
121
+
122
+ // ---------- git ----------
123
+
124
+ /** A branch name `git check-ref-format --branch` accepts, printable ASCII, with no component starting with `-`. */
125
+ export function validBranch(name: unknown): boolean {
126
+ if (typeof name !== "string" || name.length === 0 || name.length > BRANCH_MAX) return false
127
+ if (!/^[!-~]+$/.test(name) || /[~^:?*[\\]/.test(name)) return false
128
+ if (name === "@" || name === "HEAD" || name.includes("@{") || name.includes("..")) return false
129
+ if (name.startsWith("/") || name.endsWith("/") || name.endsWith(".") || name.includes("//")) return false
130
+ return name.split("/").every((part) => !part.startsWith(".") && !part.startsWith("-") && !part.endsWith(".lock"))
131
+ }
132
+
133
+ const HOST = "[A-Za-z0-9][A-Za-z0-9.-]*"
134
+ const USER = "[A-Za-z0-9_][A-Za-z0-9._-]*"
135
+ const PORT = "(?::\\d{1,5})?"
136
+ const REMOTES = [
137
+ new RegExp(`^https://${HOST}${PORT}/[!-~]+$`),
138
+ new RegExp(`^ssh://(?:${USER}@)?${HOST}${PORT}/[!-~]+$`),
139
+ new RegExp(`^${USER}@${HOST}:(?![-/])[!-~]+$`),
140
+ ]
141
+
142
+ /** An https or ssh git remote (URL or scp-like `user@host:path`), without credentials or option-like parts. */
143
+ export function validRemote(remote: unknown): boolean {
144
+ if (typeof remote !== "string" || remote.length === 0 || remote.length > REMOTE_MAX) return false
145
+ return REMOTES.some((pattern) => pattern.test(remote))
146
+ }
147
+
148
+ // ---------- parts ----------
149
+
150
+ function validLabels(value: unknown, what: string): Record<string, string> {
151
+ if (value === undefined) return {}
152
+ if (!isObject(value)) throw invalid(`${what} is an object of string values`)
153
+ const entries = Object.entries(value)
154
+ if (entries.length > LABELS_MAX) throw invalid(`${what} has at most ${LABELS_MAX} entries`)
155
+ const out: Record<string, string> = {}
156
+ for (const [key, text] of entries) {
157
+ if (key.length > LABEL_KEY_MAX || !LABEL_KEY.test(key)) throw invalid(`${what}: ${key.slice(0, LABEL_KEY_MAX)} is not a label key (letters, digits, . _ / -; up to ${LABEL_KEY_MAX})`)
158
+ if (typeof text !== "string" || text.length > LABEL_VALUE_MAX || CONTROL.test(text)) throw invalid(`${what}.${key} is a string of up to ${LABEL_VALUE_MAX} characters`)
159
+ out[key] = text
160
+ }
161
+ return out
162
+ }
163
+
164
+ function validRoles(value: unknown): string[] {
165
+ if (value === undefined) return []
166
+ if (!Array.isArray(value) || value.length > ROLES_MAX) throw invalid(`roles lists up to ${ROLES_MAX} role names`)
167
+ const roles: string[] = []
168
+ for (const role of value) {
169
+ if (typeof role !== "string" || parseAddress(`@@${role}`)?.kind !== "role") throw invalid(`roles: ${String(role)} is not a role name (such as reviewer or reviewer.hub)`)
170
+ if (!roles.includes(role)) roles.push(role)
171
+ }
172
+ return roles
173
+ }
174
+
175
+ function validWorkspace(value: unknown): LaunchWorkspace {
176
+ if (!isObject(value)) throw invalid("workspace is {repo, mode?, branch?, base?, remote?}")
177
+ onlyFields(value, WORKSPACE_FIELDS, "workspace")
178
+ // canonicalProject rejects relative, `~` and root paths.
179
+ const repo = typeof value.repo === "string" ? canonicalProject(value.repo) : undefined
180
+ if (repo === undefined) throw invalid("workspace.repo is an absolute repository path")
181
+ const mode = oneOf<WorkspaceMode>(value.mode, ["worktree", "checkout", "clone"], "worktree", "workspace.mode")
182
+ for (const field of ["branch", "base"] as const) {
183
+ if (value[field] !== undefined && !validBranch(value[field])) throw invalid(`workspace.${field} is not a safe git branch name`, "Branch names follow git check-ref-format and never start with -")
184
+ }
185
+ if (value.remote !== undefined && !validRemote(value.remote)) throw invalid("workspace.remote is an https or ssh git remote", "Use https://host/path or git@host:path, without credentials")
186
+ if (mode === "worktree" && value.branch === undefined) throw invalid("A worktree workspace needs workspace.branch")
187
+ if (mode === "clone" && value.remote === undefined) throw invalid("A clone workspace needs workspace.remote")
188
+ return {
189
+ repo,
190
+ mode,
191
+ ...(value.branch !== undefined ? { branch: value.branch as string } : {}),
192
+ ...(value.base !== undefined ? { base: value.base as string } : {}),
193
+ ...(value.remote !== undefined ? { remote: value.remote as string } : {}),
194
+ }
195
+ }
196
+
197
+ function validRuntime(value: unknown, workspace: LaunchWorkspace): LaunchRuntime {
198
+ if (value === undefined) return { kind: "host" }
199
+ if (!isObject(value)) throw invalid("runtime is {kind, profile?}")
200
+ onlyFields(value, RUNTIME_FIELDS, "runtime")
201
+ const kind = oneOf<RuntimeKind>(value.kind, ["host", "openshell"], "host", "runtime.kind")
202
+ const profile = matching(value.profile, SLUG, "runtime.profile")
203
+ if (kind === "host" && profile !== undefined) throw invalid("runtime.profile applies to the openshell runtime only")
204
+ if (kind === "openshell" && workspace.mode !== "clone") throw invalid("The openshell runtime runs clone workspaces only")
205
+ return { kind, ...(profile !== undefined ? { profile } : {}) }
206
+ }
207
+
208
+ function validPlacement(value: unknown): LaunchPlacement {
209
+ if (value === undefined) return { labels: {}, mode: "require" }
210
+ if (!isObject(value)) throw invalid("placement is {labels?, machine?, mode?}")
211
+ onlyFields(value, PLACEMENT_FIELDS, "placement")
212
+ const labels = validLabels(value.labels, "placement.labels")
213
+ const machine = matching(value.machine, MACHINE, "placement.machine")
214
+ const mode = oneOf<PlacementMode>(value.mode, ["require", "prefer"], "require", "placement.mode")
215
+ return { labels, ...(machine !== undefined ? { machine } : {}), mode }
216
+ }
217
+
218
+ function validEnd(value: unknown): LaunchEnd {
219
+ if (value === undefined) return { when: "idle", cleanup: "keep" }
220
+ if (!isObject(value)) throw invalid("end is {when?, timeout?, cleanup?}")
221
+ onlyFields(value, END_FIELDS, "end")
222
+ const when = oneOf<EndWhen>(value.when, ["idle", "asked", "never"], "idle", "end.when")
223
+ let timeoutMs: number | undefined
224
+ if (value.timeout !== undefined) {
225
+ timeoutMs = parseDuration(value.timeout)
226
+ if (timeoutMs === undefined || timeoutMs < LAUNCH_TIMEOUT_MIN_MS || timeoutMs > LAUNCH_TIMEOUT_MAX_MS) throw invalid("end.timeout is 1 minute to 30 days (seconds, or 30m, 4h, 2d)")
227
+ }
228
+ const cleanup = oneOf<CleanupPolicy>(value.cleanup, ["keep", "remove"], "keep", "end.cleanup")
229
+ return { when, ...(timeoutMs !== undefined ? { timeoutMs } : {}), cleanup }
230
+ }
231
+
232
+ // ---------- spec ----------
233
+
234
+ /** Validates a launch body into a LaunchSpec (a fresh copy, defaults filled in); throws AgentError invalid. */
235
+ export function validateLaunchSpec(input: unknown): LaunchSpec {
236
+ if (!isObject(input)) throw invalid("A launch is an object")
237
+ if ("grant" in input) throw invalid("grant is not supported yet: a launched session has its launcher's powers only")
238
+ onlyFields(input, FIELDS, "launch")
239
+ const key = matching(input.key, KEY, `key (1–${KEY_MAX} visible characters)`)
240
+ let name: string | undefined
241
+ if (input.name !== undefined) {
242
+ if (typeof input.name !== "string" || !validName(input.name) || isReservedName(input.name)) throw invalid("name is a session name (lowercase letters, digits, dashes; 2–40), without the @")
243
+ name = input.name
244
+ }
245
+ const roles = validRoles(input.roles)
246
+ const harness = matching(input.harness, SLUG, "harness") ?? "opencode"
247
+ const agent = matching(input.agent, AGENT, "agent")
248
+ const model = matching(input.model, MODEL, "model (provider/model)", MODEL_MAX)
249
+ const workspace = validWorkspace(input.workspace)
250
+ const runtime = validRuntime(input.runtime, workspace)
251
+ const placement = validPlacement(input.placement)
252
+ let prompt: string | undefined
253
+ if (input.prompt !== undefined) {
254
+ if (typeof input.prompt !== "string" || input.prompt.length > PROMPT_MAX || PROMPT_CONTROL.test(input.prompt)) throw invalid(`prompt is text of up to ${PROMPT_MAX} characters`)
255
+ prompt = input.prompt
256
+ }
257
+ const labels = validLabels(input.labels, "labels")
258
+ const end = validEnd(input.end)
259
+ return {
260
+ ...(key !== undefined ? { key } : {}),
261
+ ...(name !== undefined ? { name } : {}),
262
+ roles,
263
+ harness,
264
+ ...(agent !== undefined ? { agent } : {}),
265
+ ...(model !== undefined ? { model } : {}),
266
+ workspace,
267
+ runtime,
268
+ placement,
269
+ ...(prompt !== undefined ? { prompt } : {}),
270
+ labels,
271
+ end,
272
+ }
273
+ }
274
+
275
+ /** JSON with object keys sorted (stable for equal values). */
276
+ function stable(value: unknown): string {
277
+ if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`
278
+ if (isObject(value)) {
279
+ const keys = Object.keys(value).filter((key) => value[key] !== undefined).sort()
280
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(",")}}`
281
+ }
282
+ return JSON.stringify(value)
283
+ }
284
+
285
+ /** What idempotency compares: the validated spec without its key, as stable JSON (equal for equivalent bodies). */
286
+ export function launchBodyKey(spec: LaunchSpec): string {
287
+ const { key: _key, ...body } = spec
288
+ return stable(body)
289
+ }