@loicngr/kobo 1.6.15 → 1.7.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 (194) hide show
  1. package/README.md +13 -7
  2. package/dist/mcp-server/kobo-tasks-handlers.js +2 -1
  3. package/dist/mcp-server/kobo-tasks-server.js +51 -0
  4. package/dist/server/db/migrations.js +40 -0
  5. package/dist/server/db/schema.js +7 -5
  6. package/dist/server/index.js +12 -11
  7. package/dist/server/routes/health.js +2 -2
  8. package/dist/server/routes/settings.js +2 -1
  9. package/dist/server/routes/workspaces.js +165 -32
  10. package/dist/server/services/agent/engines/claude-code/capabilities.js +1 -1
  11. package/dist/server/services/agent/engines/claude-code/engine.js +237 -132
  12. package/dist/server/services/agent/engines/claude-code/event-mapper.js +234 -0
  13. package/dist/server/services/agent/engines/claude-code/options-builder.js +68 -0
  14. package/dist/server/services/agent/engines/claude-code/precompact-hook.js +27 -0
  15. package/dist/server/services/agent/engines/types.js +1 -0
  16. package/dist/server/services/agent/orchestrator.js +536 -94
  17. package/dist/server/services/agent/session-controller.js +14 -43
  18. package/dist/server/services/auto-loop-service.js +19 -8
  19. package/dist/server/services/content-migration-service.js +24 -94
  20. package/dist/server/services/settings-service.js +35 -1
  21. package/dist/server/services/wakeup-service.js +10 -11
  22. package/dist/server/services/workspace-service.js +42 -37
  23. package/dist/server/services/worktree-service.js +17 -7
  24. package/dist/server/utils/worktree-paths.js +134 -0
  25. package/dist/shared/consts.js +1 -0
  26. package/package.json +2 -1
  27. package/src/client/dist/spa/assets/ActivityFeed-Chn8aZvi.js +7 -0
  28. package/src/client/dist/spa/assets/ActivityFeed-LXnbg3ff.css +1 -0
  29. package/src/client/dist/spa/assets/{ClosePopup-D7BBEcaf.js → ClosePopup-BUlGXTqh.js} +1 -1
  30. package/src/client/dist/spa/assets/CreatePage-BE3xfQsC.css +1 -0
  31. package/src/client/dist/spa/assets/CreatePage-BGtqoZ8d.js +2 -0
  32. package/src/client/dist/spa/assets/{DiffViewer-BJZADilo.js → DiffViewer-qjJ-biOw.js} +3 -3
  33. package/src/client/dist/spa/assets/HealthPage-CKyf7ky6.js +1 -0
  34. package/src/client/dist/spa/assets/MainLayout-B07zv82Z.css +1 -0
  35. package/src/client/dist/spa/assets/{MainLayout-CFHf3zKv.js → MainLayout-Br3jmaOw.js} +17 -17
  36. package/src/client/dist/spa/assets/QCheckbox-CcY7ZSk9.js +1 -0
  37. package/src/client/dist/spa/assets/{QChip-D905z6BM.js → QChip-BhT0W2Dg.js} +1 -1
  38. package/src/client/dist/spa/assets/QExpansionItem-BnIPCzXR.js +1 -0
  39. package/src/client/dist/spa/assets/{QInput-6U0_avSY.js → QInput-D4WJro4e.js} +1 -1
  40. package/src/client/dist/spa/assets/{QItemSection-Cloi4ErY.js → QItemSection-KFAnxzMK.js} +1 -1
  41. package/src/client/dist/spa/assets/QMenu-0LsqhRZT.js +1 -0
  42. package/src/client/dist/spa/assets/{QPage-C6h_ah5z.js → QPage-Cu7zkfc6.js} +1 -1
  43. package/src/client/dist/spa/assets/QRadio-DaZhdLCg.js +1 -0
  44. package/src/client/dist/spa/assets/{touch-DBLw8vQK.js → QResizeObserver-Cf79V-VZ.js} +1 -1
  45. package/src/client/dist/spa/assets/QScrollArea-BDCKOKuE.js +1 -0
  46. package/src/client/dist/spa/assets/QTabPanels-Ctnrqvp9.js +1 -0
  47. package/src/client/dist/spa/assets/QToggle-CGpiJLDJ.js +1 -0
  48. package/src/client/dist/spa/assets/QTooltip-B3CmRx4j.js +1 -0
  49. package/src/client/dist/spa/assets/SearchPage-Ce8Uc7Ol.js +1 -0
  50. package/src/client/dist/spa/assets/SettingsPage-8N0X7B7o.css +1 -0
  51. package/src/client/dist/spa/assets/SettingsPage-BaaSJ3eJ.js +1 -0
  52. package/src/client/dist/spa/assets/WorkspacePage-DQxGe62K.css +1 -0
  53. package/src/client/dist/spa/assets/WorkspacePage-wZUUTDzp.js +4 -0
  54. package/src/client/dist/spa/assets/build-path-tree-DRViYT3t.js +1 -0
  55. package/src/client/dist/spa/assets/{cssMode-QQTtBrD_.js → cssMode-uAfRqG2Q.js} +1 -1
  56. package/src/client/dist/spa/assets/{editor.api-YqpktRoe.js → editor.api-5GUlxvcL.js} +1 -1
  57. package/src/client/dist/spa/assets/{editor.main-DDGqfxYm.js → editor.main-CSTJjBIa.js} +3 -3
  58. package/src/client/dist/spa/assets/expand-template-zA3pTyIP.js +1 -0
  59. package/src/client/dist/spa/assets/{formatters-DWeOzSfw.js → formatters-ejxELb0M.js} +1 -1
  60. package/src/client/dist/spa/assets/{freemarker2-BC_Lt7t3.js → freemarker2-BxBnI8Nb.js} +1 -1
  61. package/src/client/dist/spa/assets/{handlebars-BphhRg2c.js → handlebars-DrbIsXmT.js} +1 -1
  62. package/src/client/dist/spa/assets/{html-C84Ufc1n.js → html-DH7u_g5l.js} +1 -1
  63. package/src/client/dist/spa/assets/{htmlMode-CIlyKZJ4.js → htmlMode-BlY9QO3f.js} +1 -1
  64. package/src/client/dist/spa/assets/i18n-B41j--A3.js +1 -0
  65. package/src/client/dist/spa/assets/index-DoYBJtQA.js +2 -0
  66. package/src/client/dist/spa/assets/{javascript-D5LTZTWn.js → javascript-B-AL31ke.js} +1 -1
  67. package/src/client/dist/spa/assets/{jsonMode-YBOBMJNl.js → jsonMode-Dx7CA4ag.js} +1 -1
  68. package/src/client/dist/spa/assets/{kobo-commands-DFflpxts.js → kobo-commands-DiUm1Y34.js} +1 -1
  69. package/src/client/dist/spa/assets/{liquid-UNCP2Jl6.js → liquid--H7Vomnm.js} +1 -1
  70. package/src/client/dist/spa/assets/{marked.esm-D7ibHC_y.js → marked.esm-DLCrAGtO.js} +1 -1
  71. package/src/client/dist/spa/assets/{mdx-CsHyBm_B.js → mdx-BOackeU6.js} +1 -1
  72. package/src/client/dist/spa/assets/{models-tXWASlTL.js → models-BPfFBcxr.js} +1 -1
  73. package/src/client/dist/spa/assets/{monaco.contribution-Bv79M2zD.js → monaco.contribution-ydrMjZwK.js} +2 -2
  74. package/src/client/dist/spa/assets/{python-B3h-WTW0.js → python-BWGSV-nk.js} +1 -1
  75. package/src/client/dist/spa/assets/{razor-Cs79ULMl.js → razor-BGnl83cS.js} +1 -1
  76. package/src/client/dist/spa/assets/settings-lT4GB-uB.js +1 -0
  77. package/src/client/dist/spa/assets/symbols-BVRrMH2r.js +1 -0
  78. package/src/client/dist/spa/assets/touch-Co9pfjUU.js +1 -0
  79. package/src/client/dist/spa/assets/{tsMode-238NR35q.js → tsMode-Chjqq1f3.js} +1 -1
  80. package/src/client/dist/spa/assets/{typescript-C93UakWa.js → typescript-By7Y7PAP.js} +1 -1
  81. package/src/client/dist/spa/assets/{use-checkbox-w-raiu10.js → use-checkbox-DzHmcu7s.js} +1 -1
  82. package/src/client/dist/spa/assets/use-panel-DWX2aNMM.js +1 -0
  83. package/src/client/dist/spa/assets/{xml-24CcVrVJ.js → xml-DoAeCRiy.js} +1 -1
  84. package/src/client/dist/spa/assets/{yaml-BLhB8_OL.js → yaml-DlT7YOhG.js} +1 -1
  85. package/src/client/dist/spa/index.html +10 -7
  86. package/src/mcp-server/kobo-tasks-handlers.ts +2 -1
  87. package/src/mcp-server/kobo-tasks-server.ts +60 -1
  88. package/dist/server/services/agent/engines/claude-code/args-builder.js +0 -57
  89. package/dist/server/services/agent/engines/claude-code/mcp-config.js +0 -23
  90. package/dist/server/services/agent/engines/claude-code/stream-parser.js +0 -386
  91. package/src/client/dist/spa/assets/ActivityFeed-BHdMJRwS.css +0 -1
  92. package/src/client/dist/spa/assets/ActivityFeed-D7MF6IK1.js +0 -8
  93. package/src/client/dist/spa/assets/CreatePage-DJbZH8wp.css +0 -1
  94. package/src/client/dist/spa/assets/CreatePage-rp-9_jOF.js +0 -2
  95. package/src/client/dist/spa/assets/HealthPage-CZQB2pvh.js +0 -1
  96. package/src/client/dist/spa/assets/MainLayout-Db3dwSTM.css +0 -1
  97. package/src/client/dist/spa/assets/QExpansionItem-CUXuOfeR.js +0 -1
  98. package/src/client/dist/spa/assets/QMenu-BPzgTm2k.js +0 -1
  99. package/src/client/dist/spa/assets/QScrollArea-N10UpHIf.js +0 -1
  100. package/src/client/dist/spa/assets/QSlideTransition-BMX92yUu.js +0 -1
  101. package/src/client/dist/spa/assets/QTabPanels-PPompnxw.js +0 -1
  102. package/src/client/dist/spa/assets/QTooltip-DLT8jCHz.js +0 -1
  103. package/src/client/dist/spa/assets/SearchPage-CfYy4vGJ.js +0 -1
  104. package/src/client/dist/spa/assets/SettingsPage-B8DhSZw7.css +0 -1
  105. package/src/client/dist/spa/assets/SettingsPage-ONWYC-Bn.js +0 -1
  106. package/src/client/dist/spa/assets/WorkspacePage-B2VAbf6l.js +0 -4
  107. package/src/client/dist/spa/assets/WorkspacePage-k2pgeRoy.css +0 -1
  108. package/src/client/dist/spa/assets/build-path-tree-DETFP2lL.js +0 -1
  109. package/src/client/dist/spa/assets/expand-template-CZkefibF.js +0 -1
  110. package/src/client/dist/spa/assets/i18n-CNdSgNP6.js +0 -1
  111. package/src/client/dist/spa/assets/index-pGAaG7Rh.js +0 -2
  112. package/src/client/dist/spa/assets/settings-Cw4mtk9x.js +0 -1
  113. package/src/client/dist/spa/assets/stats-BrLStQKj.js +0 -1
  114. package/src/client/dist/spa/assets/symbols-TAFELniU.js +0 -1
  115. /package/src/client/dist/spa/assets/{QBadge-BUkmTO0P.js → QBadge-fsQ2AokU.js} +0 -0
  116. /package/src/client/dist/spa/assets/{QBtn-CyzfM9-_.js → QBtn-DHwAb18J.js} +0 -0
  117. /package/src/client/dist/spa/assets/{QItemLabel-DwnV_S8y.js → QItemLabel-DWwenW2S.js} +0 -0
  118. /package/src/client/dist/spa/assets/{QList-DZfpUv3n.js → QList-NmIE6Rd9.js} +0 -0
  119. /package/src/client/dist/spa/assets/{QSpace-PlDK6Fg3.js → QSpace-COlmM_4F.js} +0 -0
  120. /package/src/client/dist/spa/assets/{QSpinnerDots-D7bo_KgI.js → QSpinnerDots-DwtnRN2r.js} +0 -0
  121. /package/src/client/dist/spa/assets/{_plugin-vue_export-helper-CpNzZuug.js → _plugin-vue_export-helper-B8bB5DBd.js} +0 -0
  122. /package/src/client/dist/spa/assets/{abap-DrZwwXZX.js → abap-DzK-OTGh.js} +0 -0
  123. /package/src/client/dist/spa/assets/{apex-CrCz0btt.js → apex-Bj60_dRt.js} +0 -0
  124. /package/src/client/dist/spa/assets/{azcli-BapzKHay.js → azcli-B6NwaBAZ.js} +0 -0
  125. /package/src/client/dist/spa/assets/{bat-C_NRAiA1.js → bat-bf7wXV68.js} +0 -0
  126. /package/src/client/dist/spa/assets/{bicep-C7pp2CNk.js → bicep-C_bg8UgA.js} +0 -0
  127. /package/src/client/dist/spa/assets/{cameligo-BhhK9vxZ.js → cameligo-CTWw4D4B.js} +0 -0
  128. /package/src/client/dist/spa/assets/{clojure-D0ujmUyE.js → clojure-CgdPoH0r.js} +0 -0
  129. /package/src/client/dist/spa/assets/{coffee-DHEl7Jbb.js → coffee-gHQfdA5M.js} +0 -0
  130. /package/src/client/dist/spa/assets/{cpp-Iil-3nzZ.js → cpp-BM4Jj4aW.js} +0 -0
  131. /package/src/client/dist/spa/assets/{csharp-Dh0Ee7SY.js → csharp-D8-bh4Cd.js} +0 -0
  132. /package/src/client/dist/spa/assets/{csp-mwzjw0JL.js → csp-CXBxRx0n.js} +0 -0
  133. /package/src/client/dist/spa/assets/{css-COIa8ZTR.js → css-DKjIxrmY.js} +0 -0
  134. /package/src/client/dist/spa/assets/{cypher-GVc17FC4.js → cypher-C5e5inIh.js} +0 -0
  135. /package/src/client/dist/spa/assets/{dart-phiCaE7_.js → dart-BhRHHm4x.js} +0 -0
  136. /package/src/client/dist/spa/assets/{dockerfile-BMaDhdim.js → dockerfile-DW5REF8E.js} +0 -0
  137. /package/src/client/dist/spa/assets/{documents-BMdAS6h8.js → documents-D6A3wRry.js} +0 -0
  138. /package/src/client/dist/spa/assets/{ecl-Cj47kvqp.js → ecl-Bw4Hg3n_.js} +0 -0
  139. /package/src/client/dist/spa/assets/{elixir-DBbstcE1.js → elixir-DHmoBvpZ.js} +0 -0
  140. /package/src/client/dist/spa/assets/{flow9-ChHb1adO.js → flow9-BsFExz3v.js} +0 -0
  141. /package/src/client/dist/spa/assets/{fsharp-CDI_AxQw.js → fsharp-BaeLhgfq.js} +0 -0
  142. /package/src/client/dist/spa/assets/{go-DmsC2k-Y.js → go-Bd-NFKIC.js} +0 -0
  143. /package/src/client/dist/spa/assets/{graphql-C8hjT6Ki.js → graphql-DZVerJfy.js} +0 -0
  144. /package/src/client/dist/spa/assets/{hcl-C15cAQOZ.js → hcl-CAVzrZfH.js} +0 -0
  145. /package/src/client/dist/spa/assets/{ini-CKrAe0ag.js → ini-CyXdX58t.js} +0 -0
  146. /package/src/client/dist/spa/assets/{java-BVhjILyl.js → java-B5pNgvhy.js} +0 -0
  147. /package/src/client/dist/spa/assets/{julia-BzPDHDOG.js → julia-XRhmV3AN.js} +0 -0
  148. /package/src/client/dist/spa/assets/{kotlin-DQMAn-b6.js → kotlin-DOd3J5vr.js} +0 -0
  149. /package/src/client/dist/spa/assets/{less-428mfr1h.js → less-veZSnyw6.js} +0 -0
  150. /package/src/client/dist/spa/assets/{lexon-B09dCO6A.js → lexon-QWGkuK0H.js} +0 -0
  151. /package/src/client/dist/spa/assets/{lua-CVQ0BJif.js → lua-CYGpjuO5.js} +0 -0
  152. /package/src/client/dist/spa/assets/{m3-CiPQ1ljw.js → m3-yNnrZkdc.js} +0 -0
  153. /package/src/client/dist/spa/assets/{markdown--G0dqL-7.js → markdown-BCSWEPSX.js} +0 -0
  154. /package/src/client/dist/spa/assets/{mips-BaboCM3T.js → mips-OpYmcC30.js} +0 -0
  155. /package/src/client/dist/spa/assets/{msdax-DUaqkqre.js → msdax-2oxoTO9Z.js} +0 -0
  156. /package/src/client/dist/spa/assets/{mysql-CUE6XF4r.js → mysql-5KlC-K_9.js} +0 -0
  157. /package/src/client/dist/spa/assets/{objective-c-C4MUnzeT.js → objective-c-CcDCgtLx.js} +0 -0
  158. /package/src/client/dist/spa/assets/{pascal-CWMUMx__.js → pascal-BZGsbaEV.js} +0 -0
  159. /package/src/client/dist/spa/assets/{pascaligo-DLCVutek.js → pascaligo-DtD5qU3G.js} +0 -0
  160. /package/src/client/dist/spa/assets/{perl-JYoirQpx.js → perl-C1jNNS3E.js} +0 -0
  161. /package/src/client/dist/spa/assets/{pgsql-BqOy7sqx.js → pgsql-CT0fhiZa.js} +0 -0
  162. /package/src/client/dist/spa/assets/{php-PZqsysO1.js → php-D6DrXoPM.js} +0 -0
  163. /package/src/client/dist/spa/assets/{pla-BiwqVlg6.js → pla-b3-HN2pF.js} +0 -0
  164. /package/src/client/dist/spa/assets/{postiats-COxQtXCD.js → postiats-Bin2ApVS.js} +0 -0
  165. /package/src/client/dist/spa/assets/{powerquery-DdXUmaWa.js → powerquery-7ASnn-ZG.js} +0 -0
  166. /package/src/client/dist/spa/assets/{powershell-D05yu9sz.js → powershell-t4p7sU1H.js} +0 -0
  167. /package/src/client/dist/spa/assets/{protobuf-BDsm0ZB_.js → protobuf-BUGeWa_j.js} +0 -0
  168. /package/src/client/dist/spa/assets/{pug-3CmTiGoi.js → pug-BuKcgC9s.js} +0 -0
  169. /package/src/client/dist/spa/assets/{qsharp-C4eHfCpJ.js → qsharp-DSMtI_O7.js} +0 -0
  170. /package/src/client/dist/spa/assets/{r-Decg_RIU.js → r-DMlFgn7A.js} +0 -0
  171. /package/src/client/dist/spa/assets/{redis-Cl3EBA4R.js → redis-cXItkC5u.js} +0 -0
  172. /package/src/client/dist/spa/assets/{redshift-5ZsNLhOp.js → redshift-BZVbW7HE.js} +0 -0
  173. /package/src/client/dist/spa/assets/{restructuredtext-BulNNF_e.js → restructuredtext-BzjxwS8h.js} +0 -0
  174. /package/src/client/dist/spa/assets/{ruby-D3Axi_9w.js → ruby-C5nyLV4l.js} +0 -0
  175. /package/src/client/dist/spa/assets/{rust-Csys1Tos.js → rust-BcmMsHdf.js} +0 -0
  176. /package/src/client/dist/spa/assets/{sb-C_iBPphi.js → sb-Dnb1iy6B.js} +0 -0
  177. /package/src/client/dist/spa/assets/{scala-Cg4p-EZ2.js → scala-anMIFYpA.js} +0 -0
  178. /package/src/client/dist/spa/assets/{scheme-BlVnEL_j.js → scheme-BItQTe08.js} +0 -0
  179. /package/src/client/dist/spa/assets/{scss-CmLW8ojr.js → scss-BOv51BJ5.js} +0 -0
  180. /package/src/client/dist/spa/assets/{shell-B1DV_gpl.js → shell-BsRYRTNN.js} +0 -0
  181. /package/src/client/dist/spa/assets/{solidity-glFpNhe3.js → solidity-BtuLgGDx.js} +0 -0
  182. /package/src/client/dist/spa/assets/{sophia-D9j4cFkA.js → sophia-B0Vkc5MF.js} +0 -0
  183. /package/src/client/dist/spa/assets/{sparql-DV5Ux9cO.js → sparql-B7lvkZQM.js} +0 -0
  184. /package/src/client/dist/spa/assets/{sql-K8tNKFcf.js → sql-DvP5MpA3.js} +0 -0
  185. /package/src/client/dist/spa/assets/{st-BhIdE2hj.js → st-GVUeyB3U.js} +0 -0
  186. /package/src/client/dist/spa/assets/{swift-B0pzSmmx.js → swift-DSPIoCjm.js} +0 -0
  187. /package/src/client/dist/spa/assets/{systemverilog-CeBgixbN.js → systemverilog-Icj2-k23.js} +0 -0
  188. /package/src/client/dist/spa/assets/{tcl-B0Ji3IbZ.js → tcl-Cd8KQcm-.js} +0 -0
  189. /package/src/client/dist/spa/assets/{twig-KUgPCP41.js → twig-CBHmt8z3.js} +0 -0
  190. /package/src/client/dist/spa/assets/{typespec-ryrhjid6.js → typespec-Ckc037mq.js} +0 -0
  191. /package/src/client/dist/spa/assets/{use-quasar-Clv5nVxk.js → use-quasar-Cc4smfg5.js} +0 -0
  192. /package/src/client/dist/spa/assets/{vb-Z68-YtMY.js → vb-B97GW9Wb.js} +0 -0
  193. /package/src/client/dist/spa/assets/{vue-i18n-BVrBmgZa.js → vue-i18n-eUDnMrPl.js} +0 -0
  194. /package/src/client/dist/spa/assets/{wgsl-bH-W-d_T.js → wgsl-DIKmb3YH.js} +0 -0
@@ -1,11 +1,13 @@
1
- import { getWorkspace, listTasks } from '../workspace-service.js';
2
1
  export class SessionController {
3
2
  workspaceId;
4
3
  agentSessionId;
5
4
  engine;
6
5
  onEvent;
7
- engineProcess;
6
+ _engineProcess;
8
7
  _status = 'running';
8
+ get engineProcess() {
9
+ return this._engineProcess;
10
+ }
9
11
  constructor(workspaceId, agentSessionId, engine, onEvent) {
10
12
  this.workspaceId = workspaceId;
11
13
  this.agentSessionId = agentSessionId;
@@ -13,67 +15,36 @@ export class SessionController {
13
15
  this.onEvent = onEvent;
14
16
  }
15
17
  async start(options) {
16
- if (this.engineProcess)
18
+ if (this._engineProcess)
17
19
  throw new Error('SessionController already started');
18
- this.engineProcess = await this.engine.start(options, (ev) => this.handle(ev));
20
+ this._engineProcess = await this.engine.start(options, (ev) => this.handle(ev));
19
21
  this._status = 'running';
20
22
  }
21
23
  sendMessage(content) {
22
- if (!this.engineProcess)
24
+ if (!this._engineProcess)
23
25
  throw new Error('SessionController not started');
24
- this.engineProcess.sendMessage(content);
26
+ this._engineProcess.sendMessage(content);
25
27
  }
26
28
  interrupt() {
27
- if (!this.engineProcess)
29
+ if (!this._engineProcess)
28
30
  throw new Error('SessionController not started');
29
- this.engineProcess.interrupt();
31
+ this._engineProcess.interrupt();
30
32
  }
31
33
  async stop() {
32
34
  this._status = 'stopping';
33
- if (this.engineProcess)
34
- await this.engineProcess.stop();
35
+ if (this._engineProcess)
36
+ await this._engineProcess.stop();
35
37
  }
36
38
  get status() {
37
39
  return this._status;
38
40
  }
39
41
  get pid() {
40
- return this.engineProcess?.pid;
42
+ return this._engineProcess?.pid;
41
43
  }
42
44
  get engineSessionId() {
43
- return this.engineProcess?.engineSessionId;
45
+ return this._engineProcess?.engineSessionId;
44
46
  }
45
47
  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
48
  this.onEvent(ev);
55
49
  }
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
50
  }
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
- import path from 'node:path';
3
2
  import { buildE2eIterationBlock, buildFinalizationIterationBlock } from '../../shared/auto-loop-prompts.js';
4
3
  import { getDb } from '../db/index.js';
4
+ import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
5
5
  import * as orchestrator from './agent/orchestrator.js';
6
6
  import * as settingsService from './settings-service.js';
7
7
  import { emit, emitEphemeral } from './websocket-service.js';
@@ -10,7 +10,7 @@ const NO_PROGRESS_STALL_THRESHOLD = 3;
10
10
  function getRow(workspaceId) {
11
11
  const db = getDb();
12
12
  const row = db
13
- .prepare(`SELECT id, project_path, working_branch, worktree_path, model, permission_mode, reasoning_effort,
13
+ .prepare(`SELECT id, project_path, working_branch, worktree_path, model, permission_mode, agent_permission_mode, reasoning_effort,
14
14
  status, auto_loop, auto_loop_ready, no_progress_streak, archived_at
15
15
  FROM workspaces WHERE id = ?`)
16
16
  .get(workspaceId);
@@ -97,6 +97,10 @@ export function onSessionEnded(workspaceId, reason, tasksDoneDelta) {
97
97
  // timer), let that timer own the next spawn so the backoff delay is respected.
98
98
  if (row.status === 'quota')
99
99
  return;
100
+ // Don't spawn a competing session while paused on canUseTool — the user
101
+ // will resume the deferred turn explicitly.
102
+ if (row.status === 'awaiting-user')
103
+ return;
100
104
  if (reason === 'error' || reason === 'killed') {
101
105
  disable(workspaceId, 'error');
102
106
  return;
@@ -219,6 +223,9 @@ function spawnNextIteration(workspaceId, opts = {}) {
219
223
  const row = getRow(workspaceId);
220
224
  if (!row)
221
225
  return;
226
+ // Same guard as onSessionEnded — never race a deferred-resume start.
227
+ if (row.status === 'awaiting-user')
228
+ return;
222
229
  const task = pickNextTask(workspaceId);
223
230
  if (!task) {
224
231
  disable(workspaceId, 'completed');
@@ -245,11 +252,15 @@ function spawnNextIteration(workspaceId, opts = {}) {
245
252
  .replaceAll('{taskTitle}', task.title)
246
253
  .replaceAll('{isAcceptanceCriterion}', String(task.isAcceptanceCriterion))
247
254
  .replaceAll('{overrideBlock}', overrideBlock);
248
- const worktreePath = row.worktree_path ?? path.join(row.project_path, '.worktrees', row.working_branch);
249
- // Auto-loop iterations always run in auto-accept mode. Plan mode blocks MCP
250
- // tools (kobo__mark_task_done, etc.) and Edit/Write/Bash everything the
251
- // iteration needs so honoring a 'plan' setting here would deadlock the loop.
252
- const permissionMode = 'auto-accept';
255
+ const worktreePath = row.worktree_path ?? resolveWorkspaceWorktreePath(row.project_path, row.working_branch);
256
+ // Plan mode would deadlock the loop (blocks MCP + edits) — promote to bypass.
257
+ // Other modes (bypass/strict/interactive) are honored.
258
+ const stored = (row.agent_permission_mode ?? 'bypass');
259
+ const agentPermissionMode = stored === 'plan' ? 'bypass' : stored;
260
+ if (stored === 'plan') {
261
+ console.warn(`[auto-loop-service] Promoting plan → bypass for workspace ${workspaceId} — auto-loop cannot run in plan mode`);
262
+ emitEphemeral(workspaceId, 'autoloop:permission-overridden', { from: 'plan', to: 'bypass' });
263
+ }
253
264
  // Pre-check: if the worktree directory is gone (user `rm -rf`-ed it),
254
265
  // fail loudly rather than letting startAgent throw a deep engine error.
255
266
  if (!fs.existsSync(worktreePath)) {
@@ -263,7 +274,7 @@ function spawnNextIteration(workspaceId, opts = {}) {
263
274
  let agentSessionId;
264
275
  try {
265
276
  const agent = orchestrator.startAgent(workspaceId, worktreePath, prompt, row.model, false, // resume=false — fresh context for each iteration
266
- permissionMode, undefined, row.reasoning_effort);
277
+ agentPermissionMode, undefined, row.reasoning_effort);
267
278
  agentSessionId = agent.agentSessionId;
268
279
  }
269
280
  catch (err) {
@@ -1,7 +1,3 @@
1
- import { nanoid } from 'nanoid';
2
- import { createParserState, parseClaudeLine } from './agent/engines/claude-code/stream-parser.js';
3
- import { createPreMigrationBackup } from './db-backup-service.js';
4
- import { broadcastAll } from './websocket-service.js';
5
1
  const internal = { state: 'idle', total: 0, processed: 0 };
6
2
  let isRunning = false;
7
3
  function snapshot() {
@@ -41,105 +37,39 @@ function snapshot() {
41
37
  export function getContentMigrationStatus() {
42
38
  return snapshot();
43
39
  }
44
- export async function runContentMigrationIfNeeded(db, dbPath) {
40
+ /**
41
+ * Legacy ws_events content migration.
42
+ *
43
+ * Historical context: earlier versions of Kōbō persisted Claude Code stdout as
44
+ * `agent:output` / `agent:stderr` / `agent:status` rows. The migration to the
45
+ * unified `agent:event` shape was completed in v10. As of the Claude Agent SDK
46
+ * cutover, the stream-parser used to reconstruct AgentEvents from those legacy
47
+ * rows has been removed.
48
+ *
49
+ * All production databases have been migrated. This function is now a no-op
50
+ * kept for API compatibility — it always reports `idle`. Should any rare,
51
+ * unmigrated row remain, it is left untouched in `ws_events` (and ignored by
52
+ * the new replay path which only reads `agent:event`).
53
+ */
54
+ export async function runContentMigrationIfNeeded(_db, _dbPath) {
45
55
  if (isRunning)
46
56
  return;
47
57
  isRunning = true;
48
58
  try {
49
- const row = db
50
- .prepare("SELECT COUNT(*) AS c FROM ws_events WHERE type IN ('agent:output', 'agent:stderr', 'agent:status')")
51
- .get();
52
- if (row.c === 0) {
53
- internal.state = 'idle';
54
- isRunning = false;
55
- return;
56
- }
57
- internal.state = 'backing-up';
58
- internal.startedAt = new Date().toISOString();
59
- broadcastStatus();
60
- const backup = await createPreMigrationBackup(db, dbPath, 'v10');
61
- internal.backupPath = backup.created ?? undefined;
62
- internal.state = 'running';
63
- internal.total = row.c;
64
- internal.processed = 0;
65
- broadcastStatus();
66
- await processLoop(db);
67
- internal.state = 'done';
68
- internal.finishedAt = new Date().toISOString();
69
- broadcastStatus();
70
- }
71
- catch (err) {
72
- internal.state = 'error';
73
- internal.errorMessage = err instanceof Error ? err.message : String(err);
74
- broadcastStatus();
75
- throw err;
59
+ internal.state = 'idle';
76
60
  }
77
61
  finally {
78
62
  isRunning = false;
79
63
  }
80
64
  }
81
- function broadcastStatus() {
82
- // Content-migration events are global (no workspace context) — use broadcastAll so every
83
- // connected WS client receives them regardless of their workspace subscriptions.
84
- broadcastAll(internal.state === 'error' ? 'migration:error' : 'migration:progress', getContentMigrationStatus());
85
- }
86
- async function processLoop(db) {
87
- const batchSize = 500;
88
- const selectStmt = db.prepare("SELECT id, workspace_id, type, payload, session_id, created_at FROM ws_events WHERE type IN ('agent:output', 'agent:stderr', 'agent:status') ORDER BY created_at ASC LIMIT ?");
89
- const insertStmt = db.prepare('INSERT INTO ws_events (id, workspace_id, type, payload, session_id, created_at) VALUES (?, ?, ?, ?, ?, ?)');
90
- const deleteStmt = db.prepare('DELETE FROM ws_events WHERE id = ?');
91
- while (true) {
92
- const rows = selectStmt.all(batchSize);
93
- if (rows.length === 0)
94
- break;
95
- db.transaction(() => {
96
- for (const r of rows) {
97
- const events = convertRow(r.type, r.payload, { workspaceId: r.workspace_id });
98
- for (const ev of events) {
99
- insertStmt.run(nanoid(), r.workspace_id, 'agent:event', JSON.stringify(ev), r.session_id, r.created_at);
100
- }
101
- deleteStmt.run(r.id);
102
- }
103
- })();
104
- internal.processed += rows.length;
105
- broadcastStatus();
106
- // Yield to the event loop
107
- await new Promise((resolve) => setImmediate(resolve));
108
- }
109
- }
110
- export function convertRow(type, payload, context) {
111
- if (type === 'agent:status')
112
- return []; // redundant — re-derivable from session events
113
- if (type === 'agent:stderr') {
114
- // Drop: the new engine only logs non-quota stderr via console.warn and
115
- // does not persist it. Converting legacy stderr rows to error events
116
- // would surface every historical Claude CLI warning ("no stdin data in
117
- // 3s…", debug lines) as a UI-blocking banner. Quota-bearing stderr is
118
- // handled live by the engine's backoff path, not via replay.
119
- return [];
120
- }
121
- if (type === 'agent:output') {
122
- try {
123
- const parsed = JSON.parse(payload);
124
- // The legacy payload may be either the raw Claude NDJSON already-parsed object,
125
- // or a wrapper { type: 'raw', content: '...' } for non-JSON output. Handle both.
126
- if (parsed && typeof parsed === 'object' && parsed.type === 'raw') {
127
- return [{ kind: 'message:raw', content: String(parsed.content ?? '') }];
128
- }
129
- const state = createParserState();
130
- const { events } = parseClaudeLine(JSON.stringify(parsed), state);
131
- return events;
132
- }
133
- catch {
134
- // Log enough to debug (first 200 chars of the bad payload + the owning
135
- // workspace id when the caller passed one). We intentionally do not log
136
- // the full payload to keep the console readable on noisy migrations.
137
- const preview = payload.length > 200 ? `${payload.slice(0, 200)}…` : payload;
138
- const ctx = context?.workspaceId ? ` (workspace=${context.workspaceId})` : '';
139
- console.warn(`[content-migration] Could not parse agent:output payload${ctx}, falling back to message:raw. Preview: ${preview}`);
140
- return [{ kind: 'message:raw', content: payload }];
141
- }
142
- }
65
+ /**
66
+ * Convert a legacy ws_events row into AgentEvents.
67
+ *
68
+ * The stream-parser has been removed; this function now skips every legacy
69
+ * type and returns an empty array. It is preserved as an export for API
70
+ * compatibility with old call sites and tests.
71
+ */
72
+ export function convertRow(_type, _payload, _context) {
143
73
  return [];
144
74
  }
145
75
  /** Test-only. */
@@ -1,7 +1,9 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { WORKTREES_PATH } from '../../shared/consts.js';
3
4
  import { listClaudeMcpEntries } from '../utils/mcp-client.js';
4
5
  import { getSettingsPath } from '../utils/paths.js';
6
+ import { InvalidWorktreesPathError, resolveGlobalWorktreesRoot, sanitizeWorktreesPath, validateWorktreesPath, } from '../utils/worktree-paths.js';
5
7
  const DEFAULT_GIT_CONVENTIONS = `# Git conventions
6
8
 
7
9
  ## Commits
@@ -191,6 +193,13 @@ const settingsMigrations = [
191
193
  }
192
194
  },
193
195
  },
196
+ {
197
+ version: 11,
198
+ name: 'add-global-worktrees-path',
199
+ migrate({ global }) {
200
+ global.worktreesPath = sanitizeWorktreesPath(global.worktreesPath);
201
+ },
202
+ },
194
203
  ];
195
204
  /** Current settings schema version — always equals the highest migration version. */
196
205
  export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
@@ -231,6 +240,7 @@ function defaultSettings() {
231
240
  notionMcpKey: '',
232
241
  sentryMcpKey: '',
233
242
  tags: [...DEFAULT_WORKSPACE_TAGS],
243
+ worktreesPath: WORKTREES_PATH,
234
244
  },
235
245
  projects: [],
236
246
  };
@@ -278,6 +288,7 @@ export function runSettingsMigrations(raw) {
278
288
  version = m.version;
279
289
  }
280
290
  }
291
+ current.global.worktreesPath = sanitizeWorktreesPath(current.global.worktreesPath);
281
292
  current.schemaVersion = version;
282
293
  return current;
283
294
  }
@@ -310,9 +321,11 @@ function readSettings() {
310
321
  // Restore any global fields that may have been removed by external edits.
311
322
  // Defaults act as fallback for missing keys; existing values are preserved.
312
323
  const globalDefaults = defaultSettings().global;
324
+ const globalBeforeDefaults = JSON.stringify(migrated.global);
313
325
  migrated.global = { ...globalDefaults, ...migrated.global };
326
+ const restoredGlobalFields = JSON.stringify(migrated.global) !== globalBeforeDefaults;
314
327
  // Persist if migrations bumped the version, or if global fields were restored.
315
- if (migrated.schemaVersion !== originalVersion) {
328
+ if (migrated.schemaVersion !== originalVersion || restoredGlobalFields) {
316
329
  writeSettings(migrated);
317
330
  }
318
331
  return migrated;
@@ -450,6 +463,7 @@ export function updateGlobalSettings(data) {
450
463
  'notionMcpKey',
451
464
  'sentryMcpKey',
452
465
  'tags',
466
+ 'worktreesPath',
453
467
  ];
454
468
  const filtered = pickKnownKeys(data, allowedGlobalKeys);
455
469
  if (filtered.tags !== undefined) {
@@ -459,10 +473,30 @@ export function updateGlobalSettings(data) {
459
473
  .filter((t) => t.length > 0 && t.length <= 50)))
460
474
  : settings.global.tags;
461
475
  }
476
+ if (filtered.worktreesPath !== undefined) {
477
+ filtered.worktreesPath = validateWorktreesPath(filtered.worktreesPath, { allowEmpty: false });
478
+ ensureGlobalWorktreesRootExists(filtered.worktreesPath);
479
+ }
462
480
  settings.global = { ...settings.global, ...filtered };
463
481
  writeSettings(settings, { backup: true });
464
482
  return settings.global;
465
483
  }
484
+ function ensureGlobalWorktreesRootExists(worktreesPath) {
485
+ const root = resolveGlobalWorktreesRoot(worktreesPath);
486
+ if (!root || isNonNativeWindowsPath(root))
487
+ return;
488
+ try {
489
+ fs.mkdirSync(root, { recursive: true });
490
+ }
491
+ catch (err) {
492
+ const message = err instanceof Error ? err.message : String(err);
493
+ throw new InvalidWorktreesPathError(`Cannot create worktrees directory '${root}': ${message}`);
494
+ }
495
+ }
496
+ function isNonNativeWindowsPath(value) {
497
+ return (process.platform !== 'win32' &&
498
+ (/^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') || value.startsWith('//')));
499
+ }
466
500
  /** Create or update project-specific settings. Merges devServer, e2e, and finalization fields on update. */
467
501
  export function upsertProject(projectPath, data) {
468
502
  const allowedProjectKeys = [
@@ -1,5 +1,5 @@
1
- import path from 'node:path';
2
1
  import { getDb } from '../db/index.js';
2
+ import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
3
3
  import * as orchestrator from './agent/orchestrator.js';
4
4
  import { emitEphemeral } from './websocket-service.js';
5
5
  const MIN_DELAY_SECONDS = 60;
@@ -18,7 +18,7 @@ function rowToPending(row) {
18
18
  return { targetAt: row.target_at, reason: row.reason ?? undefined };
19
19
  }
20
20
  /** Schedule a wakeup for the given workspace. Replaces any existing pending wakeup. */
21
- export function schedule(workspaceId, delaySeconds, prompt, reason) {
21
+ export function schedule(workspaceId, delaySeconds, prompt, reason, agentSessionId) {
22
22
  try {
23
23
  const clampedSeconds = clamp(Math.floor(delaySeconds), MIN_DELAY_SECONDS, MAX_DELAY_SECONDS);
24
24
  const effectivePrompt = prompt === AUTONOMOUS_LOOP_SENTINEL ? AUTONOMOUS_LOOP_FALLBACK_PROMPT : prompt;
@@ -28,8 +28,8 @@ export function schedule(workspaceId, delaySeconds, prompt, reason) {
28
28
  clearTimeout(existing);
29
29
  const db = getDb();
30
30
  db.prepare(`INSERT OR REPLACE INTO pending_wakeups
31
- (workspace_id, target_at, prompt, reason, created_at)
32
- VALUES (?, ?, ?, ?, ?)`).run(workspaceId, targetAtIso, effectivePrompt, reason ?? null, new Date().toISOString());
31
+ (workspace_id, target_at, prompt, reason, created_at, agent_session_id)
32
+ VALUES (?, ?, ?, ?, ?, ?)`).run(workspaceId, targetAtIso, effectivePrompt, reason ?? null, new Date().toISOString(), agentSessionId ?? null);
33
33
  const timeout = setTimeout(() => fire(workspaceId), clampedSeconds * 1000);
34
34
  timeout.unref?.();
35
35
  timers.set(workspaceId, timeout);
@@ -125,20 +125,19 @@ function fire(workspaceId) {
125
125
  return;
126
126
  }
127
127
  const wsRow = db
128
- .prepare(`SELECT project_path, working_branch, worktree_path, model, permission_mode, reasoning_effort
128
+ .prepare(`SELECT project_path, working_branch, worktree_path, model, agent_permission_mode, reasoning_effort
129
129
  FROM workspaces WHERE id = ?`)
130
130
  .get(workspaceId);
131
131
  if (!wsRow) {
132
132
  emitEphemeral(workspaceId, 'wakeup:skipped', { reason: 'fire-failed' });
133
133
  return;
134
134
  }
135
- const worktreePath = wsRow.worktree_path ?? path.join(wsRow.project_path, '.worktrees', wsRow.working_branch);
136
- // Defensive: narrow `permission_mode` against the two known values rather
137
- // than trusting the DB column shape. Any unexpected value falls back to
138
- // the safer 'auto-accept'.
139
- const permissionMode = wsRow.permission_mode === 'plan' ? 'plan' : 'auto-accept';
135
+ const worktreePath = wsRow.worktree_path ?? resolveWorkspaceWorktreePath(wsRow.project_path, wsRow.working_branch);
136
+ // Narrow against the four known values; unknowns → 'bypass'.
137
+ const stored = wsRow.agent_permission_mode;
138
+ const agentPermissionMode = stored === 'plan' || stored === 'strict' || stored === 'interactive' ? stored : 'bypass';
140
139
  try {
141
- orchestrator.startAgent(workspaceId, worktreePath, row.prompt, wsRow.model, true, permissionMode, undefined, wsRow.reasoning_effort);
140
+ orchestrator.startAgent(workspaceId, worktreePath, row.prompt, wsRow.model, true, agentPermissionMode, row.agent_session_id ?? undefined, wsRow.reasoning_effort);
142
141
  emitEphemeral(workspaceId, 'wakeup:fired', {});
143
142
  }
144
143
  catch (err) {
@@ -1,14 +1,16 @@
1
1
  import { nanoid } from 'nanoid';
2
2
  import { getDb } from '../db/index.js';
3
+ import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
3
4
  import * as orchestrator from './agent/orchestrator.js';
4
5
  import * as autoLoopService from './auto-loop-service.js';
5
6
  import * as wakeupService from './wakeup-service.js';
6
7
  /** Allowed status transitions per current status. Enforced by updateWorkspaceStatus. */
7
8
  const VALID_TRANSITIONS = {
8
9
  created: ['extracting', 'brainstorming', 'idle', 'error'],
9
- extracting: ['extracting', 'brainstorming', 'idle', 'error'],
10
- brainstorming: ['executing', 'completed', 'idle', 'error'],
11
- executing: ['completed', 'idle', 'error', 'quota'],
10
+ extracting: ['extracting', 'brainstorming', 'idle', 'error', 'awaiting-user'],
11
+ brainstorming: ['executing', 'completed', 'idle', 'error', 'awaiting-user'],
12
+ executing: ['completed', 'idle', 'error', 'quota', 'awaiting-user'],
13
+ 'awaiting-user': ['executing', 'brainstorming', 'extracting', 'idle', 'error', 'completed', 'quota'],
12
14
  completed: ['idle', 'executing'],
13
15
  idle: ['executing', 'brainstorming', 'extracting', 'error'],
14
16
  error: ['idle', 'executing', 'brainstorming', 'extracting'],
@@ -19,6 +21,17 @@ const VALID_TRANSITIONS = {
19
21
  // is untyped, so we intentionally do not narrow here — validation against
20
22
  // `listEngines()` is expected to happen at workspace creation (see the
21
23
  // routes/engines handler) and when resolving an engine at agent-start time.
24
+ /**
25
+ * Coerce a raw `agent_permission_mode` cell into the typed union.
26
+ * Falls back to `bypass` for unknown / null values — guarantees callers
27
+ * always get a valid SDK-mappable mode regardless of legacy or corrupted rows.
28
+ */
29
+ function coerceAgentPermissionMode(raw) {
30
+ if (raw === 'plan' || raw === 'bypass' || raw === 'strict' || raw === 'interactive') {
31
+ return raw;
32
+ }
33
+ return 'bypass';
34
+ }
22
35
  function mapWorkspace(row) {
23
36
  return {
24
37
  id: row.id,
@@ -32,7 +45,7 @@ function mapWorkspace(row) {
32
45
  sentryUrl: row.sentry_url,
33
46
  model: row.model,
34
47
  reasoningEffort: row.reasoning_effort ?? 'auto',
35
- permissionMode: (row.permission_mode ?? 'auto-accept'),
48
+ agentPermissionMode: coerceAgentPermissionMode(row.agent_permission_mode),
36
49
  devServerStatus: row.dev_server_status,
37
50
  hasUnread: row.has_unread === 1,
38
51
  archivedAt: row.archived_at,
@@ -42,7 +55,6 @@ function mapWorkspace(row) {
42
55
  autoLoop: row.auto_loop === 1,
43
56
  autoLoopReady: row.auto_loop_ready === 1,
44
57
  noProgressStreak: row.no_progress_streak ?? 0,
45
- permissionProfile: (row.permission_profile ?? 'bypass'),
46
58
  worktreePath: row.worktree_path ?? '',
47
59
  worktreeOwned: row.worktree_owned === 1,
48
60
  createdAt: row.created_at,
@@ -80,15 +92,20 @@ export function createWorkspace(data) {
80
92
  const db = getDb();
81
93
  const now = new Date().toISOString();
82
94
  const id = nanoid();
83
- const computedWorktreePath = data.worktreePath ?? `${data.projectPath}/.worktrees/${data.workingBranch}`;
95
+ const computedWorktreePath = data.worktreePath ?? resolveWorkspaceWorktreePath(data.projectPath, data.workingBranch, data.worktreesPath);
84
96
  const owned = data.worktreeOwned ?? true;
97
+ // Mirror the unified mode into the legacy columns so older readers (in-flight
98
+ // requests during deploy, external scripts) still see a sane value.
99
+ const unifiedMode = data.agentPermissionMode ?? 'bypass';
100
+ const legacyMode = unifiedMode === 'plan' ? 'plan' : 'auto-accept';
101
+ const legacyProfile = unifiedMode === 'plan' ? 'bypass' : unifiedMode;
85
102
  db.prepare(`
86
103
  INSERT INTO workspaces (
87
104
  id, name, project_path, source_branch, working_branch, status,
88
105
  notion_url, notion_page_id, sentry_url, worktree_path, worktree_owned,
89
- model, reasoning_effort, permission_mode, engine, created_at, updated_at
90
- ) VALUES (?, ?, ?, ?, ?, 'created', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
91
- `).run(id, data.name, data.projectPath, data.sourceBranch, data.workingBranch, data.notionUrl ?? null, data.notionPageId ?? null, data.sentryUrl ?? null, computedWorktreePath, owned ? 1 : 0, data.model ?? 'claude-opus-4-7', data.reasoningEffort ?? 'auto', data.permissionMode ?? 'auto-accept', data.engine ?? 'claude-code', now, now);
106
+ model, reasoning_effort, permission_mode, permission_profile, agent_permission_mode, engine, created_at, updated_at
107
+ ) VALUES (?, ?, ?, ?, ?, 'created', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
108
+ `).run(id, data.name, data.projectPath, data.sourceBranch, data.workingBranch, data.notionUrl ?? null, data.notionPageId ?? null, data.sentryUrl ?? null, computedWorktreePath, owned ? 1 : 0, data.model ?? 'claude-opus-4-7', data.reasoningEffort ?? 'auto', legacyMode, legacyProfile, unifiedMode, data.engine ?? 'claude-code', now, now);
92
109
  return getWorkspace(id);
93
110
  }
94
111
  /** Fetch a single workspace by ID, or null if not found. */
@@ -212,13 +229,21 @@ export function updateWorktreePath(id, newPath) {
212
229
  }
213
230
  return getWorkspace(id);
214
231
  }
215
- /** Update the agent's permission mode (auto-accept vs plan/read-only). */
216
- export function updateWorkspacePermissionMode(id, permissionMode) {
232
+ /**
233
+ * Update the agent's unified permission mode (plan | bypass | strict | interactive).
234
+ *
235
+ * Also writes the legacy `permission_mode` and `permission_profile` columns to
236
+ * keep them coherent with the new value — they remain readable by legacy code
237
+ * paths during deploy. The unified column is the source of truth.
238
+ */
239
+ export function updateAgentPermissionMode(id, mode) {
217
240
  const db = getDb();
218
241
  const now = new Date().toISOString();
242
+ const legacyMode = mode === 'plan' ? 'plan' : 'auto-accept';
243
+ const legacyProfile = mode === 'plan' ? 'bypass' : mode;
219
244
  const result = db
220
- .prepare('UPDATE workspaces SET permission_mode = ?, updated_at = ? WHERE id = ?')
221
- .run(permissionMode, now, id);
245
+ .prepare('UPDATE workspaces SET agent_permission_mode = ?, permission_mode = ?, permission_profile = ?, updated_at = ? WHERE id = ?')
246
+ .run(mode, legacyMode, legacyProfile, now, id);
222
247
  if (result.changes === 0) {
223
248
  throw new Error(`Workspace '${id}' not found`);
224
249
  }
@@ -249,6 +274,10 @@ export function deleteWorkspace(id) {
249
274
  // churn. The Map has no FK to clean up for it automatically.
250
275
  orchestrator.forgetRateLimitInfo(id);
251
276
  orchestrator.forgetTasksDoneSnapshot(id);
277
+ orchestrator.forgetResumeFailed(id);
278
+ orchestrator.forgetPendingQueue(id);
279
+ orchestrator.forgetPreAwaitStatus(id);
280
+ orchestrator.forgetSessionId(id);
252
281
  autoLoopService.forgetAutoLoopState(id);
253
282
  const db = getDb();
254
283
  db.prepare('DELETE FROM workspaces WHERE id = ?').run(id);
@@ -373,30 +402,6 @@ export function setAutoLoopReady(id, ready) {
373
402
  db.prepare('UPDATE workspaces SET auto_loop_ready = ? WHERE id = ?').run(ready ? 1 : 0, id);
374
403
  return getWorkspace(id);
375
404
  }
376
- /**
377
- * Set the permission profile for a workspace.
378
- *
379
- * - `bypass` (default): Kōbō passes `--dangerously-skip-permissions` — no
380
- * prompts, but the CLI hard-denies writes under `.claude/**` and
381
- * `.github/workflows/**` regardless of the project's settings.json.
382
- * - `strict`: Kōbō passes `--permission-mode acceptEdits` — the CLI respects
383
- * the project's `.claude/settings.json` allow/deny lists. Enables writes
384
- * under `.claude/**` / `.github/workflows/**` when the user has explicitly
385
- * allowed them, at the cost of potential prompts on un-allow-listed Bash
386
- * or MCP calls.
387
- *
388
- * Takes effect on the next session spawn — running sessions keep whichever
389
- * flag they were started with.
390
- */
391
- export function setPermissionProfile(id, profile) {
392
- const workspace = getWorkspace(id);
393
- if (!workspace)
394
- throw new Error(`Workspace '${id}' not found`);
395
- const db = getDb();
396
- const now = new Date().toISOString();
397
- db.prepare('UPDATE workspaces SET permission_profile = ?, updated_at = ? WHERE id = ?').run(profile, now, id);
398
- return getWorkspace(id);
399
- }
400
405
  /** Remove a workspace from favorites. Idempotent: safe to call on a non-favorite, though `updated_at` still refreshes. */
401
406
  export function unsetFavorite(id) {
402
407
  const db = getDb();
@@ -2,21 +2,29 @@ import { execFileSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { isGitBranchExistsError } from '../utils/git-ops.js';
5
+ import { resolveWorkspaceWorktreePath, resolveWorktreesRoot } from '../utils/worktree-paths.js';
5
6
  function git(repoPath, args) {
6
7
  return execFileSync('git', args, { cwd: repoPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
7
8
  }
8
9
  function getExcludeFilePath(projectPath) {
9
10
  return path.join(projectPath, '.git', 'info', 'exclude');
10
11
  }
12
+ function projectRelativeWorktreePath(projectPath, worktreePath) {
13
+ const relativePath = path.relative(projectPath, worktreePath);
14
+ if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath))
15
+ return null;
16
+ return relativePath;
17
+ }
11
18
  function addToExclude(projectPath, worktreePath) {
19
+ const relativePath = projectRelativeWorktreePath(projectPath, worktreePath);
20
+ if (!relativePath)
21
+ return;
12
22
  const excludeFile = getExcludeFilePath(projectPath);
13
23
  // Ensure the .git/info directory exists
14
24
  const infoDir = path.dirname(excludeFile);
15
25
  if (!fs.existsSync(infoDir)) {
16
26
  fs.mkdirSync(infoDir, { recursive: true });
17
27
  }
18
- // Make the path relative to projectPath for cleaner exclude entries
19
- const relativePath = path.relative(projectPath, worktreePath);
20
28
  const entry = `/${relativePath}`;
21
29
  let current = '';
22
30
  if (fs.existsSync(excludeFile)) {
@@ -28,23 +36,25 @@ function addToExclude(projectPath, worktreePath) {
28
36
  }
29
37
  }
30
38
  function removeFromExclude(projectPath, worktreePath) {
39
+ const relativePath = projectRelativeWorktreePath(projectPath, worktreePath);
40
+ if (!relativePath)
41
+ return;
31
42
  const excludeFile = getExcludeFilePath(projectPath);
32
43
  if (!fs.existsSync(excludeFile))
33
44
  return;
34
- const relativePath = path.relative(projectPath, worktreePath);
35
45
  const entry = `/${relativePath}`;
36
46
  const lines = fs.readFileSync(excludeFile, 'utf-8').split('\n');
37
47
  const filtered = lines.filter((line) => line !== entry);
38
48
  const trimmed = filtered.join('\n').replace(/\n+$/, '');
39
49
  fs.writeFileSync(excludeFile, trimmed ? `${trimmed}\n` : '', 'utf-8');
40
50
  }
41
- /** Create a git worktree under `.worktrees/` for the given branch. Returns the worktree path. */
42
- export function createWorktree(projectPath, branchName, sourceBranch) {
43
- const worktreesDir = path.join(projectPath, '.worktrees');
51
+ /** Create a git worktree for the given branch. Returns the worktree path. */
52
+ export function createWorktree(projectPath, branchName, sourceBranch, worktreesPath) {
53
+ const worktreesDir = resolveWorktreesRoot(projectPath, worktreesPath);
44
54
  if (!fs.existsSync(worktreesDir)) {
45
55
  fs.mkdirSync(worktreesDir, { recursive: true });
46
56
  }
47
- const worktreePath = path.join(worktreesDir, branchName);
57
+ const worktreePath = resolveWorkspaceWorktreePath(projectPath, branchName, worktreesPath);
48
58
  try {
49
59
  // Use origin/<sourceBranch> as the base so the worktree starts from the
50
60
  // freshly-fetched remote ref (fetchSourceBranch is always called first).