@loicngr/kobo 1.6.14 → 1.7.0

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 (200) hide show
  1. package/README.md +4 -3
  2. package/dist/mcp-server/kobo-tasks-server.js +51 -0
  3. package/dist/server/db/migrations.js +40 -0
  4. package/dist/server/db/schema.js +7 -5
  5. package/dist/server/index.js +12 -11
  6. package/dist/server/routes/workspaces.js +207 -26
  7. package/dist/server/services/agent/engines/claude-code/capabilities.js +1 -1
  8. package/dist/server/services/agent/engines/claude-code/engine.js +237 -132
  9. package/dist/server/services/agent/engines/claude-code/event-mapper.js +234 -0
  10. package/dist/server/services/agent/engines/claude-code/options-builder.js +68 -0
  11. package/dist/server/services/agent/engines/claude-code/precompact-hook.js +27 -0
  12. package/dist/server/services/agent/engines/types.js +1 -0
  13. package/dist/server/services/agent/orchestrator.js +536 -94
  14. package/dist/server/services/agent/session-controller.js +14 -43
  15. package/dist/server/services/auto-loop-service.js +17 -6
  16. package/dist/server/services/content-migration-service.js +24 -94
  17. package/dist/server/services/usage/poller.js +4 -1
  18. package/dist/server/services/wakeup-service.js +8 -9
  19. package/dist/server/services/workspace-service.js +40 -36
  20. package/dist/server/utils/git-ops.js +67 -5
  21. package/package.json +2 -1
  22. package/src/client/dist/spa/assets/ActivityFeed-CIJPN8TH.js +7 -0
  23. package/src/client/dist/spa/assets/ActivityFeed-LXnbg3ff.css +1 -0
  24. package/src/client/dist/spa/assets/ClosePopup-DzB3mDtj.js +1 -0
  25. package/src/client/dist/spa/assets/CreatePage-BE3xfQsC.css +1 -0
  26. package/src/client/dist/spa/assets/CreatePage-U6TtJzNe.js +2 -0
  27. package/src/client/dist/spa/assets/DiffViewer-D1Sdu307.css +1 -0
  28. package/src/client/dist/spa/assets/DiffViewer-Di85TBIi.js +7 -0
  29. package/src/client/dist/spa/assets/HealthPage-B7aWFxAZ.js +1 -0
  30. package/src/client/dist/spa/assets/{MainLayout-C3TUaYvQ.js → MainLayout-BHBrz4c9.js} +17 -17
  31. package/src/client/dist/spa/assets/MainLayout-Dba6SdpU.css +1 -0
  32. package/src/client/dist/spa/assets/QCheckbox-CcY7ZSk9.js +1 -0
  33. package/src/client/dist/spa/assets/QChip-BhT0W2Dg.js +1 -0
  34. package/src/client/dist/spa/assets/QExpansionItem-VS4b4eY6.js +1 -0
  35. package/src/client/dist/spa/assets/QInput-D4WJro4e.js +1 -0
  36. package/src/client/dist/spa/assets/QMenu-CchbRXbp.js +1 -0
  37. package/src/client/dist/spa/assets/{QPage-yqdKDG7-.js → QPage-Cu7zkfc6.js} +1 -1
  38. package/src/client/dist/spa/assets/QRadio-DaZhdLCg.js +1 -0
  39. package/src/client/dist/spa/assets/QResizeObserver-Cf79V-VZ.js +1 -0
  40. package/src/client/dist/spa/assets/QScrollArea-DrVTDLU0.js +1 -0
  41. package/src/client/dist/spa/assets/QTabPanels-HXz-evuj.js +1 -0
  42. package/src/client/dist/spa/assets/QToggle-CGpiJLDJ.js +1 -0
  43. package/src/client/dist/spa/assets/QTooltip-DjJYMTkN.js +1 -0
  44. package/src/client/dist/spa/assets/SearchPage-CVm-sqxH.js +1 -0
  45. package/src/client/dist/spa/assets/SettingsPage-ayDKGo9H.js +1 -0
  46. package/src/client/dist/spa/assets/SettingsPage-wTBCvK6t.css +1 -0
  47. package/src/client/dist/spa/assets/WorkspacePage-DQxGe62K.css +1 -0
  48. package/src/client/dist/spa/assets/WorkspacePage-xaVy8s5i.js +4 -0
  49. package/src/client/dist/spa/assets/build-path-tree-CdY1A6aP.js +1 -0
  50. package/src/client/dist/spa/assets/{cssMode-wNaxOrgG.js → cssMode-BVNBMOxh.js} +1 -1
  51. package/src/client/dist/spa/assets/documents-D6A3wRry.js +1 -0
  52. package/src/client/dist/spa/assets/{editor.api-CcDntllS.js → editor.api-D6Vfp5yv.js} +1 -1
  53. package/src/client/dist/spa/assets/{editor.main-Chu4hc0J.js → editor.main-CTCYF6V4.js} +3 -3
  54. package/src/client/dist/spa/assets/{expand-template-CcQus77v.js → expand-template-vHV2iwXf.js} +1 -1
  55. package/src/client/dist/spa/assets/{formatters-CX2gvLFv.js → formatters-ejxELb0M.js} +1 -1
  56. package/src/client/dist/spa/assets/{freemarker2-CO_b202E.js → freemarker2-nmzwPmzi.js} +1 -1
  57. package/src/client/dist/spa/assets/{handlebars-CJnTWNLs.js → handlebars-CI9lR7Ef.js} +1 -1
  58. package/src/client/dist/spa/assets/{html-DeArYseI.js → html-BQ21REnv.js} +1 -1
  59. package/src/client/dist/spa/assets/{htmlMode-BnNgEgdx.js → htmlMode-io5J5Qr1.js} +1 -1
  60. package/src/client/dist/spa/assets/i18n-Do8Kn8n0.js +1 -0
  61. package/src/client/dist/spa/assets/index-C_e7KOYh.js +2 -0
  62. package/src/client/dist/spa/assets/{javascript-C0pxfNu4.js → javascript--u9PDBCv.js} +1 -1
  63. package/src/client/dist/spa/assets/{jsonMode-ety87201.js → jsonMode-DBG5llk4.js} +1 -1
  64. package/src/client/dist/spa/assets/{kobo-commands-Cpl4IFon.js → kobo-commands-DiUm1Y34.js} +1 -1
  65. package/src/client/dist/spa/assets/{liquid-kanevKvC.js → liquid-DxAS4nYF.js} +1 -1
  66. package/src/client/dist/spa/assets/marked.esm-DuOsJx63.js +60 -0
  67. package/src/client/dist/spa/assets/{mdx-DkmtbRD7.js → mdx-BNXTiODW.js} +1 -1
  68. package/src/client/dist/spa/assets/models-DNYEhFF7.js +1 -0
  69. package/src/client/dist/spa/assets/{monaco.contribution-DsZsua59.js → monaco.contribution-CT3LAK0J.js} +2 -2
  70. package/src/client/dist/spa/assets/{python-DrxH1xl7.js → python-DztNww13.js} +1 -1
  71. package/src/client/dist/spa/assets/{razor-CU4khv8N.js → razor-Cyr82NZF.js} +1 -1
  72. package/src/client/dist/spa/assets/settings-Dbx1_ksA.js +1 -0
  73. package/src/client/dist/spa/assets/symbols-BVRrMH2r.js +1 -0
  74. package/src/client/dist/spa/assets/touch-Co9pfjUU.js +1 -0
  75. package/src/client/dist/spa/assets/{tsMode-CQ5yxoz_.js → tsMode-CbQVgsIP.js} +1 -1
  76. package/src/client/dist/spa/assets/{typescript-CSwKmP7l.js → typescript-UHOe4d1S.js} +1 -1
  77. package/src/client/dist/spa/assets/use-checkbox-DzHmcu7s.js +1 -0
  78. package/src/client/dist/spa/assets/use-id-CDuXkR0Z.js +1 -0
  79. package/src/client/dist/spa/assets/use-panel-Br8QNRMk.js +1 -0
  80. package/src/client/dist/spa/assets/{xml-9bnWANPJ.js → xml-DC88eFpV.js} +1 -1
  81. package/src/client/dist/spa/assets/{yaml-sUtDJGxo.js → yaml-DSTsIRJr.js} +1 -1
  82. package/src/client/dist/spa/index.html +11 -10
  83. package/src/mcp-server/kobo-tasks-server.ts +60 -1
  84. package/dist/server/services/agent/engines/claude-code/args-builder.js +0 -57
  85. package/dist/server/services/agent/engines/claude-code/mcp-config.js +0 -23
  86. package/dist/server/services/agent/engines/claude-code/stream-parser.js +0 -386
  87. package/src/client/dist/spa/assets/ActivityFeed-Be0QQryJ.css +0 -1
  88. package/src/client/dist/spa/assets/ActivityFeed-BtIOkIy6.js +0 -8
  89. package/src/client/dist/spa/assets/ClosePopup-DkLittac.js +0 -1
  90. package/src/client/dist/spa/assets/CreatePage-D6Q3nxkX.js +0 -2
  91. package/src/client/dist/spa/assets/CreatePage-DJbZH8wp.css +0 -1
  92. package/src/client/dist/spa/assets/DiffViewer-1s165rFm.css +0 -1
  93. package/src/client/dist/spa/assets/DiffViewer-D5u9p7il.js +0 -2
  94. package/src/client/dist/spa/assets/HealthPage-Cr7aAUy6.js +0 -1
  95. package/src/client/dist/spa/assets/MainLayout-CBnSwSfy.css +0 -1
  96. package/src/client/dist/spa/assets/QChip-bl3YRhax.js +0 -1
  97. package/src/client/dist/spa/assets/QExpansionItem-CWw6ZujM.js +0 -1
  98. package/src/client/dist/spa/assets/QScrollArea-DpCqRRE0.js +0 -1
  99. package/src/client/dist/spa/assets/QSeparator-DNSiXYrN.js +0 -1
  100. package/src/client/dist/spa/assets/QSlideTransition-BQxI8l5r.js +0 -1
  101. package/src/client/dist/spa/assets/QTabPanels-C4bZGqml.js +0 -1
  102. package/src/client/dist/spa/assets/QTooltip-BIDjo2hJ.js +0 -1
  103. package/src/client/dist/spa/assets/SearchPage-CavRaij6.js +0 -1
  104. package/src/client/dist/spa/assets/SettingsPage-B8DhSZw7.css +0 -1
  105. package/src/client/dist/spa/assets/SettingsPage-C13T1l_t.js +0 -1
  106. package/src/client/dist/spa/assets/TouchPan-vsl78kxF.js +0 -1
  107. package/src/client/dist/spa/assets/WorkspacePage-BEqEuPrb.js +0 -4
  108. package/src/client/dist/spa/assets/WorkspacePage-k2pgeRoy.css +0 -1
  109. package/src/client/dist/spa/assets/build-path-tree-BeAS10oa.js +0 -1
  110. package/src/client/dist/spa/assets/documents-Cw05r3zs.js +0 -60
  111. package/src/client/dist/spa/assets/i18n-CuT4b7ns.js +0 -1
  112. package/src/client/dist/spa/assets/index-CZA4BFN5.js +0 -2
  113. package/src/client/dist/spa/assets/models-CPFeBEQS.js +0 -1
  114. package/src/client/dist/spa/assets/private.use-form-Dlb0iQZh.js +0 -1
  115. package/src/client/dist/spa/assets/scroll-CYWyxBdv.js +0 -1
  116. package/src/client/dist/spa/assets/settings-CAILUJXO.js +0 -1
  117. package/src/client/dist/spa/assets/stats-C3n1k51k.js +0 -1
  118. package/src/client/dist/spa/assets/symbols-DCYodwb2.js +0 -1
  119. package/src/client/dist/spa/assets/touch-Bj_Fr4kC.js +0 -1
  120. package/src/client/dist/spa/assets/use-checkbox-B_o-iLG2.js +0 -1
  121. package/src/client/dist/spa/assets/use-id-C93QQwrt.js +0 -1
  122. /package/src/client/dist/spa/assets/{QBadge-DqtcDv8D.js → QBadge-fsQ2AokU.js} +0 -0
  123. /package/src/client/dist/spa/assets/{QItemLabel-Codqjisk.js → QItemLabel-DWwenW2S.js} +0 -0
  124. /package/src/client/dist/spa/assets/{QItemSection-CiY_LK5Y.js → QItemSection-KFAnxzMK.js} +0 -0
  125. /package/src/client/dist/spa/assets/{QList-Bl9824vi.js → QList-NmIE6Rd9.js} +0 -0
  126. /package/src/client/dist/spa/assets/{QSpace-BNr0AftG.js → QSpace-COlmM_4F.js} +0 -0
  127. /package/src/client/dist/spa/assets/{QSpinnerDots-DEiRooBD.js → QSpinnerDots-DwtnRN2r.js} +0 -0
  128. /package/src/client/dist/spa/assets/{_plugin-vue_export-helper-r4mAJOHR.js → _plugin-vue_export-helper-B8bB5DBd.js} +0 -0
  129. /package/src/client/dist/spa/assets/{abap-Bgec7Keq.js → abap-DzK-OTGh.js} +0 -0
  130. /package/src/client/dist/spa/assets/{apex-VBlPwEoQ.js → apex-Bj60_dRt.js} +0 -0
  131. /package/src/client/dist/spa/assets/{azcli-DKqrEFBx.js → azcli-B6NwaBAZ.js} +0 -0
  132. /package/src/client/dist/spa/assets/{bat-DdgQWy_0.js → bat-bf7wXV68.js} +0 -0
  133. /package/src/client/dist/spa/assets/{bicep-CRMM43EB.js → bicep-C_bg8UgA.js} +0 -0
  134. /package/src/client/dist/spa/assets/{cameligo-UatALtML.js → cameligo-CTWw4D4B.js} +0 -0
  135. /package/src/client/dist/spa/assets/{clojure-D8JU08RA.js → clojure-CgdPoH0r.js} +0 -0
  136. /package/src/client/dist/spa/assets/{coffee-C56wu358.js → coffee-gHQfdA5M.js} +0 -0
  137. /package/src/client/dist/spa/assets/{cpp-CyZLvhJG.js → cpp-BM4Jj4aW.js} +0 -0
  138. /package/src/client/dist/spa/assets/{csharp-BJl3ixva.js → csharp-D8-bh4Cd.js} +0 -0
  139. /package/src/client/dist/spa/assets/{csp-CxEKxmO-.js → csp-CXBxRx0n.js} +0 -0
  140. /package/src/client/dist/spa/assets/{css-B0t_muXd.js → css-DKjIxrmY.js} +0 -0
  141. /package/src/client/dist/spa/assets/{cypher-D1hqiMFD.js → cypher-C5e5inIh.js} +0 -0
  142. /package/src/client/dist/spa/assets/{dart-Bz550Pyv.js → dart-BhRHHm4x.js} +0 -0
  143. /package/src/client/dist/spa/assets/{dockerfile-CIXgVAuA.js → dockerfile-DW5REF8E.js} +0 -0
  144. /package/src/client/dist/spa/assets/{ecl-D9qbvZoA.js → ecl-Bw4Hg3n_.js} +0 -0
  145. /package/src/client/dist/spa/assets/{elixir-b2M38fAy.js → elixir-DHmoBvpZ.js} +0 -0
  146. /package/src/client/dist/spa/assets/{flow9-Dq1UYMkt.js → flow9-BsFExz3v.js} +0 -0
  147. /package/src/client/dist/spa/assets/{fsharp-CFNadkg7.js → fsharp-BaeLhgfq.js} +0 -0
  148. /package/src/client/dist/spa/assets/{go-dSur1iB2.js → go-Bd-NFKIC.js} +0 -0
  149. /package/src/client/dist/spa/assets/{graphql-qyhAo11d.js → graphql-DZVerJfy.js} +0 -0
  150. /package/src/client/dist/spa/assets/{hcl-DFzjMyzm.js → hcl-CAVzrZfH.js} +0 -0
  151. /package/src/client/dist/spa/assets/{ini-TdzA8TIl.js → ini-CyXdX58t.js} +0 -0
  152. /package/src/client/dist/spa/assets/{is-DUKatk8N.js → is-BbsvEMaT.js} +0 -0
  153. /package/src/client/dist/spa/assets/{java-CSGA9pkE.js → java-B5pNgvhy.js} +0 -0
  154. /package/src/client/dist/spa/assets/{julia-9izz5OsY.js → julia-XRhmV3AN.js} +0 -0
  155. /package/src/client/dist/spa/assets/{kotlin-DuPK7AtF.js → kotlin-DOd3J5vr.js} +0 -0
  156. /package/src/client/dist/spa/assets/{less-B8d93iCg.js → less-veZSnyw6.js} +0 -0
  157. /package/src/client/dist/spa/assets/{lexon-DWtEIyu7.js → lexon-QWGkuK0H.js} +0 -0
  158. /package/src/client/dist/spa/assets/{lua-Ciq0OGgt.js → lua-CYGpjuO5.js} +0 -0
  159. /package/src/client/dist/spa/assets/{m3-Cki6JWj_.js → m3-yNnrZkdc.js} +0 -0
  160. /package/src/client/dist/spa/assets/{markdown-Cu47xwU0.js → markdown-BCSWEPSX.js} +0 -0
  161. /package/src/client/dist/spa/assets/{mips-BM8ui995.js → mips-OpYmcC30.js} +0 -0
  162. /package/src/client/dist/spa/assets/{msdax-DqLio0_c.js → msdax-2oxoTO9Z.js} +0 -0
  163. /package/src/client/dist/spa/assets/{mysql-v1wbjJOq.js → mysql-5KlC-K_9.js} +0 -0
  164. /package/src/client/dist/spa/assets/{objective-c-CQl3PGSB.js → objective-c-CcDCgtLx.js} +0 -0
  165. /package/src/client/dist/spa/assets/{pascal-D4iW0ZtD.js → pascal-BZGsbaEV.js} +0 -0
  166. /package/src/client/dist/spa/assets/{pascaligo-BdC9CZdj.js → pascaligo-DtD5qU3G.js} +0 -0
  167. /package/src/client/dist/spa/assets/{perl-BL10m4XD.js → perl-C1jNNS3E.js} +0 -0
  168. /package/src/client/dist/spa/assets/{pgsql-Be_oqVo3.js → pgsql-CT0fhiZa.js} +0 -0
  169. /package/src/client/dist/spa/assets/{php-BtvXSFRI.js → php-D6DrXoPM.js} +0 -0
  170. /package/src/client/dist/spa/assets/{pla-B2vUy15C.js → pla-b3-HN2pF.js} +0 -0
  171. /package/src/client/dist/spa/assets/{postiats-CbmTTfXr.js → postiats-Bin2ApVS.js} +0 -0
  172. /package/src/client/dist/spa/assets/{powerquery-DszLhJGx.js → powerquery-7ASnn-ZG.js} +0 -0
  173. /package/src/client/dist/spa/assets/{powershell-B0dYktF6.js → powershell-t4p7sU1H.js} +0 -0
  174. /package/src/client/dist/spa/assets/{protobuf-CZvaj1VX.js → protobuf-BUGeWa_j.js} +0 -0
  175. /package/src/client/dist/spa/assets/{pug-CPDx1B3S.js → pug-BuKcgC9s.js} +0 -0
  176. /package/src/client/dist/spa/assets/{qsharp-CDP9TFLl.js → qsharp-DSMtI_O7.js} +0 -0
  177. /package/src/client/dist/spa/assets/{r-8DbbFX2l.js → r-DMlFgn7A.js} +0 -0
  178. /package/src/client/dist/spa/assets/{redis-DRWj9MtJ.js → redis-cXItkC5u.js} +0 -0
  179. /package/src/client/dist/spa/assets/{redshift-C6cElE_5.js → redshift-BZVbW7HE.js} +0 -0
  180. /package/src/client/dist/spa/assets/{restructuredtext-W9pS9n3m.js → restructuredtext-BzjxwS8h.js} +0 -0
  181. /package/src/client/dist/spa/assets/{ruby-BKnzWnk-.js → ruby-C5nyLV4l.js} +0 -0
  182. /package/src/client/dist/spa/assets/{rust-YPCclWwe.js → rust-BcmMsHdf.js} +0 -0
  183. /package/src/client/dist/spa/assets/{sb-BgM4DTFb.js → sb-Dnb1iy6B.js} +0 -0
  184. /package/src/client/dist/spa/assets/{scala-fz1OPLMl.js → scala-anMIFYpA.js} +0 -0
  185. /package/src/client/dist/spa/assets/{scheme-8Uz1RIbu.js → scheme-BItQTe08.js} +0 -0
  186. /package/src/client/dist/spa/assets/{scss-Djo3IYXr.js → scss-BOv51BJ5.js} +0 -0
  187. /package/src/client/dist/spa/assets/{shell-CINF5Tx_.js → shell-BsRYRTNN.js} +0 -0
  188. /package/src/client/dist/spa/assets/{solidity-GgiNEuUm.js → solidity-BtuLgGDx.js} +0 -0
  189. /package/src/client/dist/spa/assets/{sophia-Culj97P9.js → sophia-B0Vkc5MF.js} +0 -0
  190. /package/src/client/dist/spa/assets/{sparql-C2ZlpxOY.js → sparql-B7lvkZQM.js} +0 -0
  191. /package/src/client/dist/spa/assets/{sql-BEf5Pg7Y.js → sql-DvP5MpA3.js} +0 -0
  192. /package/src/client/dist/spa/assets/{st-CT6UUoeH.js → st-GVUeyB3U.js} +0 -0
  193. /package/src/client/dist/spa/assets/{swift-B5g0xTG3.js → swift-DSPIoCjm.js} +0 -0
  194. /package/src/client/dist/spa/assets/{systemverilog-CEgQz9DR.js → systemverilog-Icj2-k23.js} +0 -0
  195. /package/src/client/dist/spa/assets/{tcl-D0qL2L0I.js → tcl-Cd8KQcm-.js} +0 -0
  196. /package/src/client/dist/spa/assets/{twig-BFUAVf1E.js → twig-CBHmt8z3.js} +0 -0
  197. /package/src/client/dist/spa/assets/{typespec-CjVVcNKm.js → typespec-Ckc037mq.js} +0 -0
  198. /package/src/client/dist/spa/assets/{vb-CZJr-DQz.js → vb-B97GW9Wb.js} +0 -0
  199. /package/src/client/dist/spa/assets/{vue-i18n-BJlZEYnA.js → vue-i18n-eUDnMrPl.js} +0 -0
  200. /package/src/client/dist/spa/assets/{wgsl-ivoXUo2e.js → wgsl-DIKmb3YH.js} +0 -0
package/README.md CHANGED
@@ -12,7 +12,8 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
12
12
  ## Features
13
13
 
14
14
  - **Isolated git worktrees** — every workspace runs on its own branch in its own directory, so concurrent Claude sessions never step on each other
15
- - **Pluggable agent engine** — Kōbō talks to agents through an `AgentEngine` contract with a normalised `AgentEvent` stream (`src/server/services/agent/engines/`). Claude Code is the first engine; dropping in another runtime (e.g. the Claude Agent SDK) only requires a new adapter, not a rewrite of the UI or orchestration layer. Migration to the official Claude Agent SDK is tracked in [#9](https://github.com/loicngr/Kobo/issues/9)
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
+ - **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
16
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
17
18
  - **Task & acceptance criteria tracking** — the agent reports progress through a dedicated MCP server (`kobo-tasks`) that reads and updates tasks directly from the SQLite database
18
19
  - **Documents panel** — tree view in the right drawer that surfaces every AI-generated markdown file under `docs/plans/`, `docs/superpowers/`, and `.ai/thoughts/`. Paths mentioned in chat messages are auto-detected against the catalogue and become one-click deep-links into the panel
@@ -47,7 +48,7 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
47
48
  ### Prerequisites
48
49
 
49
50
  - Node.js ≥ 20
50
- - [Claude Code CLI](https://claude.com/claude-code) installed and authenticated (`claude` on your `PATH`)
51
+ - [Claude Code](https://claude.com/claude-code) authenticated via `claude /login` once. The `claude` CLI is **no longer required at runtime** — Kōbō embeds the official [`@anthropic-ai/claude-agent-sdk`](https://github.com/anthropics/claude-agent-sdk-typescript), which reuses the same login.
51
52
  - Git
52
53
  - Optional: Docker (if you configure per-workspace dev servers)
53
54
  - Optional: `gh` CLI (if you use the PR automation)
@@ -62,7 +63,7 @@ SERVER_PORT=9998 PORT=9999 npx @loicngr/kobo@latest
62
63
 
63
64
  That's it. npm downloads the package, installs dependencies, starts the Kōbō server on the port you specified, and serves the web UI at `http://localhost:9999`. Data is persisted to `~/.config/kobo/` (overridable via `KOBO_HOME`).
64
65
 
65
- On first launch Kōbō creates `~/.config/kobo/` if it doesn't exist. If the `claude` CLI is missing from your `PATH` you will see a warning in the terminal install Claude Code before creating your first workspace.
66
+ On first launch Kōbō creates `~/.config/kobo/` if it doesn't exist. If you have not yet logged in to Claude Code (`claude /login`), the SDK will prompt for an `ANTHROPIC_API_KEY` instead log in once to share the same authentication across Claude Code, the embedded SDK, and the quota poller.
66
67
 
67
68
  ### Run from source (contributors)
68
69
 
@@ -292,6 +292,33 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
292
292
  description: 'CALL when you need to self-regulate on long missions — returns token/cost totals for the workspace lifetime and for the currently running agent_session. Useful before spawning heavy subagents or deep reasoning on already-expensive sessions.',
293
293
  inputSchema: { type: 'object', properties: {}, required: [] },
294
294
  },
295
+ {
296
+ name: 'schedule_wakeup',
297
+ description: 'CALL to schedule a follow-up turn on THIS workspace after a delay. End the current turn normally; once it finishes and the workspace is idle, Kōbō waits `delaySeconds`, then resumes the same conversation by injecting `prompt` as the next user message. The wakeup is scoped to the current workspace and resumes its latest session — you cannot target another workspace or another session. If a turn is still active when the timer fires, the wakeup is skipped (status: `session-active`). Replaces any previously pending wakeup on this workspace. Delay is clamped to [60, 3600] seconds. Prefer this over the built-in `ScheduleWakeup` tool — it is the SDK-supported entry point.',
298
+ inputSchema: {
299
+ type: 'object',
300
+ properties: {
301
+ delaySeconds: {
302
+ type: 'number',
303
+ description: 'Seconds from now until the wakeup fires. Clamped to [60, 3600].',
304
+ },
305
+ prompt: {
306
+ type: 'string',
307
+ description: 'Prompt sent to the agent when the wakeup fires.',
308
+ },
309
+ reason: {
310
+ type: 'string',
311
+ description: 'Short label shown to the user explaining the wakeup (optional).',
312
+ },
313
+ },
314
+ required: ['delaySeconds', 'prompt'],
315
+ },
316
+ },
317
+ {
318
+ name: 'cancel_wakeup',
319
+ description: 'CALL to cancel any pending wakeup on this workspace (e.g. the condition you were waiting on resolved early, or you decided not to continue). Idempotent — safe to call when nothing is pending.',
320
+ inputSchema: { type: 'object', properties: {}, required: [] },
321
+ },
295
322
  ],
296
323
  }));
297
324
  /** Wrap a successful result as an MCP tool response with JSON text content. */
@@ -441,6 +468,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
441
468
  if (name === 'get_session_usage') {
442
469
  return ok(getSessionUsageHandler(db, workspaceId));
443
470
  }
471
+ if (name === 'schedule_wakeup') {
472
+ const delaySeconds = a.delaySeconds;
473
+ const prompt = a.prompt;
474
+ if (typeof delaySeconds !== 'number' || !Number.isFinite(delaySeconds) || delaySeconds <= 0) {
475
+ return fail('delaySeconds must be a positive number');
476
+ }
477
+ if (typeof prompt !== 'string' || prompt.trim().length === 0) {
478
+ return fail('prompt is required');
479
+ }
480
+ const reason = a.reason;
481
+ if (reason !== undefined && typeof reason !== 'string') {
482
+ return fail('reason must be a string when provided');
483
+ }
484
+ const result = await backendRequest('POST', `/api/workspaces/${workspaceId}/pending-wakeup`, {
485
+ delaySeconds,
486
+ prompt,
487
+ reason,
488
+ });
489
+ return ok(result);
490
+ }
491
+ if (name === 'cancel_wakeup') {
492
+ const result = await backendRequest('DELETE', `/api/workspaces/${workspaceId}/pending-wakeup`);
493
+ return ok(result);
494
+ }
444
495
  if (name === 'search_codebase') {
445
496
  const query = a.query;
446
497
  if (!query)
@@ -154,6 +154,46 @@ export const migrations = [
154
154
  )`).run();
155
155
  },
156
156
  },
157
+ {
158
+ version: 17,
159
+ name: 'add-agent-permission-mode',
160
+ migrate: (db) => {
161
+ // Unifies the legacy `permission_mode` (auto-accept | plan) and
162
+ // `permission_profile` (bypass | strict | interactive) into a single
163
+ // SDK-aligned column with four values: plan | bypass | strict | interactive.
164
+ //
165
+ // Migration rule (preserves user-visible behaviour):
166
+ // permission_mode='plan' → 'plan'
167
+ // permission_mode='auto-accept' + permission_profile=* → permission_profile (default 'bypass')
168
+ //
169
+ // The two legacy columns are kept for backward compatibility — they are
170
+ // no longer the source of truth but stay readable so older code paths
171
+ // (or in-flight requests during deploy) don't crash.
172
+ db.transaction(() => {
173
+ db.prepare("ALTER TABLE workspaces ADD COLUMN agent_permission_mode TEXT NOT NULL DEFAULT 'bypass'").run();
174
+ // Plan mode is preserved verbatim.
175
+ db.prepare("UPDATE workspaces SET agent_permission_mode = 'plan' WHERE permission_mode = 'plan'").run();
176
+ // For 'auto-accept' rows, promote the profile (or fall back to bypass).
177
+ db.prepare(`UPDATE workspaces
178
+ SET agent_permission_mode = CASE
179
+ WHEN permission_profile IN ('bypass', 'strict', 'interactive') THEN permission_profile
180
+ ELSE 'bypass'
181
+ END
182
+ WHERE permission_mode != 'plan'`).run();
183
+ })();
184
+ },
185
+ },
186
+ {
187
+ version: 18,
188
+ name: 'add-pending-wakeup-agent-session-id',
189
+ migrate: (db) => {
190
+ // Pin a wakeup to the session that scheduled it, so the wakeup resumes
191
+ // that conversation instead of whichever session happens to be the
192
+ // latest at fire time. Nullable: pre-migration rows fall back to the
193
+ // legacy "last session" behaviour.
194
+ db.prepare('ALTER TABLE pending_wakeups ADD COLUMN agent_session_id TEXT').run();
195
+ },
196
+ },
157
197
  ];
158
198
  /** Current schema version — always equals the highest migration version. */
159
199
  export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
@@ -26,6 +26,7 @@ export function initSchema(db) {
26
26
  auto_loop_ready INTEGER NOT NULL DEFAULT 0,
27
27
  no_progress_streak INTEGER NOT NULL DEFAULT 0,
28
28
  permission_profile TEXT NOT NULL DEFAULT 'bypass',
29
+ agent_permission_mode TEXT NOT NULL DEFAULT 'bypass',
29
30
  created_at TEXT NOT NULL,
30
31
  updated_at TEXT NOT NULL
31
32
  );
@@ -62,11 +63,12 @@ export function initSchema(db) {
62
63
  );
63
64
 
64
65
  CREATE TABLE IF NOT EXISTS pending_wakeups (
65
- workspace_id TEXT PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE,
66
- target_at TEXT NOT NULL,
67
- prompt TEXT NOT NULL,
68
- reason TEXT,
69
- created_at TEXT NOT NULL
66
+ workspace_id TEXT PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE,
67
+ target_at TEXT NOT NULL,
68
+ prompt TEXT NOT NULL,
69
+ reason TEXT,
70
+ created_at TEXT NOT NULL,
71
+ agent_session_id TEXT
70
72
  );
71
73
 
72
74
  CREATE TABLE IF NOT EXISTS usage_snapshots (
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import { spawnSync } from 'node:child_process';
3
2
  import fs from 'node:fs';
4
3
  import path from 'node:path';
5
4
  import { serve } from '@hono/node-server';
@@ -34,14 +33,6 @@ import { emit, emitEphemeral, handleConnection, setMessageHandler } from './serv
34
33
  import { getActiveSession, getWorkspace, updateWorkspaceStatus } from './services/workspace-service.js';
35
34
  import { getClientSpaPath, getDbPath, getKoboHome, getPackageVersion } from './utils/paths.js';
36
35
  import { initProcessCleanup, killAll as killAllTrackedProcesses } from './utils/process-tracker.js';
37
- // Runtime prerequisite check — warn if the claude CLI is missing. Don't block
38
- // startup: the user may still want to configure settings or browse workspaces.
39
- {
40
- const check = spawnSync('claude', ['--version'], { stdio: 'ignore' });
41
- if (check.error && check.error.code === 'ENOENT') {
42
- console.warn("[kobo] WARNING: 'claude' CLI not found on PATH. Kōbō will fail to spawn agents until Claude Code is installed. See https://claude.com/claude-code");
43
- }
44
- }
45
36
  console.log(`[kobo] Kōbō home: ${getKoboHome()}`);
46
37
  // Initialize DB + run migrations
47
38
  const db = getDb();
@@ -250,6 +241,16 @@ setMessageHandler((type, payload) => {
250
241
  });
251
242
  return;
252
243
  }
244
+ // Reject chat input while paused on canUseTool — sending here would spawn
245
+ // a parallel session and orphan the pending callback.
246
+ const wsRow = getWorkspace(p.workspaceId);
247
+ if (wsRow?.status === 'awaiting-user') {
248
+ emitEphemeral(p.workspaceId, 'chat:rejected', {
249
+ reason: 'awaiting-user',
250
+ message: 'Answer via the question panel — typing in chat would orphan the pending callback',
251
+ });
252
+ return;
253
+ }
253
254
  // Prefer the session explicitly selected by the client (sessionId hint),
254
255
  // falling back to the running/most-recent non-idle session so idle sessions
255
256
  // never steal the tagging.
@@ -278,7 +279,7 @@ setMessageHandler((type, payload) => {
278
279
  // Plan mode blocks MCP tools — when the caller knows the message
279
280
  // requires them (e.g. grooming), it sets the override to bypass the
280
281
  // workspace default for this spawn only.
281
- const effectiveMode = p.permissionModeOverride ?? workspace.permissionMode;
282
+ const effectiveMode = p.agentPermissionModeOverride ?? workspace.agentPermissionMode;
282
283
  startAgent(p.workspaceId, worktreePath, p.content, workspace.model, true, effectiveMode, p.sessionId, workspace.reasoningEffort);
283
284
  updateWorkspaceStatus(p.workspaceId, 'executing');
284
285
  }
@@ -297,7 +298,7 @@ setMessageHandler((type, payload) => {
297
298
  }
298
299
  const worktreePath = workspace.worktreePath;
299
300
  const prompt = p.prompt ?? 'Continue the previous task where you left off.';
300
- startAgent(p.workspaceId, worktreePath, prompt, workspace.model, false, workspace.permissionMode, undefined, workspace.reasoningEffort);
301
+ startAgent(p.workspaceId, worktreePath, prompt, workspace.model, false, workspace.agentPermissionMode, undefined, workspace.reasoningEffort);
301
302
  }
302
303
  catch (err) {
303
304
  console.error('[ws] Failed to start agent:', err);
@@ -27,6 +27,29 @@ import * as gitOps from '../utils/git-ops.js';
27
27
  const app = new Hono();
28
28
  /** Tracks workspaces currently running a setup script to prevent concurrent executions. */
29
29
  const setupScriptRunning = new Set();
30
+ const VALID_AGENT_PERMISSION_MODES = ['plan', 'bypass', 'strict', 'interactive'];
31
+ function isAgentPermissionMode(value) {
32
+ return typeof value === 'string' && VALID_AGENT_PERMISSION_MODES.includes(value);
33
+ }
34
+ /**
35
+ * Resolve the unified permission mode for a new workspace.
36
+ *
37
+ * Cascade: explicit body field → global default (validated) → 'bypass'.
38
+ *
39
+ * Legacy `defaultPermissionMode` values ('plan' / 'auto-accept') are honored:
40
+ * 'plan' stays 'plan'; 'auto-accept' falls through to 'bypass' (the safest
41
+ * non-plan default — matches the pre-refactor "skip prompts" behaviour).
42
+ */
43
+ function resolveCreateAgentPermissionMode(bodyValue, _projectPath, globalSettings) {
44
+ if (isAgentPermissionMode(bodyValue))
45
+ return bodyValue;
46
+ const global = globalSettings.defaultPermissionMode;
47
+ if (isAgentPermissionMode(global))
48
+ return global;
49
+ if (global === 'plan')
50
+ return 'plan';
51
+ return 'bypass';
52
+ }
30
53
  app.get('/', (c) => {
31
54
  try {
32
55
  const workspaces = workspaceService.listWorkspaces();
@@ -172,7 +195,7 @@ app.post('/', migrationGuard, async (c) => {
172
195
  ...(useReusedWorktree ? { worktreePath: body.worktreePath, worktreeOwned: false } : {}),
173
196
  model: body.model,
174
197
  reasoningEffort: body.reasoningEffort,
175
- permissionMode: body.permissionMode || globalSettings.defaultPermissionMode || 'plan',
198
+ agentPermissionMode: resolveCreateAgentPermissionMode(body.agentPermissionMode, body.projectPath, globalSettings),
176
199
  engine: body.engine,
177
200
  });
178
201
  // Auto-tag the workspace based on its creation source — `notion` when
@@ -197,9 +220,12 @@ app.post('/', migrationGuard, async (c) => {
197
220
  }
198
221
  }
199
222
  // Update workspace name with Sentry issue title if the user did not provide
200
- // a custom name and Notion hasn't already filled it.
223
+ // a custom name and Notion hasn't already filled it. Prefix with the Sentry
224
+ // short-id (e.g. "SEKUR-IOS-9 | TypeError: …") so the workspace stays
225
+ // identifiable in the sidebar without opening the panel.
201
226
  if (sentryContent?.title && !notionContent?.title && workspace.name === 'workspace') {
202
- workspace = workspaceService.updateWorkspaceName(workspace.id, sentryContent.title);
227
+ const prefix = sentryContent.issueId ? `${sentryContent.issueId} | ` : '';
228
+ workspace = workspaceService.updateWorkspaceName(workspace.id, `${prefix}${sentryContent.title}`);
203
229
  }
204
230
  // Create tasks from extracted Notion data
205
231
  if (notionContent) {
@@ -218,9 +244,13 @@ app.post('/', migrationGuard, async (c) => {
218
244
  sortOrder: sortOrder++,
219
245
  });
220
246
  }
221
- // Update workspace name with Notion page title only if user didn't provide a custom name
247
+ // Update workspace name with Notion page title only if user didn't
248
+ // provide a custom name. Prefix with the Notion unique-id (e.g.
249
+ // "TK-123 | …") when the page has one — it makes the workspace
250
+ // immediately scannable in the sidebar.
222
251
  if (notionContent.title && workspace.name === 'workspace') {
223
- workspace = workspaceService.updateWorkspaceName(workspace.id, notionContent.title);
252
+ const prefix = notionContent.ticketId ? `${notionContent.ticketId} | ` : '';
253
+ workspace = workspaceService.updateWorkspaceName(workspace.id, `${prefix}${notionContent.title}`);
224
254
  }
225
255
  }
226
256
  // Create manual tasks/criteria if no Notion content was extracted
@@ -537,7 +567,7 @@ ${AUTO_LOOP_HARD_RULES}`;
537
567
  brainstormPrompt += `\n\nThen brainstorm the implementation approach. Explore the codebase to understand the existing structure. Ask clarifying questions if needed. When you're done brainstorming and have a clear plan, create a plan file and proceed with implementation. Once you have completed the brainstorming phase, output [BRAINSTORM_COMPLETE] on its own line.`;
538
568
  }
539
569
  try {
540
- const agent = agentManager.startAgent(workspace.id, worktreePath, brainstormPrompt, workspace.model, false, workspace.permissionMode, undefined, workspace.reasoningEffort);
570
+ const agent = agentManager.startAgent(workspace.id, worktreePath, brainstormPrompt, workspace.model, false, workspace.agentPermissionMode, undefined, workspace.reasoningEffort);
541
571
  // Persist the initial prompt in the feed so it's visible in the chat,
542
572
  // tagged with the freshly created session id so the strict session filter shows it.
543
573
  wsService.emit(workspace.id, 'user:message', { content: brainstormPrompt, sender: 'system-prompt' }, agent.agentSessionId);
@@ -716,7 +746,8 @@ app.get('/:id/pending-wakeup', (c) => {
716
746
  return c.json({ error: message }, 500);
717
747
  }
718
748
  });
719
- // DELETE /api/workspaces/:id/pending-wakeup — user-initiated cancel ("×" button).
749
+ // DELETE /api/workspaces/:id/pending-wakeup — user-initiated cancel ("×" button)
750
+ // or agent-initiated cancel via the `kobo__cancel_wakeup` MCP tool.
720
751
  app.delete('/:id/pending-wakeup', (c) => {
721
752
  try {
722
753
  const id = c.req.param('id');
@@ -728,6 +759,41 @@ app.delete('/:id/pending-wakeup', (c) => {
728
759
  return c.json({ error: message }, 500);
729
760
  }
730
761
  });
762
+ // POST /api/workspaces/:id/pending-wakeup — agent-initiated schedule via the
763
+ // `kobo__schedule_wakeup` MCP tool. Replaces any existing pending wakeup.
764
+ app.post('/:id/pending-wakeup', async (c) => {
765
+ try {
766
+ const id = c.req.param('id');
767
+ const body = (await c.req.json().catch(() => ({})));
768
+ const delaySeconds = body.delaySeconds;
769
+ const prompt = body.prompt;
770
+ const reason = body.reason;
771
+ if (typeof delaySeconds !== 'number' || !Number.isFinite(delaySeconds) || delaySeconds <= 0) {
772
+ return c.json({ error: 'delaySeconds must be a positive number' }, 400);
773
+ }
774
+ if (typeof prompt !== 'string' || prompt.trim().length === 0) {
775
+ return c.json({ error: 'prompt is required' }, 400);
776
+ }
777
+ if (reason !== undefined && typeof reason !== 'string') {
778
+ return c.json({ error: 'reason must be a string when provided' }, 400);
779
+ }
780
+ // Pin the wakeup to the session that scheduled it, so the resume targets
781
+ // that conversation instead of whichever session happens to be the latest
782
+ // at fire time. The MCP tool is invoked from inside an active session, so
783
+ // a missing controller signals misuse — reject explicitly.
784
+ const agentSessionId = agentManager.getActiveSessionId(id);
785
+ if (!agentSessionId) {
786
+ return c.json({ error: 'no active agent session for this workspace' }, 409);
787
+ }
788
+ wakeupService.schedule(id, delaySeconds, prompt, reason, agentSessionId);
789
+ const pending = wakeupService.getPending(id);
790
+ return c.json({ ok: true, pending });
791
+ }
792
+ catch (err) {
793
+ const message = err instanceof Error ? err.message : String(err);
794
+ return c.json({ error: message }, 500);
795
+ }
796
+ });
731
797
  // PATCH /api/workspaces/:id/sessions/:sessionId — rename a session
732
798
  app.patch('/:id/sessions/:sessionId', async (c) => {
733
799
  try {
@@ -752,6 +818,94 @@ app.patch('/:id/sessions/:sessionId', async (c) => {
752
818
  return c.json({ error: message }, 500);
753
819
  }
754
820
  });
821
+ // POST /api/workspaces/:id/deferred-tool-use/answer — resume a deferred
822
+ // AskUserQuestion call by feeding the user's answers back to the SDK.
823
+ app.post('/:id/deferred-tool-use/answer', async (c) => {
824
+ try {
825
+ const id = c.req.param('id');
826
+ const body = await c.req
827
+ .json()
828
+ .catch(() => ({}));
829
+ if (!body?.answers || typeof body.answers !== 'object') {
830
+ return c.json({ error: 'answers payload required' }, 400);
831
+ }
832
+ await agentManager.answerPendingQuestion(id, body.answers, body.toolCallId);
833
+ return c.json({ ok: true });
834
+ }
835
+ catch (err) {
836
+ const message = err instanceof Error ? err.message : 'unknown';
837
+ // "No deferred tool use pending" is a benign race — the frontend
838
+ // self-heals on this string. Use 409 (Conflict) so dev tools don't
839
+ // surface it as a 400 validation failure.
840
+ const status = /no deferred tool use pending/i.test(message) ? 409 : 400;
841
+ return c.json({ error: message }, status);
842
+ }
843
+ });
844
+ // POST /api/workspaces/:id/deferred-tool-use/cancel — cancel a deferred
845
+ // AskUserQuestion. The SDK callback resolves with deny + a message; the
846
+ // agent sees an error tool_result and adapts. Does NOT abort the session.
847
+ app.post('/:id/deferred-tool-use/cancel', async (c) => {
848
+ try {
849
+ const id = c.req.param('id');
850
+ const body = await c.req
851
+ .json()
852
+ .catch(() => ({}));
853
+ await agentManager.cancelPendingQuestion(id, body.reason, body.toolCallId);
854
+ return c.json({ ok: true });
855
+ }
856
+ catch (err) {
857
+ const message = err instanceof Error ? err.message : 'unknown';
858
+ const status = /no deferred tool use pending/i.test(message) ? 409 : 400;
859
+ return c.json({ error: message }, status);
860
+ }
861
+ });
862
+ // POST /api/workspaces/:id/deferred-permission/decision — resume a deferred
863
+ // interactive permission request with the user's allow/deny decision.
864
+ app.post('/:id/deferred-permission/decision', async (c) => {
865
+ try {
866
+ const id = c.req.param('id');
867
+ const body = await c.req
868
+ .json()
869
+ .catch(() => ({}));
870
+ if (!body?.toolCallId || typeof body.toolCallId !== 'string') {
871
+ return c.json({ error: 'toolCallId required' }, 400);
872
+ }
873
+ if (body.decision !== 'allow' && body.decision !== 'deny') {
874
+ return c.json({ error: "decision must be 'allow' or 'deny'" }, 400);
875
+ }
876
+ await agentManager.answerPendingPermission(id, {
877
+ toolCallId: body.toolCallId,
878
+ decision: body.decision,
879
+ reason: typeof body.reason === 'string' ? body.reason : undefined,
880
+ });
881
+ return c.json({ ok: true });
882
+ }
883
+ catch (err) {
884
+ const message = err instanceof Error ? err.message : 'unknown';
885
+ const status = /no deferred tool use pending/i.test(message) ? 409 : 400;
886
+ return c.json({ error: message }, status);
887
+ }
888
+ });
889
+ // DELETE /api/workspaces/:id/events/:eventId — permanently dismiss a single
890
+ // persisted ws_events row (used today by the agent error banner so a closed
891
+ // error doesn't replay on F5 / reconnect). Defensive: only deletes if the row
892
+ // exists in this workspace; idempotent on missing row (returns 200).
893
+ app.delete('/:id/events/:eventId', (c) => {
894
+ try {
895
+ const workspaceId = c.req.param('id');
896
+ const eventId = c.req.param('eventId');
897
+ if (!workspaceService.getWorkspace(workspaceId)) {
898
+ return c.json({ error: `Workspace '${workspaceId}' not found` }, 404);
899
+ }
900
+ const db = getDb();
901
+ db.prepare('DELETE FROM ws_events WHERE id = ? AND workspace_id = ?').run(eventId, workspaceId);
902
+ return c.json({ ok: true });
903
+ }
904
+ catch (err) {
905
+ const message = err instanceof Error ? err.message : 'unknown';
906
+ return c.json({ error: message }, 500);
907
+ }
908
+ });
755
909
  // POST /api/workspaces/:id/tasks — create a new task
756
910
  app.post('/:id/tasks', async (c) => {
757
911
  try {
@@ -1058,7 +1212,7 @@ app.put('/:id/tags', async (c) => {
1058
1212
  return c.json({ error: msg }, status);
1059
1213
  }
1060
1214
  });
1061
- // PATCH /api/workspaces/:id — update workspace fields (status, model, permissionMode, name)
1215
+ // PATCH /api/workspaces/:id — update workspace fields (status, model, agentPermissionMode, name)
1062
1216
  app.patch('/:id', migrationGuard, async (c) => {
1063
1217
  try {
1064
1218
  const id = c.req.param('id');
@@ -1074,18 +1228,11 @@ app.patch('/:id', migrationGuard, async (c) => {
1074
1228
  if (body.reasoningEffort !== undefined) {
1075
1229
  updated = workspaceService.updateWorkspaceReasoningEffort(id, body.reasoningEffort);
1076
1230
  }
1077
- if (body.permissionMode !== undefined) {
1078
- const validModes = ['auto-accept', 'plan'];
1079
- if (!validModes.includes(body.permissionMode)) {
1080
- return c.json({ error: `Invalid permission mode. Must be one of: ${validModes.join(', ')}` }, 400);
1231
+ if (body.agentPermissionMode !== undefined) {
1232
+ if (!isAgentPermissionMode(body.agentPermissionMode)) {
1233
+ return c.json({ error: `Invalid agentPermissionMode. Must be one of: ${VALID_AGENT_PERMISSION_MODES.join(', ')}` }, 400);
1081
1234
  }
1082
- updated = workspaceService.updateWorkspacePermissionMode(id, body.permissionMode);
1083
- }
1084
- if (body.permissionProfile !== undefined) {
1085
- if (body.permissionProfile !== 'bypass' && body.permissionProfile !== 'strict') {
1086
- return c.json({ error: "Invalid permission profile. Must be 'bypass' or 'strict'." }, 400);
1087
- }
1088
- updated = workspaceService.setPermissionProfile(id, body.permissionProfile);
1235
+ updated = workspaceService.updateAgentPermissionMode(id, body.agentPermissionMode);
1089
1236
  }
1090
1237
  if (body.status) {
1091
1238
  updated = workspaceService.updateWorkspaceStatus(id, body.status);
@@ -1096,10 +1243,9 @@ app.patch('/:id', migrationGuard, async (c) => {
1096
1243
  if (!body.status &&
1097
1244
  body.model === undefined &&
1098
1245
  body.reasoningEffort === undefined &&
1099
- body.permissionMode === undefined &&
1100
- body.permissionProfile === undefined &&
1246
+ body.agentPermissionMode === undefined &&
1101
1247
  body.name === undefined) {
1102
- return c.json({ error: 'Missing field: status, model, reasoningEffort, permissionMode, permissionProfile, or name' }, 400);
1248
+ return c.json({ error: 'Missing field: status, model, reasoningEffort, agentPermissionMode, or name' }, 400);
1103
1249
  }
1104
1250
  return c.json(updated);
1105
1251
  }
@@ -1387,7 +1533,7 @@ app.post('/:id/start', migrationGuard, async (c) => {
1387
1533
  // Agent may not be running — ignore
1388
1534
  }
1389
1535
  const worktreePath = workspace.worktreePath;
1390
- const agent = agentManager.startAgent(id, worktreePath, prompt, workspace.model, resume, workspace.permissionMode, agentSessionId, workspace.reasoningEffort);
1536
+ const agent = agentManager.startAgent(id, worktreePath, prompt, workspace.model, resume, workspace.agentPermissionMode, agentSessionId, workspace.reasoningEffort);
1391
1537
  workspaceService.updateWorkspaceStatus(id, 'executing');
1392
1538
  // Persist the user prompt so it survives page refresh.
1393
1539
  // When agentSessionId is provided (idle-session flow), the prompt was typed
@@ -1441,6 +1587,9 @@ app.get('/:id/diff', (c) => {
1441
1587
  try {
1442
1588
  const id = c.req.param('id');
1443
1589
  const mode = c.req.query('mode') === 'unpushed' ? 'unpushed' : 'branch';
1590
+ // Opt-in flag from the diff viewer toggle. Only meaningful in `branch`
1591
+ // mode — `unpushed` is committed-only by definition.
1592
+ const includeUntracked = c.req.query('includeUntracked') === '1';
1444
1593
  const workspace = workspaceService.getWorkspace(id);
1445
1594
  if (!workspace) {
1446
1595
  return c.json({ error: `Workspace '${id}' not found` }, 404);
@@ -1448,7 +1597,7 @@ app.get('/:id/diff', (c) => {
1448
1597
  const worktreePath = workspace.worktreePath;
1449
1598
  const files = mode === 'unpushed'
1450
1599
  ? gitOps.getUnpushedChangedFiles(worktreePath, workspace.workingBranch)
1451
- : gitOps.getChangedFiles(worktreePath, workspace.sourceBranch);
1600
+ : gitOps.getChangedFiles(worktreePath, workspace.sourceBranch, includeUntracked);
1452
1601
  c.header('Cache-Control', 'no-store');
1453
1602
  return c.json({
1454
1603
  files,
@@ -1491,6 +1640,38 @@ app.get('/:id/diff-file', (c) => {
1491
1640
  return c.json({ error: message }, 500);
1492
1641
  }
1493
1642
  });
1643
+ // POST /api/workspaces/:id/rollback-file { path }
1644
+ // Reset a single file to its `origin/<workingBranch>` version (overwrites
1645
+ // working tree + index). Used by the right-click menu in the diff viewer.
1646
+ // Returns 422 when the branch has never been pushed (no remote ref to
1647
+ // rollback to) so the UI can disable the action gracefully.
1648
+ app.post('/:id/rollback-file', async (c) => {
1649
+ try {
1650
+ const id = c.req.param('id');
1651
+ const workspace = workspaceService.getWorkspace(id);
1652
+ if (!workspace) {
1653
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
1654
+ }
1655
+ const body = await c.req.json();
1656
+ const filePath = typeof body?.path === 'string' ? body.path.trim() : '';
1657
+ if (!filePath) {
1658
+ return c.json({ error: 'Missing or invalid `path` field' }, 400);
1659
+ }
1660
+ let target;
1661
+ try {
1662
+ target = gitOps.rollbackFile(workspace.worktreePath, workspace.workingBranch, filePath);
1663
+ }
1664
+ catch (err) {
1665
+ const message = err instanceof Error ? err.message : String(err);
1666
+ return c.json({ error: message }, 422);
1667
+ }
1668
+ return c.json({ ok: true, path: filePath, target });
1669
+ }
1670
+ catch (err) {
1671
+ const message = err instanceof Error ? err.message : String(err);
1672
+ return c.json({ error: message }, 500);
1673
+ }
1674
+ });
1494
1675
  // GET /api/workspaces/:id/commits?limit=50 — list commits between sourceBranch
1495
1676
  // and HEAD, each tagged with whether it's already pushed to origin/<branch>.
1496
1677
  app.get('/:id/commits', (c) => {
@@ -1808,7 +1989,7 @@ Start now.`;
1808
1989
  }
1809
1990
  catch {
1810
1991
  try {
1811
- agentManager.startAgent(workspace.id, worktreePath, prompt, workspace.model, true, workspace.permissionMode, undefined, workspace.reasoningEffort);
1992
+ agentManager.startAgent(workspace.id, worktreePath, prompt, workspace.model, true, workspace.agentPermissionMode, undefined, workspace.reasoningEffort);
1812
1993
  workspaceService.updateWorkspaceStatus(workspace.id, 'executing');
1813
1994
  messageSent = true;
1814
1995
  }
@@ -1947,7 +2128,7 @@ app.post('/:id/open-pr', async (c) => {
1947
2128
  // Agent not running — resume it with the PR prompt
1948
2129
  try {
1949
2130
  const worktreePathForResume = workspace.worktreePath;
1950
- agentManager.startAgent(workspace.id, worktreePathForResume, rendered, workspace.model, true, workspace.permissionMode, undefined, workspace.reasoningEffort);
2131
+ agentManager.startAgent(workspace.id, worktreePathForResume, rendered, workspace.model, true, workspace.agentPermissionMode, undefined, workspace.reasoningEffort);
1951
2132
  workspaceService.updateWorkspaceStatus(workspace.id, 'executing');
1952
2133
  messageSent = true;
1953
2134
  }
@@ -10,7 +10,7 @@ export const CLAUDE_CODE_CAPABILITIES = {
10
10
  { id: 'medium', label: 'Medium' },
11
11
  { id: 'high', label: 'High' },
12
12
  ],
13
- permissionModes: ['auto-accept', 'plan'],
13
+ permissionModes: ['plan', 'bypass', 'strict', 'interactive'],
14
14
  supportsResume: true,
15
15
  supportsMcp: true,
16
16
  supportsSkills: true,