@loicngr/kobo 1.6.0 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/AGENTS.md +6 -1
  2. package/README.md +43 -29
  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 +26 -5
  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/documents.js +113 -0
  10. package/dist/server/routes/engines.js +9 -0
  11. package/dist/server/routes/migration.js +5 -0
  12. package/dist/server/routes/workspaces.js +35 -11
  13. package/dist/server/services/agent/engines/claude-code/args-builder.js +22 -0
  14. package/dist/server/services/agent/engines/claude-code/capabilities.js +17 -0
  15. package/dist/server/services/agent/engines/claude-code/engine.js +163 -0
  16. package/dist/server/services/agent/engines/claude-code/mcp-config.js +23 -0
  17. package/dist/server/services/agent/engines/claude-code/stream-parser.js +224 -0
  18. package/dist/server/services/agent/engines/registry.js +21 -0
  19. package/dist/server/services/agent/engines/types.js +18 -0
  20. package/dist/server/services/agent/event-router.js +4 -0
  21. package/dist/server/services/agent/orchestrator.js +582 -0
  22. package/dist/server/services/agent/session-controller.js +79 -0
  23. package/dist/server/services/content-migration-service.js +155 -0
  24. package/dist/server/services/db-backup-service.js +15 -0
  25. package/dist/server/services/websocket-service.js +81 -50
  26. package/dist/server/services/workspace-service.js +11 -5
  27. package/dist/server/utils/paths.js +1 -1
  28. package/dist/shared/models.js +50 -0
  29. package/package.json +1 -1
  30. package/src/client/dist/spa/assets/ActivityFeed-CFuT6H5u.js +7 -0
  31. package/src/client/dist/spa/assets/ActivityFeed-DXYafbn4.css +1 -0
  32. package/src/client/dist/spa/assets/ClosePopup-DhM1C4Zw.js +1 -0
  33. package/src/client/dist/spa/assets/CreatePage-sGrkfyOm.js +2 -0
  34. package/src/client/dist/spa/assets/CreatePage-yu2IH7GW.css +1 -0
  35. package/src/client/dist/spa/assets/DiffViewer-BVU58ujc.css +1 -0
  36. package/src/client/dist/spa/assets/DiffViewer-BwSRtVRI.js +2 -0
  37. package/src/client/dist/spa/assets/HealthPage-BO-bMpEu.js +1 -0
  38. package/src/client/dist/spa/assets/MainLayout-BiBJtDTk.css +1 -0
  39. package/src/client/dist/spa/assets/{MainLayout-_oPM07ln.js → MainLayout-BpqOChIX.js} +17 -17
  40. package/src/client/dist/spa/assets/QBadge-DqtcDv8D.js +1 -0
  41. package/src/client/dist/spa/assets/QBtn-CyzfM9-_.js +1 -0
  42. package/src/client/dist/spa/assets/QChip-KJoHYE6F.js +1 -0
  43. package/src/client/dist/spa/assets/QDialog-DQeAxY3-.js +1 -0
  44. package/src/client/dist/spa/assets/QExpansionItem-DCRks-Ra.js +1 -0
  45. package/src/client/dist/spa/assets/{QSpinner-CliSLjf8.js → QIcon-B0-pH3Qs.js} +1 -1
  46. package/src/client/dist/spa/assets/QItemLabel-Codqjisk.js +1 -0
  47. package/src/client/dist/spa/assets/QItemSection-CGpX7GcL.js +1 -0
  48. package/src/client/dist/spa/assets/QList-B-MkPF7n.js +1 -0
  49. package/src/client/dist/spa/assets/QPage-yqdKDG7-.js +1 -0
  50. package/src/client/dist/spa/assets/QScrollArea-e5qTqwcb.js +1 -0
  51. package/src/client/dist/spa/assets/QSeparator-rkjCbX2M.js +1 -0
  52. package/src/client/dist/spa/assets/QSlideTransition-BQxI8l5r.js +1 -0
  53. package/src/client/dist/spa/assets/QSpace-BNr0AftG.js +1 -0
  54. package/src/client/dist/spa/assets/QSpinnerDots-DEiRooBD.js +1 -0
  55. package/src/client/dist/spa/assets/QTabPanels--6cYe2US.js +1 -0
  56. package/src/client/dist/spa/assets/QTooltip-C4CPesBX.js +1 -0
  57. package/src/client/dist/spa/assets/SearchPage-BrUbbtgI.js +1 -0
  58. package/src/client/dist/spa/assets/SettingsPage-B3elO1PX.js +1 -0
  59. package/src/client/dist/spa/assets/TouchPan-BT6phK1f.js +1 -0
  60. package/src/client/dist/spa/assets/WorkspacePage-BHl17_tY.js +4 -0
  61. package/src/client/dist/spa/assets/WorkspacePage-DPGiH02q.css +1 -0
  62. package/src/client/dist/spa/assets/build-path-tree-Bgl2q74t.js +1 -0
  63. package/src/client/dist/spa/assets/{cssMode-DMX8jq8u.js → cssMode-B1wQ-79R.js} +1 -1
  64. package/src/client/dist/spa/assets/documents-CHc8t22V.js +60 -0
  65. package/src/client/dist/spa/assets/{editor.api-DirOkGGg.js → editor.api-CRb_5Zw6.js} +1 -1
  66. package/src/client/dist/spa/assets/{editor.main-DC4ezIu0.js → editor.main-C5sdCvGW.js} +3 -3
  67. package/src/client/dist/spa/assets/format-Bttc9ToS.js +1 -0
  68. package/src/client/dist/spa/assets/{formatters-BzaS4w0I.js → formatters-BDadphwz.js} +1 -1
  69. package/src/client/dist/spa/assets/{freemarker2-DI9xJfj0.js → freemarker2-CVSnsZk-.js} +1 -1
  70. package/src/client/dist/spa/assets/{handlebars-B9F-pScn.js → handlebars-uL_pucGI.js} +1 -1
  71. package/src/client/dist/spa/assets/{html-DTe2v8Q8.js → html-CatZVwWp.js} +1 -1
  72. package/src/client/dist/spa/assets/{htmlMode-F_XLjWfJ.js → htmlMode-DTDzEngo.js} +1 -1
  73. package/src/client/dist/spa/assets/i18n-D-VdPLEh.js +1 -0
  74. package/src/client/dist/spa/assets/index-CUI-zN26.js +2 -0
  75. package/src/client/dist/spa/assets/{javascript-B9xJRPC6.js → javascript-DeHBpolA.js} +1 -1
  76. package/src/client/dist/spa/assets/{jsonMode-DTZ6j6UO.js → jsonMode-Bma_YGGm.js} +1 -1
  77. package/src/client/dist/spa/assets/{liquid-BjU5MtW6.js → liquid-CW7xQEG_.js} +1 -1
  78. package/src/client/dist/spa/assets/{mdx-BMUpG7Be.js → mdx-BsYUhMzF.js} +1 -1
  79. package/src/client/dist/spa/assets/models-BbSRHL9b.js +1 -0
  80. package/src/client/dist/spa/assets/{monaco.contribution-D7JUf8DP.js → monaco.contribution-Du0atePv.js} +2 -2
  81. package/src/client/dist/spa/assets/private.use-form-D1RuEt2P.js +1 -0
  82. package/src/client/dist/spa/assets/{python-Dz0D4uSk.js → python-D7DQWXZm.js} +1 -1
  83. package/src/client/dist/spa/assets/{razor-D7CFxuwR.js → razor-B2ZxF301.js} +1 -1
  84. package/src/client/dist/spa/assets/scroll-JVVkg2Ng.js +1 -0
  85. package/src/client/dist/spa/assets/touch-CBLrR6_z.js +1 -0
  86. package/src/client/dist/spa/assets/{tsMode-DjscaxpS.js → tsMode-B4Xul5xA.js} +1 -1
  87. package/src/client/dist/spa/assets/{typescript-DozCWZl2.js → typescript-CdsKQuLT.js} +1 -1
  88. package/src/client/dist/spa/assets/use-checkbox-DYiZQsbF.js +1 -0
  89. package/src/client/dist/spa/assets/use-id-CeduaJbU.js +1 -0
  90. package/src/client/dist/spa/assets/use-portal-DBe4lcC2.js +1 -0
  91. package/src/client/dist/spa/assets/use-quasar-Ch82z8H5.js +1 -0
  92. package/src/client/dist/spa/assets/{xml-DFOJMT39.js → xml-Wap00dMv.js} +1 -1
  93. package/src/client/dist/spa/assets/{yaml-yEefnsXm.js → yaml-BxRDHC24.js} +1 -1
  94. package/src/client/dist/spa/index.html +12 -14
  95. package/src/mcp-server/README.md +1 -1
  96. package/dist/server/routes/plans.js +0 -89
  97. package/dist/server/services/agent-manager.js +0 -621
  98. package/src/client/dist/spa/assets/ActivityFeed-0GR1zPoc.js +0 -10
  99. package/src/client/dist/spa/assets/ActivityFeed-CfsKExt9.css +0 -1
  100. package/src/client/dist/spa/assets/ClosePopup-CdSn7HO8.js +0 -1
  101. package/src/client/dist/spa/assets/CreatePage-dMi4xVYN.css +0 -1
  102. package/src/client/dist/spa/assets/CreatePage-je_7dC5I.js +0 -2
  103. package/src/client/dist/spa/assets/DiffViewer-DREYX-8k.js +0 -2
  104. package/src/client/dist/spa/assets/DiffViewer-DiHFLSk4.css +0 -1
  105. package/src/client/dist/spa/assets/HealthPage-Do8QZdxw.js +0 -1
  106. package/src/client/dist/spa/assets/MainLayout-B5poKEy_.css +0 -1
  107. package/src/client/dist/spa/assets/QBadge-Bvh-hQ8K.js +0 -1
  108. package/src/client/dist/spa/assets/QBtn-BsD8vrWq.js +0 -1
  109. package/src/client/dist/spa/assets/QDialog-CkbLS1If.js +0 -1
  110. package/src/client/dist/spa/assets/QExpansionItem-UgkE560c.js +0 -1
  111. package/src/client/dist/spa/assets/QList-D80ms7bw.js +0 -1
  112. package/src/client/dist/spa/assets/QMenu-DU-wiY_A.js +0 -1
  113. package/src/client/dist/spa/assets/QPage-BKY2-sf-.js +0 -1
  114. package/src/client/dist/spa/assets/QSpace-C5Ebr0vq.js +0 -1
  115. package/src/client/dist/spa/assets/QSpinnerDots-Dp12eHrB.js +0 -1
  116. package/src/client/dist/spa/assets/QTabPanels-C7lWp1yU.js +0 -1
  117. package/src/client/dist/spa/assets/QToggle-B0HvuNEg.js +0 -1
  118. package/src/client/dist/spa/assets/QTooltip-kLXuUa_m.js +0 -1
  119. package/src/client/dist/spa/assets/SearchPage-CCfyqBKh.js +0 -1
  120. package/src/client/dist/spa/assets/SettingsPage-CmyIsV-S.js +0 -1
  121. package/src/client/dist/spa/assets/TouchPan-CVMnGs0y.js +0 -1
  122. package/src/client/dist/spa/assets/WorkspacePage-CWRMLYs-.css +0 -1
  123. package/src/client/dist/spa/assets/WorkspacePage-Cl7YrG51.js +0 -4
  124. package/src/client/dist/spa/assets/focus-manager-DYbz9jFW.js +0 -1
  125. package/src/client/dist/spa/assets/format-Cyg8IgRi.js +0 -1
  126. package/src/client/dist/spa/assets/i18n-B13zBh1H.js +0 -1
  127. package/src/client/dist/spa/assets/i18n-CCWLBc0p.js +0 -1
  128. package/src/client/dist/spa/assets/index-DoNZ_5QK.js +0 -5
  129. package/src/client/dist/spa/assets/marked.esm-DCmk6NO8.js +0 -60
  130. package/src/client/dist/spa/assets/models-B8fzv7K4.js +0 -1
  131. package/src/client/dist/spa/assets/pinia-C3JsrLkB.js +0 -1
  132. package/src/client/dist/spa/assets/private.use-form-BhKyDtO7.js +0 -1
  133. package/src/client/dist/spa/assets/scroll-CLibRGI-.js +0 -1
  134. package/src/client/dist/spa/assets/settings-B69lIVX0.js +0 -1
  135. package/src/client/dist/spa/assets/touch-ChrvzrnI.js +0 -1
  136. package/src/client/dist/spa/assets/use-dark-DnuCB6tC.js +0 -1
  137. package/src/client/dist/spa/assets/use-quasar-DBoizHBW.js +0 -1
  138. /package/src/client/dist/spa/assets/{_plugin-vue_export-helper-Cxt1D8wE.js → _plugin-vue_export-helper-r4mAJOHR.js} +0 -0
  139. /package/src/client/dist/spa/assets/{abap-CFuyUYKP.js → abap-Bgec7Keq.js} +0 -0
  140. /package/src/client/dist/spa/assets/{apex-Ctq_xcrv.js → apex-VBlPwEoQ.js} +0 -0
  141. /package/src/client/dist/spa/assets/{azcli-BBQSVn-C.js → azcli-DKqrEFBx.js} +0 -0
  142. /package/src/client/dist/spa/assets/{bat-DbnqAfvr.js → bat-DdgQWy_0.js} +0 -0
  143. /package/src/client/dist/spa/assets/{bicep-BtDlIXop.js → bicep-CRMM43EB.js} +0 -0
  144. /package/src/client/dist/spa/assets/{cameligo-BLeJgKTj.js → cameligo-UatALtML.js} +0 -0
  145. /package/src/client/dist/spa/assets/{clojure-aZUQIUKP.js → clojure-D8JU08RA.js} +0 -0
  146. /package/src/client/dist/spa/assets/{coffee-Secadq9U.js → coffee-C56wu358.js} +0 -0
  147. /package/src/client/dist/spa/assets/{cpp-JicRPTRv.js → cpp-CyZLvhJG.js} +0 -0
  148. /package/src/client/dist/spa/assets/{csharp-C7NSOZyj.js → csharp-BJl3ixva.js} +0 -0
  149. /package/src/client/dist/spa/assets/{csp-CIje7830.js → csp-CxEKxmO-.js} +0 -0
  150. /package/src/client/dist/spa/assets/{css-G0bm1q_M.js → css-B0t_muXd.js} +0 -0
  151. /package/src/client/dist/spa/assets/{cypher-CldD5D0u.js → cypher-D1hqiMFD.js} +0 -0
  152. /package/src/client/dist/spa/assets/{dart-DIK3l8YT.js → dart-Bz550Pyv.js} +0 -0
  153. /package/src/client/dist/spa/assets/{dockerfile-czxaGh2L.js → dockerfile-CIXgVAuA.js} +0 -0
  154. /package/src/client/dist/spa/assets/{ecl-BqdYhwmw.js → ecl-D9qbvZoA.js} +0 -0
  155. /package/src/client/dist/spa/assets/{elixir-m52LePTW.js → elixir-b2M38fAy.js} +0 -0
  156. /package/src/client/dist/spa/assets/{flow9-B5QJ9GvZ.js → flow9-Dq1UYMkt.js} +0 -0
  157. /package/src/client/dist/spa/assets/{fsharp-B15czHsH.js → fsharp-CFNadkg7.js} +0 -0
  158. /package/src/client/dist/spa/assets/{go-BkoQxDo1.js → go-dSur1iB2.js} +0 -0
  159. /package/src/client/dist/spa/assets/{graphql-BnI6uRa_.js → graphql-qyhAo11d.js} +0 -0
  160. /package/src/client/dist/spa/assets/{hcl-CAwwENT7.js → hcl-DFzjMyzm.js} +0 -0
  161. /package/src/client/dist/spa/assets/{ini-BHM5zh1H.js → ini-TdzA8TIl.js} +0 -0
  162. /package/src/client/dist/spa/assets/{java-B5i95QvQ.js → java-CSGA9pkE.js} +0 -0
  163. /package/src/client/dist/spa/assets/{julia-DPDm885q.js → julia-9izz5OsY.js} +0 -0
  164. /package/src/client/dist/spa/assets/{kotlin-qoccd5BP.js → kotlin-DuPK7AtF.js} +0 -0
  165. /package/src/client/dist/spa/assets/{less-B6RU166D.js → less-B8d93iCg.js} +0 -0
  166. /package/src/client/dist/spa/assets/{lexon-YfUeoL1V.js → lexon-DWtEIyu7.js} +0 -0
  167. /package/src/client/dist/spa/assets/{lua-BIUI5y9b.js → lua-Ciq0OGgt.js} +0 -0
  168. /package/src/client/dist/spa/assets/{m3-D5SAbSdU.js → m3-Cki6JWj_.js} +0 -0
  169. /package/src/client/dist/spa/assets/{markdown-CVJLwHzJ.js → markdown-Cu47xwU0.js} +0 -0
  170. /package/src/client/dist/spa/assets/{mips-R-FZ3zOR.js → mips-BM8ui995.js} +0 -0
  171. /package/src/client/dist/spa/assets/{msdax-Blveyl9r.js → msdax-DqLio0_c.js} +0 -0
  172. /package/src/client/dist/spa/assets/{mysql-D4mY1AFx.js → mysql-v1wbjJOq.js} +0 -0
  173. /package/src/client/dist/spa/assets/{objective-c-BmXrLr4h.js → objective-c-CQl3PGSB.js} +0 -0
  174. /package/src/client/dist/spa/assets/{pascal-yxckoyvV.js → pascal-D4iW0ZtD.js} +0 -0
  175. /package/src/client/dist/spa/assets/{pascaligo-Q5JCwXMI.js → pascaligo-BdC9CZdj.js} +0 -0
  176. /package/src/client/dist/spa/assets/{perl-BF1Rrs5h.js → perl-BL10m4XD.js} +0 -0
  177. /package/src/client/dist/spa/assets/{pgsql-CnYB97wm.js → pgsql-Be_oqVo3.js} +0 -0
  178. /package/src/client/dist/spa/assets/{php-CdDfQfSg.js → php-BtvXSFRI.js} +0 -0
  179. /package/src/client/dist/spa/assets/{pla-whj-d71F.js → pla-B2vUy15C.js} +0 -0
  180. /package/src/client/dist/spa/assets/{postiats-ClfLr4I-.js → postiats-CbmTTfXr.js} +0 -0
  181. /package/src/client/dist/spa/assets/{powerquery-iRaBhuuk.js → powerquery-DszLhJGx.js} +0 -0
  182. /package/src/client/dist/spa/assets/{powershell-DjiEt5xK.js → powershell-B0dYktF6.js} +0 -0
  183. /package/src/client/dist/spa/assets/{protobuf-B6dcIEUr.js → protobuf-CZvaj1VX.js} +0 -0
  184. /package/src/client/dist/spa/assets/{pug-DtmHnjM9.js → pug-CPDx1B3S.js} +0 -0
  185. /package/src/client/dist/spa/assets/{qsharp-CELCyd79.js → qsharp-CDP9TFLl.js} +0 -0
  186. /package/src/client/dist/spa/assets/{r-ZpJXWV-o.js → r-8DbbFX2l.js} +0 -0
  187. /package/src/client/dist/spa/assets/{rate-limit-labels-dCPVjS61.js → rate-limit-labels-BoDORKFj.js} +0 -0
  188. /package/src/client/dist/spa/assets/{redis-BiHSNkAl.js → redis-DRWj9MtJ.js} +0 -0
  189. /package/src/client/dist/spa/assets/{redshift-DzuwYCHP.js → redshift-C6cElE_5.js} +0 -0
  190. /package/src/client/dist/spa/assets/{restructuredtext-YOT94bbS.js → restructuredtext-W9pS9n3m.js} +0 -0
  191. /package/src/client/dist/spa/assets/{ruby-BfiHr6Uu.js → ruby-BKnzWnk-.js} +0 -0
  192. /package/src/client/dist/spa/assets/{rust-JZ-uOoYM.js → rust-YPCclWwe.js} +0 -0
  193. /package/src/client/dist/spa/assets/{sb-CBglP1-t.js → sb-BgM4DTFb.js} +0 -0
  194. /package/src/client/dist/spa/assets/{scala-C9l41paw.js → scala-fz1OPLMl.js} +0 -0
  195. /package/src/client/dist/spa/assets/{scheme-B-InQ6hy.js → scheme-8Uz1RIbu.js} +0 -0
  196. /package/src/client/dist/spa/assets/{scss-v6OmJRN9.js → scss-Djo3IYXr.js} +0 -0
  197. /package/src/client/dist/spa/assets/{shell-Dyp6iwB6.js → shell-CINF5Tx_.js} +0 -0
  198. /package/src/client/dist/spa/assets/{solidity-D5epNWue.js → solidity-GgiNEuUm.js} +0 -0
  199. /package/src/client/dist/spa/assets/{sophia-Eva-79sB.js → sophia-Culj97P9.js} +0 -0
  200. /package/src/client/dist/spa/assets/{sparql-gvALLO1w.js → sparql-C2ZlpxOY.js} +0 -0
  201. /package/src/client/dist/spa/assets/{sql-COdamZYI.js → sql-BEf5Pg7Y.js} +0 -0
  202. /package/src/client/dist/spa/assets/{st-eMoImIwE.js → st-CT6UUoeH.js} +0 -0
  203. /package/src/client/dist/spa/assets/{swift-7R_T9RYH.js → swift-B5g0xTG3.js} +0 -0
  204. /package/src/client/dist/spa/assets/{symbols-CAg-nBkV.js → symbols-DCYodwb2.js} +0 -0
  205. /package/src/client/dist/spa/assets/{systemverilog-1pCEfaHU.js → systemverilog-CEgQz9DR.js} +0 -0
  206. /package/src/client/dist/spa/assets/{tcl-B_KgnhfE.js → tcl-D0qL2L0I.js} +0 -0
  207. /package/src/client/dist/spa/assets/{twig-CFZUJxb9.js → twig-BFUAVf1E.js} +0 -0
  208. /package/src/client/dist/spa/assets/{typespec-B1ZgHlud.js → typespec-CjVVcNKm.js} +0 -0
  209. /package/src/client/dist/spa/assets/{vb-DKdun5tL.js → vb-CZJr-DQz.js} +0 -0
  210. /package/src/client/dist/spa/assets/{vue-i18n-eUDnMrPl.js → vue-i18n-CeG0hR0Z.js} +0 -0
  211. /package/src/client/dist/spa/assets/{wgsl-CzNaxTrn.js → wgsl-ivoXUo2e.js} +0 -0
@@ -0,0 +1,582 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { nanoid } from 'nanoid';
3
+ import { getDb } from '../../db/index.js';
4
+ import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getKoboHome, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../../utils/paths.js';
5
+ import { unregisterProcess } from '../../utils/process-tracker.js';
6
+ import { getEffectiveSettings } from '../settings-service.js';
7
+ import { emitEphemeral } from '../websocket-service.js';
8
+ import { getWorkspace as getWs, markWorkspaceUnread, updateWorkspaceStatus } from '../workspace-service.js';
9
+ import { resolveEngine } from './engines/registry.js';
10
+ import { routeEvent } from './event-router.js';
11
+ import { SessionController } from './session-controller.js';
12
+ // ── State ──────────────────────────────────────────────────────────────────────
13
+ /** Actual bound port of the running backend — set at startup via setBackendPort() */
14
+ let backendPort = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000;
15
+ /** Called from index.ts once the HTTP server is listening so MCP children can reach it. */
16
+ export function setBackendPort(port) {
17
+ backendPort = port;
18
+ }
19
+ /** workspaceId -> SessionController */
20
+ const controllers = new Map();
21
+ /** workspaceId -> last engine session ID (for resume) */
22
+ const sessionIds = new Map();
23
+ /** Cached list of available slash commands — persisted to <KOBO_HOME>/skills.json */
24
+ let availableSkills = (() => {
25
+ try {
26
+ const data = JSON.parse(readFileSync(getSkillsPath(), 'utf-8'));
27
+ return Array.isArray(data) ? data : [];
28
+ }
29
+ catch {
30
+ return [];
31
+ }
32
+ })();
33
+ /** workspaceId -> retry count (for quota backoff) */
34
+ const retryCounts = new Map();
35
+ /** workspaceId -> backoff timer */
36
+ const backoffTimers = new Map();
37
+ // ── Watchdog ──────────────────────────────────────────────────────────────────
38
+ const WATCHDOG_INTERVAL_MS = 30_000;
39
+ let watchdogTimer = null;
40
+ function isProcessAlive(pid) {
41
+ try {
42
+ process.kill(pid, 0);
43
+ return true;
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ function runWatchdog() {
50
+ for (const [workspaceId, ctrl] of controllers) {
51
+ const pid = ctrl.pid;
52
+ if (pid === undefined || isProcessAlive(pid))
53
+ continue;
54
+ console.error(`[watchdog] Agent process for workspace '${workspaceId}' (PID ${pid}) is dead — cleaning up`);
55
+ // Emit an error + session:ended AgentEvent pair so clients can react uniformly
56
+ try {
57
+ routeEvent(workspaceId, ctrl.agentSessionId, {
58
+ kind: 'error',
59
+ category: 'other',
60
+ message: 'Agent process died unexpectedly',
61
+ });
62
+ routeEvent(workspaceId, ctrl.agentSessionId, {
63
+ kind: 'session:ended',
64
+ reason: 'killed',
65
+ exitCode: null,
66
+ });
67
+ }
68
+ catch (err) {
69
+ console.warn('[watchdog] Failed to route death notification events:', err);
70
+ }
71
+ unregisterProcess(workspaceId);
72
+ if (controllers.get(workspaceId) === ctrl)
73
+ controllers.delete(workspaceId);
74
+ retryCounts.delete(workspaceId);
75
+ try {
76
+ const db = getDb();
77
+ db.prepare('UPDATE agent_sessions SET status = ?, ended_at = ? WHERE id = ?').run('error', new Date().toISOString(), ctrl.agentSessionId);
78
+ }
79
+ catch (err) {
80
+ console.error('[watchdog] Failed to update agent_sessions:', err);
81
+ }
82
+ try {
83
+ updateWorkspaceStatus(workspaceId, 'error');
84
+ }
85
+ catch (err) {
86
+ console.warn('[watchdog] Failed to transition workspace to error (likely invalid transition):', err);
87
+ }
88
+ try {
89
+ markWorkspaceUnread(workspaceId);
90
+ emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
91
+ }
92
+ catch (err) {
93
+ console.warn('[watchdog] Failed to mark workspace unread:', err);
94
+ }
95
+ }
96
+ }
97
+ /**
98
+ * On server start, any `agent_sessions` row still in `running` status is
99
+ * necessarily orphaned — the process that owned it died with the previous
100
+ * server run. Mark those rows as `error` (or `completed` if the PID is
101
+ * somehow still alive and reachable) so the health check stops complaining
102
+ * and the UI doesn't show ghost agents.
103
+ *
104
+ * Called once at boot, BEFORE `startWatchdog`.
105
+ */
106
+ export function reconcileOrphanSessions() {
107
+ try {
108
+ const db = getDb();
109
+ const rows = db.prepare("SELECT id, pid FROM agent_sessions WHERE status = 'running'").all();
110
+ if (rows.length === 0)
111
+ return;
112
+ const now = new Date().toISOString();
113
+ const update = db.prepare("UPDATE agent_sessions SET status = 'error', ended_at = ? WHERE id = ?");
114
+ let fixed = 0;
115
+ for (const row of rows) {
116
+ if (row.pid && isProcessAlive(row.pid))
117
+ continue; // genuine leftover from a graceful restart — skip
118
+ update.run(now, row.id);
119
+ fixed++;
120
+ }
121
+ if (fixed > 0) {
122
+ console.log(`[orchestrator] Reconciled ${fixed} orphan agent_sessions row(s) at boot.`);
123
+ }
124
+ }
125
+ catch (err) {
126
+ console.error('[orchestrator] Failed to reconcile orphan agent_sessions at boot:', err);
127
+ }
128
+ }
129
+ /** Start the watchdog (called once from server bootstrap). */
130
+ export function startWatchdog() {
131
+ if (watchdogTimer)
132
+ return;
133
+ watchdogTimer = setInterval(runWatchdog, WATCHDOG_INTERVAL_MS);
134
+ watchdogTimer.unref?.();
135
+ }
136
+ /** Stop the watchdog (for clean shutdown / tests). */
137
+ export function stopWatchdog() {
138
+ if (watchdogTimer) {
139
+ clearInterval(watchdogTimer);
140
+ watchdogTimer = null;
141
+ }
142
+ }
143
+ // ── Engine + settings helpers ─────────────────────────────────────────────────
144
+ function readWorkspaceEngineId(workspaceId) {
145
+ const db = getDb();
146
+ try {
147
+ const row = db
148
+ .prepare('SELECT engine FROM workspaces WHERE id = ?')
149
+ .get(workspaceId);
150
+ return row?.engine ?? 'claude-code';
151
+ }
152
+ catch (err) {
153
+ // Guard against a test DB or mid-migration DB where the column doesn't
154
+ // exist yet. Only treat "no such column" as a benign fallback; every
155
+ // other DB error propagates so we don't silently mask real failures.
156
+ const message = err instanceof Error ? err.message : String(err);
157
+ if (message.includes('no such column: engine')) {
158
+ console.warn(`[orchestrator] 'engine' column missing on workspaces, defaulting to claude-code`);
159
+ return 'claude-code';
160
+ }
161
+ throw err;
162
+ }
163
+ }
164
+ function readEffectiveSettingsSafe(projectPath) {
165
+ try {
166
+ return getEffectiveSettings(projectPath);
167
+ }
168
+ catch (err) {
169
+ console.warn('[orchestrator] Failed to load settings, using defaults:', err);
170
+ return {
171
+ model: 'claude-opus-4-7',
172
+ dangerouslySkipPermissions: true,
173
+ prPromptTemplate: '',
174
+ gitConventions: '',
175
+ sourceBranch: 'main',
176
+ devServer: null,
177
+ setupScript: '',
178
+ notionStatusProperty: '',
179
+ notionInProgressStatus: '',
180
+ };
181
+ }
182
+ }
183
+ function buildMcpServers(workspaceId) {
184
+ const mcpServerCompiled = getCompiledMcpServerPath();
185
+ const mcpServerSource = getMcpServerSourcePath();
186
+ return [
187
+ {
188
+ name: 'kobo-tasks',
189
+ command: mcpServerCompiled ? 'node' : 'npx',
190
+ args: mcpServerCompiled ? [mcpServerCompiled] : ['tsx', mcpServerSource],
191
+ env: {
192
+ KOBO_WORKSPACE_ID: workspaceId,
193
+ KOBO_DB_PATH: getDbPath(),
194
+ KOBO_SETTINGS_PATH: getSettingsPath(),
195
+ KOBO_BACKEND_URL: `http://127.0.0.1:${backendPort}`,
196
+ },
197
+ },
198
+ ];
199
+ }
200
+ function resolveSessionForResume(workspaceId, existingSessionId) {
201
+ const db = getDb();
202
+ let lastSession;
203
+ if (existingSessionId) {
204
+ lastSession = db
205
+ .prepare('SELECT id, engine_session_id FROM agent_sessions WHERE id = ? AND workspace_id = ? AND engine_session_id IS NOT NULL LIMIT 1')
206
+ .get(existingSessionId, workspaceId);
207
+ if (!lastSession) {
208
+ throw new Error(`Cannot resume session '${existingSessionId}' for workspace '${workspaceId}': ` +
209
+ 'session not found or has no associated engine conversation');
210
+ }
211
+ }
212
+ else {
213
+ lastSession = db
214
+ .prepare('SELECT id, engine_session_id FROM agent_sessions WHERE workspace_id = ? AND engine_session_id IS NOT NULL ORDER BY started_at DESC LIMIT 1')
215
+ .get(workspaceId);
216
+ }
217
+ const engineSessionId = lastSession?.engine_session_id ?? (existingSessionId ? undefined : sessionIds.get(workspaceId));
218
+ if (engineSessionId) {
219
+ const existingId = lastSession?.id ??
220
+ db
221
+ .prepare('SELECT id FROM agent_sessions WHERE engine_session_id = ? ORDER BY started_at DESC LIMIT 1')
222
+ .get(engineSessionId)?.id;
223
+ const agentSessionId = existingId ?? nanoid();
224
+ if (existingId) {
225
+ db.prepare('UPDATE agent_sessions SET status = ?, ended_at = NULL WHERE id = ?').run('running', agentSessionId);
226
+ }
227
+ else {
228
+ db.prepare('INSERT INTO agent_sessions (id, workspace_id, pid, status, engine_session_id, started_at) VALUES (?, ?, ?, ?, ?, ?)').run(agentSessionId, workspaceId, null, 'running', engineSessionId, new Date().toISOString());
229
+ }
230
+ return { agentSessionId, engineSessionId, existed: Boolean(existingId) };
231
+ }
232
+ // No engine session to resume — fall through to fresh session creation
233
+ const agentSessionId = nanoid();
234
+ db.prepare('INSERT INTO agent_sessions (id, workspace_id, pid, status, started_at) VALUES (?, ?, ?, ?, ?)').run(agentSessionId, workspaceId, null, 'running', new Date().toISOString());
235
+ return { agentSessionId, engineSessionId: undefined, existed: false };
236
+ }
237
+ function reuseOrCreateFreshSession(workspaceId, existingSessionId) {
238
+ const db = getDb();
239
+ if (existingSessionId) {
240
+ const result = db
241
+ .prepare('UPDATE agent_sessions SET status = ?, started_at = ?, ended_at = NULL WHERE id = ? AND workspace_id = ?')
242
+ .run('running', new Date().toISOString(), existingSessionId, workspaceId);
243
+ if (result.changes === 0) {
244
+ throw new Error(`Agent session '${existingSessionId}' not found for workspace '${workspaceId}'`);
245
+ }
246
+ return existingSessionId;
247
+ }
248
+ const agentSessionId = nanoid();
249
+ db.prepare('INSERT INTO agent_sessions (id, workspace_id, pid, status, started_at) VALUES (?, ?, ?, ?, ?)').run(agentSessionId, workspaceId, null, 'running', new Date().toISOString());
250
+ return agentSessionId;
251
+ }
252
+ // ── Event handler ─────────────────────────────────────────────────────────────
253
+ function handleEvent(workspaceId, agentSessionId, ev) {
254
+ routeEvent(workspaceId, agentSessionId, ev);
255
+ if (ev.kind === 'skills:discovered') {
256
+ availableSkills = ev.skills;
257
+ try {
258
+ ensureKoboHome();
259
+ writeFileSync(getSkillsPath(), JSON.stringify(availableSkills));
260
+ }
261
+ catch (err) {
262
+ console.error('[orchestrator] Failed to persist skills:', err);
263
+ }
264
+ }
265
+ if (ev.kind === 'session:brainstorm-complete') {
266
+ try {
267
+ updateWorkspaceStatus(workspaceId, 'executing');
268
+ }
269
+ catch (err) {
270
+ console.error('[orchestrator] Failed to transition to executing:', err);
271
+ }
272
+ }
273
+ if (ev.kind === 'error' && ev.category === 'quota') {
274
+ handleQuota(workspaceId, agentSessionId);
275
+ }
276
+ if (ev.kind === 'session:ended') {
277
+ onSessionEnded(workspaceId, agentSessionId, ev.exitCode);
278
+ }
279
+ if (ev.kind === 'session:started' && ev.engineSessionId) {
280
+ sessionIds.set(workspaceId, ev.engineSessionId);
281
+ try {
282
+ const db = getDb();
283
+ db.prepare('UPDATE agent_sessions SET engine_session_id = ? WHERE id = ?').run(ev.engineSessionId, agentSessionId);
284
+ }
285
+ catch (err) {
286
+ console.error('[orchestrator] Failed to persist engine session id:', err);
287
+ }
288
+ // The workspace must be in an active status while the agent is
289
+ // running — otherwise the frontend's `sessionActive` check stays
290
+ // false and streaming messages render without the "typing" spinner.
291
+ // Transition from a terminal state (completed/idle/error/quota) to
292
+ // executing so the UI reflects that a new turn is happening.
293
+ try {
294
+ const ws = getWs(workspaceId);
295
+ if (ws && (ws.status === 'completed' || ws.status === 'idle' || ws.status === 'error' || ws.status === 'quota')) {
296
+ updateWorkspaceStatus(workspaceId, 'executing');
297
+ }
298
+ }
299
+ catch (err) {
300
+ // Transition may be invalid for some edge states — best-effort.
301
+ console.warn('[orchestrator] Could not transition workspace to executing on session:started:', err);
302
+ }
303
+ }
304
+ }
305
+ function onSessionEnded(workspaceId, agentSessionId, exitCode) {
306
+ const ctrl = controllers.get(workspaceId);
307
+ const wasStopping = ctrl?.status === 'stopping';
308
+ // Identity-preserving cleanup: only remove the controller if the map still
309
+ // points to this exact instance (a new controller may have been started in
310
+ // the meantime via stop-then-start).
311
+ if (ctrl && controllers.get(workspaceId) === ctrl) {
312
+ controllers.delete(workspaceId);
313
+ }
314
+ unregisterProcess(workspaceId);
315
+ retryCounts.delete(workspaceId);
316
+ // Update the agent_sessions row
317
+ try {
318
+ const db = getDb();
319
+ db.prepare('UPDATE agent_sessions SET status = ?, ended_at = ? WHERE id = ?').run(exitCode === 0 ? 'completed' : 'error', new Date().toISOString(), agentSessionId);
320
+ }
321
+ catch (err) {
322
+ console.error('[orchestrator] Failed to update agent_sessions on exit:', err);
323
+ }
324
+ if (wasStopping) {
325
+ // session:ended with reason='killed' already emitted by the engine covers
326
+ // the "stopped" status. No legacy emit needed.
327
+ return;
328
+ }
329
+ // Clear any pending backoff timer on non-stopping exits
330
+ const pendingBackoff = backoffTimers.get(workspaceId);
331
+ if (pendingBackoff) {
332
+ clearTimeout(pendingBackoff);
333
+ backoffTimers.delete(workspaceId);
334
+ }
335
+ if (exitCode !== null && exitCode !== 0) {
336
+ try {
337
+ updateWorkspaceStatus(workspaceId, 'error');
338
+ }
339
+ catch (err) {
340
+ console.error('[orchestrator] Failed to update workspace status on exit:', err);
341
+ }
342
+ try {
343
+ markWorkspaceUnread(workspaceId);
344
+ emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
345
+ }
346
+ catch {
347
+ // best-effort
348
+ }
349
+ }
350
+ else {
351
+ try {
352
+ updateWorkspaceStatus(workspaceId, 'completed');
353
+ }
354
+ catch (err) {
355
+ console.error('[orchestrator] Failed to update workspace status on exit:', err);
356
+ }
357
+ try {
358
+ markWorkspaceUnread(workspaceId);
359
+ emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
360
+ }
361
+ catch {
362
+ // best-effort
363
+ }
364
+ }
365
+ }
366
+ // ── Public API ────────────────────────────────────────────────────────────────
367
+ /**
368
+ * Spawn an agent (via the resolved engine) for a workspace. Returns
369
+ * synchronously with the DB agent session id. The PID becomes available only
370
+ * after `engine.start` resolves — callers should subscribe to WS events or
371
+ * query the controller via `_getControllers()` for tests.
372
+ */
373
+ export function startAgent(workspaceId, workingDir, prompt, model, resume = false, permissionMode = 'auto-accept', existingSessionId, reasoningEffort) {
374
+ if (controllers.has(workspaceId)) {
375
+ throw new Error(`Agent already running for workspace '${workspaceId}'`);
376
+ }
377
+ const ws = getWs(workspaceId);
378
+ const engineId = readWorkspaceEngineId(workspaceId);
379
+ const engine = resolveEngine(engineId);
380
+ let agentSessionId;
381
+ let resumeFromEngineSessionId;
382
+ // Note: plan-mode prompt prefixing is an engine-specific concern handled by
383
+ // the Claude Code engine's args-builder. Do NOT prepend it here — that would
384
+ // double-prepend the marker when the engine applies its own prefix.
385
+ if (resume) {
386
+ const r = resolveSessionForResume(workspaceId, existingSessionId);
387
+ agentSessionId = r.agentSessionId;
388
+ resumeFromEngineSessionId = r.engineSessionId;
389
+ }
390
+ else {
391
+ agentSessionId = reuseOrCreateFreshSession(workspaceId, existingSessionId);
392
+ }
393
+ const settings = ws ? readEffectiveSettingsSafe(ws.projectPath) : readEffectiveSettingsSafe(workingDir);
394
+ const options = {
395
+ workspaceId,
396
+ workingDir,
397
+ prompt,
398
+ model,
399
+ effort: reasoningEffort,
400
+ permissionMode,
401
+ resumeFromEngineSessionId,
402
+ backendUrl: `http://127.0.0.1:${backendPort}`,
403
+ koboHome: (() => {
404
+ try {
405
+ return getKoboHome();
406
+ }
407
+ catch {
408
+ return '';
409
+ }
410
+ })(),
411
+ settings,
412
+ mcpServers: buildMcpServers(workspaceId),
413
+ };
414
+ const controller = new SessionController(workspaceId, agentSessionId, engine, (ev) => handleEvent(workspaceId, agentSessionId, ev));
415
+ controllers.set(workspaceId, controller);
416
+ // "Agent running" is signalled via the engine's session:started event.
417
+ // The legacy `agent:status { status: 'executing' }` emit is gone.
418
+ // Kick off engine.start asynchronously. Errors surface as error events.
419
+ void controller
420
+ .start(options)
421
+ .then(() => {
422
+ const pid = controller.pid;
423
+ if (pid !== undefined) {
424
+ try {
425
+ const db = getDb();
426
+ db.prepare('UPDATE agent_sessions SET pid = ? WHERE id = ?').run(pid, agentSessionId);
427
+ }
428
+ catch (err) {
429
+ console.error('[orchestrator] Failed to update pid:', err);
430
+ }
431
+ }
432
+ })
433
+ .catch((err) => {
434
+ console.error('[orchestrator] engine.start failed:', err);
435
+ const message = err instanceof Error ? err.message : String(err);
436
+ handleEvent(workspaceId, agentSessionId, {
437
+ kind: 'error',
438
+ category: 'spawn_failed',
439
+ message,
440
+ });
441
+ handleEvent(workspaceId, agentSessionId, {
442
+ kind: 'session:ended',
443
+ reason: 'error',
444
+ exitCode: null,
445
+ });
446
+ });
447
+ return { agentSessionId, pid: undefined };
448
+ }
449
+ /**
450
+ * Soft-interrupt the running agent by sending SIGINT. The session remains
451
+ * alive — the current tool call is aborted and the agent waits for the next
452
+ * user message.
453
+ */
454
+ export function interruptAgent(workspaceId) {
455
+ const ctrl = controllers.get(workspaceId);
456
+ if (!ctrl) {
457
+ throw new Error(`No agent running for workspace '${workspaceId}'`);
458
+ }
459
+ try {
460
+ ctrl.interrupt();
461
+ }
462
+ catch (err) {
463
+ const message = err instanceof Error ? err.message : String(err);
464
+ throw new Error(`Failed to interrupt agent for workspace '${workspaceId}': ${message}`);
465
+ }
466
+ }
467
+ /** Gracefully stop an agent (the engine handles SIGTERM + SIGKILL). */
468
+ export function stopAgent(workspaceId) {
469
+ const ctrl = controllers.get(workspaceId);
470
+ if (!ctrl) {
471
+ throw new Error(`No agent running for workspace '${workspaceId}'`);
472
+ }
473
+ // Remove from the map immediately so startAgent can proceed right away.
474
+ // The session:ended handler checks identity before removing, so a new
475
+ // controller started in the meantime is preserved.
476
+ controllers.delete(workspaceId);
477
+ const timer = backoffTimers.get(workspaceId);
478
+ if (timer) {
479
+ clearTimeout(timer);
480
+ backoffTimers.delete(workspaceId);
481
+ }
482
+ // Fire-and-forget: controller.stop is async but we don't block callers.
483
+ void ctrl.stop().catch((err) => {
484
+ console.error('[orchestrator] controller.stop failed:', err);
485
+ });
486
+ }
487
+ /** Write a user message to the running agent. */
488
+ export function sendMessage(workspaceId, content) {
489
+ const ctrl = controllers.get(workspaceId);
490
+ if (!ctrl) {
491
+ throw new Error(`No agent running for workspace '${workspaceId}'`);
492
+ }
493
+ ctrl.sendMessage(content);
494
+ }
495
+ /** In-memory status of the agent for a workspace, or null if not running. */
496
+ export function getAgentStatus(workspaceId) {
497
+ return controllers.get(workspaceId)?.status ?? null;
498
+ }
499
+ /** Number of currently running controllers. */
500
+ export function getRunningCount() {
501
+ return controllers.size;
502
+ }
503
+ /** Kobo built-in slash commands injected into the skill list (without leading /). */
504
+ const KOBO_COMMANDS = ['kobo-check-progress'];
505
+ /** Cached list of slash commands discovered from the last agent init, plus Kobo built-ins. */
506
+ export function getAvailableSkills() {
507
+ return [...KOBO_COMMANDS, ...availableSkills];
508
+ }
509
+ // ── Quota handling ────────────────────────────────────────────────────────────
510
+ function handleQuota(workspaceId, _agentSessionId) {
511
+ try {
512
+ updateWorkspaceStatus(workspaceId, 'quota');
513
+ }
514
+ catch {
515
+ // May fail if transition is not valid
516
+ }
517
+ // The quota state is already signalled by the `error { category: 'quota' }`
518
+ // AgentEvent that triggered this handler. No legacy `agent:status { quota }`
519
+ // emit needed.
520
+ // 15min first, then 30min, then 60min cap
521
+ const retryCount = retryCounts.get(workspaceId) ?? 0;
522
+ const backoffMinutes = Math.min(15 * 2 ** retryCount, 60);
523
+ const backoffMs = backoffMinutes * 60 * 1000;
524
+ retryCounts.set(workspaceId, retryCount + 1);
525
+ // Surface the backoff schedule as an ephemeral event so the UI can display
526
+ // retry count / wait time without polluting the persistent event log.
527
+ emitEphemeral(workspaceId, 'agent:quota-backoff', {
528
+ retryCount: retryCount + 1,
529
+ backoffMinutes,
530
+ });
531
+ const timer = setTimeout(() => {
532
+ backoffTimers.delete(workspaceId);
533
+ if (!controllers.has(workspaceId)) {
534
+ const freshWs = getWs(workspaceId);
535
+ if (!freshWs || freshWs.archivedAt !== null || freshWs.status !== 'quota') {
536
+ return;
537
+ }
538
+ try {
539
+ const freshWorkingDir = `${freshWs.projectPath}/.worktrees/${freshWs.workingBranch}`;
540
+ startAgent(workspaceId, freshWorkingDir, 'Continue the previous task where you left off.', undefined, true);
541
+ }
542
+ catch (err) {
543
+ console.error(`[orchestrator] Quota retry for workspace '${workspaceId}' failed:`, err);
544
+ const msg = err instanceof Error ? err.message : String(err);
545
+ try {
546
+ updateWorkspaceStatus(workspaceId, 'error');
547
+ }
548
+ catch {
549
+ // transition may not be valid
550
+ }
551
+ routeEvent(workspaceId, '', {
552
+ kind: 'error',
553
+ category: 'other',
554
+ message: `Quota retry failed: ${msg}`,
555
+ });
556
+ }
557
+ }
558
+ }, backoffMs);
559
+ timer.unref?.();
560
+ backoffTimers.set(workspaceId, timer);
561
+ }
562
+ // ── Testing utilities ─────────────────────────────────────────────────────────
563
+ /** @internal test-only */
564
+ export function _getControllers() {
565
+ return controllers;
566
+ }
567
+ /** @internal test-only */
568
+ export function _getRetryCounts() {
569
+ return retryCounts;
570
+ }
571
+ /** @internal test-only */
572
+ export function _getBackoffTimers() {
573
+ return backoffTimers;
574
+ }
575
+ /** @internal test-only */
576
+ export function _getSessionIds() {
577
+ return sessionIds;
578
+ }
579
+ /** @internal test-only — runs a single watchdog sweep synchronously. */
580
+ export function _runWatchdogForTest() {
581
+ runWatchdog();
582
+ }
@@ -0,0 +1,79 @@
1
+ import { getWorkspace, listTasks } from '../workspace-service.js';
2
+ export class SessionController {
3
+ workspaceId;
4
+ agentSessionId;
5
+ engine;
6
+ onEvent;
7
+ engineProcess;
8
+ _status = 'running';
9
+ constructor(workspaceId, agentSessionId, engine, onEvent) {
10
+ this.workspaceId = workspaceId;
11
+ this.agentSessionId = agentSessionId;
12
+ this.engine = engine;
13
+ this.onEvent = onEvent;
14
+ }
15
+ async start(options) {
16
+ if (this.engineProcess)
17
+ throw new Error('SessionController already started');
18
+ this.engineProcess = await this.engine.start(options, (ev) => this.handle(ev));
19
+ this._status = 'running';
20
+ }
21
+ sendMessage(content) {
22
+ if (!this.engineProcess)
23
+ throw new Error('SessionController not started');
24
+ this.engineProcess.sendMessage(content);
25
+ }
26
+ interrupt() {
27
+ if (!this.engineProcess)
28
+ throw new Error('SessionController not started');
29
+ this.engineProcess.interrupt();
30
+ }
31
+ async stop() {
32
+ this._status = 'stopping';
33
+ if (this.engineProcess)
34
+ await this.engineProcess.stop();
35
+ }
36
+ get status() {
37
+ return this._status;
38
+ }
39
+ get pid() {
40
+ return this.engineProcess?.pid;
41
+ }
42
+ get engineSessionId() {
43
+ return this.engineProcess?.engineSessionId;
44
+ }
45
+ handle(ev) {
46
+ if (ev.kind === 'session:compacted') {
47
+ try {
48
+ this.injectPostCompactReminder();
49
+ }
50
+ catch (err) {
51
+ console.error('[session-controller] post-compact reminder failed:', err);
52
+ }
53
+ }
54
+ this.onEvent(ev);
55
+ }
56
+ injectPostCompactReminder() {
57
+ if (!this.engineProcess)
58
+ return;
59
+ const ws = getWorkspace(this.workspaceId);
60
+ const tasks = listTasks(this.workspaceId);
61
+ const criteria = tasks.filter((t) => t.isAcceptanceCriterion);
62
+ const todos = tasks.filter((t) => !t.isAcceptanceCriterion);
63
+ if (criteria.length === 0 && todos.length === 0)
64
+ return;
65
+ let reminder = `\n--- Context reminder after compaction ---\n`;
66
+ reminder += `Task: ${ws?.name ?? this.workspaceId}\n`;
67
+ if (todos.length > 0) {
68
+ reminder += `\nTasks:\n${todos.map((t) => `- [${t.status === 'done' ? 'x' : ' '}] ${t.title}`).join('\n')}\n`;
69
+ }
70
+ if (criteria.length > 0) {
71
+ reminder += `\nAcceptance criteria:\n${criteria
72
+ .map((t) => `- [${t.status === 'done' ? 'x' : ' '}] ${t.title}`)
73
+ .join('\n')}\n`;
74
+ reminder += `\nWhen you complete a criterion, tell me which one so I can mark it as done.\n`;
75
+ }
76
+ reminder += `--- End of reminder ---\n`;
77
+ this.engineProcess.sendMessage(reminder);
78
+ }
79
+ }