@loicngr/kobo 1.6.8 → 1.6.10

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 (168) hide show
  1. package/AGENTS.md +4 -1
  2. package/README.md +4 -1
  3. package/dist/mcp-server/kobo-tasks-handlers.js +19 -1
  4. package/dist/mcp-server/kobo-tasks-server.js +27 -1
  5. package/dist/server/db/migrations.js +22 -0
  6. package/dist/server/db/schema.js +4 -0
  7. package/dist/server/index.js +2 -0
  8. package/dist/server/routes/images.js +59 -0
  9. package/dist/server/routes/workspaces.js +211 -21
  10. package/dist/server/services/agent/engines/claude-code/args-builder.js +6 -0
  11. package/dist/server/services/agent/engines/claude-code/engine.js +6 -0
  12. package/dist/server/services/agent/engines/claude-code/stream-parser.js +162 -0
  13. package/dist/server/services/agent/orchestrator.js +171 -18
  14. package/dist/server/services/auto-loop-service.js +311 -0
  15. package/dist/server/services/workspace-service.js +47 -0
  16. package/dist/server/utils/git-ops.js +13 -4
  17. package/dist/shared/auto-loop-prompts.js +28 -0
  18. package/package.json +1 -1
  19. package/src/client/dist/spa/assets/{ActivityFeed-ZLFD0ABF.css → ActivityFeed-DtM6pJvz.css} +1 -1
  20. package/src/client/dist/spa/assets/ActivityFeed-jxfDBgtk.js +7 -0
  21. package/src/client/dist/spa/assets/{ClosePopup-DTgXzcoa.js → ClosePopup-DkLittac.js} +1 -1
  22. package/src/client/dist/spa/assets/CreatePage-Bk5v8_20.css +1 -0
  23. package/src/client/dist/spa/assets/CreatePage-uBHjVyx5.js +2 -0
  24. package/src/client/dist/spa/assets/DiffViewer-B5spOKjh.js +2 -0
  25. package/src/client/dist/spa/assets/{HealthPage-BNv_dnMz.js → HealthPage-DnUDXD7f.js} +1 -1
  26. package/src/client/dist/spa/assets/MainLayout-CDR4Le5c.css +1 -0
  27. package/src/client/dist/spa/assets/{MainLayout-NzuypipH.js → MainLayout-Cu2p6Yzp.js} +17 -17
  28. package/src/client/dist/spa/assets/QChip-bl3YRhax.js +1 -0
  29. package/src/client/dist/spa/assets/{QExpansionItem-HLBjHx-0.js → QExpansionItem-CWw6ZujM.js} +1 -1
  30. package/src/client/dist/spa/assets/{QItemSection-BzWLL-V-.js → QItemSection-CiY_LK5Y.js} +1 -1
  31. package/src/client/dist/spa/assets/{QScrollArea-CBW6shMb.js → QScrollArea-DpCqRRE0.js} +1 -1
  32. package/src/client/dist/spa/assets/QTabPanels-C4bZGqml.js +1 -0
  33. package/src/client/dist/spa/assets/{QTooltip-DbEBexRN.js → QTooltip-BIDjo2hJ.js} +1 -1
  34. package/src/client/dist/spa/assets/{SearchPage-B3m_OWli.js → SearchPage-BL03e4yO.js} +1 -1
  35. package/src/client/dist/spa/assets/SettingsPage-DODqugln.js +1 -0
  36. package/src/client/dist/spa/assets/{TouchPan-Y_Bxzun2.js → TouchPan-vsl78kxF.js} +1 -1
  37. package/src/client/dist/spa/assets/{WorkspacePage-CM676R3B.css → WorkspacePage-CI1BxN04.css} +1 -1
  38. package/src/client/dist/spa/assets/WorkspacePage-CvR1wkIu.js +4 -0
  39. package/src/client/dist/spa/assets/{build-path-tree-DOPXkGhj.js → build-path-tree-BOfvTwdg.js} +1 -1
  40. package/src/client/dist/spa/assets/{cssMode-BPObkLMQ.js → cssMode-CoOgcS9Q.js} +1 -1
  41. package/src/client/dist/spa/assets/{documents-DMvdjtPf.js → documents-Capxg1Is.js} +1 -1
  42. package/src/client/dist/spa/assets/{editor.api-BpCtstKS.js → editor.api-BXQZAhGS.js} +1 -1
  43. package/src/client/dist/spa/assets/{editor.main-C2h6FfOt.js → editor.main-DFavPtYi.js} +3 -3
  44. package/src/client/dist/spa/assets/{formatters-D7eTm7uK.js → formatters-CX2gvLFv.js} +1 -1
  45. package/src/client/dist/spa/assets/{freemarker2-DUmHGv4C.js → freemarker2-CxnHsTrj.js} +1 -1
  46. package/src/client/dist/spa/assets/{handlebars-BU6pjzPg.js → handlebars-MdkEOy37.js} +1 -1
  47. package/src/client/dist/spa/assets/{html-A5-15bWl.js → html-BWqDGW4J.js} +1 -1
  48. package/src/client/dist/spa/assets/{htmlMode-C3KkomG3.js → htmlMode-CO3tFPX5.js} +1 -1
  49. package/src/client/dist/spa/assets/i18n-BshFP-3_.js +1 -0
  50. package/src/client/dist/spa/assets/index-ljurK0Xv.js +2 -0
  51. package/src/client/dist/spa/assets/is-DUKatk8N.js +1 -0
  52. package/src/client/dist/spa/assets/{javascript-ggaOKiy5.js → javascript-I8UtlP5w.js} +1 -1
  53. package/src/client/dist/spa/assets/{jsonMode-Bk-QMPGJ.js → jsonMode-Z4_dv7Ex.js} +1 -1
  54. package/src/client/dist/spa/assets/{liquid-CJdzn-JB.js → liquid-MmYIYsxN.js} +1 -1
  55. package/src/client/dist/spa/assets/{mdx-D5wRO-st.js → mdx-05Yi5ibq.js} +1 -1
  56. package/src/client/dist/spa/assets/models-BWwzb9Qz.js +1 -0
  57. package/src/client/dist/spa/assets/{monaco.contribution-CPqJifAu.js → monaco.contribution-BcmbPJhi.js} +2 -2
  58. package/src/client/dist/spa/assets/{python-DHI9rQDm.js → python-DApFIC6r.js} +1 -1
  59. package/src/client/dist/spa/assets/rate-limit-labels-BeAbIcPH.js +10 -0
  60. package/src/client/dist/spa/assets/{razor-CzQWNzhW.js → razor-IqeohLNL.js} +1 -1
  61. package/src/client/dist/spa/assets/{scroll-C-Vz5BD9.js → scroll-CYWyxBdv.js} +1 -1
  62. package/src/client/dist/spa/assets/settings-CAILUJXO.js +1 -0
  63. package/src/client/dist/spa/assets/{tsMode-DPkpdkNr.js → tsMode-B6nLj3Ks.js} +1 -1
  64. package/src/client/dist/spa/assets/{typescript-Dgm0x_-O.js → typescript-DHsUK_D5.js} +1 -1
  65. package/src/client/dist/spa/assets/{use-checkbox-BduGd8xg.js → use-checkbox-B_o-iLG2.js} +1 -1
  66. package/src/client/dist/spa/assets/{xml-BeXyffrj.js → xml-B_o_LoiA.js} +1 -1
  67. package/src/client/dist/spa/assets/{yaml-D4UE_1wU.js → yaml-mPCNKMRE.js} +1 -1
  68. package/src/client/dist/spa/index.html +9 -8
  69. package/src/mcp-server/kobo-tasks-handlers.ts +24 -1
  70. package/src/mcp-server/kobo-tasks-server.ts +29 -0
  71. package/src/client/dist/spa/assets/ActivityFeed-Bn9tpyLw.js +0 -7
  72. package/src/client/dist/spa/assets/CreatePage-CYtKx6Ji.css +0 -1
  73. package/src/client/dist/spa/assets/CreatePage-DDPmb3I-.js +0 -2
  74. package/src/client/dist/spa/assets/DiffViewer-CM3g7W7U.js +0 -2
  75. package/src/client/dist/spa/assets/MainLayout-BeKCjOA2.css +0 -1
  76. package/src/client/dist/spa/assets/QChip-1nQ_KMFF.js +0 -1
  77. package/src/client/dist/spa/assets/QDialog-G448EJG4.js +0 -1
  78. package/src/client/dist/spa/assets/QTabPanels-Cw4nnIbR.js +0 -1
  79. package/src/client/dist/spa/assets/SettingsPage-CpQm15XA.js +0 -1
  80. package/src/client/dist/spa/assets/WorkspacePage-BQzk5qfr.js +0 -4
  81. package/src/client/dist/spa/assets/i18n-CIduhxS0.js +0 -1
  82. package/src/client/dist/spa/assets/index-QcUb2Iwh.js +0 -2
  83. package/src/client/dist/spa/assets/models-CwWSex3X.js +0 -1
  84. package/src/client/dist/spa/assets/rate-limit-labels-Su-L56A2.js +0 -6
  85. /package/src/client/dist/spa/assets/{QBadge-Di02fu2H.js → QBadge-DqtcDv8D.js} +0 -0
  86. /package/src/client/dist/spa/assets/{QBtn-CyzfM9-_.js → QBtn-DHwAb18J.js} +0 -0
  87. /package/src/client/dist/spa/assets/{QItemLabel-Czw5g0px.js → QItemLabel-Codqjisk.js} +0 -0
  88. /package/src/client/dist/spa/assets/{QList-D2GuTeLl.js → QList-Bl9824vi.js} +0 -0
  89. /package/src/client/dist/spa/assets/{QPage-BTzNQlb1.js → QPage-Dn4E3GHB.js} +0 -0
  90. /package/src/client/dist/spa/assets/{QSlideTransition-s6ZkYsLs.js → QSlideTransition-BQxI8l5r.js} +0 -0
  91. /package/src/client/dist/spa/assets/{QSpace-0zdF1m5x.js → QSpace-BNr0AftG.js} +0 -0
  92. /package/src/client/dist/spa/assets/{QSpinnerDots-By20ptst.js → QSpinnerDots-DEiRooBD.js} +0 -0
  93. /package/src/client/dist/spa/assets/{_plugin-vue_export-helper-Cj6tcsj6.js → _plugin-vue_export-helper-r4mAJOHR.js} +0 -0
  94. /package/src/client/dist/spa/assets/{abap-DiwvWnMr.js → abap-Bgec7Keq.js} +0 -0
  95. /package/src/client/dist/spa/assets/{apex-CmtZjKlf.js → apex-VBlPwEoQ.js} +0 -0
  96. /package/src/client/dist/spa/assets/{azcli-DL2My_i-.js → azcli-DKqrEFBx.js} +0 -0
  97. /package/src/client/dist/spa/assets/{bat-B-nC98wG.js → bat-DdgQWy_0.js} +0 -0
  98. /package/src/client/dist/spa/assets/{bicep-Ju5MwOgh.js → bicep-CRMM43EB.js} +0 -0
  99. /package/src/client/dist/spa/assets/{cameligo-8Eu1TyBr.js → cameligo-UatALtML.js} +0 -0
  100. /package/src/client/dist/spa/assets/{clojure-u-RpMkH3.js → clojure-D8JU08RA.js} +0 -0
  101. /package/src/client/dist/spa/assets/{coffee-CdA7bbTe.js → coffee-C56wu358.js} +0 -0
  102. /package/src/client/dist/spa/assets/{cpp-CzNFP8ks.js → cpp-CyZLvhJG.js} +0 -0
  103. /package/src/client/dist/spa/assets/{csharp-j1LThmcE.js → csharp-BJl3ixva.js} +0 -0
  104. /package/src/client/dist/spa/assets/{csp-CLRC61y6.js → csp-CxEKxmO-.js} +0 -0
  105. /package/src/client/dist/spa/assets/{css-r6rC_7P2.js → css-B0t_muXd.js} +0 -0
  106. /package/src/client/dist/spa/assets/{cypher-CW08XVUh.js → cypher-D1hqiMFD.js} +0 -0
  107. /package/src/client/dist/spa/assets/{dart-Cs9aL5T_.js → dart-Bz550Pyv.js} +0 -0
  108. /package/src/client/dist/spa/assets/{dockerfile-BWM0M184.js → dockerfile-CIXgVAuA.js} +0 -0
  109. /package/src/client/dist/spa/assets/{ecl-MJJuer5P.js → ecl-D9qbvZoA.js} +0 -0
  110. /package/src/client/dist/spa/assets/{elixir-D2AIuXqn.js → elixir-b2M38fAy.js} +0 -0
  111. /package/src/client/dist/spa/assets/{flow9-B2H24giC.js → flow9-Dq1UYMkt.js} +0 -0
  112. /package/src/client/dist/spa/assets/{fsharp-CMk2OIJN.js → fsharp-CFNadkg7.js} +0 -0
  113. /package/src/client/dist/spa/assets/{go-BrMkuJg0.js → go-dSur1iB2.js} +0 -0
  114. /package/src/client/dist/spa/assets/{graphql-PSR1UKGv.js → graphql-qyhAo11d.js} +0 -0
  115. /package/src/client/dist/spa/assets/{hcl-DAQrbDOW.js → hcl-DFzjMyzm.js} +0 -0
  116. /package/src/client/dist/spa/assets/{ini-0TG5BxW0.js → ini-TdzA8TIl.js} +0 -0
  117. /package/src/client/dist/spa/assets/{java-rgorz17v.js → java-CSGA9pkE.js} +0 -0
  118. /package/src/client/dist/spa/assets/{julia-C8VMdHm8.js → julia-9izz5OsY.js} +0 -0
  119. /package/src/client/dist/spa/assets/{kotlin-CllWo3gX.js → kotlin-DuPK7AtF.js} +0 -0
  120. /package/src/client/dist/spa/assets/{less-Cgca25AP.js → less-B8d93iCg.js} +0 -0
  121. /package/src/client/dist/spa/assets/{lexon-D0GHdBaw.js → lexon-DWtEIyu7.js} +0 -0
  122. /package/src/client/dist/spa/assets/{lua-DmRsNG-P.js → lua-Ciq0OGgt.js} +0 -0
  123. /package/src/client/dist/spa/assets/{m3-BgL5dNKT.js → m3-Cki6JWj_.js} +0 -0
  124. /package/src/client/dist/spa/assets/{markdown-BuJfycGS.js → markdown-Cu47xwU0.js} +0 -0
  125. /package/src/client/dist/spa/assets/{mips-C9m_93PR.js → mips-BM8ui995.js} +0 -0
  126. /package/src/client/dist/spa/assets/{msdax-CpFHC9OI.js → msdax-DqLio0_c.js} +0 -0
  127. /package/src/client/dist/spa/assets/{mysql-qFvltsqN.js → mysql-v1wbjJOq.js} +0 -0
  128. /package/src/client/dist/spa/assets/{objective-c-Bnmr858J.js → objective-c-CQl3PGSB.js} +0 -0
  129. /package/src/client/dist/spa/assets/{pascal-WP0_D5AO.js → pascal-D4iW0ZtD.js} +0 -0
  130. /package/src/client/dist/spa/assets/{pascaligo-Blom4Rij.js → pascaligo-BdC9CZdj.js} +0 -0
  131. /package/src/client/dist/spa/assets/{perl-B-vk8g64.js → perl-BL10m4XD.js} +0 -0
  132. /package/src/client/dist/spa/assets/{pgsql-Cgvz6v67.js → pgsql-Be_oqVo3.js} +0 -0
  133. /package/src/client/dist/spa/assets/{php-8a3Lrw9m.js → php-BtvXSFRI.js} +0 -0
  134. /package/src/client/dist/spa/assets/{pla-DuFqEZ8V.js → pla-B2vUy15C.js} +0 -0
  135. /package/src/client/dist/spa/assets/{postiats-DkLtSgkp.js → postiats-CbmTTfXr.js} +0 -0
  136. /package/src/client/dist/spa/assets/{powerquery-BJ1aNepW.js → powerquery-DszLhJGx.js} +0 -0
  137. /package/src/client/dist/spa/assets/{powershell-rE98k687.js → powershell-B0dYktF6.js} +0 -0
  138. /package/src/client/dist/spa/assets/{private.use-form-C5G_3nU5.js → private.use-form-Dlb0iQZh.js} +0 -0
  139. /package/src/client/dist/spa/assets/{protobuf-CUheFacr.js → protobuf-CZvaj1VX.js} +0 -0
  140. /package/src/client/dist/spa/assets/{pug-LDcAMD8w.js → pug-CPDx1B3S.js} +0 -0
  141. /package/src/client/dist/spa/assets/{qsharp-DUKSQoR1.js → qsharp-CDP9TFLl.js} +0 -0
  142. /package/src/client/dist/spa/assets/{r-D-QApv87.js → r-8DbbFX2l.js} +0 -0
  143. /package/src/client/dist/spa/assets/{redis-SXdDyWR9.js → redis-DRWj9MtJ.js} +0 -0
  144. /package/src/client/dist/spa/assets/{redshift-Y6lsCryn.js → redshift-C6cElE_5.js} +0 -0
  145. /package/src/client/dist/spa/assets/{restructuredtext-edObr9a8.js → restructuredtext-W9pS9n3m.js} +0 -0
  146. /package/src/client/dist/spa/assets/{ruby-CNnUfF-8.js → ruby-BKnzWnk-.js} +0 -0
  147. /package/src/client/dist/spa/assets/{rust-IHUZWzBr.js → rust-YPCclWwe.js} +0 -0
  148. /package/src/client/dist/spa/assets/{sb-DrUvY44N.js → sb-BgM4DTFb.js} +0 -0
  149. /package/src/client/dist/spa/assets/{scala-B4hbXGLM.js → scala-fz1OPLMl.js} +0 -0
  150. /package/src/client/dist/spa/assets/{scheme-BGrd12j3.js → scheme-8Uz1RIbu.js} +0 -0
  151. /package/src/client/dist/spa/assets/{scss-x5G1ES4U.js → scss-Djo3IYXr.js} +0 -0
  152. /package/src/client/dist/spa/assets/{shell-DOehe2Y8.js → shell-CINF5Tx_.js} +0 -0
  153. /package/src/client/dist/spa/assets/{solidity-BeRvcwWV.js → solidity-GgiNEuUm.js} +0 -0
  154. /package/src/client/dist/spa/assets/{sophia-DZbkUNjy.js → sophia-Culj97P9.js} +0 -0
  155. /package/src/client/dist/spa/assets/{sparql-B7_oi5-h.js → sparql-C2ZlpxOY.js} +0 -0
  156. /package/src/client/dist/spa/assets/{sql-CTlsFWVE.js → sql-BEf5Pg7Y.js} +0 -0
  157. /package/src/client/dist/spa/assets/{st-DJVEJdPE.js → st-CT6UUoeH.js} +0 -0
  158. /package/src/client/dist/spa/assets/{swift-CwhT3fYa.js → swift-B5g0xTG3.js} +0 -0
  159. /package/src/client/dist/spa/assets/{systemverilog-BQN63pkN.js → systemverilog-CEgQz9DR.js} +0 -0
  160. /package/src/client/dist/spa/assets/{tcl-DqwfpskA.js → tcl-D0qL2L0I.js} +0 -0
  161. /package/src/client/dist/spa/assets/{touch-B2uuAH_y.js → touch-Bj_Fr4kC.js} +0 -0
  162. /package/src/client/dist/spa/assets/{twig-BiyenUgc.js → twig-BFUAVf1E.js} +0 -0
  163. /package/src/client/dist/spa/assets/{typespec-CWOJribt.js → typespec-CjVVcNKm.js} +0 -0
  164. /package/src/client/dist/spa/assets/{use-id-BmXMngYX.js → use-id-C93QQwrt.js} +0 -0
  165. /package/src/client/dist/spa/assets/{use-quasar-BBrzedjR.js → use-quasar-Cc4smfg5.js} +0 -0
  166. /package/src/client/dist/spa/assets/{vb-Cq5F87m3.js → vb-CZJr-DQz.js} +0 -0
  167. /package/src/client/dist/spa/assets/{vue-i18n-eUDnMrPl.js → vue-i18n-BJlZEYnA.js} +0 -0
  168. /package/src/client/dist/spa/assets/{wgsl-BAvW2lVr.js → wgsl-ivoXUo2e.js} +0 -0
package/AGENTS.md CHANGED
@@ -96,15 +96,18 @@ src/
96
96
 
97
97
  | Table | Purpose |
98
98
  |---|---|
99
- | `workspaces` | the unit of work — id, name, project_path, source_branch, working_branch, status, notion_url, model, dev_server_status, `archived_at`, timestamps |
99
+ | `workspaces` | the unit of work — id, name, project_path, source_branch, working_branch, status, notion_url, model, dev_server_status, `archived_at`, `auto_loop`, `auto_loop_ready`, `no_progress_streak`, timestamps |
100
100
  | `tasks` | workspace sub-items — title, status, `is_acceptance_criterion`, sort_order; CASCADE DELETE on workspace |
101
101
  | `agent_sessions` | Claude Code CLI invocations — pid, `claude_session_id`, status, started_at, ended_at, `name` |
102
102
  | `ws_events` | persisted WebSocket events for replay on reconnect — type, payload, session_id, created_at |
103
+ | `pending_wakeups` | one-row-per-workspace scheduler for the `ScheduleWakeup` tool — target_at (ISO UTC), prompt, reason; CASCADE DELETE on workspace |
103
104
 
104
105
  `status` enum: `created | extracting | brainstorming | executing | completed | idle | error | quota`. Transitions are validated in `updateWorkspaceStatus` against `VALID_TRANSITIONS`.
105
106
 
106
107
  `archived_at` is **orthogonal** to `status` — archiving is a visibility flag, not a lifecycle state. Unarchive restores the exact pre-archive `status`.
107
108
 
109
+ `auto_loop` (bool, default 0), `auto_loop_ready` (bool, default 0) and `no_progress_streak` (int, default 0) drive the auto-loop feature: when `auto_loop=1`, `session:ended` triggers `auto-loop-service.onSessionEnded` which either spawns the next iteration via a fresh `startAgent(resume=false)`, disables with `reason='completed'` (no pending tasks), or disables with `reason='stall'` (3 consecutive sessions without a task completed). Archive + delete both auto-disable.
110
+
108
111
  ## Database migrations
109
112
 
110
113
  **The project is in production**. Every schema change MUST ship as an incremental migration that preserves existing data. Never drop-and-recreate, never rely on `initSchema` alone to patch running databases.
package/README.md CHANGED
@@ -12,7 +12,7 @@ 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
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)
16
16
  - **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
17
  - **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
18
  - **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
@@ -30,6 +30,9 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
30
30
  - **Resizable right drawer** — drag-to-resize horizontally and vertically, with tab state and split ratio persisted to localStorage
31
31
  - **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
32
32
  - **Archive instead of delete** — soft-remove workspaces without losing the worktree, branches, or history; unarchive restores the exact pre-archive state
33
+ - **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
34
+ - **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
35
+ - **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
33
36
 
34
37
  ## Tech stack
35
38
 
@@ -18,6 +18,22 @@ export function listTasksHandler(db, workspaceId) {
18
18
  .all(workspaceId);
19
19
  return rows.map(rowToDto);
20
20
  }
21
+ /**
22
+ * Flip the workspace's `auto_loop_ready` flag. Called at the end of a
23
+ * `/kobo-prep-autoloop` grooming session to unlock the auto-loop toggle.
24
+ *
25
+ * The DB write itself happens here; the caller in kobo-tasks-server.ts
26
+ * also fires a notify-autoloop-ready POST so the backend emits
27
+ * `autoloop:ready-flipped` over WebSocket and any live frontend refreshes.
28
+ */
29
+ export function markAutoLoopReadyHandler(db, workspaceId) {
30
+ const row = db.prepare('SELECT id FROM workspaces WHERE id = ?').get(workspaceId);
31
+ if (!row) {
32
+ throw new Error(`Workspace '${workspaceId}' not found`);
33
+ }
34
+ db.prepare('UPDATE workspaces SET auto_loop_ready = 1 WHERE id = ?').run(workspaceId);
35
+ return { ok: true };
36
+ }
21
37
  /** Set a task's status to "done" and return the updated task. */
22
38
  export function markTaskDoneHandler(db, workspaceId, taskId) {
23
39
  const now = new Date().toISOString();
@@ -136,7 +152,7 @@ export function getSettingsHandler(settingsPath, projectPath) {
136
152
  /** Fetch workspace metadata from the database, computing the worktree path from project_path and working_branch. */
137
153
  export function getWorkspaceInfoHandler(db, workspaceId) {
138
154
  const row = db
139
- .prepare('SELECT id, name, project_path, source_branch, working_branch, status, notion_url, notion_page_id, model, dev_server_status, has_unread, created_at, updated_at FROM workspaces WHERE id = ?')
155
+ .prepare('SELECT id, name, project_path, source_branch, working_branch, status, notion_url, notion_page_id, model, dev_server_status, has_unread, auto_loop, auto_loop_ready, created_at, updated_at FROM workspaces WHERE id = ?')
140
156
  .get(workspaceId);
141
157
  if (!row) {
142
158
  throw new Error(`Workspace '${workspaceId}' not found`);
@@ -154,6 +170,8 @@ export function getWorkspaceInfoHandler(db, workspaceId) {
154
170
  notionPageId: row.notion_page_id,
155
171
  devServerStatus: row.dev_server_status,
156
172
  hasUnread: row.has_unread === 1,
173
+ autoLoop: row.auto_loop === 1,
174
+ autoLoopReady: row.auto_loop_ready === 1,
157
175
  createdAt: row.created_at,
158
176
  updatedAt: row.updated_at,
159
177
  };
@@ -5,7 +5,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
5
5
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
6
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
7
7
  import Database from 'better-sqlite3';
8
- import { createTaskHandler, deleteTaskHandler, getDevServerStatusHandler, getSessionUsageHandler, getSettingsHandler, getWorkspaceInfoHandler, listDocumentsHandler, listTasksHandler, listWorkspaceImagesHandler, logThoughtHandler, markTaskDoneHandler, readDocumentHandler, updateTaskHandler, } from './kobo-tasks-handlers.js';
8
+ import { createTaskHandler, deleteTaskHandler, getDevServerStatusHandler, getSessionUsageHandler, getSettingsHandler, getWorkspaceInfoHandler, listDocumentsHandler, listTasksHandler, listWorkspaceImagesHandler, logThoughtHandler, markAutoLoopReadyHandler, markTaskDoneHandler, readDocumentHandler, updateTaskHandler, } from './kobo-tasks-handlers.js';
9
9
  const workspaceId = process.env.KOBO_WORKSPACE_ID;
10
10
  const dbPath = process.env.KOBO_DB_PATH;
11
11
  const settingsPath = process.env.KOBO_SETTINGS_PATH;
@@ -52,6 +52,22 @@ async function notifyTasksUpdated() {
52
52
  console.error('[kobo-tasks-server] notify-updated failed:', err);
53
53
  }
54
54
  }
55
+ /**
56
+ * Fire-and-forget POST that lands on `/auto-loop-ready`, which itself emits
57
+ * the `autoloop:ready-flipped` WS event so the frontend's toggle unlocks
58
+ * immediately after the grooming session completes. The handler already
59
+ * flipped the DB flag; this call is ONLY for the event emission + the
60
+ * (harmless) idempotent second write.
61
+ */
62
+ async function notifyAutoLoopReady() {
63
+ try {
64
+ const url = `${backendUrl}/api/workspaces/${workspaceId}/auto-loop-ready`;
65
+ await fetch(url, { method: 'POST' });
66
+ }
67
+ catch (err) {
68
+ console.error('[kobo-tasks-server] notify-autoloop-ready failed:', err);
69
+ }
70
+ }
55
71
  /** Generic HTTP request to the Kobo backend, returning parsed JSON or null. */
56
72
  async function backendRequest(method, pathname, body) {
57
73
  const url = `${backendUrl}${pathname}`;
@@ -87,6 +103,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
87
103
  required: ['task_id'],
88
104
  },
89
105
  },
106
+ {
107
+ name: 'mark_auto_loop_ready',
108
+ description: 'CALL ONLY at the end of a `/kobo-prep-autoloop` grooming session, once all tasks look atomic and implementable in one session. Flips a flag on the workspace that unlocks the auto-loop toggle in the UI. Do NOT call during normal sessions.',
109
+ inputSchema: { type: 'object', properties: {}, required: [] },
110
+ },
90
111
  {
91
112
  name: 'create_task',
92
113
  description: 'CALL WHEN you discover follow-up work that was not in the original list and needs to stick around (e.g. "refactor this helper later", "add a test for edge case"). Appends at the end of the list. Do not use it for ephemeral internal notes — prefer log_thought for those.',
@@ -296,6 +317,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
296
317
  void notifyBackend(taskId);
297
318
  return ok(result);
298
319
  }
320
+ if (name === 'mark_auto_loop_ready') {
321
+ const result = markAutoLoopReadyHandler(db, workspaceId);
322
+ void notifyAutoLoopReady();
323
+ return ok(result);
324
+ }
299
325
  if (name === 'create_task') {
300
326
  const title = a.title;
301
327
  if (!title)
@@ -101,6 +101,28 @@ export const migrations = [
101
101
  )`).run();
102
102
  },
103
103
  },
104
+ {
105
+ version: 12,
106
+ name: 'add-auto-loop-columns',
107
+ migrate: (db) => {
108
+ db.transaction(() => {
109
+ db.prepare('ALTER TABLE workspaces ADD COLUMN auto_loop INTEGER NOT NULL DEFAULT 0').run();
110
+ db.prepare('ALTER TABLE workspaces ADD COLUMN auto_loop_ready INTEGER NOT NULL DEFAULT 0').run();
111
+ db.prepare('ALTER TABLE workspaces ADD COLUMN no_progress_streak INTEGER NOT NULL DEFAULT 0').run();
112
+ })();
113
+ },
114
+ },
115
+ {
116
+ version: 13,
117
+ name: 'add-permission-profile-column',
118
+ migrate: (db) => {
119
+ // 'bypass' (default, pre-existing behavior) or 'strict' (respects
120
+ // the project's .claude/settings.json allow/deny lists — needed when
121
+ // the user wants to authorize writes under .claude/** or .github/workflows/**
122
+ // which the CLI hard-denies under --dangerously-skip-permissions).
123
+ db.prepare("ALTER TABLE workspaces ADD COLUMN permission_profile TEXT NOT NULL DEFAULT 'bypass'").run();
124
+ },
125
+ },
104
126
  ];
105
127
  /** Current schema version — always equals the highest migration version. */
106
128
  export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
@@ -19,6 +19,10 @@ export function initSchema(db) {
19
19
  favorited_at TEXT,
20
20
  tags TEXT NOT NULL DEFAULT '[]',
21
21
  engine TEXT NOT NULL DEFAULT 'claude-code',
22
+ auto_loop INTEGER NOT NULL DEFAULT 0,
23
+ auto_loop_ready INTEGER NOT NULL DEFAULT 0,
24
+ no_progress_streak INTEGER NOT NULL DEFAULT 0,
25
+ permission_profile TEXT NOT NULL DEFAULT 'bypass',
22
26
  created_at TEXT NOT NULL,
23
27
  updated_at TEXT NOT NULL
24
28
  );
@@ -21,6 +21,7 @@ import settingsRouter from './routes/settings.js';
21
21
  import templatesRouter from './routes/templates.js';
22
22
  import workspacesRouter from './routes/workspaces.js';
23
23
  import { getAvailableSkills, reconcileOrphanSessions, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopWatchdog, } from './services/agent/orchestrator.js';
24
+ import * as autoLoopService from './services/auto-loop-service.js';
24
25
  import { runContentMigrationIfNeeded } from './services/content-migration-service.js';
25
26
  import { createDailyDbBackupIfNeeded } from './services/db-backup-service.js';
26
27
  import { startDevServer, stopDevServer } from './services/dev-server-service.js';
@@ -59,6 +60,7 @@ initProcessCleanup();
59
60
  reconcileOrphanSessions();
60
61
  startWatchdog();
61
62
  wakeupService.rehydrate();
63
+ autoLoopService.rehydrate();
62
64
  startPrWatcher();
63
65
  // Create Hono app
64
66
  const app = new Hono();
@@ -1,3 +1,5 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
1
3
  import { Hono } from 'hono';
2
4
  import * as imageService from '../services/image-service.js';
3
5
  import * as workspaceService from '../services/workspace-service.js';
@@ -5,6 +7,14 @@ import * as workspaceService from '../services/workspace-service.js';
5
7
  const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
6
8
  /** MIME types accepted for image uploads. */
7
9
  const ALLOWED_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
10
+ /** File extension → MIME type, used to serve uploaded images back to the frontend. */
11
+ const EXT_TO_MIME = {
12
+ '.png': 'image/png',
13
+ '.jpg': 'image/jpeg',
14
+ '.jpeg': 'image/jpeg',
15
+ '.gif': 'image/gif',
16
+ '.webp': 'image/webp',
17
+ };
8
18
  /** Hono sub-router for workspace image upload and deletion. */
9
19
  const app = new Hono();
10
20
  // POST /:id/images — upload an image
@@ -37,6 +47,55 @@ app.post('/:id/images', async (c) => {
37
47
  return c.json({ error: message }, 500);
38
48
  }
39
49
  });
50
+ // GET /:id/images/file?path=.ai/images/<file>
51
+ // Serve an uploaded image file from the worktree so the chat feed can show
52
+ // inline previews for `[image: <path>]` tokens the user pastes in messages.
53
+ //
54
+ // Security: only paths under `.ai/images/` are served, the resolved absolute
55
+ // path must stay inside the worktree's images directory (defense against
56
+ // `..` traversal and absolute paths). Anything outside → 400.
57
+ app.get('/:id/images/file', async (c) => {
58
+ try {
59
+ const { id } = c.req.param();
60
+ const workspace = workspaceService.getWorkspace(id);
61
+ if (!workspace) {
62
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
63
+ }
64
+ const requested = c.req.query('path');
65
+ if (!requested || typeof requested !== 'string') {
66
+ return c.json({ error: 'Missing required query param: path' }, 400);
67
+ }
68
+ // Allowlist: only the upload storage layout is served. No symlink escape
69
+ // either — we resolve and check containment.
70
+ if (!/^(\.ai\/images\/|images\/)[^/]/.test(requested) || requested.includes('..')) {
71
+ return c.json({ error: 'Invalid or disallowed image path' }, 400);
72
+ }
73
+ const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
74
+ const imagesRoot = path.resolve(worktreePath, '.ai/images');
75
+ const fullPath = path.resolve(worktreePath, requested);
76
+ // Containment check: fullPath must be a descendant of imagesRoot. `+ path.sep`
77
+ // guards against prefix collisions (`.../images-evil`).
78
+ if (fullPath !== imagesRoot && !fullPath.startsWith(imagesRoot + path.sep)) {
79
+ return c.json({ error: 'Path escapes images root' }, 400);
80
+ }
81
+ let buffer;
82
+ try {
83
+ buffer = await fs.promises.readFile(fullPath);
84
+ }
85
+ catch {
86
+ return c.json({ error: 'Image not found' }, 404);
87
+ }
88
+ const mime = EXT_TO_MIME[path.extname(fullPath).toLowerCase()] ?? 'application/octet-stream';
89
+ c.header('Content-Type', mime);
90
+ // Uploads are immutable content-addressed (uid filename) — cache aggressively.
91
+ c.header('Cache-Control', 'private, max-age=3600, immutable');
92
+ return c.body(new Uint8Array(buffer));
93
+ }
94
+ catch (err) {
95
+ const message = err instanceof Error ? err.message : String(err);
96
+ return c.json({ error: message }, 500);
97
+ }
98
+ });
40
99
  // DELETE /:id/images/:uid — delete an uploaded image
41
100
  app.delete('/:id/images/:uid', async (c) => {
42
101
  try {
@@ -4,10 +4,12 @@ const execFileAsync = promisify(execFileCb);
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { Hono } from 'hono';
7
+ import { AUTO_LOOP_GROOMING_STEPS, AUTO_LOOP_HARD_RULES } from '../../shared/auto-loop-prompts.js';
7
8
  import { getDb } from '../db/index.js';
8
9
  import { migrationGuard } from '../middleware/migration-guard.js';
9
10
  import { listEngines } from '../services/agent/engines/registry.js';
10
11
  import * as agentManager from '../services/agent/orchestrator.js';
12
+ import * as autoLoopService from '../services/auto-loop-service.js';
11
13
  import * as devServerService from '../services/dev-server-service.js';
12
14
  import * as notionService from '../services/notion-service.js';
13
15
  import { renderPrTemplate } from '../services/pr-template-service.js';
@@ -453,7 +455,23 @@ app.post('/', migrationGuard, async (c) => {
453
455
  brainstormPrompt += `\n# Git conventions\nIMPORTANT: Before any git operation (commit, branch, rebase, merge, push), read and apply the conventions defined in \`.ai/.git-conventions.md\`. They are project-specific and override any default behavior. Re-read this file if you're unsure or if context was compacted.\n`;
454
456
  }
455
457
  brainstormPrompt += `\nIMPORTANT: Start by reading CLAUDE.md and/or AGENTS.md at the project root if they exist — they contain project conventions and instructions you must follow.`;
456
- 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.`;
458
+ if (body.autoLoop === true) {
459
+ // Auto-loop is armed — brainstorm must end with task seeding + mark-ready,
460
+ // NOT with implementation. The auto-loop will drive implementation after.
461
+ // The grooming steps + hard rules are shared with the PREP_AUTOLOOP_PROMPT
462
+ // sent by the "Prepare for auto-loop" button (src/shared/auto-loop-prompts.ts).
463
+ brainstormPrompt += `\n\nThen brainstorm the implementation approach. Explore the codebase to understand the existing structure. Ask clarifying questions if needed. When you have a clear plan, create a plan file.
464
+
465
+ Auto-loop mode is active for this workspace. After the plan is ready, DO NOT implement anything. Instead:
466
+
467
+ ${AUTO_LOOP_GROOMING_STEPS}
468
+ 5. Output [BRAINSTORM_COMPLETE] on its own line and end your turn cleanly.
469
+
470
+ ${AUTO_LOOP_HARD_RULES}`;
471
+ }
472
+ else {
473
+ 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.`;
474
+ }
457
475
  try {
458
476
  const agent = agentManager.startAgent(workspace.id, worktreePath, brainstormPrompt, workspace.model, false, workspace.permissionMode, undefined, workspace.reasoningEffort);
459
477
  // Persist the initial prompt in the feed so it's visible in the chat,
@@ -471,6 +489,22 @@ app.post('/', migrationGuard, async (c) => {
471
489
  }
472
490
  }
473
491
  }
492
+ // Apply the auto-loop checkbox from CreatePage. Notion-imported workspaces
493
+ // with both todos AND gherkin features auto-unlock `auto_loop_ready=1` —
494
+ // they're considered good enough to drive the loop without grooming.
495
+ if (body.autoLoop === true) {
496
+ const notionProducedTasks = body.notionUrl !== undefined &&
497
+ notionContent != null &&
498
+ notionContent.todos.length > 0 &&
499
+ notionContent.gherkinFeatures.length > 0;
500
+ const db = getDb();
501
+ db.prepare('UPDATE workspaces SET auto_loop = 1, auto_loop_ready = ? WHERE id = ?').run(notionProducedTasks ? 1 : 0, workspace.id);
502
+ // Emit events so the frontend refreshes autoLoopStates without F5.
503
+ wsService.emitEphemeral(workspace.id, 'autoloop:enabled', {});
504
+ if (notionProducedTasks) {
505
+ wsService.emitEphemeral(workspace.id, 'autoloop:ready-flipped', {});
506
+ }
507
+ }
474
508
  // Return created workspace with tasks
475
509
  const workspaceWithTasks = workspaceService.getWorkspaceWithTasks(workspace.id);
476
510
  return c.json(workspaceWithTasks, 201);
@@ -531,6 +565,81 @@ app.get('/pr-states', (c) => {
531
565
  return c.json({ error: message }, 500);
532
566
  }
533
567
  });
568
+ // GET /api/workspaces/auto-loop-states — batch snapshot keyed by workspace id.
569
+ // Used by the drawer + Pinia store. Static path — must be BEFORE /:id.
570
+ app.get('/auto-loop-states', (c) => {
571
+ try {
572
+ const db = getDb();
573
+ const rows = db
574
+ .prepare('SELECT id, auto_loop, auto_loop_ready, no_progress_streak FROM workspaces WHERE archived_at IS NULL')
575
+ .all();
576
+ const out = {};
577
+ for (const r of rows) {
578
+ out[r.id] = {
579
+ auto_loop: r.auto_loop === 1,
580
+ auto_loop_ready: r.auto_loop_ready === 1,
581
+ no_progress_streak: r.no_progress_streak,
582
+ };
583
+ }
584
+ return c.json(out);
585
+ }
586
+ catch (err) {
587
+ const message = err instanceof Error ? err.message : String(err);
588
+ return c.json({ error: message }, 500);
589
+ }
590
+ });
591
+ // GET /api/workspaces/:id/auto-loop — current auto-loop status for one workspace.
592
+ app.get('/:id/auto-loop', (c) => {
593
+ try {
594
+ return c.json(autoLoopService.getStatus(c.req.param('id')));
595
+ }
596
+ catch (err) {
597
+ const message = err instanceof Error ? err.message : String(err);
598
+ return c.json({ error: message }, 500);
599
+ }
600
+ });
601
+ // POST /api/workspaces/:id/auto-loop — enable the loop (user toggle ON).
602
+ app.post('/:id/auto-loop', (c) => {
603
+ try {
604
+ autoLoopService.enable(c.req.param('id'));
605
+ return c.json({ ok: true });
606
+ }
607
+ catch (err) {
608
+ const message = err instanceof Error ? err.message : String(err);
609
+ return c.json({ error: message }, 400);
610
+ }
611
+ });
612
+ // DELETE /api/workspaces/:id/auto-loop — disable the loop (user toggle OFF).
613
+ app.delete('/:id/auto-loop', (c) => {
614
+ try {
615
+ autoLoopService.disable(c.req.param('id'), 'user-action');
616
+ return c.json({ ok: true });
617
+ }
618
+ catch (err) {
619
+ const message = err instanceof Error ? err.message : String(err);
620
+ return c.json({ error: message }, 500);
621
+ }
622
+ });
623
+ // POST /api/workspaces/:id/auto-loop-ready — force auto_loop_ready=true.
624
+ // Used by the "Force ready (skip grooming)" UI button AND by the MCP tool
625
+ // `kobo__mark_auto_loop_ready` at the end of a grooming session. Emits a
626
+ // WS event so any live frontend refreshes the toggle state immediately.
627
+ app.post('/:id/auto-loop-ready', (c) => {
628
+ try {
629
+ const id = c.req.param('id');
630
+ const ws = workspaceService.getWorkspace(id);
631
+ if (!ws)
632
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
633
+ workspaceService.setAutoLoopReady(id, true);
634
+ wsService.emitEphemeral(id, 'autoloop:ready-flipped', {});
635
+ autoLoopService.onAutoLoopReadySet(id);
636
+ return c.json({ ok: true });
637
+ }
638
+ catch (err) {
639
+ const message = err instanceof Error ? err.message : String(err);
640
+ return c.json({ error: message }, 500);
641
+ }
642
+ });
534
643
  // GET /api/workspaces/:id/pending-wakeup — returns the pending wakeup or null.
535
644
  app.get('/:id/pending-wakeup', (c) => {
536
645
  try {
@@ -747,6 +856,9 @@ app.get('/:id/events', (c) => {
747
856
  return c.json({ error: `Workspace '${id}' not found` }, 404);
748
857
  }
749
858
  const before = c.req.query('before'); // event ID cursor
859
+ // optional: scope to a session view. Session views also include
860
+ // workspace-level rows where session_id IS NULL (legacy/pre-session items).
861
+ const session = c.req.query('session');
750
862
  const limit = Math.min(parseInt(c.req.query('limit') ?? '100', 10) || 100, 500);
751
863
  const db = getDb();
752
864
  let rows;
@@ -756,18 +868,29 @@ app.get('/:id/events', (c) => {
756
868
  if (!cursorRow) {
757
869
  return c.json({ events: [], hasMore: false });
758
870
  }
759
- rows = db
760
- .prepare('SELECT * FROM ws_events WHERE workspace_id = ? AND rowid < ? ORDER BY rowid DESC LIMIT ?')
761
- .all(id, cursorRow.rowid, limit);
871
+ rows = session
872
+ ? db
873
+ .prepare('SELECT * FROM ws_events WHERE workspace_id = ? AND (session_id = ? OR session_id IS NULL) AND rowid < ? ORDER BY rowid DESC LIMIT ?')
874
+ .all(id, session, cursorRow.rowid, limit)
875
+ : db
876
+ .prepare('SELECT * FROM ws_events WHERE workspace_id = ? AND rowid < ? ORDER BY rowid DESC LIMIT ?')
877
+ .all(id, cursorRow.rowid, limit);
762
878
  }
763
879
  else {
764
- // No cursor — return the oldest events
765
- rows = db
766
- .prepare('SELECT * FROM ws_events WHERE workspace_id = ? ORDER BY rowid ASC LIMIT ?')
767
- .all(id, limit);
768
- }
769
- // Reverse to chronological order (we queried DESC for "before" pagination)
770
- if (before)
880
+ // No cursor — return events. When filtering by session, we want the
881
+ // MOST RECENT events of that session first (so the feed renders from
882
+ // the end), reversed to chronological order below.
883
+ rows = session
884
+ ? db
885
+ .prepare('SELECT * FROM ws_events WHERE workspace_id = ? AND (session_id = ? OR session_id IS NULL) ORDER BY rowid DESC LIMIT ?')
886
+ .all(id, session, limit)
887
+ : db
888
+ .prepare('SELECT * FROM ws_events WHERE workspace_id = ? ORDER BY rowid ASC LIMIT ?')
889
+ .all(id, limit);
890
+ }
891
+ // Reverse to chronological order (we queried DESC for "before" pagination,
892
+ // or for the "session + no cursor" case where we fetched the newest first).
893
+ if (before || session)
771
894
  rows.reverse();
772
895
  const events = rows.map((row) => {
773
896
  let parsedPayload;
@@ -788,13 +911,25 @@ app.get('/:id/events', (c) => {
788
911
  });
789
912
  // Check if there are more older events beyond what we returned
790
913
  let hasMore = false;
791
- if (before && rows.length > 0) {
792
- const firstRow = db.prepare('SELECT rowid FROM ws_events WHERE id = ?').get(rows[0].id);
793
- if (firstRow) {
794
- const older = db
795
- .prepare('SELECT COUNT(*) as c FROM ws_events WHERE workspace_id = ? AND rowid < ?')
796
- .get(id, firstRow.rowid);
797
- hasMore = older.c > 0;
914
+ if (rows.length > 0) {
915
+ if (before) {
916
+ const firstRow = db.prepare('SELECT rowid FROM ws_events WHERE id = ?').get(rows[0].id);
917
+ if (firstRow) {
918
+ const older = session
919
+ ? db
920
+ .prepare('SELECT COUNT(*) as c FROM ws_events WHERE workspace_id = ? AND (session_id = ? OR session_id IS NULL) AND rowid < ?')
921
+ .get(id, session, firstRow.rowid)
922
+ : db
923
+ .prepare('SELECT COUNT(*) as c FROM ws_events WHERE workspace_id = ? AND rowid < ?')
924
+ .get(id, firstRow.rowid);
925
+ hasMore = older.c > 0;
926
+ }
927
+ }
928
+ else if (session) {
929
+ const total = db
930
+ .prepare('SELECT COUNT(*) as c FROM ws_events WHERE workspace_id = ? AND (session_id = ? OR session_id IS NULL)')
931
+ .get(id, session);
932
+ hasMore = total.c > rows.length;
798
933
  }
799
934
  }
800
935
  return c.json({ events, hasMore });
@@ -898,6 +1033,12 @@ app.patch('/:id', migrationGuard, async (c) => {
898
1033
  }
899
1034
  updated = workspaceService.updateWorkspacePermissionMode(id, body.permissionMode);
900
1035
  }
1036
+ if (body.permissionProfile !== undefined) {
1037
+ if (body.permissionProfile !== 'bypass' && body.permissionProfile !== 'strict') {
1038
+ return c.json({ error: "Invalid permission profile. Must be 'bypass' or 'strict'." }, 400);
1039
+ }
1040
+ updated = workspaceService.setPermissionProfile(id, body.permissionProfile);
1041
+ }
901
1042
  if (body.status) {
902
1043
  updated = workspaceService.updateWorkspaceStatus(id, body.status);
903
1044
  }
@@ -908,8 +1049,9 @@ app.patch('/:id', migrationGuard, async (c) => {
908
1049
  body.model === undefined &&
909
1050
  body.reasoningEffort === undefined &&
910
1051
  body.permissionMode === undefined &&
1052
+ body.permissionProfile === undefined &&
911
1053
  body.name === undefined) {
912
- return c.json({ error: 'Missing field: status, model, reasoningEffort, permissionMode, or name' }, 400);
1054
+ return c.json({ error: 'Missing field: status, model, reasoningEffort, permissionMode, permissionProfile, or name' }, 400);
913
1055
  }
914
1056
  return c.json(updated);
915
1057
  }
@@ -1086,6 +1228,13 @@ app.delete('/:id', migrationGuard, async (c) => {
1086
1228
  catch {
1087
1229
  // Terminal may not exist — ignore
1088
1230
  }
1231
+ // Collected best-effort warnings: the DB deletion always proceeds, but
1232
+ // side-effects (worktree, local/remote branches) can fail independently.
1233
+ // We surface a user-friendly message per failure so the UI can show a
1234
+ // sticky toast with a copy-pasteable recovery command — common case:
1235
+ // Docker leaves root-owned files inside the worktree, git worktree
1236
+ // remove fails with EACCES.
1237
+ const warnings = [];
1089
1238
  // Remove worktree
1090
1239
  const worktreesDir = `${workspace.projectPath}/.worktrees`;
1091
1240
  const worktreePath = `${worktreesDir}/${workspace.workingBranch}`;
@@ -1095,6 +1244,11 @@ app.delete('/:id', migrationGuard, async (c) => {
1095
1244
  catch (err) {
1096
1245
  const message = err instanceof Error ? err.message : String(err);
1097
1246
  console.error(`[workspaces] Failed to remove worktree: ${message}`);
1247
+ warnings.push(`Failed to remove worktree directory '${worktreePath}'. The git entry may still reference it. ` +
1248
+ `Fix manually:\n` +
1249
+ ` sudo rm -rf '${worktreePath}'\n` +
1250
+ ` cd '${workspace.projectPath}' && git worktree prune\n` +
1251
+ `Reason: ${message}`);
1098
1252
  }
1099
1253
  // Delete local branch if requested
1100
1254
  if (body.deleteLocalBranch) {
@@ -1104,6 +1258,9 @@ app.delete('/:id', migrationGuard, async (c) => {
1104
1258
  catch (err) {
1105
1259
  const message = err instanceof Error ? err.message : String(err);
1106
1260
  console.error(`[workspaces] Failed to delete local branch: ${message}`);
1261
+ warnings.push(`Failed to delete local branch '${workspace.workingBranch}'. Fix manually:\n` +
1262
+ ` cd '${workspace.projectPath}' && git branch -D '${workspace.workingBranch}'\n` +
1263
+ `Reason: ${message}`);
1107
1264
  }
1108
1265
  }
1109
1266
  // Delete remote branch if requested
@@ -1114,11 +1271,20 @@ app.delete('/:id', migrationGuard, async (c) => {
1114
1271
  catch (err) {
1115
1272
  const message = err instanceof Error ? err.message : String(err);
1116
1273
  console.error(`[workspaces] Failed to delete remote branch: ${message}`);
1274
+ warnings.push(`Failed to delete remote branch '${workspace.workingBranch}'. Fix manually:\n` +
1275
+ ` cd '${workspace.projectPath}' && git push origin --delete '${workspace.workingBranch}'\n` +
1276
+ `Reason: ${message}`);
1117
1277
  }
1118
1278
  }
1119
1279
  // Delete workspace from DB (cascades to tasks, sessions, events)
1120
1280
  workspaceService.deleteWorkspace(id);
1121
- return new Response(null, { status: 204 });
1281
+ // When everything worked cleanly we keep the legacy 204 response so
1282
+ // existing clients aren't surprised by a JSON body. Warnings promote the
1283
+ // response to 200 so the body is readable.
1284
+ if (warnings.length === 0) {
1285
+ return new Response(null, { status: 204 });
1286
+ }
1287
+ return c.json({ ok: true, warnings }, 200);
1122
1288
  }
1123
1289
  catch (err) {
1124
1290
  const message = err instanceof Error ? err.message : String(err);
@@ -1364,6 +1530,20 @@ app.post('/:id/resync-branch', (c) => {
1364
1530
  if (!actual || actual === workspace.workingBranch) {
1365
1531
  return c.json({ ok: true, changed: false, workingBranch: workspace.workingBranch });
1366
1532
  }
1533
+ // Branch was renamed in-place by the agent (`git branch -m ...`). The
1534
+ // worktree directory is still at .worktrees/<old-name>; move it so it
1535
+ // matches the new ref, otherwise Kōbō's path resolver (projectPath +
1536
+ // .worktrees + workingBranch) breaks and subsequent session spawns fail
1537
+ // with ENOENT on .mcp.json. Best-effort: if the move fails (dir already
1538
+ // moved, lockfile, dirty tree), we still update the DB so git ops stay
1539
+ // aligned with the current ref name — the user can repair the dir manually.
1540
+ const newWorktreePath = path.join(workspace.projectPath, '.worktrees', actual);
1541
+ try {
1542
+ gitOps.moveWorktree(workspace.projectPath, worktreePath, newWorktreePath);
1543
+ }
1544
+ catch (err) {
1545
+ console.error('[workspaces] resync-branch: moveWorktree failed (DB update proceeds):', err);
1546
+ }
1367
1547
  const updated = workspaceService.updateWorkingBranch(id, actual);
1368
1548
  return c.json({ ok: true, changed: true, workingBranch: updated.workingBranch });
1369
1549
  }
@@ -1380,9 +1560,19 @@ app.post('/:id/push', async (c) => {
1380
1560
  if (!workspace) {
1381
1561
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1382
1562
  }
1563
+ const body = await c.req.json().catch(() => ({}));
1564
+ const force = body?.force === true;
1383
1565
  const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1384
1566
  try {
1385
- gitOps.pushBranch(worktreePath, workspace.workingBranch);
1567
+ // Only pass an options arg when force is requested — keeps the
1568
+ // no-options call shape identical to before for callers/tests that
1569
+ // assert on argument count.
1570
+ if (force) {
1571
+ gitOps.pushBranch(worktreePath, workspace.workingBranch, { force: true });
1572
+ }
1573
+ else {
1574
+ gitOps.pushBranch(worktreePath, workspace.workingBranch);
1575
+ }
1386
1576
  }
1387
1577
  catch (err) {
1388
1578
  const message = err instanceof Error ? err.message : String(err);
@@ -24,6 +24,12 @@ export function buildClaudeArgs(input) {
24
24
  if (input.permissionMode === 'plan') {
25
25
  args.push('--permission-mode', 'plan');
26
26
  }
27
+ else if (input.permissionProfile === 'strict') {
28
+ // Strict profile: respect the project's settings.json allow/deny.
29
+ // `acceptEdits` auto-accepts Edit/Write but enforces the allow list,
30
+ // so `Edit(.claude/**)` and similar take effect (unlike in bypass mode).
31
+ args.push('--permission-mode', 'acceptEdits');
32
+ }
27
33
  else if (input.skipPermissions) {
28
34
  args.push('--dangerously-skip-permissions');
29
35
  }