airaknit 1.1.2-rc.9

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 (440) hide show
  1. package/LICENSE +84 -0
  2. package/README.md +202 -0
  3. package/bin/airaknit +9 -0
  4. package/bin/airaknit-project +14 -0
  5. package/bin/kanna +9 -0
  6. package/dist/client/assets/CompactSummaryMessage-Yw0BDWEJ.js +1 -0
  7. package/dist/client/assets/ExitPlanModeMessage-DIdkQ4uF.js +1 -0
  8. package/dist/client/assets/LocalFilePreviewDialog-DQx2eiCc.js +3 -0
  9. package/dist/client/assets/LocalProjectsSection-C4xlWkgS.js +1 -0
  10. package/dist/client/assets/TextMessage-B5G39DEJ.js +1 -0
  11. package/dist/client/assets/UserMessage-CIkWk-0L.js +1 -0
  12. package/dist/client/assets/_basePickBy-CVrAFfnZ.js +1 -0
  13. package/dist/client/assets/_baseUniq-JL-aaF4P.js +1 -0
  14. package/dist/client/assets/arc-B07zg7ol.js +1 -0
  15. package/dist/client/assets/architecture-YZFGNWBL-PSLVJL3p.js +1 -0
  16. package/dist/client/assets/architectureDiagram-Q4EWVU46-DfWIF1G_.js +36 -0
  17. package/dist/client/assets/array-BGFCBI0e.js +1 -0
  18. package/dist/client/assets/blockDiagram-DXYQGD6D-CzwIeo_B.js +132 -0
  19. package/dist/client/assets/bundle-mjs-BDE2gWbQ.js +1 -0
  20. package/dist/client/assets/button-DO50qOGv.js +1 -0
  21. package/dist/client/assets/c4Diagram-AHTNJAMY-CR8DCQRE.js +10 -0
  22. package/dist/client/assets/channel-Dj-UUfaF.js +1 -0
  23. package/dist/client/assets/chunk-2KRD3SAO-dznP-cn8.js +1 -0
  24. package/dist/client/assets/chunk-336JU56O-Dqss5Vu6.js +2 -0
  25. package/dist/client/assets/chunk-426QAEUC-CEKis_0_.js +1 -0
  26. package/dist/client/assets/chunk-4BX2VUAB-BYOv0Gm1.js +1 -0
  27. package/dist/client/assets/chunk-4TB4RGXK-BxzubH5S.js +206 -0
  28. package/dist/client/assets/chunk-55IACEB6-BSlTj03a.js +1 -0
  29. package/dist/client/assets/chunk-5FUZZQ4R-9Au93Bi1.js +62 -0
  30. package/dist/client/assets/chunk-5PVQY5BW-BhksHFEZ.js +2 -0
  31. package/dist/client/assets/chunk-67CJDMHE-BFwhz-8t.js +1 -0
  32. package/dist/client/assets/chunk-7N4EOEYR-BDUPds87.js +1 -0
  33. package/dist/client/assets/chunk-AA7GKIK3-CEWTdyXO.js +1 -0
  34. package/dist/client/assets/chunk-BO2N2NFS-D0LvxnhU.js +103 -0
  35. package/dist/client/assets/chunk-BSJP7CBP-BNJnK6sq.js +1 -0
  36. package/dist/client/assets/chunk-Bj-mKKzh.js +1 -0
  37. package/dist/client/assets/chunk-CIAEETIT-CYhfoCeN.js +1 -0
  38. package/dist/client/assets/chunk-EDXVE4YY-C5ovJLc0.js +1 -0
  39. package/dist/client/assets/chunk-ENJZ2VHE-HOhYaeGr.js +10 -0
  40. package/dist/client/assets/chunk-FMBD7UC4-BLCiKcAQ.js +15 -0
  41. package/dist/client/assets/chunk-FOC6F5B3-B6GtY2ek.js +1 -0
  42. package/dist/client/assets/chunk-ICPOFSXX-DPoIZoC5.js +122 -0
  43. package/dist/client/assets/chunk-K5T4RW27-BsKN63rv.js +94 -0
  44. package/dist/client/assets/chunk-KGLVRYIC-BUGn9uuY.js +1 -0
  45. package/dist/client/assets/chunk-LIHQZDEY-DhaZyo03.js +1 -0
  46. package/dist/client/assets/chunk-ORNJ4GCN-DlpeeJyi.js +1 -0
  47. package/dist/client/assets/chunk-OYMX7WX6-Dc9q7aYA.js +231 -0
  48. package/dist/client/assets/chunk-QZHKN3VN-BEdrPoSb.js +1 -0
  49. package/dist/client/assets/chunk-U2HBQHQK-CIB3Bjjd.js +70 -0
  50. package/dist/client/assets/chunk-X2U36JSP-CtB-o8Yp.js +1 -0
  51. package/dist/client/assets/chunk-XPW4576I-C6iHhX_8.js +32 -0
  52. package/dist/client/assets/chunk-YZCP3GAM-CTmKr6ZH.js +1 -0
  53. package/dist/client/assets/chunk-ZZ45TVLE-BgU8A2RF.js +1 -0
  54. package/dist/client/assets/classDiagram-6PBFFD2Q-Bqk5e679.js +1 -0
  55. package/dist/client/assets/classDiagram-v2-HSJHXN6E-6pSaZOkC.js +1 -0
  56. package/dist/client/assets/client-BrKWI4CM.js +1 -0
  57. package/dist/client/assets/client-CGgNRU9w.js +1 -0
  58. package/dist/client/assets/client-DMSLRzg9.js +6 -0
  59. package/dist/client/assets/clone-DWcL7whJ.js +1 -0
  60. package/dist/client/assets/cose-bilkent-S5V4N54A-CrV5wsV_.js +1 -0
  61. package/dist/client/assets/cytoscape.esm--aLzKuep.js +321 -0
  62. package/dist/client/assets/dagre-CuRxWcrj.js +1 -0
  63. package/dist/client/assets/dagre-KV5264BT-BIDiVnkA.js +4 -0
  64. package/dist/client/assets/defaultLocale-CRZydyG6.js +1 -0
  65. package/dist/client/assets/diagram-5BDNPKRD-i1kjKRCB.js +10 -0
  66. package/dist/client/assets/diagram-G4DWMVQ6-9ZSLuhbl.js +24 -0
  67. package/dist/client/assets/diagram-MMDJMWI5-B4_CUjgv.js +43 -0
  68. package/dist/client/assets/diagram-TYMM5635-Ct5eTGS8.js +24 -0
  69. package/dist/client/assets/dist-CuB4kiSK.js +1 -0
  70. package/dist/client/assets/erDiagram-SMLLAGMA-Cy38ercc.js +85 -0
  71. package/dist/client/assets/flowDiagram-DWJPFMVM-CZKuYl0V.js +162 -0
  72. package/dist/client/assets/ganttDiagram-T4ZO3ILL-DLPjCh7a.js +292 -0
  73. package/dist/client/assets/gitGraph-7Q5UKJZL-DqbrtEp9.js +1 -0
  74. package/dist/client/assets/gitGraphDiagram-UUTBAWPF-BoRBkDhQ.js +106 -0
  75. package/dist/client/assets/graphlib-BcQ6qlQh.js +1 -0
  76. package/dist/client/assets/highlighted-body-OFNGDK62-BEpBVDTX.js +1 -0
  77. package/dist/client/assets/index-CetCiuqP.js +105 -0
  78. package/dist/client/assets/index-N29Mip7A.css +1 -0
  79. package/dist/client/assets/info-OMHHGYJF-D98DRBJX.js +1 -0
  80. package/dist/client/assets/infoDiagram-42DDH7IO-BAcdTWbt.js +2 -0
  81. package/dist/client/assets/init-B8gtcn7T.js +1 -0
  82. package/dist/client/assets/isArrayLikeObject-D8SJFmkN.js +1 -0
  83. package/dist/client/assets/isEmpty-BF3YX5Jk.js +1 -0
  84. package/dist/client/assets/ishikawaDiagram-UXIWVN3A-Ynu2VKdC.js +70 -0
  85. package/dist/client/assets/journeyDiagram-VCZTEJTY-BjfhQaN3.js +139 -0
  86. package/dist/client/assets/jsx-runtime-CyI9ICYU.js +1 -0
  87. package/dist/client/assets/kanban-definition-6JOO6SKY-JLXH9zUJ.js +89 -0
  88. package/dist/client/assets/katex-B94qP8b6.js +265 -0
  89. package/dist/client/assets/lib--QVjyxmL.js +29 -0
  90. package/dist/client/assets/lib-B6rgJiZ9.js +1 -0
  91. package/dist/client/assets/line-DCrYfLBn.js +1 -0
  92. package/dist/client/assets/linear-_4upLmeo.js +1 -0
  93. package/dist/client/assets/mermaid-GHXKKRXX-rwJHYUmW.js +1 -0
  94. package/dist/client/assets/mermaid-parser.core-KZinfW8o.js +4 -0
  95. package/dist/client/assets/mermaid.core-QqY9gSNe.js +11 -0
  96. package/dist/client/assets/mindmap-definition-QFDTVHPH-TWgHDAzp.js +96 -0
  97. package/dist/client/assets/ordinal-CCj7PWgZ.js +1 -0
  98. package/dist/client/assets/packet-4T2RLAQJ-DEvfkn3F.js +1 -0
  99. package/dist/client/assets/path-DZF-JdEe.js +1 -0
  100. package/dist/client/assets/pie-ZZUOXDRM-72e6WVjb.js +1 -0
  101. package/dist/client/assets/pieDiagram-DEJITSTG-Cl8PCsoj.js +30 -0
  102. package/dist/client/assets/preload-helper-rov5CBGT.js +1 -0
  103. package/dist/client/assets/pty-client-DZ27IS00.js +1 -0
  104. package/dist/client/assets/ptyInstancesStore-D9ag7SYd.js +1 -0
  105. package/dist/client/assets/quadrantDiagram-34T5L4WZ-CHyVGp9E.js +7 -0
  106. package/dist/client/assets/radar-PYXPWWZC-Cp7xd_EY.js +1 -0
  107. package/dist/client/assets/react-CClhXMB2.js +1 -0
  108. package/dist/client/assets/react-Dd6D81m0.js +1 -0
  109. package/dist/client/assets/react-dom--G6_6fQ_.js +1 -0
  110. package/dist/client/assets/requirementDiagram-MS252O5E-DaanG2iM.js +84 -0
  111. package/dist/client/assets/rough.esm-BsmKo2S5.js +1 -0
  112. package/dist/client/assets/sankeyDiagram-XADWPNL6-B_fhLY36.js +10 -0
  113. package/dist/client/assets/sequenceDiagram-FGHM5R23-C5FNrveI.js +157 -0
  114. package/dist/client/assets/src-DeTlMJAU.js +1 -0
  115. package/dist/client/assets/stateDiagram-FHFEXIEX-nTTcdjjQ.js +1 -0
  116. package/dist/client/assets/stateDiagram-v2-QKLJ7IA2-Dw0632j_.js +1 -0
  117. package/dist/client/assets/timeline-definition-GMOUNBTQ-DkQV1yP8.js +120 -0
  118. package/dist/client/assets/treeView-SZITEDCU-ZLIgC7_K.js +1 -0
  119. package/dist/client/assets/treemap-W4RFUUIX-BqaMbB6N.js +1 -0
  120. package/dist/client/assets/uiIdentityOverlay-Ba7GNj7m.js +1 -0
  121. package/dist/client/assets/vennDiagram-DHZGUBPP-DbZ2xgs6.js +34 -0
  122. package/dist/client/assets/wardley-RL74JXVD-DXQS8zf4.js +1 -0
  123. package/dist/client/assets/wardleyDiagram-NUSXRM2D-BzCJ6MAu.js +20 -0
  124. package/dist/client/assets/xychartDiagram-5P7HB3ND-BSlFecop.js +7 -0
  125. package/dist/client/favicon.png +0 -0
  126. package/dist/client/favicon.svg +15 -0
  127. package/dist/client/fonts/body-medium.woff2 +0 -0
  128. package/dist/client/fonts/body-regular-italic.woff2 +0 -0
  129. package/dist/client/fonts/body-regular.woff2 +0 -0
  130. package/dist/client/fonts/body-semibold.woff2 +0 -0
  131. package/dist/client/index.html +31 -0
  132. package/dist/client/manifest.webmanifest +12 -0
  133. package/dist/client/sw.js +32 -0
  134. package/package.json +122 -0
  135. package/src/nats/auth-callout/callout-config.test.ts +93 -0
  136. package/src/nats/auth-callout/callout-config.ts +109 -0
  137. package/src/nats/auth-callout/callout.integration.test.ts +332 -0
  138. package/src/nats/auth-callout/keys.ts +103 -0
  139. package/src/nats/auth-callout/responder.ts +241 -0
  140. package/src/nats/auth-callout/scope-policy.test.ts +159 -0
  141. package/src/nats/auth-callout/scope-policy.ts +210 -0
  142. package/src/nats/auth-callout/token.test.ts +163 -0
  143. package/src/nats/auth-callout/token.ts +157 -0
  144. package/src/nats/nats-daemon-callout.ts +194 -0
  145. package/src/nats/nats-daemon.test.ts +77 -0
  146. package/src/nats/nats-daemon.ts +50 -0
  147. package/src/nats/nats-token.test.ts +61 -0
  148. package/src/nats/nats-token.ts +59 -0
  149. package/src/runner/coordination-mcp-integration.test.ts +134 -0
  150. package/src/runner/nats-coordination-client.test.ts +49 -0
  151. package/src/runner/nats-coordination-client.ts +94 -0
  152. package/src/runner/runner-agent.test.ts +469 -0
  153. package/src/runner/runner-agent.ts +453 -0
  154. package/src/runner/runner-credential.test.ts +93 -0
  155. package/src/runner/runner-credential.ts +82 -0
  156. package/src/runner/runner-nats.test.ts +495 -0
  157. package/src/runner/runner-nats.ts +323 -0
  158. package/src/runner/runner-pair.test.ts +107 -0
  159. package/src/runner/runner-pair.ts +81 -0
  160. package/src/runner/runner.test.ts +135 -0
  161. package/src/runner/runner.ts +212 -0
  162. package/src/runner/turn-factories.test.ts +97 -0
  163. package/src/runner/turn-factories.ts +475 -0
  164. package/src/server/agent-config-journey.test.ts +106 -0
  165. package/src/server/agent.ts +8 -0
  166. package/src/server/auto-continue/auth-error-detector.ts +66 -0
  167. package/src/server/auto-continue/limit-detector.ts +194 -0
  168. package/src/server/bm25.test.ts +92 -0
  169. package/src/server/bm25.ts +101 -0
  170. package/src/server/chat-events-jetstream.test.ts +135 -0
  171. package/src/server/claude-harness.ts +360 -0
  172. package/src/server/claude-pty/agent-normalizers.ts +309 -0
  173. package/src/server/claude-pty/auth.test.ts +38 -0
  174. package/src/server/claude-pty/auth.ts +32 -0
  175. package/src/server/claude-pty/claude-session-registry.adapter.ts +81 -0
  176. package/src/server/claude-pty/claude-session-registry.test.ts +149 -0
  177. package/src/server/claude-pty/driver.test.ts +902 -0
  178. package/src/server/claude-pty/driver.ts +807 -0
  179. package/src/server/claude-pty/jsonl-path.adapter.ts +57 -0
  180. package/src/server/claude-pty/jsonl-path.test.ts +114 -0
  181. package/src/server/claude-pty/jsonl-to-event.test.ts +241 -0
  182. package/src/server/claude-pty/jsonl-to-event.ts +174 -0
  183. package/src/server/claude-pty/output-ring.test.ts +35 -0
  184. package/src/server/claude-pty/output-ring.ts +25 -0
  185. package/src/server/claude-pty/parity-matrix.test.ts +227 -0
  186. package/src/server/claude-pty/pid-registry.adapter.ts +135 -0
  187. package/src/server/claude-pty/pid-registry.test.ts +122 -0
  188. package/src/server/claude-pty/preflight/binary-fingerprint.adapter.ts +20 -0
  189. package/src/server/claude-pty/preflight/binary-fingerprint.test.ts +32 -0
  190. package/src/server/claude-pty/pty-instance-registry.test.ts +177 -0
  191. package/src/server/claude-pty/pty-instance-registry.ts +166 -0
  192. package/src/server/claude-pty/pty-memory-sampler.adapter.test.ts +103 -0
  193. package/src/server/claude-pty/pty-memory-sampler.adapter.ts +85 -0
  194. package/src/server/claude-pty/pty-process.adapter.ts +66 -0
  195. package/src/server/claude-pty/pty-process.test.ts +49 -0
  196. package/src/server/claude-pty/resolve-binary.adapter.ts +106 -0
  197. package/src/server/claude-pty/resolve-binary.test.ts +118 -0
  198. package/src/server/claude-pty/runtime-dir.adapter.ts +19 -0
  199. package/src/server/claude-pty/settings-writer.adapter.ts +27 -0
  200. package/src/server/claude-pty/settings-writer.test.ts +22 -0
  201. package/src/server/claude-pty/smoke-test-io.adapter.ts +28 -0
  202. package/src/server/claude-pty/smoke-test.test.ts +191 -0
  203. package/src/server/claude-pty/smoke-test.ts +185 -0
  204. package/src/server/claude-pty/subagent-orchestrator.ts +887 -0
  205. package/src/server/claude-pty/tool-callback.ts +274 -0
  206. package/src/server/claude-pty/tui-control.test.ts +272 -0
  207. package/src/server/claude-pty/tui-control.ts +182 -0
  208. package/src/server/claude-pty/tui-source.adapter.test.ts +360 -0
  209. package/src/server/claude-pty/tui-source.adapter.ts +343 -0
  210. package/src/server/claude-pty/tunnel-gateway.ts +12 -0
  211. package/src/server/claude-pty-mcp/canonical-args.ts +15 -0
  212. package/src/server/claude-pty-mcp/fs-stat.adapter.ts +8 -0
  213. package/src/server/claude-pty-mcp/history-primer.ts +90 -0
  214. package/src/server/claude-pty-mcp/http-server.adapter.ts +33 -0
  215. package/src/server/claude-pty-mcp/mcp-http.ts +177 -0
  216. package/src/server/claude-pty-mcp/mcp.ts +412 -0
  217. package/src/server/claude-pty-mcp/mention-parser.ts +25 -0
  218. package/src/server/claude-pty-mcp/paths.ts +24 -0
  219. package/src/server/claude-pty-mcp/permission-gate.ts +243 -0
  220. package/src/server/claude-pty-mcp/terminal-pid-registry.adapter.ts +107 -0
  221. package/src/server/claude-pty-mcp/tools/ask-user-question.test.ts +119 -0
  222. package/src/server/claude-pty-mcp/tools/ask-user-question.ts +61 -0
  223. package/src/server/claude-pty-mcp/tools/bash.adapter.ts +76 -0
  224. package/src/server/claude-pty-mcp/tools/bash.test.ts +56 -0
  225. package/src/server/claude-pty-mcp/tools/delegate-subagent.test.ts +155 -0
  226. package/src/server/claude-pty-mcp/tools/delegate-subagent.ts +111 -0
  227. package/src/server/claude-pty-mcp/tools/edit.adapter.ts +95 -0
  228. package/src/server/claude-pty-mcp/tools/edit.test.ts +93 -0
  229. package/src/server/claude-pty-mcp/tools/exit-plan-mode.test.ts +61 -0
  230. package/src/server/claude-pty-mcp/tools/exit-plan-mode.ts +50 -0
  231. package/src/server/claude-pty-mcp/tools/glob.adapter.ts +86 -0
  232. package/src/server/claude-pty-mcp/tools/glob.test.ts +61 -0
  233. package/src/server/claude-pty-mcp/tools/grep.adapter.ts +126 -0
  234. package/src/server/claude-pty-mcp/tools/grep.test.ts +62 -0
  235. package/src/server/claude-pty-mcp/tools/read.adapter.ts +58 -0
  236. package/src/server/claude-pty-mcp/tools/read.test.ts +62 -0
  237. package/src/server/claude-pty-mcp/tools/tool-callback-shim.ts +42 -0
  238. package/src/server/claude-pty-mcp/tools/webfetch.test.ts +81 -0
  239. package/src/server/claude-pty-mcp/tools/webfetch.ts +82 -0
  240. package/src/server/claude-pty-mcp/tools/websearch.test.ts +40 -0
  241. package/src/server/claude-pty-mcp/tools/websearch.ts +42 -0
  242. package/src/server/claude-pty-mcp/tools/write.adapter.ts +60 -0
  243. package/src/server/claude-pty-mcp/tools/write.test.ts +52 -0
  244. package/src/server/claude-pty-mcp/uploads.adapter.ts +98 -0
  245. package/src/server/claude-pty-mcp/uploads.ts +38 -0
  246. package/src/server/claude-turn.test.ts +176 -0
  247. package/src/server/cli-runtime.test.ts +456 -0
  248. package/src/server/cli-runtime.ts +374 -0
  249. package/src/server/cli-supervisor.ts +81 -0
  250. package/src/server/cli.ts +78 -0
  251. package/src/server/client-log-forwarder.test.ts +74 -0
  252. package/src/server/client-log-forwarder.ts +75 -0
  253. package/src/server/codex-app-server-protocol.ts +449 -0
  254. package/src/server/codex-app-server.test.ts +2990 -0
  255. package/src/server/codex-app-server.ts +1713 -0
  256. package/src/server/coordination-integration.test.ts +63 -0
  257. package/src/server/coordination-mcp.test.ts +149 -0
  258. package/src/server/coordination-mcp.ts +197 -0
  259. package/src/server/delegation-coordinator.test.ts +675 -0
  260. package/src/server/delegation-coordinator.ts +454 -0
  261. package/src/server/discovery.test.ts +211 -0
  262. package/src/server/discovery.ts +301 -0
  263. package/src/server/event-store-agent-config.test.ts +124 -0
  264. package/src/server/event-store-coordination.test.ts +149 -0
  265. package/src/server/event-store-profile.test.ts +132 -0
  266. package/src/server/event-store-repo.test.ts +154 -0
  267. package/src/server/event-store-runner-team.test.ts +104 -0
  268. package/src/server/event-store.test.ts +342 -0
  269. package/src/server/event-store.ts +2208 -0
  270. package/src/server/events.ts +379 -0
  271. package/src/server/extension-router.test.ts +183 -0
  272. package/src/server/extension-router.ts +114 -0
  273. package/src/server/extensions/agents/server.test.ts +191 -0
  274. package/src/server/extensions/agents/server.ts +108 -0
  275. package/src/server/extensions/c3/server.test.ts +284 -0
  276. package/src/server/extensions/c3/server.ts +212 -0
  277. package/src/server/extensions/code/server.test.ts +200 -0
  278. package/src/server/extensions/code/server.ts +150 -0
  279. package/src/server/extensions.config.ts +10 -0
  280. package/src/server/external-open.ts +69 -0
  281. package/src/server/generate-fork-context.ts +58 -0
  282. package/src/server/generate-merge-context.test.ts +290 -0
  283. package/src/server/generate-merge-context.ts +141 -0
  284. package/src/server/generate-title.ts +36 -0
  285. package/src/server/git-clone-policy.test.ts +138 -0
  286. package/src/server/git-clone-policy.ts +27 -0
  287. package/src/server/harness-types.ts +1 -0
  288. package/src/server/journey-verification.test.ts +640 -0
  289. package/src/server/journey-verification.ts +195 -0
  290. package/src/server/machine-name.ts +22 -0
  291. package/src/server/nats-auth.test.ts +92 -0
  292. package/src/server/nats-auth.ts +6 -0
  293. package/src/server/nats-bind-guard.test.ts +71 -0
  294. package/src/server/nats-bind-guard.ts +42 -0
  295. package/src/server/nats-bridge.test.ts +141 -0
  296. package/src/server/nats-bridge.ts +111 -0
  297. package/src/server/nats-connector.test.ts +56 -0
  298. package/src/server/nats-connector.ts +71 -0
  299. package/src/server/nats-daemon-manager.test.ts +76 -0
  300. package/src/server/nats-daemon-manager.ts +174 -0
  301. package/src/server/nats-publisher.test.ts +356 -0
  302. package/src/server/nats-publisher.ts +271 -0
  303. package/src/server/nats-responders.test.ts +1018 -0
  304. package/src/server/nats-responders.ts +925 -0
  305. package/src/server/nats-streams.test.ts +112 -0
  306. package/src/server/nats-streams.ts +129 -0
  307. package/src/server/oauth-pool/oauth-responders.ts +185 -0
  308. package/src/server/oauth-pool/oauth-settings-store.ts +173 -0
  309. package/src/server/oauth-pool/oauth-token-pool.ts +303 -0
  310. package/src/server/orchestration.test.ts +1063 -0
  311. package/src/server/orchestration.ts +716 -0
  312. package/src/server/pairing-endpoints.test.ts +171 -0
  313. package/src/server/pairing-store.test.ts +154 -0
  314. package/src/server/pairing-store.ts +124 -0
  315. package/src/server/paths.ts +27 -0
  316. package/src/server/pr3-liveness.test.ts +252 -0
  317. package/src/server/process-utils.ts +10 -0
  318. package/src/server/project-cli.ts +180 -0
  319. package/src/server/provider-catalog.test.ts +177 -0
  320. package/src/server/provider-catalog.ts +146 -0
  321. package/src/server/pty-responders.ts +345 -0
  322. package/src/server/push-notifications.test.ts +234 -0
  323. package/src/server/push-notifications.ts +188 -0
  324. package/src/server/quick-response.test.ts +196 -0
  325. package/src/server/quick-response.ts +154 -0
  326. package/src/server/read-models-agent-config.test.ts +59 -0
  327. package/src/server/read-models-coordination.test.ts +69 -0
  328. package/src/server/read-models.test.ts +332 -0
  329. package/src/server/read-models.ts +258 -0
  330. package/src/server/repo-journey.test.ts +96 -0
  331. package/src/server/repo-manager.test.ts +118 -0
  332. package/src/server/repo-manager.ts +97 -0
  333. package/src/server/repo-status.test.ts +54 -0
  334. package/src/server/repo-status.ts +82 -0
  335. package/src/server/restart.test.ts +27 -0
  336. package/src/server/restart.ts +30 -0
  337. package/src/server/runner-incompatible-gate.test.ts +383 -0
  338. package/src/server/runner-manager.test.ts +72 -0
  339. package/src/server/runner-manager.ts +312 -0
  340. package/src/server/runner-pairing-urls.test.ts +59 -0
  341. package/src/server/runner-pairing-urls.ts +67 -0
  342. package/src/server/runner-proxy.test.ts +456 -0
  343. package/src/server/runner-proxy.ts +494 -0
  344. package/src/server/runner-router.test.ts +429 -0
  345. package/src/server/runner-router.ts +212 -0
  346. package/src/server/runner-routing.test.ts +584 -0
  347. package/src/server/runtime-registry.test.ts +436 -0
  348. package/src/server/runtime-registry.ts +307 -0
  349. package/src/server/sandbox-health.test.ts +127 -0
  350. package/src/server/sandbox-health.ts +94 -0
  351. package/src/server/sandbox-journey.test.ts +232 -0
  352. package/src/server/sandbox-manager.test.ts +146 -0
  353. package/src/server/sandbox-manager.ts +159 -0
  354. package/src/server/server.test.ts +287 -0
  355. package/src/server/server.ts +1108 -0
  356. package/src/server/session-discovery.test.ts +129 -0
  357. package/src/server/session-discovery.ts +475 -0
  358. package/src/server/session-index.test.ts +362 -0
  359. package/src/server/session-index.ts +119 -0
  360. package/src/server/session-seed.ts +288 -0
  361. package/src/server/share.test.ts +108 -0
  362. package/src/server/share.ts +113 -0
  363. package/src/server/skill-discovery.test.ts +201 -0
  364. package/src/server/skill-discovery.ts +77 -0
  365. package/src/server/storage/test-helpers.ts +67 -0
  366. package/src/server/terminal-manager.test.ts +309 -0
  367. package/src/server/terminal-manager.ts +354 -0
  368. package/src/server/transcript-consumer.test.ts +339 -0
  369. package/src/server/transcript-consumer.ts +162 -0
  370. package/src/server/transcript-search.test.ts +193 -0
  371. package/src/server/transcript-search.ts +83 -0
  372. package/src/server/transcript-utils.ts +52 -0
  373. package/src/server/update-manager.test.ts +107 -0
  374. package/src/server/update-manager.ts +230 -0
  375. package/src/server/workflow-engine.test.ts +251 -0
  376. package/src/server/workflow-engine.ts +169 -0
  377. package/src/server/workflow-mcp.ts +49 -0
  378. package/src/server/workflow-store.test.ts +155 -0
  379. package/src/server/workflow-store.ts +139 -0
  380. package/src/server/workspace-agent-integration.test.ts +167 -0
  381. package/src/server/workspace-agent-routes.test.ts +127 -0
  382. package/src/server/workspace-agent-routes.ts +89 -0
  383. package/src/server/workspace-agent.test.ts +103 -0
  384. package/src/server/workspace-agent.ts +102 -0
  385. package/src/server/workspace-cli.test.ts +79 -0
  386. package/src/server/workspace-config-manager.test.ts +109 -0
  387. package/src/server/workspace-config-manager.ts +83 -0
  388. package/src/server/workspace-directory-policy.test.ts +109 -0
  389. package/src/server/workspace-directory-policy.ts +56 -0
  390. package/src/shared/agent-config-types.ts +25 -0
  391. package/src/shared/branding.test.ts +42 -0
  392. package/src/shared/branding.ts +54 -0
  393. package/src/shared/compression.test.ts +85 -0
  394. package/src/shared/compression.ts +42 -0
  395. package/src/shared/coordination-store.test.ts +24 -0
  396. package/src/shared/coordination-store.ts +26 -0
  397. package/src/shared/dev-ports.test.ts +84 -0
  398. package/src/shared/dev-ports.ts +100 -0
  399. package/src/shared/extension-types.ts +45 -0
  400. package/src/shared/fork-presets.ts +54 -0
  401. package/src/shared/harness-types.test.ts +15 -0
  402. package/src/shared/harness-types.ts +21 -0
  403. package/src/shared/log-sink.test.ts +112 -0
  404. package/src/shared/log-sink.ts +185 -0
  405. package/src/shared/mention-pattern.ts +7 -0
  406. package/src/shared/merge-presets.ts +41 -0
  407. package/src/shared/nats-subjects.test.ts +61 -0
  408. package/src/shared/nats-subjects.ts +131 -0
  409. package/src/shared/permission-policy.ts +136 -0
  410. package/src/shared/ports.ts +3 -0
  411. package/src/shared/preset-types.ts +15 -0
  412. package/src/shared/profile-types.ts +49 -0
  413. package/src/shared/projectFileUrl.ts +36 -0
  414. package/src/shared/protocol.ts +153 -0
  415. package/src/shared/pty-instance.ts +43 -0
  416. package/src/shared/puggy/diagnostics/result.ts +18 -0
  417. package/src/shared/puggy/expressions/evaluate.ts +292 -0
  418. package/src/shared/puggy/index.test.ts +101 -0
  419. package/src/shared/puggy/index.ts +69 -0
  420. package/src/shared/puggy/parser/ast.ts +110 -0
  421. package/src/shared/puggy/parser/parser.ts +624 -0
  422. package/src/shared/puggy/renderer/html.ts +447 -0
  423. package/src/shared/runner-protocol.test.ts +277 -0
  424. package/src/shared/runner-protocol.ts +210 -0
  425. package/src/shared/runner-team-types.ts +73 -0
  426. package/src/shared/runtime-types.ts +48 -0
  427. package/src/shared/sandbox-types.ts +53 -0
  428. package/src/shared/tailwind-build.test.ts +12 -0
  429. package/src/shared/tinkaria-system-prompt.ts +101 -0
  430. package/src/shared/tools.test.ts +335 -0
  431. package/src/shared/tools.ts +397 -0
  432. package/src/shared/transcript-entries.ts +27 -0
  433. package/src/shared/transcript-render.test.ts +225 -0
  434. package/src/shared/transcript-render.ts +467 -0
  435. package/src/shared/types.ts +1031 -0
  436. package/src/shared/vite-config.test.ts +47 -0
  437. package/src/shared/web-context.test.ts +110 -0
  438. package/src/shared/web-context.ts +76 -0
  439. package/src/shared/workflow-types.ts +51 -0
  440. package/src/shared/workspace-types.ts +100 -0
@@ -0,0 +1,429 @@
1
+ /**
2
+ * Unit tests for RunnerRouter Stage 1.
3
+ *
4
+ * No NATS server — all tests exercise the pure functions `buildDescriptors`
5
+ * and `selectFrom` directly. The KV-reading class methods (`list`, `get`,
6
+ * `select`) are thin wrappers that compose these two pure functions; they are
7
+ * covered by integration tests in later stages.
8
+ */
9
+
10
+ import { describe, expect, test } from "bun:test"
11
+ import { buildDescriptors, eligibleFor, selectFrom } from "./runner-router"
12
+ import type { RunnerDescriptor } from "./runner-router"
13
+ import type { RunnerRegistration } from "../shared/runner-protocol"
14
+ import {
15
+ LIVENESS_DEGRADED_MS,
16
+ LIVENESS_OFFLINE_MS,
17
+ SUPPORTED_RANGE,
18
+ } from "../shared/runner-protocol"
19
+ import type { AgentProvider } from "../shared/types"
20
+
21
+ // ── Helpers ──────────────────────────────────────────────────────────────────
22
+
23
+ const NOW = 1_000_000_000_000 // arbitrary fixed "now" ms
24
+
25
+ /** A fresh online registration (lastSeenAt = NOW - 1s, well within DEGRADED threshold). */
26
+ function makeReg(overrides: Partial<RunnerRegistration> = {}): RunnerRegistration {
27
+ return {
28
+ runnerId: "runner-a",
29
+ pid: 1234,
30
+ startedAt: NOW - 60_000,
31
+ providers: ["claude"],
32
+ protocolVersion: SUPPORTED_RANGE.min,
33
+ lastSeenAt: NOW - 1_000, // 1 s ago → online
34
+ ...overrides,
35
+ }
36
+ }
37
+
38
+ function makeEntry(key: string, overrides: Partial<RunnerRegistration> = {}) {
39
+ return { key, reg: makeReg({ runnerId: key, ...overrides }) }
40
+ }
41
+
42
+ // ── buildDescriptors ─────────────────────────────────────────────────────────
43
+
44
+ describe("buildDescriptors", () => {
45
+ test("annotates state=online for a fresh runner", () => {
46
+ const [d] = buildDescriptors([makeEntry("r1")], null, NOW)
47
+ expect(d.state).toBe("online")
48
+ })
49
+
50
+ test("annotates state=degraded when lastSeenAt is between thresholds", () => {
51
+ const age = LIVENESS_DEGRADED_MS + 1_000 // just past degraded threshold
52
+ const [d] = buildDescriptors(
53
+ [makeEntry("r1", { lastSeenAt: NOW - age })],
54
+ null,
55
+ NOW,
56
+ )
57
+ expect(d.state).toBe("degraded")
58
+ })
59
+
60
+ test("annotates state=offline when lastSeenAt is too old", () => {
61
+ const age = LIVENESS_OFFLINE_MS + 1_000
62
+ const [d] = buildDescriptors(
63
+ [makeEntry("r1", { lastSeenAt: NOW - age })],
64
+ null,
65
+ NOW,
66
+ )
67
+ expect(d.state).toBe("offline")
68
+ })
69
+
70
+ test("annotates state=offline when lastSeenAt is null", () => {
71
+ // lastSeenAt missing → treated as null → offline
72
+ const entry = { key: "r1", reg: { ...makeReg(), lastSeenAt: undefined } as RunnerRegistration }
73
+ const [d] = buildDescriptors([entry], null, NOW)
74
+ expect(d.state).toBe("offline")
75
+ })
76
+
77
+ test("sets incompatible=false for a supported protocolVersion", () => {
78
+ const [d] = buildDescriptors([makeEntry("r1", { protocolVersion: SUPPORTED_RANGE.min })], null, NOW)
79
+ expect(d.incompatible).toBe(false)
80
+ })
81
+
82
+ test("sets incompatible=true when protocolVersion is outside range", () => {
83
+ const [d] = buildDescriptors([makeEntry("r1", { protocolVersion: 999 })], null, NOW)
84
+ expect(d.incompatible).toBe(true)
85
+ })
86
+
87
+ test("sets incompatible=true when protocolVersion is missing (defensive)", () => {
88
+ const entry = {
89
+ key: "r1",
90
+ reg: { ...makeReg(), protocolVersion: undefined } as unknown as RunnerRegistration,
91
+ }
92
+ const [d] = buildDescriptors([entry], null, NOW)
93
+ expect(d.incompatible).toBe(true)
94
+ })
95
+
96
+ test("sets capabilities from reg.capabilities when present", () => {
97
+ const caps = { providers: ["claude"] as AgentProvider[] }
98
+ const [d] = buildDescriptors(
99
+ [makeEntry("r1", { capabilities: { providers: ["claude"] } })],
100
+ null,
101
+ NOW,
102
+ )
103
+ expect(d.capabilities).toEqual(caps)
104
+ })
105
+
106
+ test("sets capabilities=null when reg.capabilities is absent", () => {
107
+ const entry = { key: "r1", reg: { ...makeReg(), capabilities: undefined } }
108
+ const [d] = buildDescriptors([entry], null, NOW)
109
+ expect(d.capabilities).toBeNull()
110
+ })
111
+
112
+ test("marks isShared=true when key matches sharedId", () => {
113
+ const [d] = buildDescriptors([makeEntry("shared-runner")], "shared-runner", NOW)
114
+ expect(d.isShared).toBe(true)
115
+ })
116
+
117
+ test("marks isShared=false when key does not match sharedId", () => {
118
+ const [d] = buildDescriptors([makeEntry("r1")], "shared-runner", NOW)
119
+ expect(d.isShared).toBe(false)
120
+ })
121
+
122
+ test("marks isShared=false when sharedId is null", () => {
123
+ const [d] = buildDescriptors([makeEntry("r1")], null, NOW)
124
+ expect(d.isShared).toBe(false)
125
+ })
126
+
127
+ test("reads ownerId defensively from reg", () => {
128
+ const reg = { ...makeReg(), ownerId: "user-42" } as RunnerRegistration & { ownerId?: string }
129
+ const [d] = buildDescriptors([{ key: "r1", reg }], null, NOW)
130
+ expect(d.ownerId).toBe("user-42")
131
+ })
132
+
133
+ test("ownerId is null when absent", () => {
134
+ const [d] = buildDescriptors([makeEntry("r1")], null, NOW)
135
+ expect(d.ownerId).toBeNull()
136
+ })
137
+
138
+ test("returns empty array for empty entries", () => {
139
+ expect(buildDescriptors([], null, NOW)).toEqual([])
140
+ })
141
+
142
+ test("builds multiple descriptors in entry order", () => {
143
+ const entries = [makeEntry("r1"), makeEntry("r2"), makeEntry("r3")]
144
+ const ds = buildDescriptors(entries, null, NOW)
145
+ expect(ds.map((d) => d.runnerId)).toEqual(["r1", "r2", "r3"])
146
+ })
147
+ })
148
+
149
+ // ── eligibleFor ──────────────────────────────────────────────────────────────
150
+
151
+ describe("eligibleFor", () => {
152
+ function makeDescriptor(overrides: Partial<RunnerDescriptor> = {}): RunnerDescriptor {
153
+ return {
154
+ runnerId: "r1",
155
+ state: "online",
156
+ capabilities: null,
157
+ protocolVersion: SUPPORTED_RANGE.min,
158
+ incompatible: false,
159
+ lastSeenAt: NOW - 1_000,
160
+ pid: 1234,
161
+ ownerId: null,
162
+ isShared: false,
163
+ ...overrides,
164
+ }
165
+ }
166
+
167
+ test("online + compatible + null capabilities → eligible", () => {
168
+ expect(eligibleFor("claude")(makeDescriptor())).toBe(true)
169
+ })
170
+
171
+ test("offline → not eligible", () => {
172
+ expect(eligibleFor("claude")(makeDescriptor({ state: "offline" }))).toBe(false)
173
+ })
174
+
175
+ test("degraded → eligible (not offline)", () => {
176
+ expect(eligibleFor("claude")(makeDescriptor({ state: "degraded" }))).toBe(true)
177
+ })
178
+
179
+ test("incompatible → not eligible", () => {
180
+ expect(eligibleFor("claude")(makeDescriptor({ incompatible: true }))).toBe(false)
181
+ })
182
+
183
+ test("capabilities.providers includes provider → eligible", () => {
184
+ const d = makeDescriptor({ capabilities: { providers: ["claude", "codex"] } })
185
+ expect(eligibleFor("claude")(d)).toBe(true)
186
+ })
187
+
188
+ test("capabilities.providers excludes provider → not eligible", () => {
189
+ const d = makeDescriptor({ capabilities: { providers: ["codex"] } })
190
+ expect(eligibleFor("claude")(d)).toBe(false)
191
+ })
192
+
193
+ test("capabilities=null treated as capable (fail-open) for any provider", () => {
194
+ expect(eligibleFor("codex")(makeDescriptor({ capabilities: null }))).toBe(true)
195
+ expect(eligibleFor("claude")(makeDescriptor({ capabilities: null }))).toBe(true)
196
+ })
197
+ })
198
+
199
+ // ── selectFrom ───────────────────────────────────────────────────────────────
200
+
201
+ describe("selectFrom", () => {
202
+ /** Online, compatible, no-capabilities descriptor */
203
+ function desc(
204
+ id: string,
205
+ overrides: Partial<RunnerDescriptor> = {},
206
+ ): RunnerDescriptor {
207
+ return {
208
+ runnerId: id,
209
+ state: "online",
210
+ capabilities: null,
211
+ protocolVersion: SUPPORTED_RANGE.min,
212
+ incompatible: false,
213
+ lastSeenAt: NOW - 1_000,
214
+ pid: 1,
215
+ ownerId: null,
216
+ isShared: false,
217
+ ...overrides,
218
+ }
219
+ }
220
+
221
+ // ── sticky-hit ────────────────────────────────────────────────────────────
222
+
223
+ test("sticky-hit: eligible preferred runner → selected with sticky=true", () => {
224
+ const descriptors = [desc("r1"), desc("r2")]
225
+ const result = selectFrom(descriptors, {
226
+ provider: "claude",
227
+ preferredRunnerId: "r1",
228
+ })
229
+ expect(result).toEqual({ kind: "selected", runnerId: "r1", sticky: true })
230
+ })
231
+
232
+ // ── sticky-offline ────────────────────────────────────────────────────────
233
+
234
+ test("sticky-offline: preferred runner offline → needs_pick/sticky_offline", () => {
235
+ const descriptors = [
236
+ desc("r1", { state: "offline" }), // preferred but offline
237
+ desc("r2"), // eligible alternative
238
+ ]
239
+ const result = selectFrom(descriptors, {
240
+ provider: "claude",
241
+ preferredRunnerId: "r1",
242
+ })
243
+ expect(result.kind).toBe("needs_pick")
244
+ if (result.kind === "needs_pick") {
245
+ expect(result.reason).toBe("sticky_offline")
246
+ expect(result.candidates.map((c) => c.runnerId)).toContain("r2")
247
+ expect(result.candidates.map((c) => c.runnerId)).not.toContain("r1")
248
+ }
249
+ })
250
+
251
+ test("sticky-offline: preferred runner incompatible → needs_pick/sticky_offline", () => {
252
+ const descriptors = [
253
+ desc("r1", { incompatible: true }),
254
+ desc("r2"),
255
+ ]
256
+ const result = selectFrom(descriptors, {
257
+ provider: "claude",
258
+ preferredRunnerId: "r1",
259
+ })
260
+ expect(result.kind).toBe("needs_pick")
261
+ if (result.kind === "needs_pick") expect(result.reason).toBe("sticky_offline")
262
+ })
263
+
264
+ test("sticky-offline: preferred runner gone entirely → needs_pick/sticky_offline", () => {
265
+ // "r-gone" is not in descriptors at all
266
+ const descriptors = [desc("r2")]
267
+ const result = selectFrom(descriptors, {
268
+ provider: "claude",
269
+ preferredRunnerId: "r-gone",
270
+ })
271
+ expect(result.kind).toBe("needs_pick")
272
+ if (result.kind === "needs_pick") {
273
+ expect(result.reason).toBe("sticky_offline")
274
+ expect(result.candidates.map((c) => c.runnerId)).toEqual(["r2"])
275
+ }
276
+ })
277
+
278
+ // ── sole-eligible ─────────────────────────────────────────────────────────
279
+
280
+ test("sole-eligible: single eligible runner → selected with sticky=false", () => {
281
+ const descriptors = [desc("r1")]
282
+ const result = selectFrom(descriptors, { provider: "claude" })
283
+ expect(result).toEqual({ kind: "selected", runnerId: "r1", sticky: false })
284
+ })
285
+
286
+ test("sole-eligible: other runners offline/incompatible, one eligible → selected/!sticky", () => {
287
+ const descriptors = [
288
+ desc("r-offline", { state: "offline" }),
289
+ desc("r-incompat", { incompatible: true }),
290
+ desc("r-ok"),
291
+ ]
292
+ const result = selectFrom(descriptors, { provider: "claude" })
293
+ expect(result).toEqual({ kind: "selected", runnerId: "r-ok", sticky: false })
294
+ })
295
+
296
+ // ── shared-runner auto-select ─────────────────────────────────────────────
297
+
298
+ test("shared-only: sole shared runner is auto-selected when no personal runner exists", () => {
299
+ const descriptors = [desc("shared", { isShared: true })]
300
+ const result = selectFrom(descriptors, { provider: "claude" })
301
+ expect(result).toEqual({ kind: "selected", runnerId: "shared", sticky: false })
302
+ })
303
+
304
+ // ── ambiguous ─────────────────────────────────────────────────────────────
305
+
306
+ test("ambiguous: two eligible runners, no preference → needs_pick/ambiguous", () => {
307
+ const descriptors = [desc("r1"), desc("r2")]
308
+ const result = selectFrom(descriptors, { provider: "claude" })
309
+ expect(result.kind).toBe("needs_pick")
310
+ if (result.kind === "needs_pick") {
311
+ expect(result.reason).toBe("ambiguous")
312
+ expect(result.candidates.length).toBe(2)
313
+ }
314
+ })
315
+
316
+ test("ambiguous: three eligible runners → needs_pick/ambiguous with all candidates", () => {
317
+ const descriptors = [desc("r1"), desc("r2"), desc("r3")]
318
+ const result = selectFrom(descriptors, { provider: "claude" })
319
+ expect(result.kind).toBe("needs_pick")
320
+ if (result.kind === "needs_pick") {
321
+ expect(result.reason).toBe("ambiguous")
322
+ expect(result.candidates.length).toBe(3)
323
+ }
324
+ })
325
+
326
+ // ── unavailable ───────────────────────────────────────────────────────────
327
+
328
+ test("none-eligible → unavailable with reason mentioning provider", () => {
329
+ const result = selectFrom([], { provider: "claude" })
330
+ expect(result.kind).toBe("unavailable")
331
+ if (result.kind === "unavailable") {
332
+ expect(result.reason).toContain("claude")
333
+ }
334
+ })
335
+
336
+ test("all offline → unavailable", () => {
337
+ const descriptors = [
338
+ desc("r1", { state: "offline" }),
339
+ desc("r2", { state: "offline" }),
340
+ ]
341
+ const result = selectFrom(descriptors, { provider: "claude" })
342
+ expect(result.kind).toBe("unavailable")
343
+ if (result.kind === "unavailable") expect(result.reason).toContain("claude")
344
+ })
345
+
346
+ test("all incompatible → unavailable", () => {
347
+ const descriptors = [
348
+ desc("r1", { incompatible: true }),
349
+ desc("r2", { incompatible: true }),
350
+ ]
351
+ const result = selectFrom(descriptors, { provider: "claude" })
352
+ expect(result.kind).toBe("unavailable")
353
+ })
354
+
355
+ // ── capability filtering ──────────────────────────────────────────────────
356
+
357
+ test("runner without claude capability is excluded for claude provider", () => {
358
+ const descriptors = [
359
+ desc("r-codex-only", { capabilities: { providers: ["codex"] } }),
360
+ desc("r-claude", { capabilities: { providers: ["claude"] } }),
361
+ ]
362
+ const result = selectFrom(descriptors, { provider: "claude" })
363
+ expect(result).toEqual({ kind: "selected", runnerId: "r-claude", sticky: false })
364
+ })
365
+
366
+ test("capabilities=null treated capable for any provider (fail-open)", () => {
367
+ const descriptors = [desc("r1", { capabilities: null })]
368
+ const result = selectFrom(descriptors, { provider: "codex" })
369
+ expect(result).toEqual({ kind: "selected", runnerId: "r1", sticky: false })
370
+ })
371
+
372
+ // ── candidate ordering ────────────────────────────────────────────────────
373
+
374
+ test("candidates: non-shared runners come before shared", () => {
375
+ const descriptors = [
376
+ desc("shared", { isShared: true, lastSeenAt: NOW - 500 }),
377
+ desc("personal", { isShared: false, lastSeenAt: NOW - 1_000 }),
378
+ ]
379
+ const result = selectFrom(descriptors, { provider: "claude" })
380
+ // Two eligible → ambiguous
381
+ expect(result.kind).toBe("needs_pick")
382
+ if (result.kind === "needs_pick") {
383
+ expect(result.candidates[0].runnerId).toBe("personal")
384
+ expect(result.candidates[1].runnerId).toBe("shared")
385
+ }
386
+ })
387
+
388
+ test("candidates: within non-shared group, sorted by lastSeenAt desc", () => {
389
+ const descriptors = [
390
+ desc("r-old", { lastSeenAt: NOW - 10_000 }),
391
+ desc("r-new", { lastSeenAt: NOW - 500 }),
392
+ desc("r-mid", { lastSeenAt: NOW - 5_000 }),
393
+ ]
394
+ const result = selectFrom(descriptors, { provider: "claude" })
395
+ expect(result.kind).toBe("needs_pick")
396
+ if (result.kind === "needs_pick") {
397
+ expect(result.candidates.map((c) => c.runnerId)).toEqual(["r-new", "r-mid", "r-old"])
398
+ }
399
+ })
400
+
401
+ test("candidates: shared runner comes after all non-shared, even if more recently seen", () => {
402
+ const descriptors = [
403
+ desc("shared", { isShared: true, lastSeenAt: NOW - 100 }), // most recent
404
+ desc("personal-a", { lastSeenAt: NOW - 5_000 }),
405
+ desc("personal-b", { lastSeenAt: NOW - 10_000 }),
406
+ ]
407
+ const result = selectFrom(descriptors, { provider: "claude" })
408
+ expect(result.kind).toBe("needs_pick")
409
+ if (result.kind === "needs_pick") {
410
+ const ids = result.candidates.map((c) => c.runnerId)
411
+ expect(ids[ids.length - 1]).toBe("shared")
412
+ expect(ids[0]).toBe("personal-a")
413
+ expect(ids[1]).toBe("personal-b")
414
+ }
415
+ })
416
+
417
+ test("candidates: nulls in lastSeenAt go last within their group", () => {
418
+ const descriptors = [
419
+ desc("r-null", { lastSeenAt: null }),
420
+ desc("r-ts", { lastSeenAt: NOW - 1_000 }),
421
+ ]
422
+ const result = selectFrom(descriptors, { provider: "claude" })
423
+ expect(result.kind).toBe("needs_pick")
424
+ if (result.kind === "needs_pick") {
425
+ expect(result.candidates[0].runnerId).toBe("r-ts")
426
+ expect(result.candidates[1].runnerId).toBe("r-null")
427
+ }
428
+ })
429
+ })
@@ -0,0 +1,212 @@
1
+ import type { NatsConnection } from "@nats-io/transport-node"
2
+ import { Kvm } from "@nats-io/kv"
3
+ import {
4
+ RUNNER_REGISTRY_BUCKET,
5
+ isProtocolSupported,
6
+ runnerLivenessState,
7
+ type RunnerCapabilities,
8
+ type RunnerLivenessState,
9
+ type RunnerRegistration,
10
+ } from "../shared/runner-protocol"
11
+ import type { AgentProvider } from "../shared/types"
12
+
13
+ // ── Types ────────────────────────────────────────────────────────────────────
14
+
15
+ export type RunnerDescriptor = {
16
+ runnerId: string
17
+ state: RunnerLivenessState
18
+ capabilities: RunnerCapabilities | null
19
+ protocolVersion: number | null
20
+ /** True when protocolVersion is null or outside SUPPORTED_RANGE. Fail-closed. */
21
+ incompatible: boolean
22
+ lastSeenAt: number | null
23
+ pid: number | null
24
+ /** Carried from reg.ownerId if present; not yet enforced (deferred to post-PR2). */
25
+ ownerId: string | null
26
+ /** True when runnerId matches the server-spawned shared runner. */
27
+ isShared: boolean
28
+ }
29
+
30
+ export type RunnerSelection =
31
+ | { kind: "selected"; runnerId: string; sticky: boolean }
32
+ | { kind: "needs_pick"; candidates: RunnerDescriptor[]; reason: "ambiguous" | "sticky_offline" }
33
+ | { kind: "unavailable"; reason: string }
34
+
35
+ // ── Pure helpers ─────────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Build a descriptor from a raw KV entry. Exported for unit tests (no NATS needed).
39
+ */
40
+ export function buildDescriptors(
41
+ entries: { key: string; reg: RunnerRegistration }[],
42
+ sharedId: string | null,
43
+ now: number,
44
+ ): RunnerDescriptor[] {
45
+ return entries.map(({ key, reg }) => {
46
+ const lastSeenAt = reg.lastSeenAt ?? null
47
+ const state = runnerLivenessState(lastSeenAt, now)
48
+ const capabilities = reg.capabilities ?? null
49
+ // Defensive: missing protocolVersion treated as incompatible (fail-closed).
50
+ const protocolVersion = (reg as { protocolVersion?: number }).protocolVersion ?? null
51
+ const incompatible =
52
+ protocolVersion === null ? true : !isProtocolSupported(protocolVersion)
53
+ return {
54
+ runnerId: key,
55
+ state,
56
+ capabilities,
57
+ protocolVersion,
58
+ incompatible,
59
+ lastSeenAt,
60
+ pid: reg.pid ?? null,
61
+ ownerId: (reg as { ownerId?: string }).ownerId ?? null,
62
+ isShared: key === sharedId,
63
+ }
64
+ })
65
+ }
66
+
67
+ /**
68
+ * Returns true when a descriptor is eligible to handle `provider`.
69
+ *
70
+ * Eligibility rules:
71
+ * - state !== "offline" (liveness, fail-closed)
72
+ * - !incompatible (protocol compat, fail-closed)
73
+ * - capabilities === null OR capabilities.providers.includes(provider)
74
+ * (capability, fail-open: pre-PR4 runners without capabilities are assumed capable)
75
+ *
76
+ * Exported for unit tests.
77
+ */
78
+ export function eligibleFor(provider: AgentProvider): (d: RunnerDescriptor) => boolean {
79
+ return (d) =>
80
+ d.state !== "offline" &&
81
+ !d.incompatible &&
82
+ (d.capabilities === null || d.capabilities.providers.includes(provider))
83
+ }
84
+
85
+ /**
86
+ * Sort candidates for display: non-shared first, then shared; within each group
87
+ * by lastSeenAt descending (nulls last).
88
+ */
89
+ function sortCandidates(candidates: RunnerDescriptor[]): RunnerDescriptor[] {
90
+ return [...candidates].sort((a, b) => {
91
+ // Shared runners go last
92
+ if (a.isShared !== b.isShared) return a.isShared ? 1 : -1
93
+ // Within group: most-recently-seen first; nulls go to the end
94
+ const aTs = a.lastSeenAt ?? -Infinity
95
+ const bTs = b.lastSeenAt ?? -Infinity
96
+ return bTs - aTs
97
+ })
98
+ }
99
+
100
+ /**
101
+ * Pure selection logic. Exported for unit tests.
102
+ *
103
+ * Policy (deterministic, in order):
104
+ * 1. eligible = descriptors.filter(eligibleFor(provider))
105
+ * 2. preferredRunnerId present:
106
+ * a. eligible contains preferred → { selected, sticky: true }
107
+ * b. otherwise (preferred exists in descriptors OR is gone) → { needs_pick, sticky_offline }
108
+ * 3. eligible.length === 0 → { unavailable }
109
+ * 4. eligible.length === 1 → { selected, sticky: false }
110
+ * 5. else → { needs_pick, ambiguous }
111
+ */
112
+ export function selectFrom(
113
+ descriptors: RunnerDescriptor[],
114
+ req: {
115
+ provider: AgentProvider
116
+ preferredRunnerId?: string | null
117
+ now?: number
118
+ },
119
+ ): RunnerSelection {
120
+ const { provider, preferredRunnerId } = req
121
+ const eligible = descriptors.filter(eligibleFor(provider))
122
+ const orderedCandidates = sortCandidates(eligible)
123
+
124
+ if (preferredRunnerId) {
125
+ const hit = eligible.find((d) => d.runnerId === preferredRunnerId)
126
+ if (hit) {
127
+ return { kind: "selected", runnerId: hit.runnerId, sticky: true }
128
+ }
129
+ // Preferred runner exists in any state OR is gone entirely → needs a new pick.
130
+ return {
131
+ kind: "needs_pick",
132
+ candidates: orderedCandidates,
133
+ reason: "sticky_offline",
134
+ }
135
+ }
136
+
137
+ if (eligible.length === 0) {
138
+ return {
139
+ kind: "unavailable",
140
+ reason: `No online runner for "${provider}" — pair or start a runner.`,
141
+ }
142
+ }
143
+
144
+ if (eligible.length === 1) {
145
+ return { kind: "selected", runnerId: eligible[0].runnerId, sticky: false }
146
+ }
147
+
148
+ return { kind: "needs_pick", candidates: orderedCandidates, reason: "ambiguous" }
149
+ }
150
+
151
+ // ── RunnerRouter ─────────────────────────────────────────────────────────────
152
+
153
+ const decoder = new TextDecoder()
154
+
155
+ export class RunnerRouter {
156
+ private readonly nc: NatsConnection
157
+ private readonly sharedRunnerId: () => string | null
158
+
159
+ constructor(opts: { nc: NatsConnection; sharedRunnerId: () => string | null }) {
160
+ this.nc = opts.nc
161
+ this.sharedRunnerId = opts.sharedRunnerId
162
+ }
163
+
164
+ /**
165
+ * Enumerate all registered runners from the KV bucket, annotated with
166
+ * liveness, compat, and isShared. Returns [] on missing/empty bucket.
167
+ */
168
+ async list(now = Date.now()): Promise<RunnerDescriptor[]> {
169
+ const entries: { key: string; reg: RunnerRegistration }[] = []
170
+ try {
171
+ const kvm = new Kvm(this.nc)
172
+ const kvStore = await kvm.open(RUNNER_REGISTRY_BUCKET)
173
+ const keys = await kvStore.keys()
174
+ for await (const key of keys) {
175
+ const entry = await kvStore.get(key)
176
+ if (!entry) continue
177
+ try {
178
+ const reg = JSON.parse(decoder.decode(entry.value)) as RunnerRegistration
179
+ entries.push({ key, reg })
180
+ } catch (e) {
181
+ console.warn(`[RunnerRouter] skipping corrupt KV entry "${key}":`, e instanceof Error ? e.message : String(e))
182
+ continue
183
+ }
184
+ }
185
+ } catch {
186
+ // Bucket missing or NATS not ready — return what we have (possibly empty).
187
+ }
188
+ return buildDescriptors(entries, this.sharedRunnerId(), now)
189
+ }
190
+
191
+ /**
192
+ * Fetch a single runner descriptor by id. Returns null if not found.
193
+ */
194
+ async get(runnerId: string, now = Date.now()): Promise<RunnerDescriptor | null> {
195
+ const all = await this.list(now)
196
+ return all.find((d) => d.runnerId === runnerId) ?? null
197
+ }
198
+
199
+ /**
200
+ * Select a runner for `provider`, honoring sticky preference when eligible.
201
+ * See `selectFrom` for the full deterministic policy.
202
+ */
203
+ async select(req: {
204
+ provider: AgentProvider
205
+ preferredRunnerId?: string | null
206
+ now?: number
207
+ }): Promise<RunnerSelection> {
208
+ const now = req.now ?? Date.now()
209
+ const descriptors = await this.list(now)
210
+ return selectFrom(descriptors, req)
211
+ }
212
+ }