@loicngr/kobo 1.7.34 → 1.8.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 (205) hide show
  1. package/AGENTS.md +53 -52
  2. package/CHANGELOG.md +12 -1
  3. package/README.md +59 -37
  4. package/dist/mcp-server/kobo-tasks-server.js +4 -0
  5. package/dist/server/index.js +39 -1
  6. package/dist/server/middleware/network-auth-middleware.js +27 -0
  7. package/dist/server/routes/changelog.js +1 -1
  8. package/dist/server/routes/settings.js +49 -0
  9. package/dist/server/routes/workspaces.js +28 -9
  10. package/dist/server/services/agent/engines/claude-code/engine.js +37 -4
  11. package/dist/server/services/agent/engines/claude-code/stop-hook.js +56 -0
  12. package/dist/server/services/agent/orchestrator.js +4 -0
  13. package/dist/server/services/cron-service.js +12 -6
  14. package/dist/server/services/network-access-service.js +67 -0
  15. package/dist/server/services/settings-service.js +50 -1
  16. package/dist/server/services/templates-service.js +80 -11
  17. package/dist/server/services/wakeup-service.js +9 -0
  18. package/dist/server/services/worktree-purge-service.js +2 -2
  19. package/dist/server/services/worktree-service.js +64 -2
  20. package/package.json +7 -7
  21. package/src/client/dist/spa/assets/ActivityFeed-qE7kgNNI.js +8 -0
  22. package/src/client/dist/spa/assets/ChangelogPage-P-cSkc52.js +1 -0
  23. package/src/client/dist/spa/assets/ClosePopup-BWi4AXm0.js +1 -0
  24. package/src/client/dist/spa/assets/CreatePage-CdeIZxPs.js +2 -0
  25. package/src/client/dist/spa/assets/DiffViewer-aEMR55x8.js +8 -0
  26. package/src/client/dist/spa/assets/HealthPage-Oq6n1qu0.js +1 -0
  27. package/src/client/dist/spa/assets/{MainLayout-DtTxmFXf.css → MainLayout-DXz5Vnxf.css} +1 -1
  28. package/src/client/dist/spa/assets/MainLayout-y1VMkOwI.js +37 -0
  29. package/src/client/dist/spa/assets/{QBadge-CIC5n8w7.js → QBadge-DoPfOZ_x.js} +1 -1
  30. package/src/client/dist/spa/assets/{QBanner-BxBEdhfp.js → QBanner-C5hABcB2.js} +1 -1
  31. package/src/client/dist/spa/assets/QChip-xeL4Nxy_.js +1 -0
  32. package/src/client/dist/spa/assets/QExpansionItem-QIQTvw7U.js +1 -0
  33. package/src/client/dist/spa/assets/{QIcon-C6C3QeM4.js → QIcon-CfOEsLsF.js} +1 -1
  34. package/src/client/dist/spa/assets/QInput-7G0OXYP-.js +1 -0
  35. package/src/client/dist/spa/assets/{QList-DRW_oyZ4.js → QList-kGlJAb-p.js} +1 -1
  36. package/src/client/dist/spa/assets/{QPage-C6xc9fOe.js → QPage-B-ixPPKx.js} +1 -1
  37. package/src/client/dist/spa/assets/QScrollArea-cWkysQ9Q.js +1 -0
  38. package/src/client/dist/spa/assets/QSelect-PP69fnCX.js +36 -0
  39. package/src/client/dist/spa/assets/{use-id-By86THzm.js → QSeparator-Vt0kN1I8.js} +1 -1
  40. package/src/client/dist/spa/assets/QSpace-e68xsYE1.js +1 -0
  41. package/src/client/dist/spa/assets/{QSpinnerDots-atHe_AUn.js → QSpinnerDots-BOryc_9y.js} +1 -1
  42. package/src/client/dist/spa/assets/QTooltip-Brh5sChu.js +1 -0
  43. package/src/client/dist/spa/assets/SearchPage-BcXygFyE.js +1 -0
  44. package/src/client/dist/spa/assets/SettingsPage-Co97HPUU.js +16 -0
  45. package/src/client/dist/spa/assets/SettingsPage-DfYhuzv0.css +1 -0
  46. package/src/client/dist/spa/assets/TouchPan-BfqX_pZK.js +1 -0
  47. package/src/client/dist/spa/assets/{WorkspacePage-36QGRRCt.css → WorkspacePage-Bo6aKwl_.css} +1 -1
  48. package/src/client/dist/spa/assets/WorkspacePage-e-ktRS-M.js +4 -0
  49. package/src/client/dist/spa/assets/build-path-tree-D7rqJH7g.js +1 -0
  50. package/src/client/dist/spa/assets/chunk-QTnfLwEv.js +1 -0
  51. package/src/client/dist/spa/assets/{css.worker-BtW9exzf.js → css.worker-CBlhdqTa.js} +31 -31
  52. package/src/client/dist/spa/assets/{cssMode-CdNIff6x.js → cssMode-DwJTukMi.js} +1 -1
  53. package/src/client/dist/spa/assets/documents-eaoEREpp.js +1 -0
  54. package/src/client/dist/spa/assets/{editor.api2-CEAFHkwY.js → editor.api2-CCQ74UFa.js} +163 -163
  55. package/src/client/dist/spa/assets/{editor.main-DuQa2C4S.js → editor.main-sdLzVGjZ.js} +2 -2
  56. package/src/client/dist/spa/assets/editor.worker-Cu3tR8iJ.js +26 -0
  57. package/src/client/dist/spa/assets/expand-template-DTSWVfFm.js +1 -0
  58. package/src/client/dist/spa/assets/formatters-DnqeH9c3.js +1 -0
  59. package/src/client/dist/spa/assets/{freemarker2-DXz2Sr_a.js → freemarker2-DnJdVbFY.js} +1 -1
  60. package/src/client/dist/spa/assets/{handlebars-B6DHJ8jd.js → handlebars-CQ9FsB3q.js} +1 -1
  61. package/src/client/dist/spa/assets/{html-_UvZHIUo.js → html-DbwSidb2.js} +1 -1
  62. package/src/client/dist/spa/assets/{html.worker-Dg1SpGQ4.js → html.worker-BKBrNna1.js} +24 -24
  63. package/src/client/dist/spa/assets/{htmlMode-DZ0ixGc6.js → htmlMode-TsbX8xv5.js} +1 -1
  64. package/src/client/dist/spa/assets/i18n-CrwtYRcs.js +1 -0
  65. package/src/client/dist/spa/assets/index-UPQqj74q.js +84 -0
  66. package/src/client/dist/spa/assets/{javascript-C6Gf4Ys2.js → javascript-CdAxNn6v.js} +1 -1
  67. package/src/client/dist/spa/assets/json.worker-BaLYt9Ss.js +58 -0
  68. package/src/client/dist/spa/assets/{jsonMode-aE8EPidy.js → jsonMode-DsX17jzq.js} +1 -1
  69. package/src/client/dist/spa/assets/kobo-commands-Cu30N0Ht.js +9 -0
  70. package/src/client/dist/spa/assets/layout-DOF1L6Vf.js +1 -0
  71. package/src/client/dist/spa/assets/{liquid-CSp27lUC.js → liquid-Bc7-UBG5.js} +1 -1
  72. package/src/client/dist/spa/assets/{lspLanguageFeatures-UxO-LpRp.js → lspLanguageFeatures-yZgUujPG.js} +1 -1
  73. package/src/client/dist/spa/assets/{mdx-BSCEgQSy.js → mdx-BvDiub4z.js} +1 -1
  74. package/src/client/dist/spa/assets/{monaco.contribution-DqqsS1kc.js → monaco.contribution-CFTqxJMP.js} +2 -2
  75. package/src/client/dist/spa/assets/network-auth-DtdN0iy_.js +1 -0
  76. package/src/client/dist/spa/assets/{permissionModes-CJN6Olox.js → permissionModes-CUZkcBev.js} +1 -1
  77. package/src/client/dist/spa/assets/{python-CA5lSk1U.js → python-DZZeQhwZ.js} +1 -1
  78. package/src/client/dist/spa/assets/{razor-BznM4hXD.js → razor-RaxEa4MG.js} +1 -1
  79. package/src/client/dist/spa/assets/render-chat-markdown-D1XyjSW1.js +66 -0
  80. package/src/client/dist/spa/assets/{ts.worker-DI5g4t5j.js → ts.worker-PmaSgaZk.js} +185 -170
  81. package/src/client/dist/spa/assets/{tsMode-NbGeOvoN.js → tsMode-C6gZAb22.js} +1 -1
  82. package/src/client/dist/spa/assets/{typescript-kHDph5jb.js → typescript-BCAqEI1V.js} +1 -1
  83. package/src/client/dist/spa/assets/{use-checkbox-BnkSQgTJ.js → use-checkbox-DE50asz4.js} +1 -1
  84. package/src/client/dist/spa/assets/{use-onboarding-C4tGLHsr.js → use-onboarding-gBmXW7wm.js} +2 -2
  85. package/src/client/dist/spa/assets/use-quasar-Dyujo9Ue.js +1 -0
  86. package/src/client/dist/spa/assets/{vue.runtime.esm-bundler-BAtKyT0Y.js → vue.runtime.esm-bundler-JZnIeD9D.js} +2 -2
  87. package/src/client/dist/spa/assets/{workers-DFjmWZva.js → workers-okv2EabB.js} +1 -1
  88. package/src/client/dist/spa/assets/{xml-BXzZhhmL.js → xml-iWTTgx9j.js} +1 -1
  89. package/src/client/dist/spa/assets/{yaml-DQNlz7_W.js → yaml-am8-T4BQ.js} +1 -1
  90. package/src/client/dist/spa/index.html +7 -13
  91. package/src/mcp-server/kobo-tasks-server.ts +4 -0
  92. package/src/client/dist/spa/assets/ActivityFeed-COdkQaiZ.js +0 -8
  93. package/src/client/dist/spa/assets/ChangelogPage-BXD8H3j-.js +0 -1
  94. package/src/client/dist/spa/assets/ClosePopup-DD10nToj.js +0 -1
  95. package/src/client/dist/spa/assets/CreatePage-DX4TjLqr.js +0 -2
  96. package/src/client/dist/spa/assets/DiffViewer-BUjVXGyZ.js +0 -8
  97. package/src/client/dist/spa/assets/HealthPage-DM7fvP4v.js +0 -1
  98. package/src/client/dist/spa/assets/MainLayout-CbUZOTwz.js +0 -37
  99. package/src/client/dist/spa/assets/QBtn-DwemGTZv.js +0 -1
  100. package/src/client/dist/spa/assets/QCheckbox-o3UHW596.js +0 -1
  101. package/src/client/dist/spa/assets/QChip-BpS8c1sW.js +0 -1
  102. package/src/client/dist/spa/assets/QExpansionItem-C9vmJqEO.js +0 -1
  103. package/src/client/dist/spa/assets/QInput-CLZtb8E0.js +0 -1
  104. package/src/client/dist/spa/assets/QItemLabel-BYSjzk-t.js +0 -1
  105. package/src/client/dist/spa/assets/QItemSection-O9WBXftL.js +0 -1
  106. package/src/client/dist/spa/assets/QMenu-D_9kEp2i.js +0 -1
  107. package/src/client/dist/spa/assets/QRadio-B_TurTzx.js +0 -1
  108. package/src/client/dist/spa/assets/QScrollArea-B5jf9S4y.js +0 -1
  109. package/src/client/dist/spa/assets/QScrollObserver-Cxj52Zfg.js +0 -1
  110. package/src/client/dist/spa/assets/QSelect-DERXhq6x.js +0 -36
  111. package/src/client/dist/spa/assets/QSpace-CrVsndpV.js +0 -1
  112. package/src/client/dist/spa/assets/QToggle-Dwr3hSLw.js +0 -1
  113. package/src/client/dist/spa/assets/QTooltip-CyRLTG6i.js +0 -1
  114. package/src/client/dist/spa/assets/SearchPage-OaTRqd2Q.js +0 -1
  115. package/src/client/dist/spa/assets/SettingsPage-B7H6sD7r.css +0 -1
  116. package/src/client/dist/spa/assets/SettingsPage-BBk3MB8w.js +0 -9
  117. package/src/client/dist/spa/assets/TouchPan-BmfIMD00.js +0 -1
  118. package/src/client/dist/spa/assets/WorkspacePage-k_qzLoLC.js +0 -4
  119. package/src/client/dist/spa/assets/build-path-tree-D4_LR3mz.js +0 -1
  120. package/src/client/dist/spa/assets/chunk-DtRyYLXJ.js +0 -1
  121. package/src/client/dist/spa/assets/documents-N8PwB_Gh.js +0 -1
  122. package/src/client/dist/spa/assets/editor.worker-DWlYVeeX.js +0 -26
  123. package/src/client/dist/spa/assets/engineFeatures-DChekJQO.js +0 -1
  124. package/src/client/dist/spa/assets/expand-template-BXNt_oWH.js +0 -1
  125. package/src/client/dist/spa/assets/formatters-wq5wP2If.js +0 -1
  126. package/src/client/dist/spa/assets/i18n-DXMuiAyu.js +0 -1
  127. package/src/client/dist/spa/assets/index-CNin8jPU.js +0 -82
  128. package/src/client/dist/spa/assets/json.worker-CCzEOxDx.js +0 -58
  129. package/src/client/dist/spa/assets/kobo-commands-Ip0ObeCE.js +0 -9
  130. package/src/client/dist/spa/assets/notifications-C4MxuXC7.js +0 -1
  131. package/src/client/dist/spa/assets/render-chat-markdown-BJsZCnSw.js +0 -66
  132. package/src/client/dist/spa/assets/touch-yfnu5R3D.js +0 -1
  133. package/src/client/dist/spa/assets/use-quasar-DcJRs0ay.js +0 -1
  134. package/src/client/dist/spa/assets/vue-i18n-C5Tx4bGk.js +0 -3
  135. /package/src/client/dist/spa/assets/{_plugin-vue_export-helper-BzmG9fMN.js → _plugin-vue_export-helper-BDNMzG2s.js} +0 -0
  136. /package/src/client/dist/spa/assets/{abap-DiwvWnMr.js → abap-08VXUWAP.js} +0 -0
  137. /package/src/client/dist/spa/assets/{apex-CmtZjKlf.js → apex-BWPQTe0t.js} +0 -0
  138. /package/src/client/dist/spa/assets/{azcli-DL2My_i-.js → azcli-Bc_sGQ0U.js} +0 -0
  139. /package/src/client/dist/spa/assets/{bat-B-nC98wG.js → bat-i0X4ZdIN.js} +0 -0
  140. /package/src/client/dist/spa/assets/{bicep-Ju5MwOgh.js → bicep-B5-_aFwp.js} +0 -0
  141. /package/src/client/dist/spa/assets/{cameligo-8Eu1TyBr.js → cameligo-DMUM7wLl.js} +0 -0
  142. /package/src/client/dist/spa/assets/{clojure-u-RpMkH3.js → clojure-Cm7r79vr.js} +0 -0
  143. /package/src/client/dist/spa/assets/{coffee-CdA7bbTe.js → coffee-Ba7i2nA0.js} +0 -0
  144. /package/src/client/dist/spa/assets/{cpp-CzNFP8ks.js → cpp-C7h46wYY.js} +0 -0
  145. /package/src/client/dist/spa/assets/{csharp-j1LThmcE.js → csharp-BKxtCVv1.js} +0 -0
  146. /package/src/client/dist/spa/assets/{csp-CLRC61y6.js → csp-bTuwJoIa.js} +0 -0
  147. /package/src/client/dist/spa/assets/{css-r6rC_7P2.js → css-DIMkf-bt.js} +0 -0
  148. /package/src/client/dist/spa/assets/{cypher-CW08XVUh.js → cypher-CVaqCwHa.js} +0 -0
  149. /package/src/client/dist/spa/assets/{dart-Cs9aL5T_.js → dart-onAF5SnQ.js} +0 -0
  150. /package/src/client/dist/spa/assets/{dockerfile-BWM0M184.js → dockerfile-DZFCIeNp.js} +0 -0
  151. /package/src/client/dist/spa/assets/{ecl-MJJuer5P.js → ecl-D05T4iGw.js} +0 -0
  152. /package/src/client/dist/spa/assets/{elixir-D2AIuXqn.js → elixir-6RTg0lbw.js} +0 -0
  153. /package/src/client/dist/spa/assets/{flow9-B2H24giC.js → flow9-C5_-GSwl.js} +0 -0
  154. /package/src/client/dist/spa/assets/{fsharp-CFNadkg7.js → fsharp-C8Ef5oNN.js} +0 -0
  155. /package/src/client/dist/spa/assets/{go-dSur1iB2.js → go-C-y9NEjX.js} +0 -0
  156. /package/src/client/dist/spa/assets/{graphql-qyhAo11d.js → graphql-fmXr3nnJ.js} +0 -0
  157. /package/src/client/dist/spa/assets/{hcl-DFzjMyzm.js → hcl-CpzslTdj.js} +0 -0
  158. /package/src/client/dist/spa/assets/{ini-TdzA8TIl.js → ini-sBoK_t0W.js} +0 -0
  159. /package/src/client/dist/spa/assets/{java-CSGA9pkE.js → java-BEtHBSE6.js} +0 -0
  160. /package/src/client/dist/spa/assets/{julia-9izz5OsY.js → julia-Bri6UV-V.js} +0 -0
  161. /package/src/client/dist/spa/assets/{kotlin-DIUPrqKg.js → kotlin-BOotOW0E.js} +0 -0
  162. /package/src/client/dist/spa/assets/{less-B8d93iCg.js → less-B9JPFI3C.js} +0 -0
  163. /package/src/client/dist/spa/assets/{lexon-DWtEIyu7.js → lexon-CfSJPG6W.js} +0 -0
  164. /package/src/client/dist/spa/assets/{lua-Ciq0OGgt.js → lua-CsQS60Ue.js} +0 -0
  165. /package/src/client/dist/spa/assets/{m3-Cki6JWj_.js → m3-D-oSqn_W.js} +0 -0
  166. /package/src/client/dist/spa/assets/{markdown-Cu47xwU0.js → markdown-Cimd5fb3.js} +0 -0
  167. /package/src/client/dist/spa/assets/{mips-BM8ui995.js → mips-CIPQ_RoX.js} +0 -0
  168. /package/src/client/dist/spa/assets/{msdax-DqLio0_c.js → msdax-DauUninz.js} +0 -0
  169. /package/src/client/dist/spa/assets/{mysql-v1wbjJOq.js → mysql-SOo6toE5.js} +0 -0
  170. /package/src/client/dist/spa/assets/{objective-c-CQl3PGSB.js → objective-c-FvmIjYaQ.js} +0 -0
  171. /package/src/client/dist/spa/assets/{pascal-D4iW0ZtD.js → pascal-DrH0SRf2.js} +0 -0
  172. /package/src/client/dist/spa/assets/{pascaligo-BdC9CZdj.js → pascaligo-D-ptJ9y-.js} +0 -0
  173. /package/src/client/dist/spa/assets/{perl-BL10m4XD.js → perl-oz_6vUea.js} +0 -0
  174. /package/src/client/dist/spa/assets/{pgsql-Be_oqVo3.js → pgsql-DTj74zXo.js} +0 -0
  175. /package/src/client/dist/spa/assets/{php-BtvXSFRI.js → php-nr791fC2.js} +0 -0
  176. /package/src/client/dist/spa/assets/{pla-B2vUy15C.js → pla-CopQ2nXW.js} +0 -0
  177. /package/src/client/dist/spa/assets/{postiats-CbmTTfXr.js → postiats-43DmfD33.js} +0 -0
  178. /package/src/client/dist/spa/assets/{powerquery-DszLhJGx.js → powerquery-D3hlyOfw.js} +0 -0
  179. /package/src/client/dist/spa/assets/{powershell-B0dYktF6.js → powershell-DmHpPYUd.js} +0 -0
  180. /package/src/client/dist/spa/assets/{protobuf-CZvaj1VX.js → protobuf-C531GsRP.js} +0 -0
  181. /package/src/client/dist/spa/assets/{pug-CPDx1B3S.js → pug-Z5eAx3Zn.js} +0 -0
  182. /package/src/client/dist/spa/assets/{qsharp-CDP9TFLl.js → qsharp-DkqhCAOL.js} +0 -0
  183. /package/src/client/dist/spa/assets/{r-8DbbFX2l.js → r-BwWrilGY.js} +0 -0
  184. /package/src/client/dist/spa/assets/{redis-DRWj9MtJ.js → redis-ClamHrr6.js} +0 -0
  185. /package/src/client/dist/spa/assets/{redshift-C6cElE_5.js → redshift-DT7zqm-g.js} +0 -0
  186. /package/src/client/dist/spa/assets/{restructuredtext-W9pS9n3m.js → restructuredtext-BYgofb2h.js} +0 -0
  187. /package/src/client/dist/spa/assets/{ruby-BKnzWnk-.js → ruby-DezsRK8O.js} +0 -0
  188. /package/src/client/dist/spa/assets/{rust-YPCclWwe.js → rust-DdL9SqIa.js} +0 -0
  189. /package/src/client/dist/spa/assets/{sb-BgM4DTFb.js → sb-CcwsVR0C.js} +0 -0
  190. /package/src/client/dist/spa/assets/{scala-fz1OPLMl.js → scala-DHpiXF5c.js} +0 -0
  191. /package/src/client/dist/spa/assets/{scheme-8Uz1RIbu.js → scheme-BeGwcela.js} +0 -0
  192. /package/src/client/dist/spa/assets/{scss-Djo3IYXr.js → scss-gp-XZpBa.js} +0 -0
  193. /package/src/client/dist/spa/assets/{shell-CINF5Tx_.js → shell-CC2rA5mh.js} +0 -0
  194. /package/src/client/dist/spa/assets/{solidity-GgiNEuUm.js → solidity-BEEn4gHE.js} +0 -0
  195. /package/src/client/dist/spa/assets/{sophia-Culj97P9.js → sophia-CRfGWb83.js} +0 -0
  196. /package/src/client/dist/spa/assets/{sparql-C2ZlpxOY.js → sparql-D_Lu-MrJ.js} +0 -0
  197. /package/src/client/dist/spa/assets/{sql-BEf5Pg7Y.js → sql-NEE52Syq.js} +0 -0
  198. /package/src/client/dist/spa/assets/{st-CT6UUoeH.js → st-DbInun42.js} +0 -0
  199. /package/src/client/dist/spa/assets/{swift-B5g0xTG3.js → swift-Bxkupp3x.js} +0 -0
  200. /package/src/client/dist/spa/assets/{systemverilog-CEgQz9DR.js → systemverilog-Bz4Y3fRF.js} +0 -0
  201. /package/src/client/dist/spa/assets/{tcl-D0qL2L0I.js → tcl-DISqw1ZD.js} +0 -0
  202. /package/src/client/dist/spa/assets/{twig-BFUAVf1E.js → twig-De2hgUGE.js} +0 -0
  203. /package/src/client/dist/spa/assets/{typespec-CjVVcNKm.js → typespec-B8J7ngcE.js} +0 -0
  204. /package/src/client/dist/spa/assets/{vb-CZJr-DQz.js → vb-DV3o63ZY.js} +0 -0
  205. /package/src/client/dist/spa/assets/{wgsl-ivoXUo2e.js → wgsl-DpFanUEy.js} +0 -0
@@ -12,7 +12,7 @@ export function parseChangelog(markdown) {
12
12
  const entries = [];
13
13
  let current = null;
14
14
  for (const line of markdown.split('\n')) {
15
- const heading = line.match(/^##\s+v?(\d+\.\d+\.\d+[\w.-]*)\s*$/);
15
+ const heading = line.match(/^##\s+v?(\d+\.\d+\.\d+[\w./-]*)\s*$/);
16
16
  if (heading) {
17
17
  if (current)
18
18
  entries.push({ version: current.version, notes: current.lines.join('\n').trim() });
@@ -1,5 +1,7 @@
1
1
  import { Hono } from 'hono';
2
+ import { getBackendPort } from '../services/agent/orchestrator.js';
2
3
  import { DEFAULT_NOTION_INITIAL_PROMPT, DEFAULT_SENTRY_INITIAL_PROMPT, } from '../services/initial-prompt-template-service.js';
4
+ import { generateToken, getLanUrls } from '../services/network-access-service.js';
3
5
  import { DEFAULT_REVIEW_PROMPT_TEMPLATE } from '../services/review-template-service.js';
4
6
  import { DEFAULT_CHANGE_SOURCE_BRANCH_SCRIPT } from '../services/settings-defaults.js';
5
7
  import * as settingsService from '../services/settings-service.js';
@@ -45,6 +47,53 @@ app.get('/defaults', (c) => {
45
47
  changeSourceBranchScript: DEFAULT_CHANGE_SOURCE_BRANCH_SCRIPT,
46
48
  });
47
49
  });
50
+ // GET /api/settings/network — network access state + LAN URLs
51
+ app.get('/network', (c) => {
52
+ try {
53
+ const global = settingsService.getGlobalSettings();
54
+ return c.json({
55
+ enabled: global.networkAccessEnabled,
56
+ token: global.networkAccessToken,
57
+ urls: getLanUrls(getBackendPort()),
58
+ });
59
+ }
60
+ catch (err) {
61
+ const message = err instanceof Error ? err.message : String(err);
62
+ return c.json({ error: message }, 500);
63
+ }
64
+ });
65
+ // GET /api/settings/network/ping — token validation probe (behind the gate)
66
+ app.get('/network/ping', (c) => c.json({ ok: true }));
67
+ // POST /api/settings/network — toggle enabled / regenerate token
68
+ app.post('/network', async (c) => {
69
+ try {
70
+ const body = await c.req.json();
71
+ const current = settingsService.getGlobalSettings();
72
+ const patch = {};
73
+ let restartRequired = false;
74
+ if (typeof body.enabled === 'boolean' && body.enabled !== current.networkAccessEnabled) {
75
+ patch.networkAccessEnabled = body.enabled;
76
+ restartRequired = true;
77
+ if (body.enabled && !current.networkAccessToken) {
78
+ patch.networkAccessToken = generateToken();
79
+ }
80
+ }
81
+ if (body.regenerate) {
82
+ patch.networkAccessToken = generateToken();
83
+ }
84
+ const updated = settingsService.updateNetworkAccessSettings(patch);
85
+ return c.json({
86
+ enabled: updated.networkAccessEnabled,
87
+ token: updated.networkAccessToken,
88
+ urls: getLanUrls(getBackendPort()),
89
+ restartRequired,
90
+ });
91
+ }
92
+ catch (err) {
93
+ const message = err instanceof Error ? err.message : String(err);
94
+ return c.json({ error: message }, 500);
95
+ }
96
+ });
48
97
  // GET /api/settings/mcp-servers — list active MCP servers from Claude config
49
98
  app.get('/mcp-servers', (c) => {
50
99
  try {
@@ -1124,8 +1124,9 @@ app.delete('/:id/quota-backoff', (c) => {
1124
1124
  return c.json({ error: message }, 500);
1125
1125
  }
1126
1126
  });
1127
- // POST /api/workspaces/:id/pending-wakeup — agent-initiated schedule via the
1128
- // `kobo__schedule_wakeup` MCP tool. Replaces any existing pending wakeup.
1127
+ // POST /api/workspaces/:id/pending-wakeup — schedule a wakeup, either from
1128
+ // the `kobo__schedule_wakeup` MCP tool (agent, mode='resume') or from the UI
1129
+ // (manual, mode='fresh', default). Replaces any existing pending wakeup.
1129
1130
  app.post('/:id/pending-wakeup', async (c) => {
1130
1131
  try {
1131
1132
  const id = c.req.param('id');
@@ -1133,6 +1134,7 @@ app.post('/:id/pending-wakeup', async (c) => {
1133
1134
  const delaySeconds = body.delaySeconds;
1134
1135
  const prompt = body.prompt;
1135
1136
  const reason = body.reason;
1137
+ const rawMode = typeof body.mode === 'string' ? body.mode : 'fresh';
1136
1138
  if (typeof delaySeconds !== 'number' || !Number.isFinite(delaySeconds) || delaySeconds <= 0) {
1137
1139
  return c.json({ error: 'delaySeconds must be a positive number' }, 400);
1138
1140
  }
@@ -1142,14 +1144,14 @@ app.post('/:id/pending-wakeup', async (c) => {
1142
1144
  if (reason !== undefined && typeof reason !== 'string') {
1143
1145
  return c.json({ error: 'reason must be a string when provided' }, 400);
1144
1146
  }
1145
- // Pin the wakeup to the session that scheduled it, so the resume targets
1146
- // that conversation instead of whichever session happens to be the latest
1147
- // at fire time. The MCP tool is invoked from inside an active session, so
1148
- // a missing controller signals misuse — reject explicitly.
1149
- const agentSessionId = agentManager.getActiveSessionId(id);
1150
- if (!agentSessionId) {
1151
- return c.json({ error: 'no active agent session for this workspace' }, 409);
1147
+ if (rawMode !== 'fresh' && rawMode !== 'resume') {
1148
+ return c.json({ error: "mode must be 'fresh' or 'resume'" }, 400);
1152
1149
  }
1150
+ // 'resume' pins the active session so the wakeup resumes that conversation;
1151
+ // 'fresh' (default) — or 'resume' with no active session — leaves it unpinned
1152
+ // so the wakeup fires a brand-new session running `prompt`. This is what makes
1153
+ // manual scheduling on an idle workspace possible (no more hard 409).
1154
+ const agentSessionId = rawMode === 'resume' ? (agentManager.getActiveSessionId(id) ?? undefined) : undefined;
1153
1155
  wakeupService.schedule(id, delaySeconds, prompt, reason, agentSessionId);
1154
1156
  const pending = wakeupService.getPending(id);
1155
1157
  return c.json({ ok: true, pending });
@@ -2658,6 +2660,23 @@ app.post('/:id/push', async (c) => {
2658
2660
  return c.json({ error: message }, 500);
2659
2661
  }
2660
2662
  });
2663
+ // POST /api/workspaces/:id/fetch — git fetch the workspace repo (all branches of origin).
2664
+ // Read-only: updates remote-tracking refs, never touches the working tree.
2665
+ app.post('/:id/fetch', (c) => {
2666
+ try {
2667
+ const id = c.req.param('id');
2668
+ const workspace = workspaceService.getWorkspace(id);
2669
+ if (!workspace) {
2670
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
2671
+ }
2672
+ gitOps.fetchAllBranches(workspace.worktreePath);
2673
+ return c.json({ ok: true });
2674
+ }
2675
+ catch (err) {
2676
+ const message = err instanceof Error ? err.message : String(err);
2677
+ return c.json({ error: message }, 500);
2678
+ }
2679
+ });
2661
2680
  // POST /api/workspaces/:id/pull — pull working branch from origin (fast-forward only)
2662
2681
  app.post('/:id/pull', (c) => {
2663
2682
  try {
@@ -5,6 +5,7 @@ import { createMapperState, mapSdkMessage, QUOTA_PATTERN, tryEmitQuota } from '.
5
5
  import { buildClaudeOptions } from './options-builder.js';
6
6
  import { buildCompactionSessionStartOutput } from './precompact-hook.js';
7
7
  import { resolveClaudeBinaryPath } from './resolve-binary.js';
8
+ import { buildStopHookOutput } from './stop-hook.js';
8
9
  /**
9
10
  * Grace window between the SDK's terminal `result` message and the generator
10
11
  * reaching `done`. A healthy run closes within milliseconds; if the generator
@@ -89,6 +90,19 @@ export function createClaudeCodeEngine() {
89
90
  ],
90
91
  },
91
92
  ],
93
+ // Decision-point enforcement of the "schedule a wakeup or the session
94
+ // stalls" invariant: when the agent tries to end its turn with
95
+ // background work still in flight and nothing scheduled to resume the
96
+ // session, inject a reminder so it calls `kobo__schedule_wakeup` instead
97
+ // of going idle. A passive system-prompt rule isn't enough — see
98
+ // stop-hook.ts. `additionalContext` continues the turn so the model acts.
99
+ Stop: [
100
+ {
101
+ hooks: [
102
+ async (input) => buildStopHookOutput(options.workspaceId, input),
103
+ ],
104
+ },
105
+ ],
92
106
  };
93
107
  const { options: sdkOptions, effectivePrompt } = buildClaudeOptions({
94
108
  prompt: options.prompt,
@@ -167,10 +181,29 @@ export function createClaudeCodeEngine() {
167
181
  console.warn(`[claude-engine] SDK generator still open ${RESULT_DRAIN_TIMEOUT_MS}ms after 'result' — forcing session:ended`);
168
182
  const reason = userInterrupted ? 'killed' : mapperState.sawErrorResult ? 'error' : 'completed';
169
183
  emitSessionEnded(reason, reason === 'completed' ? 0 : null);
170
- // Best-effort: unstick the SDK so its subprocesses / MCP children
171
- // tear down. The session is reported ended regardless of whether
172
- // the abort actually propagates through the parked generator.
173
- abortController.abort();
184
+ // Normally we abort here to unstick the SDK so its subprocesses / MCP
185
+ // children tear down. BUT if a wakeup is scheduled for this workspace,
186
+ // the agent intentionally left background work running for the wakeup
187
+ // to resume and check — aborting would kill that work. Skip the abort
188
+ // in that case so the background tasks survive. `session:ended` was
189
+ // already emitted (so the orchestrator/auto-loop proceed and the
190
+ // controller is removed, letting the wakeup fire later); the parked
191
+ // generator drains on its own once the background work finishes.
192
+ //
193
+ // The wakeup check is a dynamic import: a static `import` of
194
+ // wakeup-service here would form an engine → wakeup-service →
195
+ // orchestrator → engine cycle that breaks module init under the test
196
+ // SSR transform. Resolving it at call-time (once, 15s after result)
197
+ // sidesteps the cycle with no hot-path cost.
198
+ void (async () => {
199
+ const { isWakeupScheduled } = await import('../../../wakeup-service.js');
200
+ if (isWakeupScheduled(options.workspaceId)) {
201
+ console.warn(`[claude-engine] generator still open ${RESULT_DRAIN_TIMEOUT_MS}ms after 'result' but a wakeup is scheduled for ${options.workspaceId} — leaving background work alive (no abort)`);
202
+ }
203
+ else {
204
+ abortController.abort();
205
+ }
206
+ })();
174
207
  }, RESULT_DRAIN_TIMEOUT_MS);
175
208
  resultDrainTimer.unref?.();
176
209
  };
@@ -0,0 +1,56 @@
1
+ import { isWakeupScheduled } from '../../../wakeup-service.js';
2
+ /**
3
+ * Decide whether to nudge the agent to schedule a wakeup before it ends its
4
+ * turn. The agent should be reminded ONLY when it leaves background work
5
+ * in-flight with nothing scheduled to resume the session — otherwise the Kōbō
6
+ * session goes idle and the work stalls.
7
+ *
8
+ * Returns false (clean stop, no nudge) when:
9
+ * - the stop hook is already active (anti-loop — we nudged on the prior stop);
10
+ * - there is no in-flight background work (a genuine end of turn);
11
+ * - an SDK-level cron/wakeup is scheduled (`session_crons`); or
12
+ * - a Kōbō-level wakeup is scheduled (`pending_wakeups`).
13
+ */
14
+ export function shouldNudgeWakeup(input) {
15
+ if (input.stopHookActive)
16
+ return false;
17
+ if (input.backgroundTaskCount <= 0)
18
+ return false;
19
+ if (input.sdkScheduledWakeupCount > 0)
20
+ return false;
21
+ if (input.koboWakeupScheduled)
22
+ return false;
23
+ return true;
24
+ }
25
+ /** The decision-point reminder injected when {@link shouldNudgeWakeup} is true. */
26
+ export function buildNudgeText(backgroundTaskCount) {
27
+ return [
28
+ `⚠️ [Kōbō] You are about to end your turn with ${backgroundTaskCount} background task(s) still running, but NO wakeup is scheduled.`,
29
+ 'This Kōbō session will go idle and will NOT resume itself — the background work will stall and never be checked.',
30
+ '',
31
+ 'Before ending your turn, do ONE of:',
32
+ '• If you need that work to finish: call `kobo__schedule_wakeup` now with `delaySeconds` ≈ the expected remaining duration and a `prompt` telling future-you exactly what to check (log path + next step). Then end the turn.',
33
+ '• If the background work is no longer needed: stop it (kill the process), then end the turn.',
34
+ '',
35
+ 'Do not end the turn passively waiting — nothing will wake the session.',
36
+ ].join('\n');
37
+ }
38
+ /**
39
+ * Build the Stop hook output for a workspace: cross-checks the SDK signals with
40
+ * Kōbō's own `pending_wakeups` table (since `kobo__schedule_wakeup` is an MCP
41
+ * tool that never appears in the SDK's `session_crons`) and returns the wakeup
42
+ * nudge only when the agent is genuinely about to stall.
43
+ */
44
+ export function buildStopHookOutput(workspaceId, input) {
45
+ const backgroundTaskCount = input.background_tasks?.length ?? 0;
46
+ const koboWakeupScheduled = isWakeupScheduled(workspaceId);
47
+ const nudge = shouldNudgeWakeup({
48
+ stopHookActive: input.stop_hook_active ?? false,
49
+ backgroundTaskCount,
50
+ sdkScheduledWakeupCount: input.session_crons?.length ?? 0,
51
+ koboWakeupScheduled,
52
+ });
53
+ if (!nudge)
54
+ return {};
55
+ return { hookSpecificOutput: { hookEventName: 'Stop', additionalContext: buildNudgeText(backgroundTaskCount) } };
56
+ }
@@ -22,6 +22,10 @@ let backendPort = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 300
22
22
  export function setBackendPort(port) {
23
23
  backendPort = port;
24
24
  }
25
+ /** Current bound port of the running backend. */
26
+ export function getBackendPort() {
27
+ return backendPort;
28
+ }
25
29
  /** workspaceId -> SessionController */
26
30
  const controllers = new Map();
27
31
  /** workspaceId -> last engine session ID (for resume) */
@@ -72,15 +72,21 @@ function nextAfter(expression, from) {
72
72
  }
73
73
  /**
74
74
  * Validate the expression, persist the row, arm a setTimeout for the next
75
- * fire, emit `cron:created`. Throws on invalid expression OR when the next
76
- * fire is < MIN_DELAY_BETWEEN_FIRES_SECONDS seconds in the future.
75
+ * fire, emit `cron:created`. Throws on an invalid expression. If the first
76
+ * occurrence falls within MIN_DELAY_BETWEEN_FIRES_SECONDS of now, that imminent
77
+ * fire is SKIPPED (scheduled to the following occurrence) rather than rejected
78
+ * — so a valid recurring cron created near a boundary (e.g. a 15-min cron made
79
+ * 30s before the tick, or a daily cron made shortly before its time) still
80
+ * arms, and never fires within a minute of creation.
77
81
  */
78
82
  export function arm(workspaceId, args) {
79
83
  const now = new Date();
80
- const next = nextAfter(args.expression, now);
81
- const deltaMs = next.getTime() - now.getTime();
82
- if (deltaMs < MIN_DELAY_BETWEEN_FIRES_SECONDS * 1000) {
83
- throw new Error(`Cron expression resolves too close to now (minimum ${MIN_DELAY_BETWEEN_FIRES_SECONDS}s); use a longer interval`);
84
+ let next = nextAfter(args.expression, now);
85
+ if (next.getTime() - now.getTime() < MIN_DELAY_BETWEEN_FIRES_SECONDS * 1000) {
86
+ // Skip the imminent occurrence. The one after is guaranteed ≥60s out for
87
+ // any standard 5-field cron (min granularity is 1 minute), so a single skip
88
+ // always suffices.
89
+ next = nextAfter(args.expression, next);
84
90
  }
85
91
  const id = nanoid();
86
92
  const db = getDb();
@@ -0,0 +1,67 @@
1
+ import crypto from 'node:crypto';
2
+ import os from 'node:os';
3
+ const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
4
+ /** True for loopback remote addresses. Undefined → false (deny-safe). */
5
+ export function isLoopbackAddress(address) {
6
+ if (!address)
7
+ return false;
8
+ return LOOPBACK_ADDRESSES.has(address);
9
+ }
10
+ /** Bind host for `serve()`: localhost-only when disabled, all interfaces when enabled. */
11
+ export function resolveBindHost(enabled) {
12
+ return enabled ? undefined : '127.0.0.1';
13
+ }
14
+ /** Non-internal IPv4 URLs for the running server, for display + QR. */
15
+ export function getLanUrls(port) {
16
+ const urls = [];
17
+ for (const infos of Object.values(os.networkInterfaces())) {
18
+ if (!infos)
19
+ continue;
20
+ for (const info of infos) {
21
+ if (info.family === 'IPv4' && !info.internal) {
22
+ urls.push(`http://${info.address}:${port}`);
23
+ }
24
+ }
25
+ }
26
+ return urls;
27
+ }
28
+ /** ~32-char url-safe random token. */
29
+ export function generateToken() {
30
+ return crypto.randomBytes(24).toString('base64url');
31
+ }
32
+ /** Constant-time token comparison; false on empty/length mismatch (never throws). */
33
+ export function tokenMatches(provided, expected) {
34
+ if (!provided || !expected)
35
+ return false;
36
+ const a = Buffer.from(provided);
37
+ const b = Buffer.from(expected);
38
+ if (a.length !== b.length)
39
+ return false;
40
+ return crypto.timingSafeEqual(a, b);
41
+ }
42
+ /** Core gate decision shared by the HTTP middleware and the WS upgrade guard. */
43
+ export function evaluateNetworkAccess(params) {
44
+ if (isLoopbackAddress(params.address))
45
+ return { allow: true, status: 200 };
46
+ if (!params.enabled)
47
+ return { allow: false, status: 403 };
48
+ if (tokenMatches(params.providedToken, params.expectedToken))
49
+ return { allow: true, status: 200 };
50
+ return { allow: false, status: 401 };
51
+ }
52
+ /** WS upgrade authorization: parses `?token=` from the raw URL. */
53
+ export function authorizeWsUpgrade(params) {
54
+ let providedToken;
55
+ try {
56
+ providedToken = new URL(params.rawUrl ?? '/', 'http://localhost').searchParams.get('token') ?? undefined;
57
+ }
58
+ catch {
59
+ providedToken = undefined;
60
+ }
61
+ return evaluateNetworkAccess({
62
+ address: params.address,
63
+ enabled: params.enabled,
64
+ expectedToken: params.expectedToken,
65
+ providedToken,
66
+ }).allow;
67
+ }
@@ -629,6 +629,31 @@ const settingsMigrations = [
629
629
  }
630
630
  },
631
631
  },
632
+ {
633
+ version: 39,
634
+ name: 'add-network-access',
635
+ migrate: ({ global }) => {
636
+ // Opt-in LAN access with token auth. Disabled by default (localhost-only
637
+ // bind). Token is generated lazily on first enable via POST /network.
638
+ if (typeof global.networkAccessEnabled !== 'boolean') {
639
+ global.networkAccessEnabled = false;
640
+ }
641
+ if (typeof global.networkAccessToken !== 'string') {
642
+ global.networkAccessToken = '';
643
+ }
644
+ },
645
+ },
646
+ {
647
+ version: 40,
648
+ name: 'add-question-notification-sound',
649
+ migrate: ({ global }) => {
650
+ // Distinct sound played when the agent asks a question. Defaults to 'hey.mp3'
651
+ // so questions are audibly distinct from a customised task-done sound.
652
+ if (typeof global.audioQuestionSound !== 'string' || global.audioQuestionSound.length === 0) {
653
+ global.audioQuestionSound = 'hey.mp3';
654
+ }
655
+ },
656
+ },
632
657
  ];
633
658
  /** Current settings schema version — always equals the highest migration version. */
634
659
  export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
@@ -675,9 +700,12 @@ function defaultSettings() {
675
700
  fileManagerCommand: '',
676
701
  terminalCommand: '',
677
702
  autoPurgeOnPrMerged: false,
703
+ networkAccessEnabled: false,
704
+ networkAccessToken: '',
678
705
  browserNotifications: true,
679
706
  audioNotifications: true,
680
707
  audioNotificationSound: 'hey.mp3',
708
+ audioQuestionSound: 'hey.mp3',
681
709
  audioNotificationVolume: 1,
682
710
  notionStatusProperty: '',
683
711
  notionInProgressStatus: '',
@@ -867,7 +895,7 @@ export function getSettings() {
867
895
  return readSettings();
868
896
  }
869
897
  /** Keys stripped from exports — secrets that should stay on the machine. */
870
- const SECRET_GLOBAL_KEYS = ['notionMcpKey', 'sentryMcpKey'];
898
+ const SECRET_GLOBAL_KEYS = ['notionMcpKey', 'sentryMcpKey', 'networkAccessToken'];
871
899
  /** Build an export bundle with settings + templates. MCP keys are stripped. */
872
900
  export function exportConfigBundle(templates) {
873
901
  const settings = readSettings();
@@ -1033,6 +1061,7 @@ export function updateGlobalSettings(data) {
1033
1061
  'browserNotifications',
1034
1062
  'audioNotifications',
1035
1063
  'audioNotificationSound',
1064
+ 'audioQuestionSound',
1036
1065
  'audioNotificationVolume',
1037
1066
  'notionStatusProperty',
1038
1067
  'notionInProgressStatus',
@@ -1093,6 +1122,26 @@ export function updateGlobalSettings(data) {
1093
1122
  writeSettings(settings, { backup: true });
1094
1123
  return settings.global;
1095
1124
  }
1125
+ /**
1126
+ * Persist network-access settings (enabled flag and/or token) directly.
1127
+ *
1128
+ * Network access is managed exclusively through this path (POST /api/settings/network),
1129
+ * never the generic `updateGlobalSettings` allowlist: `networkAccessToken` is a secret
1130
+ * (kept out of the allowlist so a config import can't inject one) and `networkAccessEnabled`
1131
+ * is kept out too so it can't be flipped on via PUT /global without an accompanying token
1132
+ * (which would leave the server bound wide but unauthenticatable after a restart).
1133
+ */
1134
+ export function updateNetworkAccessSettings(patch) {
1135
+ const settings = readSettings();
1136
+ if (typeof patch.networkAccessEnabled === 'boolean') {
1137
+ settings.global.networkAccessEnabled = patch.networkAccessEnabled;
1138
+ }
1139
+ if (typeof patch.networkAccessToken === 'string') {
1140
+ settings.global.networkAccessToken = patch.networkAccessToken;
1141
+ }
1142
+ writeSettings(settings, { backup: true });
1143
+ return settings.global;
1144
+ }
1096
1145
  function ensureGlobalWorktreesRootExists(worktreesPath) {
1097
1146
  const root = resolveGlobalWorktreesRoot(worktreesPath);
1098
1147
  if (!root || isNonNativeWindowsPath(root))
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { getTemplatesPath } from '../utils/paths.js';
4
- const CURRENT_FILE_VERSION = 1;
4
+ const CURRENT_FILE_VERSION = 2;
5
5
  const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
6
6
  const MAX_CONTENT_LENGTH = 4096;
7
7
  const MAX_DESCRIPTION_LENGTH = 120;
@@ -117,10 +117,23 @@ function validateTemplateInput(input) {
117
117
  throw new Error(`Invalid content: must be 1..${MAX_CONTENT_LENGTH} chars`);
118
118
  }
119
119
  }
120
- function writeTemplates(templates) {
120
+ function readSeededSlugs() {
121
+ const filePath = getTemplatesPath();
122
+ if (!existsSync(filePath))
123
+ return undefined;
124
+ try {
125
+ const parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
126
+ return Array.isArray(parsed.seededDefaultSlugs) ? parsed.seededDefaultSlugs : undefined;
127
+ }
128
+ catch {
129
+ return undefined;
130
+ }
131
+ }
132
+ function writeTemplates(templates, seededDefaultSlugs) {
121
133
  const filePath = getTemplatesPath();
122
134
  mkdirSync(path.dirname(filePath), { recursive: true });
123
- const file = { version: CURRENT_FILE_VERSION, templates };
135
+ const seeded = seededDefaultSlugs ?? readSeededSlugs() ?? [...LEGACY_DEFAULT_SLUGS];
136
+ const file = { version: CURRENT_FILE_VERSION, templates, seededDefaultSlugs: seeded };
124
137
  writeFileSync(filePath, JSON.stringify(file, null, 2), 'utf-8');
125
138
  }
126
139
  /**
@@ -154,6 +167,24 @@ export function replaceAllTemplates(templates) {
154
167
  }
155
168
  writeTemplates(validated);
156
169
  }
170
+ /**
171
+ * The default slugs that existed BEFORE the seededDefaultSlugs watermark shipped.
172
+ * Bootstraps the watermark for pre-watermark installs so a default the user deleted
173
+ * earlier is not re-added on the first boot. Frozen historical snapshot; never change it.
174
+ */
175
+ const LEGACY_DEFAULT_SLUGS = [
176
+ 'kobo-context',
177
+ 'review-quality',
178
+ 'add-tests',
179
+ 'explain',
180
+ 'refactor',
181
+ 'plan-tasks',
182
+ 'show-tasks',
183
+ 'mark-done',
184
+ 'sync-tasks',
185
+ 'pr-review-comments',
186
+ 'ci-status',
187
+ ];
157
188
  export const DEFAULT_TEMPLATES = [
158
189
  {
159
190
  slug: 'kobo-context',
@@ -192,7 +223,9 @@ export const DEFAULT_TEMPLATES = [
192
223
  `# Boundaries\n` +
193
224
  `- The user owns the \`description\` field of the workspace — never write it; you only own \`agent_description\`\n` +
194
225
  `- The user can interrupt you at any time via the chat; treat their messages as authoritative redirections\n` +
195
- `- Auto-loop is automatically disabled if the user sends a chat message during a loop — they'll re-enable it manually after\n`,
226
+ `- Auto-loop is automatically disabled if the user sends a chat message during a loop — they'll re-enable it manually after\n` +
227
+ `\n# Decision support\n` +
228
+ `For a high-stakes engineering decision with no obvious answer (architecture choice, risky refactor, a real tradeoff), run a "council": the \`/council\` template spawns 5 sub-agent advisors with distinct lenses, peer-reviews them, and synthesises a verdict into \`.ai/thoughts/\`. It costs ~11 sub-agent calls, so use it sparingly.\n`,
196
229
  },
197
230
  {
198
231
  slug: 'review-quality',
@@ -244,28 +277,64 @@ export const DEFAULT_TEMPLATES = [
244
277
  description: 'Check GitHub Actions status on PR',
245
278
  content: 'Check the CI/CD status for the pull request on branch {working_branch}.\n\nIf a PR exists (PR {pr_url}):\n1. Use the GitHub MCP tools to list the check runs / status checks on the latest commit of the PR\n2. For each check, report:\n - Check name\n - Status (queued, in_progress, completed)\n - Conclusion (success, failure, neutral, skipped, etc.)\n - Duration if available\n3. If any checks failed, fetch the logs or annotations and summarize what went wrong\n4. Give an overall summary: all green, some failing, or still running\n\nIf no PR exists, say so and suggest creating one first.',
246
279
  },
280
+ {
281
+ slug: 'council',
282
+ description: 'Pressure-test a high-stakes decision with 5 sub-agent advisors',
283
+ content: `Run a "council" to pressure-test a high-stakes decision for this workspace ("{workspace_name}").\n\n` +
284
+ `The decision: take what the user described in this message. If they gave none, ask for it in one line and stop.\n\n` +
285
+ `You orchestrate this with your own sub-agents:\n\n` +
286
+ `1. Spawn 5 sub-agents IN PARALLEL, one per lens. Each answers the decision independently in 150-300 words, fully in its lens, no hedging, no false balance:\n` +
287
+ ` - Contrarian: hunt the fatal flaw; what fails, what's missing.\n` +
288
+ ` - First Principles: ignore the surface question; what are we really solving? Is this even the right question?\n` +
289
+ ` - Expansionist: the upside everyone misses; what if it works better than expected?\n` +
290
+ ` - Outsider: zero context; react only to what's stated; catch the curse of knowledge.\n` +
291
+ ` - Executor: can it be done, and what's the fastest first step Monday morning?\n\n` +
292
+ `2. Anonymise the 5 answers as A-E (randomised). Spawn 5 reviewer sub-agents; each names the strongest answer, the biggest blind spot, and what ALL of them missed. Under 200 words each.\n\n` +
293
+ `3. Synthesise the verdict yourself:\n` +
294
+ ` - Where the council agrees (high-confidence signals).\n` +
295
+ ` - Where it clashes (present both sides honestly).\n` +
296
+ ` - Blind spots the review caught.\n` +
297
+ ` - One clear recommendation, not "it depends".\n` +
298
+ ` - The one thing to do first.\n\n` +
299
+ `4. Post the verdict in chat, and save the full transcript (decision, 5 answers, 5 reviews, verdict) to \`.ai/thoughts/council-<short-topic-slug>.md\`.\n\n` +
300
+ `Cost: this runs ~11 sub-agent calls. Use it only for decisions that are expensive to get wrong, not routine choices.\n`,
301
+ },
247
302
  ];
248
303
  function seedTemplates() {
249
304
  const now = new Date().toISOString();
250
305
  const seed = DEFAULT_TEMPLATES.map((t) => ({ ...t, createdAt: now, updatedAt: now }));
251
- writeTemplates(seed);
306
+ writeTemplates(seed, DEFAULT_TEMPLATES.map((t) => t.slug));
252
307
  }
308
+ /**
309
+ * Add any default whose slug was never seeded into this install AND is not already
310
+ * present. Each default seeds at most once (tracked in `seededDefaultSlugs`), so a
311
+ * default the user deleted does not come back. Never overwrites an existing template.
312
+ */
253
313
  export function reloadDefaultTemplates() {
254
314
  const existing = listTemplates();
255
- const existingBySlug = new Map(existing.map((t) => [t.slug, t]));
315
+ const existingBySlug = new Set(existing.map((t) => t.slug));
316
+ const rawSeeded = readSeededSlugs();
317
+ const seeded = new Set(rawSeeded ?? LEGACY_DEFAULT_SLUGS);
256
318
  const now = new Date().toISOString();
257
319
  const added = [];
258
320
  const kept = [];
259
321
  const next = [...existing];
260
322
  for (const def of DEFAULT_TEMPLATES) {
261
- if (existingBySlug.has(def.slug)) {
323
+ if (seeded.has(def.slug)) {
262
324
  kept.push(def.slug);
263
325
  continue;
264
326
  }
265
- next.push({ ...def, createdAt: now, updatedAt: now });
266
- added.push(def.slug);
327
+ if (existingBySlug.has(def.slug)) {
328
+ kept.push(def.slug);
329
+ }
330
+ else {
331
+ next.push({ ...def, createdAt: now, updatedAt: now });
332
+ added.push(def.slug);
333
+ }
334
+ seeded.add(def.slug);
335
+ }
336
+ if (added.length > 0 || rawSeeded === undefined) {
337
+ writeTemplates(next, [...seeded]);
267
338
  }
268
- if (added.length > 0)
269
- writeTemplates(next);
270
339
  return { added, kept };
271
340
  }
@@ -71,6 +71,15 @@ export function getPending(workspaceId) {
71
71
  return null;
72
72
  }
73
73
  }
74
+ /**
75
+ * Whether a wakeup is currently scheduled for the workspace. Used to decide
76
+ * that background work the agent left running is intentional (the wakeup will
77
+ * resume the session to check it) and must NOT be torn down — e.g. the engine's
78
+ * result-drain watchdog skips its abort when this is true.
79
+ */
80
+ export function isWakeupScheduled(workspaceId) {
81
+ return getPending(workspaceId) !== null;
82
+ }
74
83
  /** Re-register timers for rows persisted across restart. Skips stale entries. */
75
84
  export function rehydrate() {
76
85
  try {
@@ -6,7 +6,7 @@ import { resolveForge } from './forge/resolve.js';
6
6
  import { destroyTerminal } from './terminal-service.js';
7
7
  import { emitEphemeral } from './websocket-service.js';
8
8
  import { archiveWorkspace, getWorkspace, markWorktreePurged, } from './workspace-service.js';
9
- import { removeWorktree } from './worktree-service.js';
9
+ import { isPermissionError, removeWorktree } from './worktree-service.js';
10
10
  export async function purgeWorktree(workspaceId) {
11
11
  const workspace = getWorkspace(workspaceId);
12
12
  if (!workspace)
@@ -71,7 +71,7 @@ export async function purgeWorktree(workspaceId) {
71
71
  return { outcome: 'purged', warnings };
72
72
  }
73
73
  function buildRemovalFailureMessage(worktreePath, projectPath, errMsg) {
74
- const isPermission = /EACCES|EPERM|permission denied|operation not permitted/i.test(errMsg);
74
+ const isPermission = isPermissionError(errMsg);
75
75
  const baseLine = `Failed to remove worktree '${worktreePath}'.`;
76
76
  const recovery = ['Recovery:', ` sudo rm -rf '${worktreePath}'`, ` cd '${projectPath}' && git worktree prune`].join('\n');
77
77
  if (isPermission) {