@loicngr/kobo 1.6.0 → 1.6.1

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 (201) hide show
  1. package/AGENTS.md +6 -1
  2. package/README.md +29 -16
  3. package/dist/server/db/index.js +17 -0
  4. package/dist/server/db/migrations.js +10 -0
  5. package/dist/server/db/schema.js +2 -1
  6. package/dist/server/index.js +24 -3
  7. package/dist/server/middleware/migration-guard.js +15 -0
  8. package/dist/server/routes/dev-server.js +3 -2
  9. package/dist/server/routes/engines.js +9 -0
  10. package/dist/server/routes/migration.js +5 -0
  11. package/dist/server/routes/workspaces.js +35 -11
  12. package/dist/server/services/agent/engines/claude-code/args-builder.js +22 -0
  13. package/dist/server/services/agent/engines/claude-code/capabilities.js +17 -0
  14. package/dist/server/services/agent/engines/claude-code/engine.js +163 -0
  15. package/dist/server/services/agent/engines/claude-code/mcp-config.js +23 -0
  16. package/dist/server/services/agent/engines/claude-code/stream-parser.js +224 -0
  17. package/dist/server/services/agent/engines/registry.js +21 -0
  18. package/dist/server/services/agent/engines/types.js +18 -0
  19. package/dist/server/services/agent/event-router.js +4 -0
  20. package/dist/server/services/agent/orchestrator.js +582 -0
  21. package/dist/server/services/agent/session-controller.js +79 -0
  22. package/dist/server/services/content-migration-service.js +155 -0
  23. package/dist/server/services/db-backup-service.js +15 -0
  24. package/dist/server/services/websocket-service.js +81 -50
  25. package/dist/server/services/workspace-service.js +11 -5
  26. package/dist/server/utils/paths.js +1 -1
  27. package/dist/shared/models.js +50 -0
  28. package/package.json +1 -1
  29. package/src/client/dist/spa/assets/ActivityFeed-DYtAK49y.js +7 -0
  30. package/src/client/dist/spa/assets/ActivityFeed-DiwnrdKX.css +1 -0
  31. package/src/client/dist/spa/assets/ClosePopup-DqhgFbQo.js +1 -0
  32. package/src/client/dist/spa/assets/CreatePage-DENfwzPL.js +2 -0
  33. package/src/client/dist/spa/assets/CreatePage-yu2IH7GW.css +1 -0
  34. package/src/client/dist/spa/assets/DiffViewer-C6q11kmw.js +2 -0
  35. package/src/client/dist/spa/assets/HealthPage-Cjc79NaA.js +1 -0
  36. package/src/client/dist/spa/assets/{MainLayout-_oPM07ln.js → MainLayout-CFbMw65L.js} +17 -17
  37. package/src/client/dist/spa/assets/QBadge-BUkmTO0P.js +1 -0
  38. package/src/client/dist/spa/assets/QBtn-p1aZtrJH.js +1 -0
  39. package/src/client/dist/spa/assets/QDialog-D42GLa1i.js +1 -0
  40. package/src/client/dist/spa/assets/QExpansionItem-5ekmpO-2.js +1 -0
  41. package/src/client/dist/spa/assets/{QSpinner-CliSLjf8.js → QIcon-B0-pH3Qs.js} +1 -1
  42. package/src/client/dist/spa/assets/QItemLabel-Czw5g0px.js +1 -0
  43. package/src/client/dist/spa/assets/QItemSection-GlMrLmz3.js +1 -0
  44. package/src/client/dist/spa/assets/QList-DNzlynsS.js +1 -0
  45. package/src/client/dist/spa/assets/QMenu-Q69oVX7b.js +1 -0
  46. package/src/client/dist/spa/assets/QPage-B09NY4Nf.js +1 -0
  47. package/src/client/dist/spa/assets/QScrollArea-L6wUiA20.js +1 -0
  48. package/src/client/dist/spa/assets/QSeparator-rkjCbX2M.js +1 -0
  49. package/src/client/dist/spa/assets/QSpace-PlDK6Fg3.js +1 -0
  50. package/src/client/dist/spa/assets/QSpinnerDots-By20ptst.js +1 -0
  51. package/src/client/dist/spa/assets/QTabPanels-Crs-ujNO.js +1 -0
  52. package/src/client/dist/spa/assets/QTooltip-Cg9E3Dvw.js +1 -0
  53. package/src/client/dist/spa/assets/SearchPage-Bf-iZnyE.js +1 -0
  54. package/src/client/dist/spa/assets/SettingsPage-BdcH3BSs.js +1 -0
  55. package/src/client/dist/spa/assets/TouchPan-DFx22dM3.js +1 -0
  56. package/src/client/dist/spa/assets/WorkspacePage-DPGiH02q.css +1 -0
  57. package/src/client/dist/spa/assets/WorkspacePage-UUE0pPCR.js +4 -0
  58. package/src/client/dist/spa/assets/{cssMode-DMX8jq8u.js → cssMode-BYtqFZtm.js} +1 -1
  59. package/src/client/dist/spa/assets/{editor.api-DirOkGGg.js → editor.api-D6ZaO4A_.js} +1 -1
  60. package/src/client/dist/spa/assets/{editor.main-DC4ezIu0.js → editor.main-Cc_RDKsq.js} +3 -3
  61. package/src/client/dist/spa/assets/format-uvONOeL4.js +1 -0
  62. package/src/client/dist/spa/assets/{formatters-BzaS4w0I.js → formatters-DiJ12fKd.js} +1 -1
  63. package/src/client/dist/spa/assets/{freemarker2-DI9xJfj0.js → freemarker2-CBm--bBd.js} +1 -1
  64. package/src/client/dist/spa/assets/{handlebars-B9F-pScn.js → handlebars-whX2mkV5.js} +1 -1
  65. package/src/client/dist/spa/assets/{html-DTe2v8Q8.js → html-D7ga_o6c.js} +1 -1
  66. package/src/client/dist/spa/assets/{htmlMode-F_XLjWfJ.js → htmlMode-BXImjcsv.js} +1 -1
  67. package/src/client/dist/spa/assets/i18n-BxLBrD1J.js +1 -0
  68. package/src/client/dist/spa/assets/index-D997aY4Y.js +2 -0
  69. package/src/client/dist/spa/assets/{javascript-B9xJRPC6.js → javascript-BwmzNMn5.js} +1 -1
  70. package/src/client/dist/spa/assets/{jsonMode-DTZ6j6UO.js → jsonMode-CN5Z5bK_.js} +1 -1
  71. package/src/client/dist/spa/assets/{liquid-BjU5MtW6.js → liquid-CzMNAPor.js} +1 -1
  72. package/src/client/dist/spa/assets/{marked.esm-DCmk6NO8.js → marked.esm-DW0ulF0a.js} +1 -1
  73. package/src/client/dist/spa/assets/{mdx-BMUpG7Be.js → mdx-DC_P05Da.js} +1 -1
  74. package/src/client/dist/spa/assets/models-DMQoi09X.js +1 -0
  75. package/src/client/dist/spa/assets/{monaco.contribution-D7JUf8DP.js → monaco.contribution-BsBaFOOD.js} +2 -2
  76. package/src/client/dist/spa/assets/private.use-form-D1RuEt2P.js +1 -0
  77. package/src/client/dist/spa/assets/{python-Dz0D4uSk.js → python-9DTZ8C3K.js} +1 -1
  78. package/src/client/dist/spa/assets/{razor-D7CFxuwR.js → razor-B1LfM20o.js} +1 -1
  79. package/src/client/dist/spa/assets/scroll-Dh2g7BwR.js +1 -0
  80. package/src/client/dist/spa/assets/touch-D_A29lik.js +1 -0
  81. package/src/client/dist/spa/assets/{tsMode-DjscaxpS.js → tsMode-DI2bWo8r.js} +1 -1
  82. package/src/client/dist/spa/assets/{typescript-DozCWZl2.js → typescript-BZ9QJ2_N.js} +1 -1
  83. package/src/client/dist/spa/assets/use-id-CeduaJbU.js +1 -0
  84. package/src/client/dist/spa/assets/use-portal-mhLq4Rqk.js +1 -0
  85. package/src/client/dist/spa/assets/use-quasar-BBrzedjR.js +1 -0
  86. package/src/client/dist/spa/assets/{xml-DFOJMT39.js → xml-D6qm6rp0.js} +1 -1
  87. package/src/client/dist/spa/assets/{yaml-yEefnsXm.js → yaml-D2dUr_wY.js} +1 -1
  88. package/src/client/dist/spa/index.html +11 -14
  89. package/src/mcp-server/README.md +1 -1
  90. package/dist/server/services/agent-manager.js +0 -621
  91. package/src/client/dist/spa/assets/ActivityFeed-0GR1zPoc.js +0 -10
  92. package/src/client/dist/spa/assets/ActivityFeed-CfsKExt9.css +0 -1
  93. package/src/client/dist/spa/assets/ClosePopup-CdSn7HO8.js +0 -1
  94. package/src/client/dist/spa/assets/CreatePage-dMi4xVYN.css +0 -1
  95. package/src/client/dist/spa/assets/CreatePage-je_7dC5I.js +0 -2
  96. package/src/client/dist/spa/assets/DiffViewer-DREYX-8k.js +0 -2
  97. package/src/client/dist/spa/assets/HealthPage-Do8QZdxw.js +0 -1
  98. package/src/client/dist/spa/assets/QBadge-Bvh-hQ8K.js +0 -1
  99. package/src/client/dist/spa/assets/QBtn-BsD8vrWq.js +0 -1
  100. package/src/client/dist/spa/assets/QDialog-CkbLS1If.js +0 -1
  101. package/src/client/dist/spa/assets/QExpansionItem-UgkE560c.js +0 -1
  102. package/src/client/dist/spa/assets/QList-D80ms7bw.js +0 -1
  103. package/src/client/dist/spa/assets/QMenu-DU-wiY_A.js +0 -1
  104. package/src/client/dist/spa/assets/QPage-BKY2-sf-.js +0 -1
  105. package/src/client/dist/spa/assets/QSpace-C5Ebr0vq.js +0 -1
  106. package/src/client/dist/spa/assets/QSpinnerDots-Dp12eHrB.js +0 -1
  107. package/src/client/dist/spa/assets/QTabPanels-C7lWp1yU.js +0 -1
  108. package/src/client/dist/spa/assets/QToggle-B0HvuNEg.js +0 -1
  109. package/src/client/dist/spa/assets/QTooltip-kLXuUa_m.js +0 -1
  110. package/src/client/dist/spa/assets/SearchPage-CCfyqBKh.js +0 -1
  111. package/src/client/dist/spa/assets/SettingsPage-CmyIsV-S.js +0 -1
  112. package/src/client/dist/spa/assets/TouchPan-CVMnGs0y.js +0 -1
  113. package/src/client/dist/spa/assets/WorkspacePage-CWRMLYs-.css +0 -1
  114. package/src/client/dist/spa/assets/WorkspacePage-Cl7YrG51.js +0 -4
  115. package/src/client/dist/spa/assets/focus-manager-DYbz9jFW.js +0 -1
  116. package/src/client/dist/spa/assets/format-Cyg8IgRi.js +0 -1
  117. package/src/client/dist/spa/assets/i18n-B13zBh1H.js +0 -1
  118. package/src/client/dist/spa/assets/i18n-CCWLBc0p.js +0 -1
  119. package/src/client/dist/spa/assets/index-DoNZ_5QK.js +0 -5
  120. package/src/client/dist/spa/assets/models-B8fzv7K4.js +0 -1
  121. package/src/client/dist/spa/assets/pinia-C3JsrLkB.js +0 -1
  122. package/src/client/dist/spa/assets/private.use-form-BhKyDtO7.js +0 -1
  123. package/src/client/dist/spa/assets/scroll-CLibRGI-.js +0 -1
  124. package/src/client/dist/spa/assets/settings-B69lIVX0.js +0 -1
  125. package/src/client/dist/spa/assets/touch-ChrvzrnI.js +0 -1
  126. package/src/client/dist/spa/assets/use-dark-DnuCB6tC.js +0 -1
  127. package/src/client/dist/spa/assets/use-quasar-DBoizHBW.js +0 -1
  128. /package/src/client/dist/spa/assets/{_plugin-vue_export-helper-Cxt1D8wE.js → _plugin-vue_export-helper-CEhRWsKN.js} +0 -0
  129. /package/src/client/dist/spa/assets/{abap-CFuyUYKP.js → abap-DiwvWnMr.js} +0 -0
  130. /package/src/client/dist/spa/assets/{apex-Ctq_xcrv.js → apex-CmtZjKlf.js} +0 -0
  131. /package/src/client/dist/spa/assets/{azcli-BBQSVn-C.js → azcli-DL2My_i-.js} +0 -0
  132. /package/src/client/dist/spa/assets/{bat-DbnqAfvr.js → bat-B-nC98wG.js} +0 -0
  133. /package/src/client/dist/spa/assets/{bicep-BtDlIXop.js → bicep-Ju5MwOgh.js} +0 -0
  134. /package/src/client/dist/spa/assets/{cameligo-BLeJgKTj.js → cameligo-8Eu1TyBr.js} +0 -0
  135. /package/src/client/dist/spa/assets/{clojure-aZUQIUKP.js → clojure-u-RpMkH3.js} +0 -0
  136. /package/src/client/dist/spa/assets/{coffee-Secadq9U.js → coffee-CdA7bbTe.js} +0 -0
  137. /package/src/client/dist/spa/assets/{cpp-JicRPTRv.js → cpp-CzNFP8ks.js} +0 -0
  138. /package/src/client/dist/spa/assets/{csharp-C7NSOZyj.js → csharp-j1LThmcE.js} +0 -0
  139. /package/src/client/dist/spa/assets/{csp-CIje7830.js → csp-CLRC61y6.js} +0 -0
  140. /package/src/client/dist/spa/assets/{css-G0bm1q_M.js → css-r6rC_7P2.js} +0 -0
  141. /package/src/client/dist/spa/assets/{cypher-CldD5D0u.js → cypher-CW08XVUh.js} +0 -0
  142. /package/src/client/dist/spa/assets/{dart-DIK3l8YT.js → dart-Cs9aL5T_.js} +0 -0
  143. /package/src/client/dist/spa/assets/{dockerfile-czxaGh2L.js → dockerfile-BWM0M184.js} +0 -0
  144. /package/src/client/dist/spa/assets/{ecl-BqdYhwmw.js → ecl-MJJuer5P.js} +0 -0
  145. /package/src/client/dist/spa/assets/{elixir-m52LePTW.js → elixir-D2AIuXqn.js} +0 -0
  146. /package/src/client/dist/spa/assets/{flow9-B5QJ9GvZ.js → flow9-B2H24giC.js} +0 -0
  147. /package/src/client/dist/spa/assets/{fsharp-B15czHsH.js → fsharp-CMk2OIJN.js} +0 -0
  148. /package/src/client/dist/spa/assets/{go-BkoQxDo1.js → go-BrMkuJg0.js} +0 -0
  149. /package/src/client/dist/spa/assets/{graphql-BnI6uRa_.js → graphql-PSR1UKGv.js} +0 -0
  150. /package/src/client/dist/spa/assets/{hcl-CAwwENT7.js → hcl-DAQrbDOW.js} +0 -0
  151. /package/src/client/dist/spa/assets/{ini-BHM5zh1H.js → ini-0TG5BxW0.js} +0 -0
  152. /package/src/client/dist/spa/assets/{java-B5i95QvQ.js → java-rgorz17v.js} +0 -0
  153. /package/src/client/dist/spa/assets/{julia-DPDm885q.js → julia-C8VMdHm8.js} +0 -0
  154. /package/src/client/dist/spa/assets/{kotlin-qoccd5BP.js → kotlin-CllWo3gX.js} +0 -0
  155. /package/src/client/dist/spa/assets/{less-B6RU166D.js → less-Cgca25AP.js} +0 -0
  156. /package/src/client/dist/spa/assets/{lexon-YfUeoL1V.js → lexon-D0GHdBaw.js} +0 -0
  157. /package/src/client/dist/spa/assets/{lua-BIUI5y9b.js → lua-DmRsNG-P.js} +0 -0
  158. /package/src/client/dist/spa/assets/{m3-D5SAbSdU.js → m3-BgL5dNKT.js} +0 -0
  159. /package/src/client/dist/spa/assets/{markdown-CVJLwHzJ.js → markdown-BuJfycGS.js} +0 -0
  160. /package/src/client/dist/spa/assets/{mips-R-FZ3zOR.js → mips-C9m_93PR.js} +0 -0
  161. /package/src/client/dist/spa/assets/{msdax-Blveyl9r.js → msdax-CpFHC9OI.js} +0 -0
  162. /package/src/client/dist/spa/assets/{mysql-D4mY1AFx.js → mysql-qFvltsqN.js} +0 -0
  163. /package/src/client/dist/spa/assets/{objective-c-BmXrLr4h.js → objective-c-Bnmr858J.js} +0 -0
  164. /package/src/client/dist/spa/assets/{pascal-yxckoyvV.js → pascal-WP0_D5AO.js} +0 -0
  165. /package/src/client/dist/spa/assets/{pascaligo-Q5JCwXMI.js → pascaligo-Blom4Rij.js} +0 -0
  166. /package/src/client/dist/spa/assets/{perl-BF1Rrs5h.js → perl-B-vk8g64.js} +0 -0
  167. /package/src/client/dist/spa/assets/{pgsql-CnYB97wm.js → pgsql-Cgvz6v67.js} +0 -0
  168. /package/src/client/dist/spa/assets/{php-CdDfQfSg.js → php-8a3Lrw9m.js} +0 -0
  169. /package/src/client/dist/spa/assets/{pla-whj-d71F.js → pla-DuFqEZ8V.js} +0 -0
  170. /package/src/client/dist/spa/assets/{postiats-ClfLr4I-.js → postiats-DkLtSgkp.js} +0 -0
  171. /package/src/client/dist/spa/assets/{powerquery-iRaBhuuk.js → powerquery-BJ1aNepW.js} +0 -0
  172. /package/src/client/dist/spa/assets/{powershell-DjiEt5xK.js → powershell-rE98k687.js} +0 -0
  173. /package/src/client/dist/spa/assets/{protobuf-B6dcIEUr.js → protobuf-CUheFacr.js} +0 -0
  174. /package/src/client/dist/spa/assets/{pug-DtmHnjM9.js → pug-LDcAMD8w.js} +0 -0
  175. /package/src/client/dist/spa/assets/{qsharp-CELCyd79.js → qsharp-DUKSQoR1.js} +0 -0
  176. /package/src/client/dist/spa/assets/{r-ZpJXWV-o.js → r-D-QApv87.js} +0 -0
  177. /package/src/client/dist/spa/assets/{rate-limit-labels-dCPVjS61.js → rate-limit-labels-BvYERsho.js} +0 -0
  178. /package/src/client/dist/spa/assets/{redis-BiHSNkAl.js → redis-SXdDyWR9.js} +0 -0
  179. /package/src/client/dist/spa/assets/{redshift-DzuwYCHP.js → redshift-Y6lsCryn.js} +0 -0
  180. /package/src/client/dist/spa/assets/{restructuredtext-YOT94bbS.js → restructuredtext-edObr9a8.js} +0 -0
  181. /package/src/client/dist/spa/assets/{ruby-BfiHr6Uu.js → ruby-CNnUfF-8.js} +0 -0
  182. /package/src/client/dist/spa/assets/{rust-JZ-uOoYM.js → rust-IHUZWzBr.js} +0 -0
  183. /package/src/client/dist/spa/assets/{sb-CBglP1-t.js → sb-DrUvY44N.js} +0 -0
  184. /package/src/client/dist/spa/assets/{scala-C9l41paw.js → scala-B4hbXGLM.js} +0 -0
  185. /package/src/client/dist/spa/assets/{scheme-B-InQ6hy.js → scheme-BGrd12j3.js} +0 -0
  186. /package/src/client/dist/spa/assets/{scss-v6OmJRN9.js → scss-x5G1ES4U.js} +0 -0
  187. /package/src/client/dist/spa/assets/{shell-Dyp6iwB6.js → shell-DOehe2Y8.js} +0 -0
  188. /package/src/client/dist/spa/assets/{solidity-D5epNWue.js → solidity-BeRvcwWV.js} +0 -0
  189. /package/src/client/dist/spa/assets/{sophia-Eva-79sB.js → sophia-DZbkUNjy.js} +0 -0
  190. /package/src/client/dist/spa/assets/{sparql-gvALLO1w.js → sparql-B7_oi5-h.js} +0 -0
  191. /package/src/client/dist/spa/assets/{sql-COdamZYI.js → sql-CTlsFWVE.js} +0 -0
  192. /package/src/client/dist/spa/assets/{st-eMoImIwE.js → st-DJVEJdPE.js} +0 -0
  193. /package/src/client/dist/spa/assets/{swift-7R_T9RYH.js → swift-CwhT3fYa.js} +0 -0
  194. /package/src/client/dist/spa/assets/{symbols-CAg-nBkV.js → symbols-DCYodwb2.js} +0 -0
  195. /package/src/client/dist/spa/assets/{systemverilog-1pCEfaHU.js → systemverilog-BQN63pkN.js} +0 -0
  196. /package/src/client/dist/spa/assets/{tcl-B_KgnhfE.js → tcl-DqwfpskA.js} +0 -0
  197. /package/src/client/dist/spa/assets/{twig-CFZUJxb9.js → twig-BiyenUgc.js} +0 -0
  198. /package/src/client/dist/spa/assets/{typespec-B1ZgHlud.js → typespec-CWOJribt.js} +0 -0
  199. /package/src/client/dist/spa/assets/{vb-DKdun5tL.js → vb-Cq5F87m3.js} +0 -0
  200. /package/src/client/dist/spa/assets/{vue-i18n-eUDnMrPl.js → vue-i18n-CeG0hR0Z.js} +0 -0
  201. /package/src/client/dist/spa/assets/{wgsl-CzNaxTrn.js → wgsl-BAvW2lVr.js} +0 -0
package/AGENTS.md CHANGED
@@ -58,7 +58,12 @@ src/
58
58
  │ │ └── migrations.ts # incremental migrations, bumped per feature
59
59
  │ ├── services/ # business logic — pure functions over db + external processes
60
60
  │ │ ├── workspace-service.ts # workspaces + tasks + agent_sessions CRUD
61
- │ │ ├── agent-manager.ts # spawns Claude Code CLI, streams stdout, tracks sessions, interrupt/stop
61
+ │ │ ├── agent/ # agent engine abstraction (replaces former agent-manager.ts)
62
+ │ │ │ ├── orchestrator.ts # per-workspace engine map, retry/quota handling, watchdog, public API
63
+ │ │ │ ├── session-controller.ts # lifecycle wrapper around an AgentEngine instance
64
+ │ │ │ ├── event-router.ts # maps engine AgentEvent stream to WS emit + DB side-effects
65
+ │ │ │ └── engines/claude-code/ # Claude Code engine (spawn + stream-parser + args-builder + mcp-config + capabilities)
66
+ │ │ ├── content-migration-service.ts # runtime legacy ws_events → normalised AgentEvent migration
62
67
  │ │ ├── templates-service.ts # prompt templates CRUD (JSON file persistence, seeding)
63
68
  │ │ ├── dev-server-service.ts # per-workspace dev server lifecycle (docker or npm process)
64
69
  │ │ ├── websocket-service.ts # emit / emitEphemeral to subscribed clients
package/README.md CHANGED
@@ -4,8 +4,10 @@
4
4
 
5
5
  > [!WARNING]
6
6
  > 🚧 **Work in progress** — This project is under active development. Breaking changes may occur at any time.
7
+ > ⚠️ **Planned refactor with potential data loss** — A major refactor is planned and may require database/schema changes that can cause data loss.
8
+ > ❌ **Do not use in production** until this refactor is complete and a stable migration path is documented.
7
9
  >
8
- > **Engine refactor planned.** Kōbō currently drives the `claude` CLI via `spawn(..., ['-p', ...])` and parses stdout, which is brittle on edge cases (interrupts, long-running sessions, MCP lifecycle, tool-use streaming). A rewrite is planned to use the [Claude Agent SDK](https://docs.anthropic.com/en/docs/agents-and-tools/claude-agent-sdk/overview) directly so the agent runs in-process, with proper streaming primitives, structured tool-use events, and cleaner interrupt / resume semantics. Expect churn in `src/server/services/agent-manager.ts` and the WebSocket event shape during that migration.
10
+ > **Engine refactor in progress.** The legacy `agent-manager.ts` has been split into an agent engine abstraction (`src/server/services/agent/`) with a pluggable `AgentEngine` contract, a shared `Orchestrator`, and a normalised `AgentEvent` stream. The Claude Code CLI is now one engine among potentially several (`src/server/services/agent/engines/claude-code/`). A runtime migration (`content-migration-service.ts`) converts legacy `ws_events` into the new normalised form on first boot after upgrade; **a timestamped copy of `kobo.db` is written alongside it before the migration runs**, so you can roll back by restoring the backup if anything goes sideways. Expect continued churn until the WebSocket event surface and UI stream reducers stabilise.
9
11
 
10
12
  Kōbō lets you delegate multiple coding missions to Claude Code agents in parallel. Each workspace lives in its own isolated git worktree with its own branch, its own Claude session, optionally its own dev server, and a custom MCP tools server the agent uses to track progress. A Vue 3 dashboard shows live agent output, tasks, acceptance criteria, and git state across every workspace.
11
13
 
@@ -92,7 +94,9 @@ npm start # runs the compiled server
92
94
  ### Test & lint
93
95
 
94
96
  ```bash
95
- npm test # full vitest suite (366+ tests)
97
+ npm test # backend vitest suite (740+ tests)
98
+ npm run test:client # client vitest suite (Pinia stores + pure utils, 85+ tests)
99
+ npm run test:all # backend + client suites
96
100
  npm run lint # biome check (lint + format verification)
97
101
  npm run lint:fix # biome check with safe auto-fixes
98
102
  npm run format # biome format --write
@@ -207,22 +211,31 @@ Then start a new workspace in Kōbō — the agent will pick up the skills autom
207
211
 
208
212
  ```
209
213
  src/
210
- ├── server/ # Hono backend
211
- │ ├── index.ts # app bootstrap + WS upgrade
212
- │ ├── db/ # SQLite schema and singleton
213
- │ ├── services/ # business logic (workspace, agent, dev-server, ws, notion, settings, pr-template)
214
- │ ├── routes/ # Hono handlers
215
- └── utils/ # git-ops, process-tracker
216
- ├── client/ # Vue 3 + Quasar SPA
214
+ ├── server/ # Hono backend
215
+ │ ├── index.ts # app bootstrap + WS upgrade
216
+ │ ├── db/ # SQLite schema, migrations, singleton
217
+ │ ├── services/
218
+ ├── agent/ # agent engine abstraction (replaces agent-manager.ts)
219
+ │ │ ├── orchestrator.ts # per-workspace engine map, retry/quota, watchdog, public API
220
+ │ │ │ ├── session-controller.ts # lifecycle wrapper around one AgentEngine instance
221
+ │ │ │ ├── event-router.ts # maps engine AgentEvent stream to WS emit + DB side-effects
222
+ │ │ │ └── engines/claude-code/ # spawn + NDJSON stream-parser + args-builder + mcp-config + capabilities
223
+ │ │ ├── content-migration-service.ts # legacy ws_events → normalised AgentEvent rows, with DB backup
224
+ │ │ └── … # workspace, dev-server, ws, notion, sentry, settings, pr-template
225
+ │ ├── routes/ # Hono handlers (workspaces, engines, migration, templates, …)
226
+ │ └── utils/ # git-ops, process-tracker, paths
227
+ ├── shared/ # modules shared by backend and frontend (e.g. model catalogue)
228
+ ├── client/ # Vue 3 + Quasar SPA
217
229
  │ └── src/
218
- │ ├── stores/ # Pinia state management
219
- │ ├── components/ # WorkspaceList, NotionPanel, ChatInput, GitPanel, …
220
- │ ├── pages/ # WorkspacePage, CreatePage, SettingsPage
230
+ │ ├── stores/ # Pinia: workspace, websocket, agent-stream, migration, settings, …
231
+ │ ├── components/ # ActivityFeed, TurnCard, WorkspaceList, ChatInput, GitPanel, …
232
+ │ ├── services/ # agent-event-view (foldEvents), conversation-turns (groupIntoTurns), inline-diff
233
+ │ ├── pages/ # WorkspacePage, CreatePage, SettingsPage
221
234
  │ └── router/
222
- ├── mcp-server/ # Standalone MCP server spawned per workspace
223
- │ ├── kobo-tasks-server.ts # entry point, registers list_tasks & mark_task_done
224
- │ └── kobo-tasks-handlers.ts # pure handlers over SQLite
225
- └── __tests__/ # Vitest suite
235
+ ├── mcp-server/ # standalone MCP server spawned per workspace
236
+ │ ├── kobo-tasks-server.ts # entry point, registers list_tasks & mark_task_done
237
+ │ └── kobo-tasks-handlers.ts # pure handlers over SQLite
238
+ └── __tests__/ # Vitest suite (engines, orchestrator, migration, routes, …)
226
239
  ```
227
240
 
228
241
  See [`AGENTS.md`](./AGENTS.md) for a deeper dive into conventions, data model, WebSocket protocol, and contribution guidelines.
@@ -1,9 +1,17 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
1
3
  import Database from 'better-sqlite3';
2
4
  import { ensureKoboHome, getDbPath } from '../utils/paths.js';
3
5
  let instance = null;
4
6
  /**
5
7
  * Return the singleton SQLite database connection, creating it on first call.
6
8
  * Configures WAL mode, busy timeout, and foreign keys.
9
+ *
10
+ * Safety guard: when running under vitest, refuse to open any DB located
11
+ * under the user's real home directory. If the caller didn't pass a
12
+ * custom path AND KOBO_HOME wasn't pinned to a temp dir, the vitest
13
+ * global setup is misconfigured — better to fail loudly than to silently
14
+ * mutate production data.
7
15
  */
8
16
  export function getDb(dbPath) {
9
17
  if (instance)
@@ -13,6 +21,15 @@ export function getDb(dbPath) {
13
21
  ensureKoboHome();
14
22
  resolvedPath = getDbPath();
15
23
  }
24
+ if (process.env.VITEST) {
25
+ const home = os.homedir();
26
+ const resolved = path.resolve(resolvedPath);
27
+ if (resolved.startsWith(home) && !resolved.startsWith(path.resolve(os.tmpdir()))) {
28
+ throw new Error(`[kobo-db] Refusing to open production DB under a user home directory while VITEST is active: ${resolved}. ` +
29
+ `This is a safety guard against tests leaking into the developer's ~/.config/kobo/. ` +
30
+ `Ensure vitest.setup.ts sets KOBO_HOME to a tmp directory, or pass an explicit dbPath to getDb().`);
31
+ }
32
+ }
16
33
  instance = new Database(resolvedPath);
17
34
  instance.pragma('journal_mode=WAL');
18
35
  instance.pragma('busy_timeout=5000');
@@ -78,6 +78,16 @@ export const migrations = [
78
78
  db.prepare("ALTER TABLE workspaces ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'").run();
79
79
  },
80
80
  },
81
+ {
82
+ version: 10,
83
+ name: 'agent-engine-abstraction',
84
+ migrate: (db) => {
85
+ db.transaction(() => {
86
+ db.prepare("ALTER TABLE workspaces ADD COLUMN engine TEXT NOT NULL DEFAULT 'claude-code'").run();
87
+ db.prepare('ALTER TABLE agent_sessions RENAME COLUMN claude_session_id TO engine_session_id').run();
88
+ })();
89
+ },
90
+ },
81
91
  ];
82
92
  /** Current schema version — always equals the highest migration version. */
83
93
  export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
@@ -18,6 +18,7 @@ export function initSchema(db) {
18
18
  archived_at TEXT,
19
19
  favorited_at TEXT,
20
20
  tags TEXT NOT NULL DEFAULT '[]',
21
+ engine TEXT NOT NULL DEFAULT 'claude-code',
21
22
  created_at TEXT NOT NULL,
22
23
  updated_at TEXT NOT NULL
23
24
  );
@@ -37,7 +38,7 @@ export function initSchema(db) {
37
38
  id TEXT PRIMARY KEY,
38
39
  workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
39
40
  pid INTEGER,
40
- claude_session_id TEXT,
41
+ engine_session_id TEXT,
41
42
  status TEXT NOT NULL DEFAULT 'running',
42
43
  started_at TEXT NOT NULL,
43
44
  ended_at TEXT,
@@ -8,9 +8,11 @@ import WebSocket, { WebSocketServer } from 'ws';
8
8
  import { closeDb, getDb } from './db/index.js';
9
9
  import { runMigrations } from './db/migrations.js';
10
10
  import devServerRouter from './routes/dev-server.js';
11
+ import { enginesRouter } from './routes/engines.js';
11
12
  import gitRouter from './routes/git.js';
12
13
  import healthRouter from './routes/health.js';
13
14
  import imagesRouter from './routes/images.js';
15
+ import { migrationRouter } from './routes/migration.js';
14
16
  import notionRouter from './routes/notion.js';
15
17
  import plansRouter from './routes/plans.js';
16
18
  import searchRouter from './routes/search.js';
@@ -18,7 +20,8 @@ import sentryRouter from './routes/sentry.js';
18
20
  import settingsRouter from './routes/settings.js';
19
21
  import templatesRouter from './routes/templates.js';
20
22
  import workspacesRouter from './routes/workspaces.js';
21
- import { getAvailableSkills, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopWatchdog, } from './services/agent-manager.js';
23
+ import { getAvailableSkills, reconcileOrphanSessions, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopWatchdog, } from './services/agent/orchestrator.js';
24
+ import { runContentMigrationIfNeeded } from './services/content-migration-service.js';
22
25
  import { createDailyDbBackupIfNeeded } from './services/db-backup-service.js';
23
26
  import { startDevServer, stopDevServer } from './services/dev-server-service.js';
24
27
  import { startPrWatcher, stopPrWatcher } from './services/pr-watcher-service.js';
@@ -52,6 +55,7 @@ void createDailyDbBackupIfNeeded(db, getDbPath()).then((r) => {
52
55
  });
53
56
  // Initialize process cleanup, agent watchdog, and PR watcher
54
57
  initProcessCleanup();
58
+ reconcileOrphanSessions();
55
59
  startWatchdog();
56
60
  startPrWatcher();
57
61
  // Create Hono app
@@ -70,6 +74,8 @@ app.route('/api/templates', templatesRouter);
70
74
  app.route('/api/workspaces', plansRouter);
71
75
  app.route('/api/search', searchRouter);
72
76
  app.route('/api/health', healthRouter);
77
+ app.route('/api/engines', enginesRouter);
78
+ app.route('/api/migration', migrationRouter);
73
79
  // Skills endpoint
74
80
  app.get('/api/skills', (c) => c.json(getAvailableSkills()));
75
81
  const PORT = parseInt(process.env.SERVER_PORT || process.env.PORT || '3000', 10);
@@ -120,6 +126,13 @@ const server = serve({
120
126
  }, (info) => {
121
127
  setBackendPort(info.port);
122
128
  console.log(`Server running at http://localhost:${info.port}`);
129
+ // Content migration runs AFTER the HTTP listener is up so the frontend
130
+ // can observe progress via WS broadcasts + GET /api/migration/status.
131
+ // Not awaited — the callback returns quickly, the migration runs in the
132
+ // background.
133
+ void runContentMigrationIfNeeded(getDb(), getDbPath()).catch((err) => {
134
+ console.error('[boot] content migration failed:', err);
135
+ });
123
136
  });
124
137
  // Create WebSocketServer attached to the HTTP server
125
138
  const wss = new WebSocketServer({ noServer: true });
@@ -212,7 +225,7 @@ terminalWss.on('connection', (ws, workspaceId) => {
212
225
  }
213
226
  });
214
227
  });
215
- // Wire websocket-service message handler to agent-manager
228
+ // Wire websocket-service message handler to the agent orchestrator
216
229
  setMessageHandler((type, payload) => {
217
230
  const p = payload;
218
231
  if (type === 'chat:message' && p?.workspaceId && p?.content) {
@@ -226,7 +239,15 @@ setMessageHandler((type, payload) => {
226
239
  try {
227
240
  sendMessage(p.workspaceId, p.content);
228
241
  }
229
- catch {
242
+ catch (err) {
243
+ const msg = err instanceof Error ? err.message : String(err);
244
+ // Only resume on the specific "No agent running" path. Other errors
245
+ // (stdin closed, process dead mid-write, etc.) should surface to the
246
+ // logs instead of silently respawning a fresh agent.
247
+ if (!msg.includes('No agent running')) {
248
+ console.error(`[ws] chat:message failed for workspace ${p.workspaceId}:`, err);
249
+ return;
250
+ }
230
251
  // Agent not running — resume the session hinted by the client if any,
231
252
  // otherwise the most-recent active session.
232
253
  try {
@@ -0,0 +1,15 @@
1
+ import { getContentMigrationStatus } from '../services/content-migration-service.js';
2
+ /**
3
+ * Blocks mutating requests while the content migration is running.
4
+ *
5
+ * Returns 503 with `{ error: 'migration-in-progress' }` whenever the migration
6
+ * state is anything other than `idle` or `done`. Mounted per-handler on routes
7
+ * that write to `ws_events` or spawn agents so the user can still observe
8
+ * progress through GETs and `/api/migration/status` while the migration runs.
9
+ */
10
+ export const migrationGuard = async (c, next) => {
11
+ const state = getContentMigrationStatus().state;
12
+ if (state === 'idle' || state === 'done')
13
+ return next();
14
+ return c.json({ error: 'migration-in-progress' }, 503);
15
+ };
@@ -1,4 +1,5 @@
1
1
  import { Hono } from 'hono';
2
+ import { migrationGuard } from '../middleware/migration-guard.js';
2
3
  import { getDevServerLogs, getStatus, startDevServer, stopDevServer } from '../services/dev-server-service.js';
3
4
  import { getWorkspace } from '../services/workspace-service.js';
4
5
  /** Hono sub-router for per-workspace dev server lifecycle (start, stop, status, logs). */
@@ -24,7 +25,7 @@ app.get('/:workspaceId/status', (c) => {
24
25
  }
25
26
  });
26
27
  // POST /api/dev-server/:workspaceId/start
27
- app.post('/:workspaceId/start', (c) => {
28
+ app.post('/:workspaceId/start', migrationGuard, (c) => {
28
29
  try {
29
30
  const workspaceId = c.req.param('workspaceId');
30
31
  const workspace = getWorkspace(workspaceId);
@@ -40,7 +41,7 @@ app.post('/:workspaceId/start', (c) => {
40
41
  }
41
42
  });
42
43
  // POST /api/dev-server/:workspaceId/stop
43
- app.post('/:workspaceId/stop', (c) => {
44
+ app.post('/:workspaceId/stop', migrationGuard, (c) => {
44
45
  try {
45
46
  const workspaceId = c.req.param('workspaceId');
46
47
  const workspace = getWorkspace(workspaceId);
@@ -0,0 +1,9 @@
1
+ import { Hono } from 'hono';
2
+ import { listEngines } from '../services/agent/engines/registry.js';
3
+ /** Hono sub-router exposing `GET /` — the list of registered agent engines with capabilities. */
4
+ export const enginesRouter = new Hono();
5
+ enginesRouter.get('/', (c) => c.json(listEngines().map((e) => ({
6
+ id: e.id,
7
+ displayName: e.displayName,
8
+ capabilities: e.capabilities,
9
+ }))));
@@ -0,0 +1,5 @@
1
+ import { Hono } from 'hono';
2
+ import { getContentMigrationStatus } from '../services/content-migration-service.js';
3
+ /** Hono sub-router exposing `GET /status` for the runtime content migration. */
4
+ export const migrationRouter = new Hono();
5
+ migrationRouter.get('/status', (c) => c.json(getContentMigrationStatus()));
@@ -5,7 +5,9 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { Hono } from 'hono';
7
7
  import { getDb } from '../db/index.js';
8
- import * as agentManager from '../services/agent-manager.js';
8
+ import { migrationGuard } from '../middleware/migration-guard.js';
9
+ import { listEngines } from '../services/agent/engines/registry.js';
10
+ import * as agentManager from '../services/agent/orchestrator.js';
9
11
  import * as devServerService from '../services/dev-server-service.js';
10
12
  import * as notionService from '../services/notion-service.js';
11
13
  import { renderPrTemplate } from '../services/pr-template-service.js';
@@ -32,12 +34,21 @@ app.get('/', (c) => {
32
34
  }
33
35
  });
34
36
  // POST /api/workspaces — create workspace
35
- app.post('/', async (c) => {
37
+ app.post('/', migrationGuard, async (c) => {
36
38
  try {
37
39
  const body = await c.req.json();
38
40
  if (!body.name || !body.projectPath || !body.sourceBranch || !body.workingBranch) {
39
41
  return c.json({ error: 'Missing required fields: name, projectPath, sourceBranch, workingBranch' }, 400);
40
42
  }
43
+ // Validate the engine id (if provided) against the registry. An unknown
44
+ // engine is rejected up-front so we don't create orphan workspaces that
45
+ // can't spawn an agent.
46
+ if (body.engine) {
47
+ const validEngineIds = listEngines().map((e) => e.id);
48
+ if (!validEngineIds.includes(body.engine)) {
49
+ return c.json({ error: `Unknown engine '${body.engine}'. Valid engines: ${validEngineIds.join(', ')}` }, 400);
50
+ }
51
+ }
41
52
  // Fetch the source branch from origin first — if this fails, block creation
42
53
  // immediately (no DB records created, user stays on the create page).
43
54
  try {
@@ -61,6 +72,7 @@ app.post('/', async (c) => {
61
72
  model: body.model,
62
73
  reasoningEffort: body.reasoningEffort,
63
74
  permissionMode: body.permissionMode || globalSettings.defaultPermissionMode || 'plan',
75
+ engine: body.engine,
64
76
  });
65
77
  let notionContent = null;
66
78
  let sentryContent = null;
@@ -446,7 +458,7 @@ app.post('/', async (c) => {
446
458
  }
447
459
  });
448
460
  // POST /api/workspaces/:id/sessions — create a new idle agent session
449
- app.post('/:id/sessions', (c) => {
461
+ app.post('/:id/sessions', migrationGuard, (c) => {
450
462
  try {
451
463
  const id = c.req.param('id');
452
464
  const workspace = workspaceService.getWorkspace(id);
@@ -803,7 +815,7 @@ app.put('/:id/tags', async (c) => {
803
815
  }
804
816
  });
805
817
  // PATCH /api/workspaces/:id — update workspace fields (status, model, permissionMode, name)
806
- app.patch('/:id', async (c) => {
818
+ app.patch('/:id', migrationGuard, async (c) => {
807
819
  try {
808
820
  const id = c.req.param('id');
809
821
  const body = await c.req.json();
@@ -930,7 +942,7 @@ app.post('/:id/run-setup-script', async (c) => {
930
942
  }
931
943
  });
932
944
  // POST /api/workspaces/:id/archive — mark workspace as archived (soft-delete)
933
- app.post('/:id/archive', (c) => {
945
+ app.post('/:id/archive', migrationGuard, (c) => {
934
946
  try {
935
947
  const id = c.req.param('id');
936
948
  const workspace = workspaceService.getWorkspace(id);
@@ -969,7 +981,7 @@ app.post('/:id/archive', (c) => {
969
981
  }
970
982
  });
971
983
  // POST /api/workspaces/:id/unarchive — restore an archived workspace
972
- app.post('/:id/unarchive', (c) => {
984
+ app.post('/:id/unarchive', migrationGuard, (c) => {
973
985
  try {
974
986
  const id = c.req.param('id');
975
987
  const workspace = workspaceService.getWorkspace(id);
@@ -989,7 +1001,7 @@ app.post('/:id/unarchive', (c) => {
989
1001
  }
990
1002
  });
991
1003
  // DELETE /api/workspaces/:id — delete workspace
992
- app.delete('/:id', async (c) => {
1004
+ app.delete('/:id', migrationGuard, async (c) => {
993
1005
  try {
994
1006
  const id = c.req.param('id');
995
1007
  const workspace = workspaceService.getWorkspace(id);
@@ -1053,13 +1065,25 @@ app.delete('/:id', async (c) => {
1053
1065
  }
1054
1066
  });
1055
1067
  // POST /api/workspaces/:id/start — start/restart agent
1056
- app.post('/:id/start', async (c) => {
1068
+ app.post('/:id/start', migrationGuard, async (c) => {
1057
1069
  try {
1058
1070
  const id = c.req.param('id');
1059
1071
  const workspace = workspaceService.getWorkspace(id);
1060
1072
  if (!workspace) {
1061
1073
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1062
1074
  }
1075
+ // If the workspace declares an engine, ensure it is still registered.
1076
+ // Otherwise startAgent() would throw from deep inside resolveEngine and
1077
+ // surface as an opaque 500 — better to fail fast with a clear 400.
1078
+ const workspaceEngine = workspace.engine;
1079
+ if (workspaceEngine) {
1080
+ const validEngineIds = listEngines().map((e) => e.id);
1081
+ if (!validEngineIds.includes(workspaceEngine)) {
1082
+ return c.json({
1083
+ error: `Workspace uses engine '${workspaceEngine}' which is no longer available. Recreate or reconfigure the workspace.`,
1084
+ }, 400);
1085
+ }
1086
+ }
1063
1087
  const body = await c.req
1064
1088
  .json()
1065
1089
  .catch(() => ({ prompt: undefined, agentSessionId: undefined, resume: undefined }));
@@ -1269,7 +1293,7 @@ app.post('/:id/git/abort', (c) => {
1269
1293
  }
1270
1294
  });
1271
1295
  /** Hand off merge/rebase conflicts to the workspace agent with an intelligent-resolution prompt. */
1272
- app.post('/:id/git/resolve-with-agent', async (c) => {
1296
+ app.post('/:id/git/resolve-with-agent', migrationGuard, async (c) => {
1273
1297
  try {
1274
1298
  const id = c.req.param('id');
1275
1299
  const workspace = workspaceService.getWorkspace(id);
@@ -1489,7 +1513,7 @@ app.post('/:id/mark-read', (c) => {
1489
1513
  }
1490
1514
  });
1491
1515
  // POST /api/workspaces/:id/stop — stop agent
1492
- app.post('/:id/stop', (c) => {
1516
+ app.post('/:id/stop', migrationGuard, (c) => {
1493
1517
  try {
1494
1518
  const id = c.req.param('id');
1495
1519
  const workspace = workspaceService.getWorkspace(id);
@@ -1517,7 +1541,7 @@ app.post('/:id/stop', (c) => {
1517
1541
  }
1518
1542
  });
1519
1543
  // POST /api/workspaces/:id/interrupt — soft-interrupt agent (SIGINT, like Escape in Claude Code)
1520
- app.post('/:id/interrupt', (c) => {
1544
+ app.post('/:id/interrupt', migrationGuard, (c) => {
1521
1545
  try {
1522
1546
  const id = c.req.param('id');
1523
1547
  const workspace = workspaceService.getWorkspace(id);
@@ -0,0 +1,22 @@
1
+ export function buildClaudeArgs(input) {
2
+ const args = ['--output-format', 'stream-json', '--verbose'];
3
+ if (input.skipPermissions)
4
+ args.push('--dangerously-skip-permissions');
5
+ let prompt = input.prompt;
6
+ if (input.permissionMode === 'plan') {
7
+ prompt = `[PLAN MODE] You are in PLAN/READ-ONLY mode. You MUST NOT create, edit, write, or delete any files. Only use read-only tools (Read, Grep, Glob, LS, Bash for read-only commands). Analyze the codebase, plan your approach, and present your findings — but do NOT execute any changes.\n\n${prompt}`;
8
+ }
9
+ if (input.model && input.model !== 'auto')
10
+ args.push('--model', input.model);
11
+ if (input.effort && input.effort !== 'auto')
12
+ args.push('--effort', input.effort);
13
+ if (input.mcpConfigPath)
14
+ args.push('--mcp-config', input.mcpConfigPath);
15
+ if (input.resumeFromEngineSessionId) {
16
+ args.push('--resume', input.resumeFromEngineSessionId, '-p', prompt);
17
+ }
18
+ else {
19
+ args.push('-p', prompt);
20
+ }
21
+ return { args, effectivePrompt: prompt };
22
+ }
@@ -0,0 +1,17 @@
1
+ import { CLAUDE_MODELS } from '../../../../../shared/models.js';
2
+ export const CLAUDE_CODE_CAPABILITIES = {
3
+ // Models come from the shared catalogue in `src/shared/models.ts` — the
4
+ // ONE source of truth, consumed both by this file (for /api/engines and
5
+ // for validation in POST /api/workspaces) and by the frontend selectors.
6
+ models: CLAUDE_MODELS.map((m) => ({ id: m.id, label: m.label })),
7
+ effortLevels: [
8
+ { id: 'auto', label: 'Auto' },
9
+ { id: 'low', label: 'Low' },
10
+ { id: 'medium', label: 'Medium' },
11
+ { id: 'high', label: 'High' },
12
+ ],
13
+ permissionModes: ['auto-accept', 'plan'],
14
+ supportsResume: true,
15
+ supportsMcp: true,
16
+ supportsSkills: true,
17
+ };
@@ -0,0 +1,163 @@
1
+ import { spawn } from 'node:child_process';
2
+ import readline from 'node:readline';
3
+ import { buildClaudeArgs } from './args-builder.js';
4
+ import { CLAUDE_CODE_CAPABILITIES } from './capabilities.js';
5
+ import { cleanupMcpConfig, writeMcpConfig } from './mcp-config.js';
6
+ import { createParserState, parseClaudeLine } from './stream-parser.js';
7
+ export function createClaudeCodeEngine() {
8
+ return {
9
+ id: 'claude-code',
10
+ displayName: 'Claude Code',
11
+ capabilities: CLAUDE_CODE_CAPABILITIES,
12
+ async start(options, onEvent) {
13
+ // Write MCP config if any servers requested + engine supports MCP
14
+ let mcpConfigPath;
15
+ if (options.mcpServers && options.mcpServers.length > 0) {
16
+ mcpConfigPath = writeMcpConfig(options.workingDir, options.mcpServers);
17
+ }
18
+ const { args } = buildClaudeArgs({
19
+ prompt: options.prompt,
20
+ model: options.model,
21
+ effort: options.effort,
22
+ permissionMode: options.permissionMode ?? 'auto-accept',
23
+ skipPermissions: options.settings.dangerouslySkipPermissions ?? true,
24
+ resumeFromEngineSessionId: options.resumeFromEngineSessionId,
25
+ mcpConfigPath,
26
+ });
27
+ const proc = spawn('claude', args, {
28
+ cwd: options.workingDir,
29
+ stdio: ['pipe', 'pipe', 'pipe'],
30
+ });
31
+ const parserState = createParserState();
32
+ if (!proc.stdout)
33
+ throw new Error('Claude process has no stdout');
34
+ const rl = readline.createInterface({
35
+ input: proc.stdout,
36
+ crlfDelay: Number.POSITIVE_INFINITY,
37
+ });
38
+ let discoveredSessionId;
39
+ rl.on('line', (line) => {
40
+ const { events } = parseClaudeLine(line, parserState);
41
+ for (const ev of events) {
42
+ if (ev.kind === 'session:started')
43
+ discoveredSessionId = ev.engineSessionId;
44
+ onEvent(ev);
45
+ }
46
+ });
47
+ // Line-buffer stderr so we see one event per log line instead of
48
+ // arbitrary byte chunks, and restrict quota detection to clear rate-
49
+ // limit signals (not every occurrence of the word "rate" or "quota").
50
+ // Non-quota stderr lines are logged to the console but do NOT emit
51
+ // an error event — this avoids false positives flooding the UI.
52
+ const stderrRl = proc.stderr
53
+ ? readline.createInterface({
54
+ input: proc.stderr,
55
+ crlfDelay: Number.POSITIVE_INFINITY,
56
+ })
57
+ : undefined;
58
+ // Known benign stderr lines from the Claude CLI that should NOT be
59
+ // logged — they flood the dev console and carry no actionable info.
60
+ // Strip ANSI color codes before matching.
61
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escapes by design
62
+ const stripAnsi = (s) => s.replace(/\u001b\[\d+m/g, '');
63
+ function isBenignStderr(line) {
64
+ const cleaned = stripAnsi(line).trim();
65
+ return /^warning: no stdin data received in \d+s/i.test(cleaned);
66
+ }
67
+ stderrRl?.on('line', (line) => {
68
+ const lower = line.toLowerCase();
69
+ const isQuota = lower.includes('rate limit exceeded') ||
70
+ lower.includes('rate_limit_exceeded') ||
71
+ (lower.includes('429') && lower.includes('rate')) ||
72
+ lower.includes('quota exceeded');
73
+ if (isQuota) {
74
+ onEvent({ kind: 'error', category: 'quota', message: line });
75
+ }
76
+ else if (line.trim().length > 0 && !isBenignStderr(line)) {
77
+ console.warn(`[claude-engine stderr] ${line}`);
78
+ }
79
+ });
80
+ // 'error' fires when spawn itself fails (e.g. ENOENT if the `claude`
81
+ // binary is missing from PATH). In that case 'exit' never fires, so we
82
+ // emit the lifecycle pair here and clean the MCP config ourselves.
83
+ proc.on('error', (err) => {
84
+ onEvent({ kind: 'error', category: 'spawn_failed', message: err.message });
85
+ onEvent({ kind: 'session:ended', reason: 'error', exitCode: null });
86
+ cleanupMcpConfig(options.workingDir);
87
+ rl.close();
88
+ stderrRl?.close();
89
+ });
90
+ proc.on('exit', (code) => {
91
+ cleanupMcpConfig(options.workingDir);
92
+ rl.close();
93
+ stderrRl?.close();
94
+ onEvent({
95
+ kind: 'session:ended',
96
+ reason: code === 0 ? 'completed' : code === null ? 'killed' : 'error',
97
+ exitCode: code,
98
+ });
99
+ });
100
+ const engineProcess = {
101
+ get pid() {
102
+ return proc.pid;
103
+ },
104
+ get engineSessionId() {
105
+ return discoveredSessionId;
106
+ },
107
+ sendMessage(text) {
108
+ if (!proc.stdin?.writable)
109
+ throw new Error('Agent stdin not writable');
110
+ proc.stdin.write(`${text}\n`);
111
+ },
112
+ interrupt() {
113
+ if (proc.pid !== undefined)
114
+ process.kill(proc.pid, 'SIGINT');
115
+ },
116
+ stop() {
117
+ return new Promise((resolve) => {
118
+ if (proc.killed || proc.exitCode !== null)
119
+ return resolve();
120
+ let resolved = false;
121
+ let killTimer;
122
+ let hardTimeout;
123
+ const doResolve = () => {
124
+ if (resolved)
125
+ return;
126
+ resolved = true;
127
+ if (killTimer)
128
+ clearTimeout(killTimer);
129
+ if (hardTimeout)
130
+ clearTimeout(hardTimeout);
131
+ resolve();
132
+ };
133
+ proc.once('exit', doResolve);
134
+ try {
135
+ proc.kill('SIGTERM');
136
+ }
137
+ catch {
138
+ // Already dead
139
+ }
140
+ killTimer = setTimeout(() => {
141
+ try {
142
+ if (!proc.killed)
143
+ proc.kill('SIGKILL');
144
+ }
145
+ catch {
146
+ // Ignore
147
+ }
148
+ }, 5000);
149
+ killTimer.unref?.();
150
+ // Hard-timeout safety net: if the process hasn't exited within 10s
151
+ // (5s after SIGKILL), resolve anyway so callers never hang forever.
152
+ hardTimeout = setTimeout(() => {
153
+ console.warn('[claude-engine] stop() hard-timeout reached, resolving anyway');
154
+ doResolve();
155
+ }, 10000);
156
+ hardTimeout.unref?.();
157
+ });
158
+ },
159
+ };
160
+ return engineProcess;
161
+ },
162
+ };
163
+ }