@loicngr/kobo 1.7.0 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +9 -4
  2. package/dist/mcp-server/kobo-tasks-handlers.js +2 -1
  3. package/dist/server/routes/health.js +2 -2
  4. package/dist/server/routes/settings.js +2 -1
  5. package/dist/server/routes/workspaces.js +5 -11
  6. package/dist/server/services/agent/engines/claude-code/engine.js +6 -0
  7. package/dist/server/services/agent/engines/claude-code/resolve-binary.js +50 -0
  8. package/dist/server/services/agent/orchestrator.js +14 -5
  9. package/dist/server/services/auto-loop-service.js +2 -2
  10. package/dist/server/services/dev-server-service.js +2 -2
  11. package/dist/server/services/pr-watcher-service.js +63 -13
  12. package/dist/server/services/settings-service.js +62 -1
  13. package/dist/server/services/wakeup-service.js +2 -2
  14. package/dist/server/services/workspace-service.js +23 -1
  15. package/dist/server/services/worktree-service.js +17 -7
  16. package/dist/server/utils/git-ops.js +12 -4
  17. package/dist/server/utils/worktree-paths.js +134 -0
  18. package/dist/shared/consts.js +1 -0
  19. package/package.json +1 -1
  20. package/src/client/dist/spa/assets/ActivityFeed-B85xav_e.js +7 -0
  21. package/src/client/dist/spa/assets/ClosePopup-BP025_cK.js +1 -0
  22. package/src/client/dist/spa/assets/{CreatePage-U6TtJzNe.js → CreatePage-DyR33jFM.js} +2 -2
  23. package/src/client/dist/spa/assets/{DiffViewer-Di85TBIi.js → DiffViewer-CqhpTkym.js} +3 -3
  24. package/src/client/dist/spa/assets/{HealthPage-B7aWFxAZ.js → HealthPage-CkHv5qMK.js} +1 -1
  25. package/src/client/dist/spa/assets/{MainLayout-Dba6SdpU.css → MainLayout-B07zv82Z.css} +1 -1
  26. package/src/client/dist/spa/assets/{MainLayout-BHBrz4c9.js → MainLayout-l91ohFQA.js} +4 -4
  27. package/src/client/dist/spa/assets/{QBadge-fsQ2AokU.js → QBadge-DWH42dbo.js} +1 -1
  28. package/src/client/dist/spa/assets/{QBtn-DHwAb18J.js → QBtn-a6jxWjmW.js} +1 -1
  29. package/src/client/dist/spa/assets/{QCheckbox-CcY7ZSk9.js → QCheckbox-D5jfsxLV.js} +1 -1
  30. package/src/client/dist/spa/assets/{QChip-BhT0W2Dg.js → QChip-ByxK0Tuf.js} +1 -1
  31. package/src/client/dist/spa/assets/QExpansionItem-BaQJkGb-.js +1 -0
  32. package/src/client/dist/spa/assets/{QIcon-B0-pH3Qs.js → QIcon-BJuyqdsT.js} +1 -1
  33. package/src/client/dist/spa/assets/QInput-Cm5-AGQ4.js +1 -0
  34. package/src/client/dist/spa/assets/{QItemLabel-DWwenW2S.js → QItemLabel-DrTxqTqV.js} +1 -1
  35. package/src/client/dist/spa/assets/{QItemSection-KFAnxzMK.js → QItemSection-5YpFpPDm.js} +1 -1
  36. package/src/client/dist/spa/assets/{QList-NmIE6Rd9.js → QList-D0FtnQJI.js} +1 -1
  37. package/src/client/dist/spa/assets/QMenu-DgWZe7Uh.js +1 -0
  38. package/src/client/dist/spa/assets/QPage-ChUKoaKe.js +1 -0
  39. package/src/client/dist/spa/assets/{QRadio-DaZhdLCg.js → QRadio-B3aKjCVu.js} +1 -1
  40. package/src/client/dist/spa/assets/QScrollArea-usfgatuS.js +1 -0
  41. package/src/client/dist/spa/assets/{QSpace-COlmM_4F.js → QSpace-CLtL3aPy.js} +1 -1
  42. package/src/client/dist/spa/assets/{QSpinnerDots-DwtnRN2r.js → QSpinnerDots-CszPQQ9J.js} +1 -1
  43. package/src/client/dist/spa/assets/QTabPanels-CjpZTIJg.js +1 -0
  44. package/src/client/dist/spa/assets/{QToggle-CGpiJLDJ.js → QToggle-1-N9qWq4.js} +1 -1
  45. package/src/client/dist/spa/assets/QTooltip-D_hSPb7r.js +1 -0
  46. package/src/client/dist/spa/assets/{SearchPage-CVm-sqxH.js → SearchPage-B1WhFCUf.js} +1 -1
  47. package/src/client/dist/spa/assets/SettingsPage-B7S5fXGG.js +1 -0
  48. package/src/client/dist/spa/assets/SettingsPage-kHd651y8.css +1 -0
  49. package/src/client/dist/spa/assets/TouchPan-1PETKHN0.js +1 -0
  50. package/src/client/dist/spa/assets/WorkspacePage-D3MBshNH.js +4 -0
  51. package/src/client/dist/spa/assets/{WorkspacePage-DQxGe62K.css → WorkspacePage-d_B0-LNG.css} +1 -1
  52. package/src/client/dist/spa/assets/{build-path-tree-CdY1A6aP.js → build-path-tree-w3SEPAbh.js} +1 -1
  53. package/src/client/dist/spa/assets/{cssMode-BVNBMOxh.js → cssMode-B6CD4qMI.js} +1 -1
  54. package/src/client/dist/spa/assets/{documents-D6A3wRry.js → documents-kx0vLfSG.js} +1 -1
  55. package/src/client/dist/spa/assets/{editor.api-D6Vfp5yv.js → editor.api-wizjkvCK.js} +1 -1
  56. package/src/client/dist/spa/assets/{editor.main-CTCYF6V4.js → editor.main-Bn6fpPLF.js} +3 -3
  57. package/src/client/dist/spa/assets/expand-template-Cu5GSLCM.js +1 -0
  58. package/src/client/dist/spa/assets/{formatters-ejxELb0M.js → formatters-BD0_hovB.js} +1 -1
  59. package/src/client/dist/spa/assets/{freemarker2-nmzwPmzi.js → freemarker2-DW-DFUis.js} +1 -1
  60. package/src/client/dist/spa/assets/{handlebars-CI9lR7Ef.js → handlebars-CSSQFRHS.js} +1 -1
  61. package/src/client/dist/spa/assets/{html-BQ21REnv.js → html-Ba5lfQna.js} +1 -1
  62. package/src/client/dist/spa/assets/{htmlMode-io5J5Qr1.js → htmlMode-ocrlHn5h.js} +1 -1
  63. package/src/client/dist/spa/assets/i18n-CqK8B0Nz.js +1 -0
  64. package/src/client/dist/spa/assets/index-DE3PxEjy.js +2 -0
  65. package/src/client/dist/spa/assets/{javascript--u9PDBCv.js → javascript-DL3j24x3.js} +1 -1
  66. package/src/client/dist/spa/assets/{jsonMode-DBG5llk4.js → jsonMode-CtFp2BJe.js} +1 -1
  67. package/src/client/dist/spa/assets/{liquid-DxAS4nYF.js → liquid-B_GGNnlJ.js} +1 -1
  68. package/src/client/dist/spa/assets/marked.esm-D4t0_2pc.js +60 -0
  69. package/src/client/dist/spa/assets/{mdx-BNXTiODW.js → mdx-BXe8MrIz.js} +1 -1
  70. package/src/client/dist/spa/assets/models-BMOYJtwv.js +1 -0
  71. package/src/client/dist/spa/assets/{monaco.contribution-CT3LAK0J.js → monaco.contribution-DSSRKV2r.js} +2 -2
  72. package/src/client/dist/spa/assets/notifications-CG-oL2m2.js +1 -0
  73. package/src/client/dist/spa/assets/{python-DztNww13.js → python-DPtBXcrE.js} +1 -1
  74. package/src/client/dist/spa/assets/{razor-Cyr82NZF.js → razor-y1p5VjhT.js} +1 -1
  75. package/src/client/dist/spa/assets/{tsMode-CbQVgsIP.js → tsMode-CV2CQlAd.js} +1 -1
  76. package/src/client/dist/spa/assets/{typescript-UHOe4d1S.js → typescript-DsjWQLAN.js} +1 -1
  77. package/src/client/dist/spa/assets/use-checkbox-D7zmRxGI.js +1 -0
  78. package/src/client/dist/spa/assets/{use-id-CDuXkR0Z.js → use-id-CuaR1RiE.js} +1 -1
  79. package/src/client/dist/spa/assets/use-panel-D2MjPZiL.js +1 -0
  80. package/src/client/dist/spa/assets/{xml-DC88eFpV.js → xml-AQhpP8em.js} +1 -1
  81. package/src/client/dist/spa/assets/{yaml-DSTsIRJr.js → yaml-zZFlU7RD.js} +1 -1
  82. package/src/client/dist/spa/index.html +11 -13
  83. package/src/client/dist/spa/sounds/ca_va_peter.mp3 +0 -0
  84. package/src/client/dist/spa/sounds/dry-fart.mp3 +0 -0
  85. package/src/client/dist/spa/sounds/faaah.mp3 +0 -0
  86. package/src/client/dist/spa/sounds/for-shure.mp3 +0 -0
  87. package/src/client/dist/spa/sounds/travail_termine.mp3 +0 -0
  88. package/src/mcp-server/kobo-tasks-handlers.ts +2 -1
  89. package/src/client/dist/spa/assets/ActivityFeed-CIJPN8TH.js +0 -7
  90. package/src/client/dist/spa/assets/ClosePopup-DzB3mDtj.js +0 -1
  91. package/src/client/dist/spa/assets/QExpansionItem-VS4b4eY6.js +0 -1
  92. package/src/client/dist/spa/assets/QInput-D4WJro4e.js +0 -1
  93. package/src/client/dist/spa/assets/QMenu-CchbRXbp.js +0 -1
  94. package/src/client/dist/spa/assets/QPage-Cu7zkfc6.js +0 -1
  95. package/src/client/dist/spa/assets/QResizeObserver-Cf79V-VZ.js +0 -1
  96. package/src/client/dist/spa/assets/QScrollArea-DrVTDLU0.js +0 -1
  97. package/src/client/dist/spa/assets/QTabPanels-HXz-evuj.js +0 -1
  98. package/src/client/dist/spa/assets/QTooltip-DjJYMTkN.js +0 -1
  99. package/src/client/dist/spa/assets/SettingsPage-ayDKGo9H.js +0 -1
  100. package/src/client/dist/spa/assets/SettingsPage-wTBCvK6t.css +0 -1
  101. package/src/client/dist/spa/assets/WorkspacePage-xaVy8s5i.js +0 -4
  102. package/src/client/dist/spa/assets/expand-template-vHV2iwXf.js +0 -1
  103. package/src/client/dist/spa/assets/i18n-Do8Kn8n0.js +0 -1
  104. package/src/client/dist/spa/assets/index-C_e7KOYh.js +0 -2
  105. package/src/client/dist/spa/assets/is-BbsvEMaT.js +0 -1
  106. package/src/client/dist/spa/assets/marked.esm-DuOsJx63.js +0 -60
  107. package/src/client/dist/spa/assets/models-DNYEhFF7.js +0 -1
  108. package/src/client/dist/spa/assets/settings-Dbx1_ksA.js +0 -1
  109. package/src/client/dist/spa/assets/symbols-BVRrMH2r.js +0 -1
  110. package/src/client/dist/spa/assets/use-checkbox-DzHmcu7s.js +0 -1
  111. package/src/client/dist/spa/assets/use-panel-Br8QNRMk.js +0 -1
  112. /package/src/client/dist/spa/assets/{_plugin-vue_export-helper-B8bB5DBd.js → _plugin-vue_export-helper-Cj6tcsj6.js} +0 -0
  113. /package/src/client/dist/spa/assets/{abap-DzK-OTGh.js → abap-DiwvWnMr.js} +0 -0
  114. /package/src/client/dist/spa/assets/{apex-Bj60_dRt.js → apex-CmtZjKlf.js} +0 -0
  115. /package/src/client/dist/spa/assets/{azcli-B6NwaBAZ.js → azcli-DL2My_i-.js} +0 -0
  116. /package/src/client/dist/spa/assets/{bat-bf7wXV68.js → bat-B-nC98wG.js} +0 -0
  117. /package/src/client/dist/spa/assets/{bicep-C_bg8UgA.js → bicep-Ju5MwOgh.js} +0 -0
  118. /package/src/client/dist/spa/assets/{cameligo-CTWw4D4B.js → cameligo-8Eu1TyBr.js} +0 -0
  119. /package/src/client/dist/spa/assets/{clojure-CgdPoH0r.js → clojure-u-RpMkH3.js} +0 -0
  120. /package/src/client/dist/spa/assets/{coffee-gHQfdA5M.js → coffee-CdA7bbTe.js} +0 -0
  121. /package/src/client/dist/spa/assets/{cpp-BM4Jj4aW.js → cpp-CzNFP8ks.js} +0 -0
  122. /package/src/client/dist/spa/assets/{csharp-D8-bh4Cd.js → csharp-j1LThmcE.js} +0 -0
  123. /package/src/client/dist/spa/assets/{csp-CXBxRx0n.js → csp-CLRC61y6.js} +0 -0
  124. /package/src/client/dist/spa/assets/{css-DKjIxrmY.js → css-r6rC_7P2.js} +0 -0
  125. /package/src/client/dist/spa/assets/{cypher-C5e5inIh.js → cypher-CW08XVUh.js} +0 -0
  126. /package/src/client/dist/spa/assets/{dart-BhRHHm4x.js → dart-Cs9aL5T_.js} +0 -0
  127. /package/src/client/dist/spa/assets/{dockerfile-DW5REF8E.js → dockerfile-BWM0M184.js} +0 -0
  128. /package/src/client/dist/spa/assets/{ecl-Bw4Hg3n_.js → ecl-MJJuer5P.js} +0 -0
  129. /package/src/client/dist/spa/assets/{elixir-DHmoBvpZ.js → elixir-D2AIuXqn.js} +0 -0
  130. /package/src/client/dist/spa/assets/{flow9-BsFExz3v.js → flow9-B2H24giC.js} +0 -0
  131. /package/src/client/dist/spa/assets/{fsharp-BaeLhgfq.js → fsharp-CMk2OIJN.js} +0 -0
  132. /package/src/client/dist/spa/assets/{go-Bd-NFKIC.js → go-BrMkuJg0.js} +0 -0
  133. /package/src/client/dist/spa/assets/{graphql-DZVerJfy.js → graphql-PSR1UKGv.js} +0 -0
  134. /package/src/client/dist/spa/assets/{hcl-CAVzrZfH.js → hcl-DAQrbDOW.js} +0 -0
  135. /package/src/client/dist/spa/assets/{ini-CyXdX58t.js → ini-0TG5BxW0.js} +0 -0
  136. /package/src/client/dist/spa/assets/{java-B5pNgvhy.js → java-rgorz17v.js} +0 -0
  137. /package/src/client/dist/spa/assets/{julia-XRhmV3AN.js → julia-C8VMdHm8.js} +0 -0
  138. /package/src/client/dist/spa/assets/{kobo-commands-DiUm1Y34.js → kobo-commands-w8VepGvD.js} +0 -0
  139. /package/src/client/dist/spa/assets/{kotlin-DOd3J5vr.js → kotlin-CllWo3gX.js} +0 -0
  140. /package/src/client/dist/spa/assets/{less-veZSnyw6.js → less-Cgca25AP.js} +0 -0
  141. /package/src/client/dist/spa/assets/{lexon-QWGkuK0H.js → lexon-D0GHdBaw.js} +0 -0
  142. /package/src/client/dist/spa/assets/{lua-CYGpjuO5.js → lua-DmRsNG-P.js} +0 -0
  143. /package/src/client/dist/spa/assets/{m3-yNnrZkdc.js → m3-BgL5dNKT.js} +0 -0
  144. /package/src/client/dist/spa/assets/{markdown-BCSWEPSX.js → markdown-BuJfycGS.js} +0 -0
  145. /package/src/client/dist/spa/assets/{mips-OpYmcC30.js → mips-C9m_93PR.js} +0 -0
  146. /package/src/client/dist/spa/assets/{msdax-2oxoTO9Z.js → msdax-CpFHC9OI.js} +0 -0
  147. /package/src/client/dist/spa/assets/{mysql-5KlC-K_9.js → mysql-qFvltsqN.js} +0 -0
  148. /package/src/client/dist/spa/assets/{objective-c-CcDCgtLx.js → objective-c-Bnmr858J.js} +0 -0
  149. /package/src/client/dist/spa/assets/{pascal-BZGsbaEV.js → pascal-WP0_D5AO.js} +0 -0
  150. /package/src/client/dist/spa/assets/{pascaligo-DtD5qU3G.js → pascaligo-Blom4Rij.js} +0 -0
  151. /package/src/client/dist/spa/assets/{perl-C1jNNS3E.js → perl-B-vk8g64.js} +0 -0
  152. /package/src/client/dist/spa/assets/{pgsql-CT0fhiZa.js → pgsql-Cgvz6v67.js} +0 -0
  153. /package/src/client/dist/spa/assets/{php-D6DrXoPM.js → php-8a3Lrw9m.js} +0 -0
  154. /package/src/client/dist/spa/assets/{pla-b3-HN2pF.js → pla-DuFqEZ8V.js} +0 -0
  155. /package/src/client/dist/spa/assets/{postiats-Bin2ApVS.js → postiats-DkLtSgkp.js} +0 -0
  156. /package/src/client/dist/spa/assets/{powerquery-7ASnn-ZG.js → powerquery-BJ1aNepW.js} +0 -0
  157. /package/src/client/dist/spa/assets/{powershell-t4p7sU1H.js → powershell-rE98k687.js} +0 -0
  158. /package/src/client/dist/spa/assets/{protobuf-BUGeWa_j.js → protobuf-CUheFacr.js} +0 -0
  159. /package/src/client/dist/spa/assets/{pug-BuKcgC9s.js → pug-LDcAMD8w.js} +0 -0
  160. /package/src/client/dist/spa/assets/{qsharp-DSMtI_O7.js → qsharp-DUKSQoR1.js} +0 -0
  161. /package/src/client/dist/spa/assets/{r-DMlFgn7A.js → r-D-QApv87.js} +0 -0
  162. /package/src/client/dist/spa/assets/{redis-cXItkC5u.js → redis-SXdDyWR9.js} +0 -0
  163. /package/src/client/dist/spa/assets/{redshift-BZVbW7HE.js → redshift-Y6lsCryn.js} +0 -0
  164. /package/src/client/dist/spa/assets/{restructuredtext-BzjxwS8h.js → restructuredtext-edObr9a8.js} +0 -0
  165. /package/src/client/dist/spa/assets/{ruby-C5nyLV4l.js → ruby-CNnUfF-8.js} +0 -0
  166. /package/src/client/dist/spa/assets/{rust-BcmMsHdf.js → rust-IHUZWzBr.js} +0 -0
  167. /package/src/client/dist/spa/assets/{sb-Dnb1iy6B.js → sb-DrUvY44N.js} +0 -0
  168. /package/src/client/dist/spa/assets/{scala-anMIFYpA.js → scala-B4hbXGLM.js} +0 -0
  169. /package/src/client/dist/spa/assets/{scheme-BItQTe08.js → scheme-BGrd12j3.js} +0 -0
  170. /package/src/client/dist/spa/assets/{scss-BOv51BJ5.js → scss-x5G1ES4U.js} +0 -0
  171. /package/src/client/dist/spa/assets/{shell-BsRYRTNN.js → shell-DOehe2Y8.js} +0 -0
  172. /package/src/client/dist/spa/assets/{solidity-BtuLgGDx.js → solidity-BeRvcwWV.js} +0 -0
  173. /package/src/client/dist/spa/assets/{sophia-B0Vkc5MF.js → sophia-DZbkUNjy.js} +0 -0
  174. /package/src/client/dist/spa/assets/{sparql-B7lvkZQM.js → sparql-B7_oi5-h.js} +0 -0
  175. /package/src/client/dist/spa/assets/{sql-DvP5MpA3.js → sql-CTlsFWVE.js} +0 -0
  176. /package/src/client/dist/spa/assets/{st-GVUeyB3U.js → st-DJVEJdPE.js} +0 -0
  177. /package/src/client/dist/spa/assets/{swift-DSPIoCjm.js → swift-CwhT3fYa.js} +0 -0
  178. /package/src/client/dist/spa/assets/{systemverilog-Icj2-k23.js → systemverilog-BQN63pkN.js} +0 -0
  179. /package/src/client/dist/spa/assets/{tcl-Cd8KQcm-.js → tcl-DqwfpskA.js} +0 -0
  180. /package/src/client/dist/spa/assets/{touch-Co9pfjUU.js → touch-HRdTUO2o.js} +0 -0
  181. /package/src/client/dist/spa/assets/{twig-CBHmt8z3.js → twig-BiyenUgc.js} +0 -0
  182. /package/src/client/dist/spa/assets/{typespec-Ckc037mq.js → typespec-CWOJribt.js} +0 -0
  183. /package/src/client/dist/spa/assets/{use-quasar-Cc4smfg5.js → use-quasar-Sdcq6zzV.js} +0 -0
  184. /package/src/client/dist/spa/assets/{vb-B97GW9Wb.js → vb-Cq5F87m3.js} +0 -0
  185. /package/src/client/dist/spa/assets/{vue-i18n-eUDnMrPl.js → vue-i18n-BcfTCFFS.js} +0 -0
  186. /package/src/client/dist/spa/assets/{wgsl-DIKmb3YH.js → wgsl-BAvW2lVr.js} +0 -0
  187. /package/src/client/dist/spa/{notification.mp3 → sounds/hey.mp3} +0 -0
package/README.md CHANGED
@@ -11,7 +11,7 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
11
11
 
12
12
  ## Features
13
13
 
14
- - **Isolated git worktrees** — every workspace runs on its own branch in its own directory, so concurrent Claude sessions never step on each other
14
+ - **Isolated git worktrees** — every workspace runs on its own branch in its own directory, with a configurable global worktrees root for new workspaces, so concurrent Claude sessions never step on each other
15
15
  - **Pluggable agent engine** — Kōbō talks to agents through an `AgentEngine` contract with a normalised `AgentEvent` stream (`src/server/services/agent/engines/`). The `claude-code` engine runs on the official [`@anthropic-ai/claude-agent-sdk`](https://github.com/anthropics/claude-agent-sdk-typescript); adding a second runtime (e.g. Codex) only requires a new adapter, not a rewrite of the UI or orchestration layer
16
16
  - **Interactive `AskUserQuestion`** — when the agent invokes `AskUserQuestion`, Kōbō pauses the session via the SDK's `defer` pattern, surfaces a question panel in the UI, and resumes the agent once the user answers. The session does not occupy any resources while it waits
17
17
  - **Rich chat feed** — live streaming text, thinking blocks, inline tool calls with expandable diffs for Edit/Write, per-turn session cards, markdown rendering, jump-to-previous-user-message button, and infinite scroll-up over persisted history
@@ -32,7 +32,7 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
32
32
  - **Soft interrupt** — pause an agent mid-execution (SIGINT, like pressing Escape in Claude Code) without killing the process; the agent stops the current tool and waits for the next message
33
33
  - **Archive instead of delete** — soft-remove workspaces without losing the worktree, branches, or history; unarchive restores the exact pre-archive state
34
34
  - **Auto-loop mode** — opt-in, per-workspace: when enabled, Kōbō spawns a fresh Claude session for the next pending task after every `session:ended`, walking through the task list until all are `done`. Stops automatically on error, on stall (3 consecutive sessions with no task completed), or when the user clicks Stop. A grooming step (`/kobo-prep-autoloop`) ensures tasks are atomic before the loop runs; Notion-imported workspaces with both todos and acceptance criteria are auto-unlocked. **E2E grooming** — when a project declares an E2E framework in Settings (Cypress, Playwright, Vitest, etc.), the grooming phase injects an `[E2E] ` test sub-task between every parent task; each iteration then runs the matching E2E suite as part of its acceptance check
35
- - **Attach existing worktrees** — Kōbō detects orphan worktrees under `.worktrees/` (created outside Kōbō, or left over from an earlier install) and lets you attach them to a new workspace from the creation form, picking up the existing branch and folder instead of cloning a new one
35
+ - **Attach existing worktrees** — Kōbō detects orphan git worktrees for the selected project (created outside Kōbō, or left over from an earlier install) and lets you attach them to a new workspace from the creation form, picking up the existing branch and folder instead of cloning a new one
36
36
  - **Quota-aware retry backoff** — when a Claude rate limit is hit mid-session, Kōbō schedules the retry at the actual reset time reported by the API (via `rate_limit.info.buckets[].resetsAt`), falling back to a 15 → 30 → 60 → 180 → 300 min ladder only when the reset info is missing or implausible
37
37
  - **Scheduled wakeups** — the `ScheduleWakeup` tool is honoured server-side: Kōbō persists the wakeup in SQLite, rehydrates on restart, and respawns the agent with `--resume` at the target time
38
38
 
@@ -252,7 +252,7 @@ See [`AGENTS.md`](./AGENTS.md) for a deeper dive into conventions, data model, W
252
252
 
253
253
  | Table | Purpose |
254
254
  |---|---|
255
- | `workspaces` | the unit of work — branch, status, model, engine, `archived_at`, `favorited_at`, `tags`, Notion link, … |
255
+ | `workspaces` | the unit of work — branch, `worktree_path`, status, model, engine, `archived_at`, `favorited_at`, `tags`, Notion link, … |
256
256
  | `tasks` | workspace sub-items — tasks and acceptance criteria |
257
257
  | `agent_sessions` | agent runs — pid, `engine_session_id`, lifecycle |
258
258
  | `ws_events` | persisted WebSocket events (chat history, `agent:event` stream, user messages) for replay on reconnect |
@@ -269,9 +269,10 @@ The MCP server reads and writes the same SQLite database as the main backend. Is
269
269
 
270
270
  ## Configuration
271
271
 
272
- Kōbō reads settings from `~/.config/kobo/settings.json` (or falls back to defaults). Global settings cascade into per-project overrides:
272
+ Kōbō reads settings from `~/.config/kobo/settings.json` (or falls back to defaults). Global settings define defaults, with per-project overrides for project-scoped fields:
273
273
 
274
274
  - `defaultModel` — Claude model to use (e.g. `claude-opus-4-6`)
275
+ - `worktreesPath` — where new workspace worktrees are created. Defaults to `.worktrees`, resolved relative to the project. Absolute Linux/macOS paths, Windows paths (`C:\kobo\worktrees`, UNC shares), `$HOME/...`, `${HOME}/...`, `~/...`, and `%USERPROFILE%\...` are accepted. Paths containing parent-directory traversal (`..`) or drive-relative Windows syntax (`C:foo`) are rejected.
275
276
  - `prPromptTemplate` — template rendered when opening a PR via the `/open-pr` endpoint; supports `{{pr_number}}`, `{{pr_url}}`, `{{branch_name}}`, `{{diff_stats}}`, `{{commits}}`, etc.
276
277
  - `gitConventions` — markdown-formatted git conventions written to `.ai/.git-conventions.md` in every workspace so the agent follows them when committing
277
278
  - `devServer` — per-project `startCommand` / `stopCommand` for launching workspace-scoped dev servers
@@ -288,6 +289,10 @@ This is a personal tool, but PRs and issues are welcome. Before submitting:
288
289
 
289
290
  CI runs lint + type check + tests on every PR to `develop`.
290
291
 
292
+ ## Release
293
+
294
+ Releases are cut from `main`. Bump `package.json` and `package-lock.json` on `develop`, merge `develop` into `main`, then push `main`. The release workflow builds, tests, publishes the current package version to npm, tags it as `v<version>`, and creates the GitHub Release. If the npm version or tag already exists, the workflow fails before publishing.
295
+
291
296
  ## License
292
297
 
293
298
  GNU General Public License v3.0 or later. See [`LICENSE`](./LICENSE) for the full text.
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { nanoid } from 'nanoid';
4
+ import { resolveWorkspaceWorktreePath } from '../server/utils/worktree-paths.js';
4
5
  /** Allowed task status values. */
5
6
  export const VALID_TASK_STATUSES = ['pending', 'in_progress', 'done'];
6
7
  function rowToDto(row) {
@@ -163,7 +164,7 @@ export function getWorkspaceInfoHandler(db, workspaceId) {
163
164
  projectPath: row.project_path,
164
165
  sourceBranch: row.source_branch,
165
166
  workingBranch: row.working_branch,
166
- worktreePath: row.worktree_path ?? path.join(row.project_path, '.worktrees', row.working_branch),
167
+ worktreePath: row.worktree_path ?? resolveWorkspaceWorktreePath(row.project_path, row.working_branch),
167
168
  status: row.status,
168
169
  model: row.model,
169
170
  notionUrl: row.notion_url,
@@ -1,11 +1,11 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
- import path from 'node:path';
4
3
  import { Hono } from 'hono';
5
4
  import { getDb } from '../db/index.js';
6
5
  import { SCHEMA_VERSION } from '../db/migrations.js';
7
6
  import { getGlobalSettings, SETTINGS_SCHEMA_VERSION } from '../services/settings-service.js';
8
7
  import { getDbPath, getKoboHome } from '../utils/paths.js';
8
+ import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
9
9
  const app = new Hono();
10
10
  function checkClaudeCli() {
11
11
  try {
@@ -51,7 +51,7 @@ app.get('/report', (c) => {
51
51
  for (const ws of workspaces) {
52
52
  if (ws.archived_at)
53
53
  continue;
54
- const wtPath = ws.worktree_path ?? path.join(ws.project_path, '.worktrees', ws.working_branch);
54
+ const wtPath = ws.worktree_path ?? resolveWorkspaceWorktreePath(ws.project_path, ws.working_branch);
55
55
  if (!fs.existsSync(wtPath)) {
56
56
  worktreesMissing.push({ workspaceId: ws.id, name: ws.name, path: wtPath, exists: false });
57
57
  }
@@ -45,7 +45,8 @@ app.put('/global', async (c) => {
45
45
  }
46
46
  catch (err) {
47
47
  const message = err instanceof Error ? err.message : String(err);
48
- return c.json({ error: message }, 500);
48
+ const status = err instanceof Error && err.name === 'InvalidWorktreesPathError' ? 400 : 500;
49
+ return c.json({ error: message }, status);
49
50
  }
50
51
  });
51
52
  // GET /api/settings/projects — list all projects
@@ -23,6 +23,7 @@ import * as wsService from '../services/websocket-service.js';
23
23
  import * as workspaceService from '../services/workspace-service.js';
24
24
  import * as worktreeService from '../services/worktree-service.js';
25
25
  import * as gitOps from '../utils/git-ops.js';
26
+ import { resolveSiblingWorkspaceWorktreePath } from '../utils/worktree-paths.js';
26
27
  /** Hono sub-router for workspace CRUD, tasks, agent lifecycle, git operations, and PR creation. */
27
28
  const app = new Hono();
28
29
  /** Tracks workspaces currently running a setup script to prevent concurrent executions. */
@@ -197,6 +198,7 @@ app.post('/', migrationGuard, async (c) => {
197
198
  reasoningEffort: body.reasoningEffort,
198
199
  agentPermissionMode: resolveCreateAgentPermissionMode(body.agentPermissionMode, body.projectPath, globalSettings),
199
200
  engine: body.engine,
201
+ ...(useReusedWorktree ? {} : { worktreesPath: globalSettings.worktreesPath }),
200
202
  });
201
203
  // Auto-tag the workspace based on its creation source — `notion` when
202
204
  // imported from a Notion page, `sentry` when bootstrapped from a Sentry
@@ -287,7 +289,7 @@ app.post('/', migrationGuard, async (c) => {
287
289
  }
288
290
  else {
289
291
  try {
290
- worktreePath = worktreeService.createWorktree(body.projectPath, workingBranch, body.sourceBranch);
292
+ worktreePath = worktreeService.createWorktree(body.projectPath, workingBranch, body.sourceBranch, globalSettings.worktreesPath);
291
293
  }
292
294
  catch (err) {
293
295
  const message = err instanceof Error ? err.message : String(err);
@@ -1725,11 +1727,7 @@ app.post('/:id/rename-branch', async (c) => {
1725
1727
  // Sibling rename: keep the same worktrees-root, swap the branch leaf.
1726
1728
  // Cannot use `path.dirname` directly because branches with slashes
1727
1729
  // (e.g. `feature/x`) make the dirname end one level too deep.
1728
- const oldSuffix = `/${workspace.workingBranch}`;
1729
- const worktreesRoot = oldWorktreePath.endsWith(oldSuffix)
1730
- ? oldWorktreePath.slice(0, -oldSuffix.length)
1731
- : path.join(workspace.projectPath, '.worktrees');
1732
- const newWorktreePath = path.join(worktreesRoot, newName);
1730
+ const newWorktreePath = resolveSiblingWorkspaceWorktreePath(workspace.projectPath, oldWorktreePath, workspace.workingBranch, newName);
1733
1731
  // Reject early if the target name is already in use — either as a local
1734
1732
  // branch or on origin. Avoids git's generic "already exists" error and
1735
1733
  // protects against the same silent-fallback trap the create flow has.
@@ -1799,11 +1797,7 @@ app.post('/:id/resync-branch', (c) => {
1799
1797
  // if the move fails (dir already moved, lockfile, dirty tree), we still
1800
1798
  // update the DB so git ops stay aligned with the current ref name — the
1801
1799
  // user can repair the dir manually.
1802
- const oldSuffix = `/${workspace.workingBranch}`;
1803
- const worktreesRoot = worktreePath.endsWith(oldSuffix)
1804
- ? worktreePath.slice(0, -oldSuffix.length)
1805
- : path.join(workspace.projectPath, '.worktrees');
1806
- const newWorktreePath = path.join(worktreesRoot, actual);
1800
+ const newWorktreePath = resolveSiblingWorkspaceWorktreePath(workspace.projectPath, worktreePath, workspace.workingBranch, actual);
1807
1801
  try {
1808
1802
  gitOps.moveWorktree(workspace.projectPath, worktreePath, newWorktreePath);
1809
1803
  workspaceService.updateWorktreePath(id, newWorktreePath);
@@ -4,6 +4,7 @@ import { CLAUDE_CODE_CAPABILITIES } from './capabilities.js';
4
4
  import { createMapperState, mapSdkMessage } from './event-mapper.js';
5
5
  import { buildClaudeOptions } from './options-builder.js';
6
6
  import { buildPreCompactCustomInstructions } from './precompact-hook.js';
7
+ import { resolveClaudeBinaryPath } from './resolve-binary.js';
7
8
  function toMcpServersMap(specs) {
8
9
  if (!specs || specs.length === 0)
9
10
  return undefined;
@@ -112,6 +113,11 @@ export function createClaudeCodeEngine() {
112
113
  },
113
114
  });
114
115
  sdkOptions.abortController = abortController;
116
+ // Override the SDK's libc-blind binary resolution on Linux glibc — see
117
+ // resolve-binary.ts for the full rationale. No-op on macOS/Windows/musl.
118
+ const explicitBinary = resolveClaudeBinaryPath();
119
+ if (explicitBinary)
120
+ sdkOptions.pathToClaudeCodeExecutable = explicitBinary;
115
121
  const q = query({ prompt: effectivePrompt, options: sdkOptions });
116
122
  let discoveredSessionId;
117
123
  // A throwing onEvent handler (e.g. DB query against a closed connection
@@ -0,0 +1,50 @@
1
+ import { createRequire } from 'node:module';
2
+ const localRequire = createRequire(import.meta.url);
3
+ /**
4
+ * Detect the live host's platform/arch/libc from `process`. `glibcVersionRuntime`
5
+ * lives in the diagnostic report header on glibc systems and is absent on musl.
6
+ * Available since Node 12; Kōbō requires Node ≥ 20 (see AGENTS.md).
7
+ */
8
+ export function detectPlatform() {
9
+ const report = process.report;
10
+ const glibcVersion = report?.getReport().header?.glibcVersionRuntime;
11
+ return {
12
+ platform: process.platform,
13
+ arch: process.arch,
14
+ isGlibc: typeof glibcVersion === 'string' && glibcVersion.length > 0,
15
+ };
16
+ }
17
+ /**
18
+ * Resolve an explicit path to the Claude CLI binary that matches the host
19
+ * libc — or `undefined` to fall back to the SDK's built-in resolution.
20
+ *
21
+ * Why this exists: on Linux glibc systems npm installs both
22
+ * `@anthropic-ai/claude-agent-sdk-linux-${arch}` (glibc) and
23
+ * `@anthropic-ai/claude-agent-sdk-linux-${arch}-musl` because npm doesn't
24
+ * filter `optionalDependencies` by the `libc` field. The SDK's internal
25
+ * resolver tries the musl variant first and returns its path; that binary
26
+ * then ENOENTs at exec because its dynamic linker (`/lib/ld-musl-*.so.1`)
27
+ * is absent on glibc systems, surfacing the misleading "Claude Code native
28
+ * binary not found" error.
29
+ *
30
+ * Returning the explicit glibc path here overrides the SDK's choice and
31
+ * sidesteps the bug.
32
+ *
33
+ * On every other platform (musl Linux, macOS, Windows, unknown libc, or
34
+ * unsupported arch), this returns `undefined` so the SDK keeps its default
35
+ * resolution behaviour.
36
+ */
37
+ export function resolveClaudeBinaryPath(probe = detectPlatform(), resolveFn = (id) => localRequire.resolve(id)) {
38
+ if (probe.platform !== 'linux')
39
+ return undefined;
40
+ if (!probe.isGlibc)
41
+ return undefined;
42
+ if (probe.arch !== 'x64' && probe.arch !== 'arm64')
43
+ return undefined;
44
+ try {
45
+ return resolveFn(`@anthropic-ai/claude-agent-sdk-linux-${probe.arch}/claude`);
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
@@ -590,11 +590,20 @@ function onSessionEnded(workspaceId, agentSessionId, exitCode, reason, resumeFai
590
590
  // `resumeFailed` is benign: stale id cleared, next iteration starts fresh.
591
591
  const isErrorOutcome = !resumeFailed && (reason === 'error' || (exitCode !== null && exitCode !== 0));
592
592
  const targetStatus = isErrorOutcome ? 'error' : 'completed';
593
- try {
594
- updateWorkspaceStatus(workspaceId, targetStatus);
595
- }
596
- catch (err) {
597
- console.error('[orchestrator] Failed to update workspace status on exit:', err);
593
+ // Skip the transition when the workspace is already in a terminal state.
594
+ // This happens when stopAgent (or an equivalent caller) synchronously
595
+ // normalised the status before the engine's async stop emitted session:ended
596
+ // typically `awaiting-user → idle`. Without this guard we'd attempt e.g.
597
+ // `idle completed` and log a benign error on every such manual stop.
598
+ const currentStatus = currentWorkspace?.status;
599
+ const alreadyTerminal = currentStatus === 'idle' || currentStatus === 'completed' || currentStatus === 'error' || currentStatus === 'quota';
600
+ if (!alreadyTerminal) {
601
+ try {
602
+ updateWorkspaceStatus(workspaceId, targetStatus);
603
+ }
604
+ catch (err) {
605
+ console.error('[orchestrator] Failed to update workspace status on exit:', err);
606
+ }
598
607
  }
599
608
  try {
600
609
  markWorkspaceUnread(workspaceId);
@@ -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';
@@ -252,7 +252,7 @@ function spawnNextIteration(workspaceId, opts = {}) {
252
252
  .replaceAll('{taskTitle}', task.title)
253
253
  .replaceAll('{isAcceptanceCriterion}', String(task.isAcceptanceCriterion))
254
254
  .replaceAll('{overrideBlock}', overrideBlock);
255
- const worktreePath = row.worktree_path ?? path.join(row.project_path, '.worktrees', row.working_branch);
255
+ const worktreePath = row.worktree_path ?? resolveWorkspaceWorktreePath(row.project_path, row.working_branch);
256
256
  // Plan mode would deadlock the loop (blocks MCP + edits) — promote to bypass.
257
257
  // Other modes (bypass/strict/interactive) are honored.
258
258
  const stored = (row.agent_permission_mode ?? 'bypass');
@@ -164,7 +164,7 @@ export function startDevServer(workspaceId) {
164
164
  const instanceName = sanitizeBranchName(workspace.workingBranch);
165
165
  // Execute as bash script (supports multi-line scripts)
166
166
  const worktreePath = workspace.worktreePath;
167
- const cwd = existsSync(worktreePath) ? worktreePath : workspace.projectPath;
167
+ const cwd = worktreePath && existsSync(worktreePath) ? worktreePath : workspace.projectPath;
168
168
  const proc = spawn('bash', ['-c', settings.devServer.startCommand], {
169
169
  cwd,
170
170
  env: {
@@ -230,7 +230,7 @@ export function stopDevServer(workspaceId) {
230
230
  const config = resolveInstance(workspace.projectPath, workspace.workingBranch);
231
231
  const instanceName = config?.instanceName ?? sanitizeBranchName(workspace.workingBranch);
232
232
  const worktreePath = workspace.worktreePath;
233
- const cwd = existsSync(worktreePath) ? worktreePath : workspace.projectPath;
233
+ const cwd = worktreePath && existsSync(worktreePath) ? worktreePath : workspace.projectPath;
234
234
  // Kill tracked process first (covers Node servers and any spawned process)
235
235
  const tracked = trackedProcesses.get(workspaceId);
236
236
  if (tracked) {
@@ -2,7 +2,7 @@ import { getPrStatusAsync } from '../utils/git-ops.js';
2
2
  import { stopDevServer } from './dev-server-service.js';
3
3
  import { destroyTerminal } from './terminal-service.js';
4
4
  import { emitEphemeral } from './websocket-service.js';
5
- import { archiveWorkspace, listWorkspaces } from './workspace-service.js';
5
+ import { archiveWorkspace, listWorkspaces, updateWorkspaceSourceBranch } from './workspace-service.js';
6
6
  // ── PR Watcher ────────────────────────────────────────────────────────────────
7
7
  // Polls GitHub every POLL_INTERVAL_MS to detect merged/closed PRs and
8
8
  // automatically archive the corresponding workspace.
@@ -14,8 +14,7 @@ import { archiveWorkspace, listWorkspaces } from './workspace-service.js';
14
14
  const POLL_INTERVAL_MS = 30 * 1000; // 30 seconds
15
15
  let timer = null;
16
16
  let checking = false;
17
- /** Tracks the last known PR state per workspace to detect transitions. */
18
- const lastKnownState = new Map();
17
+ const lastKnownPr = new Map();
19
18
  /**
20
19
  * Read-only snapshot of PR states known to the watcher, keyed by workspace id.
21
20
  * Used by the drawer to show a small PR-open indicator without N separate
@@ -25,17 +24,24 @@ const lastKnownState = new Map();
25
24
  */
26
25
  export function getAllPrStates() {
27
26
  const out = {};
28
- for (const [id, state] of lastKnownState) {
29
- out[id] = state;
27
+ for (const [id, known] of lastKnownPr) {
28
+ out[id] = known.state;
30
29
  }
31
30
  return out;
32
31
  }
33
- async function checkPrStatuses() {
32
+ /**
33
+ * Test-only escape hatch — drops the in-memory cache so each test starts
34
+ * from a clean slate. Not part of the public API.
35
+ */
36
+ export function _resetForTest() {
37
+ lastKnownPr.clear();
38
+ }
39
+ export async function checkPrStatuses() {
34
40
  const workspaces = listWorkspaces(false); // non-archived only
35
41
  // Clean up entries for workspaces that no longer exist
36
- for (const id of lastKnownState.keys()) {
42
+ for (const id of lastKnownPr.keys()) {
37
43
  if (!workspaces.some((ws) => ws.id === id)) {
38
- lastKnownState.delete(id);
44
+ lastKnownPr.delete(id);
39
45
  }
40
46
  }
41
47
  for (const ws of workspaces) {
@@ -46,10 +52,15 @@ async function checkPrStatuses() {
46
52
  const pr = await getPrStatusAsync(ws.projectPath, ws.workingBranch);
47
53
  if (!pr)
48
54
  continue;
49
- const prev = lastKnownState.get(ws.id);
50
- lastKnownState.set(ws.id, pr.state);
51
- // Only archive on a transition FROM OPEN not on first sight of CLOSED/MERGED
52
- if (prev === 'OPEN' && (pr.state === 'MERGED' || pr.state === 'CLOSED')) {
55
+ const prev = lastKnownPr.get(ws.id);
56
+ // We delay updating `lastKnownPr` until after the actions succeed.
57
+ // Setting it eagerly would poison the cache: if updateWorkspaceSourceBranch
58
+ // throws (transient DB issue, race with workspace deletion), the cache
59
+ // already holds the new base and the user never sees the toast — the
60
+ // next tick computes `prev.base === pr.base` and treats it as no-op.
61
+ // Archive on a transition FROM OPEN to CLOSED/MERGED. Skips the
62
+ // base-change detection below — archiving wins.
63
+ if (prev?.state === 'OPEN' && (pr.state === 'MERGED' || pr.state === 'CLOSED')) {
53
64
  console.log(`[pr-watcher] PR ${pr.state.toLowerCase()} for workspace '${ws.name}' — archiving`);
54
65
  // Best-effort cleanup (same as manual archive): stop dev server + terminal.
55
66
  // Agent is already not running here (guarded above).
@@ -66,12 +77,51 @@ async function checkPrStatuses() {
66
77
  // Terminal may not exist — ignore
67
78
  }
68
79
  archiveWorkspace(ws.id);
69
- lastKnownState.delete(ws.id);
80
+ lastKnownPr.delete(ws.id);
70
81
  emitEphemeral(ws.id, 'workspace:archived', {
71
82
  reason: `PR ${pr.state.toLowerCase()}`,
72
83
  prUrl: pr.url,
73
84
  });
85
+ continue; // do not run base-change detection on a workspace we just archived
86
+ }
87
+ // Base-branch change detection. Only relevant for OPEN PRs — closed/
88
+ // merged PRs don't accept base changes. Skip if the GitHub response
89
+ // didn't include a baseRefName (defensive against malformed data).
90
+ if (pr.state !== 'OPEN' || !pr.base) {
91
+ // Still update the cache for the state — keeps the OPEN→CLOSED/MERGED
92
+ // archiving logic working on the next tick.
93
+ lastKnownPr.set(ws.id, { state: pr.state, base: prev?.base });
94
+ continue;
95
+ }
96
+ // Comparison baseline:
97
+ // - If we've seen this workspace before, use the previous `base`.
98
+ // - Otherwise (first sight after boot/unarchive), compare with the
99
+ // `sourceBranch` recorded in the database — that catches base changes
100
+ // that happened while Kobo was offline.
101
+ const previousBase = prev?.base ?? ws.sourceBranch;
102
+ if (previousBase === pr.base) {
103
+ // No-op path: still record the base so subsequent ticks have a baseline.
104
+ lastKnownPr.set(ws.id, { state: pr.state, base: pr.base });
105
+ continue;
106
+ }
107
+ console.log(`[pr-watcher] PR base changed for workspace '${ws.name}': ${previousBase} → ${pr.base}`);
108
+ try {
109
+ updateWorkspaceSourceBranch(ws.id, pr.base);
110
+ }
111
+ catch (err) {
112
+ console.error(`[pr-watcher] updateWorkspaceSourceBranch failed for '${ws.name}':`, err instanceof Error ? err.message : err);
113
+ // Don't poison the cache: leave the previous entry (or absence) so
114
+ // the next tick retries the detection.
115
+ continue;
74
116
  }
117
+ // Both the persistence and the emit are part of "we successfully
118
+ // observed a base change" — only NOW commit the new state to the cache.
119
+ lastKnownPr.set(ws.id, { state: pr.state, base: pr.base });
120
+ emitEphemeral(ws.id, 'pr:base-changed', {
121
+ oldBase: previousBase,
122
+ newBase: pr.base,
123
+ prUrl: pr.url,
124
+ });
75
125
  }
76
126
  catch (err) {
77
127
  console.error(`[pr-watcher] Failed to check PR for workspace '${ws.name}':`, err instanceof Error ? err.message : err);
@@ -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,32 @@ 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
+ },
203
+ {
204
+ version: 12,
205
+ name: 'add-audio-notification-sound',
206
+ migrate({ global }) {
207
+ if (typeof global.audioNotificationSound !== 'string' || global.audioNotificationSound.length === 0) {
208
+ global.audioNotificationSound = 'hey.mp3';
209
+ }
210
+ },
211
+ },
212
+ {
213
+ version: 13,
214
+ name: 'add-audio-notification-volume',
215
+ migrate({ global }) {
216
+ const v = global.audioNotificationVolume;
217
+ if (typeof v !== 'number' || Number.isNaN(v) || v < 0 || v > 1) {
218
+ global.audioNotificationVolume = 1;
219
+ }
220
+ },
221
+ },
194
222
  ];
195
223
  /** Current settings schema version — always equals the highest migration version. */
196
224
  export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
@@ -225,12 +253,15 @@ function defaultSettings() {
225
253
  editorCommand: '',
226
254
  browserNotifications: true,
227
255
  audioNotifications: true,
256
+ audioNotificationSound: 'hey.mp3',
257
+ audioNotificationVolume: 1,
228
258
  notionStatusProperty: '',
229
259
  notionInProgressStatus: '',
230
260
  defaultPermissionMode: 'plan',
231
261
  notionMcpKey: '',
232
262
  sentryMcpKey: '',
233
263
  tags: [...DEFAULT_WORKSPACE_TAGS],
264
+ worktreesPath: WORKTREES_PATH,
234
265
  },
235
266
  projects: [],
236
267
  };
@@ -278,6 +309,7 @@ export function runSettingsMigrations(raw) {
278
309
  version = m.version;
279
310
  }
280
311
  }
312
+ current.global.worktreesPath = sanitizeWorktreesPath(current.global.worktreesPath);
281
313
  current.schemaVersion = version;
282
314
  return current;
283
315
  }
@@ -310,9 +342,11 @@ function readSettings() {
310
342
  // Restore any global fields that may have been removed by external edits.
311
343
  // Defaults act as fallback for missing keys; existing values are preserved.
312
344
  const globalDefaults = defaultSettings().global;
345
+ const globalBeforeDefaults = JSON.stringify(migrated.global);
313
346
  migrated.global = { ...globalDefaults, ...migrated.global };
347
+ const restoredGlobalFields = JSON.stringify(migrated.global) !== globalBeforeDefaults;
314
348
  // Persist if migrations bumped the version, or if global fields were restored.
315
- if (migrated.schemaVersion !== originalVersion) {
349
+ if (migrated.schemaVersion !== originalVersion || restoredGlobalFields) {
316
350
  writeSettings(migrated);
317
351
  }
318
352
  return migrated;
@@ -444,12 +478,15 @@ export function updateGlobalSettings(data) {
444
478
  'editorCommand',
445
479
  'browserNotifications',
446
480
  'audioNotifications',
481
+ 'audioNotificationSound',
482
+ 'audioNotificationVolume',
447
483
  'notionStatusProperty',
448
484
  'notionInProgressStatus',
449
485
  'defaultPermissionMode',
450
486
  'notionMcpKey',
451
487
  'sentryMcpKey',
452
488
  'tags',
489
+ 'worktreesPath',
453
490
  ];
454
491
  const filtered = pickKnownKeys(data, allowedGlobalKeys);
455
492
  if (filtered.tags !== undefined) {
@@ -459,10 +496,34 @@ export function updateGlobalSettings(data) {
459
496
  .filter((t) => t.length > 0 && t.length <= 50)))
460
497
  : settings.global.tags;
461
498
  }
499
+ if (filtered.audioNotificationVolume !== undefined) {
500
+ const v = Number(filtered.audioNotificationVolume);
501
+ filtered.audioNotificationVolume = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1;
502
+ }
503
+ if (filtered.worktreesPath !== undefined) {
504
+ filtered.worktreesPath = validateWorktreesPath(filtered.worktreesPath, { allowEmpty: false });
505
+ ensureGlobalWorktreesRootExists(filtered.worktreesPath);
506
+ }
462
507
  settings.global = { ...settings.global, ...filtered };
463
508
  writeSettings(settings, { backup: true });
464
509
  return settings.global;
465
510
  }
511
+ function ensureGlobalWorktreesRootExists(worktreesPath) {
512
+ const root = resolveGlobalWorktreesRoot(worktreesPath);
513
+ if (!root || isNonNativeWindowsPath(root))
514
+ return;
515
+ try {
516
+ fs.mkdirSync(root, { recursive: true });
517
+ }
518
+ catch (err) {
519
+ const message = err instanceof Error ? err.message : String(err);
520
+ throw new InvalidWorktreesPathError(`Cannot create worktrees directory '${root}': ${message}`);
521
+ }
522
+ }
523
+ function isNonNativeWindowsPath(value) {
524
+ return (process.platform !== 'win32' &&
525
+ (/^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') || value.startsWith('//')));
526
+ }
466
527
  /** Create or update project-specific settings. Merges devServer, e2e, and finalization fields on update. */
467
528
  export function upsertProject(projectPath, data) {
468
529
  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;
@@ -132,7 +132,7 @@ function fire(workspaceId) {
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);
135
+ const worktreePath = wsRow.worktree_path ?? resolveWorkspaceWorktreePath(wsRow.project_path, wsRow.working_branch);
136
136
  // Narrow against the four known values; unknowns → 'bypass'.
137
137
  const stored = wsRow.agent_permission_mode;
138
138
  const agentPermissionMode = stored === 'plan' || stored === 'strict' || stored === 'interactive' ? stored : 'bypass';
@@ -1,5 +1,6 @@
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';
@@ -91,7 +92,7 @@ export function createWorkspace(data) {
91
92
  const db = getDb();
92
93
  const now = new Date().toISOString();
93
94
  const id = nanoid();
94
- const computedWorktreePath = data.worktreePath ?? `${data.projectPath}/.worktrees/${data.workingBranch}`;
95
+ const computedWorktreePath = data.worktreePath ?? resolveWorkspaceWorktreePath(data.projectPath, data.workingBranch, data.worktreesPath);
95
96
  const owned = data.worktreeOwned ?? true;
96
97
  // Mirror the unified mode into the legacy columns so older readers (in-flight
97
98
  // requests during deploy, external scripts) still see a sane value.
@@ -216,6 +217,27 @@ export function updateWorkingBranch(id, workingBranch) {
216
217
  }
217
218
  return getWorkspace(id);
218
219
  }
220
+ /**
221
+ * Update the source branch in the database. Called by the PR watcher when
222
+ * GitHub reports a different `baseRefName` for the workspace's PR.
223
+ * Does NOT touch the worktree — the user (or the agent) decides when to
224
+ * rebase the local branch onto the new base.
225
+ */
226
+ export function updateWorkspaceSourceBranch(id, sourceBranch) {
227
+ const sanitized = sourceBranch.trim();
228
+ if (!sanitized) {
229
+ throw new Error('Source branch cannot be empty');
230
+ }
231
+ const db = getDb();
232
+ const now = new Date().toISOString();
233
+ const result = db
234
+ .prepare('UPDATE workspaces SET source_branch = ?, updated_at = ? WHERE id = ?')
235
+ .run(sanitized, now, id);
236
+ if (result.changes === 0) {
237
+ throw new Error(`Workspace '${id}' not found`);
238
+ }
239
+ return getWorkspace(id);
240
+ }
219
241
  /** Update the on-disk worktree path. Used by rename / resync-branch on owned worktrees. */
220
242
  export function updateWorktreePath(id, newPath) {
221
243
  const db = getDb();