@loicngr/kobo 2.1.2 → 2.1.5

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/CHANGELOG.md +12 -0
  2. package/CONFIGURATION.md +11 -3
  3. package/dist/server/index.js +24 -91
  4. package/dist/server/routes/git.js +6 -7
  5. package/dist/server/routes/settings.js +21 -11
  6. package/dist/server/routes/workspaces.js +82 -41
  7. package/dist/server/services/agent/engines/claude-code/engine.js +87 -20
  8. package/dist/server/services/agent/engines/claude-code/event-mapper.js +88 -22
  9. package/dist/server/services/agent/engines/claude-code/options-builder.js +1 -0
  10. package/dist/server/services/agent/engines/codex/engine.js +27 -5
  11. package/dist/server/services/agent/engines/codex/options-builder.js +1 -0
  12. package/dist/server/services/agent/integration-mcp.js +4 -3
  13. package/dist/server/services/agent/orchestrator.js +36 -1
  14. package/dist/server/services/agent/session-controller.js +12 -0
  15. package/dist/server/services/engine-handoff-service.js +120 -24
  16. package/dist/server/services/pr-checkout-service.js +90 -53
  17. package/dist/server/services/pr-watcher-service.js +13 -7
  18. package/dist/server/services/retention/worker.js +40 -0
  19. package/dist/server/services/review-service.js +8 -4
  20. package/dist/server/services/session-event-metrics.js +43 -0
  21. package/dist/server/services/session-handoff-service.js +6 -4
  22. package/dist/server/services/templates-service.js +67 -49
  23. package/dist/server/services/terminal-service.js +45 -14
  24. package/dist/server/services/terminal-websocket-service.js +143 -0
  25. package/dist/server/services/wakeup-service.js +23 -21
  26. package/dist/server/services/workspace-service.js +3 -44
  27. package/dist/server/services/workspace-template-service.js +16 -18
  28. package/dist/server/services/worktree-purge-service.js +1 -4
  29. package/dist/server/services/ws-events-retention-service.js +197 -104
  30. package/dist/server/services/ws-events-retention-worker-service.js +112 -0
  31. package/dist/server/utils/atomic-json-file.js +23 -0
  32. package/dist/server/utils/git-ops.js +262 -60
  33. package/dist/shared/codex-models.js +20 -3
  34. package/dist/shared/models.js +12 -0
  35. package/package.json +8 -8
  36. package/src/client/dist/pwa/assets/{ActivityFeed-DCmiJP-z.css → ActivityFeed-B4lTKMVE.css} +1 -1
  37. package/src/client/dist/pwa/assets/ActivityFeed-DXWXioW9.js +8 -0
  38. package/src/client/dist/pwa/assets/ChangelogPage-BL5Jh8-v.js +1 -0
  39. package/src/client/dist/pwa/assets/ChangelogPage-DVyx9ZjV.css +1 -0
  40. package/src/client/dist/pwa/assets/{ClosePopup-BikuzYle.js → ClosePopup-C1a82pL9.js} +1 -1
  41. package/src/client/dist/pwa/assets/ComparisonPanel-Bv2SZrVZ.js +2 -0
  42. package/src/client/dist/pwa/assets/ComparisonPanel-ByVbqYCs.css +1 -0
  43. package/src/client/dist/pwa/assets/CreatePage-CMyl2j8Z.css +1 -0
  44. package/src/client/dist/pwa/assets/CreatePage-d43ITOC1.js +2 -0
  45. package/src/client/dist/pwa/assets/DashboardPage-BEXXRguE.js +1 -0
  46. package/src/client/dist/pwa/assets/DashboardPage-CtaHaVdf.css +1 -0
  47. package/src/client/dist/pwa/assets/DiffViewer-B92w_jd7.js +8 -0
  48. package/src/client/dist/pwa/assets/DocumentsPanel-C9JXyqHg.js +1 -0
  49. package/src/client/dist/pwa/assets/{DrawerToggleButton-Dfvkaro0.js → DrawerToggleButton-Btx8QwHo.js} +1 -1
  50. package/src/client/dist/pwa/assets/{GitPanel-C4wOcZV2.css → GitPanel-C3blk_Pd.css} +1 -1
  51. package/src/client/dist/pwa/assets/GitPanel-Cgz9crLN.js +3 -0
  52. package/src/client/dist/pwa/assets/HealthPage-CDhgJQkL.js +1 -0
  53. package/src/client/dist/pwa/assets/HealthPage-DT8FGrKA.css +1 -0
  54. package/src/client/dist/pwa/assets/MainLayout-DTTtj9sq.js +2 -0
  55. package/src/client/dist/pwa/assets/{MainLayout-gHENJEFQ.css → MainLayout-knNNtkre.css} +1 -1
  56. package/src/client/dist/pwa/assets/QBadge-rGILsUYl.js +1 -0
  57. package/src/client/dist/pwa/assets/QBanner-7EWRdc4U.js +1 -0
  58. package/src/client/dist/pwa/assets/QBtn-b_baQtAt.js +1 -0
  59. package/src/client/dist/pwa/assets/QBtnDropdown-vjat4GZl.js +1 -0
  60. package/src/client/dist/pwa/assets/QBtnGroup-CUmAgBV8.js +1 -0
  61. package/src/client/dist/pwa/assets/QBtnToggle-COL46j6W.js +1 -0
  62. package/src/client/dist/pwa/assets/QChip-CG1kWhFm.js +1 -0
  63. package/src/client/dist/pwa/assets/QExpansionItem-BQuy89vv.js +1 -0
  64. package/src/client/dist/pwa/assets/QItemSection-BrVklYfM.js +1 -0
  65. package/src/client/dist/pwa/assets/QList-BVX-b8nc.js +1 -0
  66. package/src/client/dist/pwa/assets/QMarkupTable-B8_FKLvo.js +1 -0
  67. package/src/client/dist/pwa/assets/QPage-BCDXjfVj.js +1 -0
  68. package/src/client/dist/pwa/assets/QSelect-DBW_TZri.js +1 -0
  69. package/src/client/dist/pwa/assets/QSpinnerDots-BsKmccpX.js +1 -0
  70. package/src/client/dist/pwa/assets/QTooltip-DzYbguTR.js +1 -0
  71. package/src/client/dist/pwa/assets/SchedulePanel-BX6iKh6S.js +1 -0
  72. package/src/client/dist/pwa/assets/SchedulePanel-CBM_GpS3.css +1 -0
  73. package/src/client/dist/pwa/assets/SearchPage-_In2gH2P.js +1 -0
  74. package/src/client/dist/pwa/assets/SettingsPage-BqkNYzC5.js +9 -0
  75. package/src/client/dist/pwa/assets/{SettingsPage-BYdLXJEJ.css → SettingsPage-CpykRKq2.css} +1 -1
  76. package/src/client/dist/pwa/assets/SplitWorkspacePage-DRoXbSRP.js +1 -0
  77. package/src/client/dist/pwa/assets/{TerminalPanel-B9LDe7uG.js → TerminalPanel-CfqiuSuk.js} +2 -2
  78. package/src/client/dist/pwa/assets/{TerminalPanel-DbVZk0vD.css → TerminalPanel-Dz9EE-Ph.css} +1 -1
  79. package/src/client/dist/pwa/assets/TouchPan-DcOCRf7x.js +1 -0
  80. package/src/client/dist/pwa/assets/{TourReplayButton-Dtv_UDaY.js → TourReplayButton-gp7m6tSV.js} +1 -1
  81. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-DSw8Upcp.css +1 -0
  82. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-IcoMGkJh.js +1 -0
  83. package/src/client/dist/pwa/assets/{WorkspacePage--5HsnMQ1.css → WorkspacePage-BnW-gp3u.css} +1 -1
  84. package/src/client/dist/pwa/assets/WorkspacePage-CR2xAzW_.js +4 -0
  85. package/src/client/dist/pwa/assets/{browser-B15GGwtW.js → browser-BveHkbe-.js} +1 -1
  86. package/src/client/dist/pwa/assets/build-path-tree-Dtp7rlxS.js +1 -0
  87. package/src/client/dist/pwa/assets/custom-sounds-CBoWzJ85.js +1 -0
  88. package/src/client/dist/pwa/assets/de-BJsWkZro.js +17 -0
  89. package/src/client/dist/pwa/assets/defineProperty-BbfpZ9Tg.js +1 -0
  90. package/src/client/dist/pwa/assets/documents-sb2sQ4JC.js +1 -0
  91. package/src/client/dist/pwa/assets/{editor.api-DhaLhNl-.js → editor.api-CbkPxoJf.js} +1 -1
  92. package/src/client/dist/pwa/assets/efforts-BCli1Unj.js +9 -0
  93. package/src/client/dist/pwa/assets/es-tNYw7itB.js +17 -0
  94. package/src/client/dist/pwa/assets/expand-template-CfaEhb2f.js +1 -0
  95. package/src/client/dist/pwa/assets/{format-2JisC3s1.js → format-BRl2DUlm.js} +7 -7
  96. package/src/client/dist/pwa/assets/formatters-C5TjPSR_.js +1 -0
  97. package/src/client/dist/pwa/assets/fr-B5sFski4.js +17 -0
  98. package/src/client/dist/pwa/assets/{freemarker2-PYrDqU04.js → freemarker2-CDmvOQyn.js} +1 -1
  99. package/src/client/dist/pwa/assets/{handlebars-BuiY0fFf.js → handlebars-Bx_w8mx1.js} +1 -1
  100. package/src/client/dist/pwa/assets/{html-Du3cjdug.js → html-DVRfiRkS.js} +1 -1
  101. package/src/client/dist/pwa/assets/i18n-Bm-L4nW3.js +1 -0
  102. package/src/client/dist/pwa/assets/i18n-D21JKsVt.js +19 -0
  103. package/src/client/dist/pwa/assets/index-C7vFf6p2.js +2 -0
  104. package/src/client/dist/pwa/assets/index-C8zAZ-k-.css +1 -0
  105. package/src/client/dist/pwa/assets/it-Bc2FncM9.js +17 -0
  106. package/src/client/dist/pwa/assets/{javascript-Bb48hPli.js → javascript-CPvttU5J.js} +1 -1
  107. package/src/client/dist/pwa/assets/latest-request-CN4-B22z.js +1 -0
  108. package/src/client/dist/pwa/assets/{liquid-Be77iQe3.js → liquid-CI0gQddb.js} +1 -1
  109. package/src/client/dist/pwa/assets/{mdx-CfohNJja.js → mdx-BveBwYJH.js} +1 -1
  110. package/src/client/dist/pwa/assets/{monaco-contributions-CyM4wsIg.js → monaco-contributions-CHXFh4P2.js} +1 -1
  111. package/src/client/dist/pwa/assets/monaco.contribution-DOK8t_9B.js +2 -0
  112. package/src/client/dist/pwa/assets/network-auth-CItnNZDh.js +1 -0
  113. package/src/client/dist/pwa/assets/{notify-theme-DvvaSyg1.js → notify-theme-BO6r1Rd7.js} +1 -1
  114. package/src/client/dist/pwa/assets/pending-diff-open-DpS4iyDM.js +1 -0
  115. package/src/client/dist/pwa/assets/{permissionModes-B-ciuUbz.js → permissionModes-Cc5VaOdC.js} +1 -1
  116. package/src/client/dist/pwa/assets/preload-helper-BZ1Pz5am.js +1 -0
  117. package/src/client/dist/pwa/assets/{private.use-form-Bk8ti264.js → private.use-form-vxvbh_VN.js} +1 -1
  118. package/src/client/dist/pwa/assets/{project-color-Be0t12-n.js → project-color-Bnv02P9D.js} +1 -1
  119. package/src/client/dist/pwa/assets/{python-BNemmtx8.js → python-BesLCxpi.js} +1 -1
  120. package/src/client/dist/pwa/assets/{razor-3NM8iFVf.js → razor-Dv1PMxuY.js} +1 -1
  121. package/src/client/dist/pwa/assets/{reactivity.esm-bundler-Bo9anJjg.js → reactivity.esm-bundler-tuEvQ47f.js} +1 -1
  122. package/src/client/dist/pwa/assets/{render-chat-markdown-BWJNIDv3.js → render-chat-markdown-CafzCcmu.js} +1 -1
  123. package/src/client/dist/pwa/assets/runtime-core.esm-bundler-DOiZcasU.js +1 -0
  124. package/src/client/dist/pwa/assets/{typescript-DwyZ4CF7.js → typescript-26L2Xsu9.js} +1 -1
  125. package/src/client/dist/pwa/assets/use-checkbox-BLeif5-t.js +1 -0
  126. package/src/client/dist/pwa/assets/use-id-b4A2I8LB.js +1 -0
  127. package/src/client/dist/pwa/assets/use-is-mobile-DLIZxEo9.js +1 -0
  128. package/src/client/dist/pwa/assets/use-search-index-status-Mv3pIO6p.js +1 -0
  129. package/src/client/dist/pwa/assets/{use-slide-transition-CiaDm-tR.js → use-slide-transition-C5eDGOmu.js} +1 -1
  130. package/src/client/dist/pwa/assets/{use-tours-CjASQcG5.js → use-tours-B5uIqxRS.js} +2 -2
  131. package/src/client/dist/pwa/assets/use-worktree-restore-BBChALUM.js +1 -0
  132. package/src/client/dist/pwa/assets/workspace-templates-CDY-MiH2.js +1 -0
  133. package/src/client/dist/pwa/assets/{xml-B1UujXdS.js → xml-CkRojTcc.js} +1 -1
  134. package/src/client/dist/pwa/assets/{yaml-mHN8kNgU.js → yaml-DKkQHwew.js} +1 -1
  135. package/src/client/dist/pwa/index.html +12 -9
  136. package/src/client/dist/pwa/sw.js +1 -1
  137. package/src/client/dist/pwa/assets/ActivityFeed-CO5rOaa5.js +0 -8
  138. package/src/client/dist/pwa/assets/ChangelogPage-DKJkFiU_.css +0 -1
  139. package/src/client/dist/pwa/assets/ChangelogPage-DNRcPRtW.js +0 -1
  140. package/src/client/dist/pwa/assets/ComparisonPanel-CRRaDs2D.css +0 -1
  141. package/src/client/dist/pwa/assets/ComparisonPanel-DZoDqgJI.js +0 -2
  142. package/src/client/dist/pwa/assets/CreatePage-B9C9J9Pw.css +0 -1
  143. package/src/client/dist/pwa/assets/CreatePage-wNH35uTG.js +0 -2
  144. package/src/client/dist/pwa/assets/DashboardPage-DUyy4SzS.js +0 -1
  145. package/src/client/dist/pwa/assets/DashboardPage-XiCj24i2.css +0 -1
  146. package/src/client/dist/pwa/assets/DiffViewer-CZ5QwXqi.js +0 -8
  147. package/src/client/dist/pwa/assets/DocumentsPanel-sBatgKA-.js +0 -1
  148. package/src/client/dist/pwa/assets/GitPanel-B8e1olfm.js +0 -3
  149. package/src/client/dist/pwa/assets/HealthPage-BwXYpczh.js +0 -1
  150. package/src/client/dist/pwa/assets/HealthPage-CFZ-tdKK.css +0 -1
  151. package/src/client/dist/pwa/assets/MainLayout-DFXIS_wv.js +0 -2
  152. package/src/client/dist/pwa/assets/QBadge-CQF1smL-.js +0 -1
  153. package/src/client/dist/pwa/assets/QBanner-bdDA-A5g.js +0 -1
  154. package/src/client/dist/pwa/assets/QBtn-CcQQrYA_.js +0 -1
  155. package/src/client/dist/pwa/assets/QBtnDropdown-C-1EhO9J.js +0 -1
  156. package/src/client/dist/pwa/assets/QBtnGroup-wd5lKRXu.js +0 -1
  157. package/src/client/dist/pwa/assets/QBtnToggle-BnC8rTql.js +0 -1
  158. package/src/client/dist/pwa/assets/QChip-D75nKi-L.js +0 -1
  159. package/src/client/dist/pwa/assets/QExpansionItem-RpMPvfxX.js +0 -1
  160. package/src/client/dist/pwa/assets/QItemSection-Bt9H35JJ.js +0 -1
  161. package/src/client/dist/pwa/assets/QList-D5msNbSZ.js +0 -1
  162. package/src/client/dist/pwa/assets/QMarkupTable-D3REW6RC.js +0 -1
  163. package/src/client/dist/pwa/assets/QPage-HAaeQ-cv.js +0 -1
  164. package/src/client/dist/pwa/assets/QSelect-Dpru_T81.js +0 -1
  165. package/src/client/dist/pwa/assets/QSpinnerDots-DDsPDUH9.js +0 -1
  166. package/src/client/dist/pwa/assets/QTooltip-C31vXtse.js +0 -1
  167. package/src/client/dist/pwa/assets/SchedulePanel-BYKSFoFj.css +0 -1
  168. package/src/client/dist/pwa/assets/SchedulePanel-D6LTiRFg.js +0 -1
  169. package/src/client/dist/pwa/assets/SearchPage-Dj487BdP.js +0 -1
  170. package/src/client/dist/pwa/assets/SettingsPage-CJmgFiAa.js +0 -9
  171. package/src/client/dist/pwa/assets/SplitWorkspacePage-BGSDgGIO.js +0 -1
  172. package/src/client/dist/pwa/assets/TouchPan-DJqqxDBF.js +0 -1
  173. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-CrLP_I5S.css +0 -1
  174. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-VlJCIkwU.js +0 -1
  175. package/src/client/dist/pwa/assets/WorkspacePage-B_SlX8YB.js +0 -4
  176. package/src/client/dist/pwa/assets/build-path-tree-CU6uXunR.js +0 -1
  177. package/src/client/dist/pwa/assets/custom-sounds-CDJsgChY.js +0 -1
  178. package/src/client/dist/pwa/assets/de-B_Y4EArT.js +0 -17
  179. package/src/client/dist/pwa/assets/documents-Cf6nmu8Z.js +0 -1
  180. package/src/client/dist/pwa/assets/efforts-DoYZi0AZ.js +0 -9
  181. package/src/client/dist/pwa/assets/engineFeatures-C9XiCfvv.js +0 -1
  182. package/src/client/dist/pwa/assets/es-DtsTTShP.js +0 -17
  183. package/src/client/dist/pwa/assets/expand-template-LDLM-6iN.js +0 -1
  184. package/src/client/dist/pwa/assets/formatters-ZVNs2VOl.js +0 -1
  185. package/src/client/dist/pwa/assets/fr-C82MIVrA.js +0 -17
  186. package/src/client/dist/pwa/assets/i18n-BumeGGx_.js +0 -1
  187. package/src/client/dist/pwa/assets/index-DolbyIM4.js +0 -20
  188. package/src/client/dist/pwa/assets/index-VMFLfvp2.css +0 -1
  189. package/src/client/dist/pwa/assets/it-BJNuTcj2.js +0 -17
  190. package/src/client/dist/pwa/assets/monaco.contribution-BcIfN6Mi.js +0 -2
  191. package/src/client/dist/pwa/assets/network-auth-B1xAaHmx.js +0 -1
  192. package/src/client/dist/pwa/assets/pending-diff-open-B6N0vI5n.js +0 -1
  193. package/src/client/dist/pwa/assets/pinia-CTJ3OfP3.js +0 -1
  194. package/src/client/dist/pwa/assets/runtime-core.esm-bundler-CNFKm4ij.js +0 -1
  195. package/src/client/dist/pwa/assets/use-checkbox-2xzX4yYj.js +0 -1
  196. package/src/client/dist/pwa/assets/use-id-vOr8PRTG.js +0 -1
  197. package/src/client/dist/pwa/assets/use-is-mobile-CJfSpTHx.js +0 -1
  198. package/src/client/dist/pwa/assets/use-search-index-status-Bluf9L99.js +0 -1
  199. package/src/client/dist/pwa/assets/workspace-templates-BxYxJt3S.js +0 -1
  200. /package/src/client/dist/pwa/assets/{engineFeatures-B-9jHocY.css → use-worktree-restore-B-9jHocY.css} +0 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes to Kōbō are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/). Each release is an `## <version>`
5
5
  section — the in-app "What's new" dialog reads this file.
6
6
 
7
+ ## 2.1.5
8
+
9
+ - fix(workspace): improve reliability and git controls
10
+
11
+ ## 2.1.4
12
+
13
+ - chore(deps): update server client and pwa packages
14
+
15
+ ## 2.1.3
16
+
17
+ - fix(subagents): reliable lifecycle tracking and a stop button
18
+
7
19
  ## 2.1.2
8
20
 
9
21
  - fix: read-only returning reviews, resumed-turn stdin, feed and tag filter
package/CONFIGURATION.md CHANGED
@@ -751,9 +751,17 @@ both global (the database is single):
751
751
  | Events always kept per workspace | `0` | The most recent events of each workspace survive whatever their age. Set it (5000 is a good starting point) at the same time you enable retention, so an old but still-open workspace keeps a readable history. |
752
752
 
753
753
  The prune runs **at server startup and every 24 hours** while the backend stays
754
- running. Each pass reads the current settings and deletes in batches of 5,000
755
- rows. When enough pages have been freed, a `VACUUM` returns the space to the
756
- filesystem.
754
+ running. Each pass reads the current settings, calculates the protected workspace
755
+ tails once, and deletes in batches of 500 rows on a dedicated worker. Preview
756
+ counts also run on that worker, so maintenance does not block HTTP or agent
757
+ event handling. Shutdown waits for the worker to finish its current batch and
758
+ reconcile affected metrics before closing the database.
759
+
760
+ Freed database pages are reused for new events. Live maintenance performs a
761
+ non-blocking WAL checkpoint and deliberately does not run a full `VACUUM`, which
762
+ would hold SQLite's writer lock and interrupt live agents. To return database
763
+ file space to the filesystem immediately, stop Kōbō and perform an explicit
764
+ offline SQLite `VACUUM` after making a backup.
757
765
 
758
766
  **What it deletes:** eligible rows of `ws_events` — the recorded agent output.
759
767
  Affected session metrics and the derived search index are reconciled with the
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { serve } from '@hono/node-server';
5
5
  import { Hono } from 'hono';
6
- import WebSocket, { WebSocketServer } from 'ws';
6
+ import { WebSocketServer } from 'ws';
7
7
  import { closeDb, getDb } from './db/index.js';
8
8
  import { getPendingMigrations, runMigrations } from './db/migrations.js';
9
9
  import { hostCheckMiddleware } from './middleware/host-check-middleware.js';
@@ -49,14 +49,16 @@ import { startSearchIndex, stopSearchIndex } from './services/search-service.js'
49
49
  import { reconcileSessionHandoffs } from './services/session-handoff-service.js';
50
50
  import { getGlobalSettings, updateNetworkAccessSettings } from './services/settings-service.js';
51
51
  import { reloadDefaultTemplates } from './services/templates-service.js';
52
- import { createTerminal, destroyAllTerminals, getTerminal } from './services/terminal-service.js';
52
+ import { destroyAllTerminals } from './services/terminal-service.js';
53
+ import { handleTerminalConnection } from './services/terminal-websocket-service.js';
53
54
  import { startUpdateChecker, stopUpdateChecker } from './services/update-check-service.js';
54
55
  import { startUsagePoller, stopUsagePoller } from './services/usage/index.js';
55
56
  import * as wakeupService from './services/wakeup-service.js';
56
57
  import { handleConnection, setMessageHandler } from './services/websocket-service.js';
57
58
  import { deliverWorkspaceMessage } from './services/workspace-message-service.js';
58
59
  import { getWorkspace } from './services/workspace-service.js';
59
- import { pruneWsEvents, resolveRetentionConfig } from './services/ws-events-retention-service.js';
60
+ import { resolveRetentionConfig } from './services/ws-events-retention-service.js';
61
+ import { runWsEventsRetention, stopWsEventsRetention } from './services/ws-events-retention-worker-service.js';
60
62
  import { getChangelogPath, getClientSpaPath, getDbPath, getKoboHome, getPackageVersion, resolveSpaFile, } from './utils/paths.js';
61
63
  import { formatStartupBanner, readStartupNotes, startStartupSpinner, startupDebug } from './utils/startup-display.js';
62
64
  startupDebug(`[kobo] Kōbō home: ${getKoboHome()}`);
@@ -87,13 +89,13 @@ startSearchIndex();
87
89
  // conversation on the next upgrade, which is precisely why the UI shows a
88
90
  // count and asks first.
89
91
  // Best-effort — a failure must never block boot.
90
- function runRetentionPass(context) {
92
+ async function runRetentionPass(context) {
91
93
  try {
92
94
  const retentionConfig = resolveRetentionConfig(getGlobalSettings());
93
95
  if (retentionConfig.retentionDays <= 0)
94
96
  return;
95
97
  const retentionStartedAt = Date.now();
96
- const retention = pruneWsEvents(db, retentionConfig);
98
+ const retention = await runWsEventsRetention(retentionConfig);
97
99
  if (retention.deleted > 0 || retention.vacuumed) {
98
100
  console.log(`[kobo] Event retention ${context} (${retentionConfig.retentionDays} d, keeping ${retentionConfig.keepPerWorkspace}/workspace): ` +
99
101
  `${retention.deleted} agent event(s) permanently deleted, ` +
@@ -106,12 +108,12 @@ function runRetentionPass(context) {
106
108
  console.error('[kobo] Event retention failed (continuing):', err);
107
109
  }
108
110
  }
109
- runRetentionPass('at boot');
111
+ void runRetentionPass('at boot');
110
112
  // Kōbō is a daemon people leave running for weeks, and `emit` writes a row per
111
113
  // agent event. Running the pass only at boot meant a window the user enabled
112
114
  // was not applied again until the next restart.
113
115
  const RETENTION_INTERVAL_MS = 24 * 60 * 60 * 1000;
114
- const retentionTimer = setInterval(() => runRetentionPass('daily'), RETENTION_INTERVAL_MS);
116
+ const retentionTimer = setInterval(() => void runRetentionPass('daily'), RETENTION_INTERVAL_MS);
115
117
  retentionTimer.unref?.();
116
118
  // Check on boot and hourly so servers running longer than a day keep backing up.
117
119
  const dailyBackupScheduler = startDailyDbBackupScheduler(async () => {
@@ -278,87 +280,7 @@ wss.on('connection', (ws) => {
278
280
  });
279
281
  // Wire terminal WebSocket connections
280
282
  terminalWss.on('connection', (ws, workspaceId) => {
281
- let currentPty = getTerminal(workspaceId);
282
- let dataDisposable = null;
283
- let exitDisposable = null;
284
- function attachListeners(ptyInstance) {
285
- // Dispose previous listeners to avoid stacking on reconnect
286
- dataDisposable?.dispose();
287
- exitDisposable?.dispose();
288
- dataDisposable = ptyInstance.onData((output) => {
289
- if (ws.readyState === WebSocket.OPEN) {
290
- ws.send(Buffer.from(output), { binary: true });
291
- }
292
- });
293
- exitDisposable = ptyInstance.onExit(({ exitCode }) => {
294
- if (ws.readyState === WebSocket.OPEN) {
295
- ws.send(JSON.stringify({ type: 'exited', code: exitCode }));
296
- ws.close();
297
- }
298
- });
299
- }
300
- ws.on('close', () => {
301
- dataDisposable?.dispose();
302
- exitDisposable?.dispose();
303
- dataDisposable = null;
304
- exitDisposable = null;
305
- });
306
- ws.on('error', (err) => {
307
- console.error(`[terminal] WebSocket error for workspace ${workspaceId}:`, err);
308
- });
309
- ws.on('message', (data, isBinary) => {
310
- if (isBinary) {
311
- if (currentPty) {
312
- currentPty.write(data.toString());
313
- }
314
- return;
315
- }
316
- let msg;
317
- try {
318
- msg = JSON.parse(data.toString());
319
- }
320
- catch {
321
- return; // Invalid JSON — ignore
322
- }
323
- if (msg.type === 'create') {
324
- if (!currentPty) {
325
- const workspace = getWorkspace(workspaceId);
326
- if (!workspace) {
327
- ws.send(JSON.stringify({ type: 'error', message: 'Workspace not found' }));
328
- return;
329
- }
330
- if (workspace.archivedAt) {
331
- ws.send(JSON.stringify({ type: 'error', message: 'Workspace is archived' }));
332
- return;
333
- }
334
- const cwd = workspace.worktreePath;
335
- try {
336
- currentPty = createTerminal(workspaceId, cwd);
337
- }
338
- catch (err) {
339
- const message = err instanceof Error ? err.message : String(err);
340
- ws.send(JSON.stringify({ type: 'error', message }));
341
- return;
342
- }
343
- }
344
- attachListeners(currentPty);
345
- ws.send(JSON.stringify({ type: 'ready' }));
346
- return;
347
- }
348
- if (msg.type === 'resize' && msg.cols && msg.rows) {
349
- if (currentPty) {
350
- const cols = Math.max(1, Math.floor(msg.cols));
351
- const rows = Math.max(1, Math.floor(msg.rows));
352
- try {
353
- currentPty.resize(cols, rows);
354
- }
355
- catch (err) {
356
- console.error(`[terminal] resize failed for workspace ${workspaceId}:`, err);
357
- }
358
- }
359
- return;
360
- }
361
- });
283
+ handleTerminalConnection(ws, workspaceId, () => isShuttingDown);
362
284
  });
363
285
  // Wire websocket-service message handler to the agent orchestrator
364
286
  setMessageHandler(async (type, payload) => {
@@ -411,6 +333,10 @@ setMessageHandler(async (type, payload) => {
411
333
  });
412
334
  // Handle WebSocket upgrade requests on /ws path
413
335
  server.on('upgrade', (request, socket, head) => {
336
+ if (isShuttingDown) {
337
+ socket.destroy();
338
+ return;
339
+ }
414
340
  const { pathname } = new URL(request.url ?? '/', `http://localhost:${PORT}`);
415
341
  const wsGlobal = getGlobalSettings();
416
342
  // WebSockets are exempt from the same-origin policy and send no preflight,
@@ -495,7 +421,7 @@ async function gracefulShutdown(signal, exitCode = 0) {
495
421
  for (const client of terminalWss.clients)
496
422
  client.close(1001, 'Server shutting down');
497
423
  try {
498
- destroyAllTerminals();
424
+ await destroyAllTerminals();
499
425
  console.log('[kobo] Terminals killed');
500
426
  }
501
427
  catch {
@@ -547,8 +473,15 @@ async function gracefulShutdown(signal, exitCode = 0) {
547
473
  }
548
474
  finally {
549
475
  try {
550
- await stopSearchIndex();
551
- await dailyBackupScheduler.stop();
476
+ const stopped = await Promise.allSettled([
477
+ stopWsEventsRetention(),
478
+ stopSearchIndex(),
479
+ dailyBackupScheduler.stop(),
480
+ ]);
481
+ for (const result of stopped) {
482
+ if (result.status === 'rejected')
483
+ console.error('[kobo] Background service shutdown failed:', result.reason);
484
+ }
552
485
  closeDb();
553
486
  console.log('[kobo] Database closed');
554
487
  }
@@ -1,18 +1,17 @@
1
1
  import { Hono } from 'hono';
2
2
  import { getDb } from '../db/index.js';
3
3
  import { listOrphanWorktrees } from '../services/worktree-service.js';
4
- import { listBranches, listRemoteBranches, listWorktreeFiles } from '../utils/git-ops.js';
4
+ import { listBranchesAsync, listRemoteBranchesAsync, listWorktreeFilesAsync } from '../utils/git-ops.js';
5
5
  /** Hono sub-router for git-related endpoints (branch listing). */
6
6
  const app = new Hono();
7
7
  // GET /api/git/branches?path=<repoPath> — list branches for a repo
8
- app.get('/branches', (c) => {
8
+ app.get('/branches', async (c) => {
9
9
  try {
10
10
  const repoPath = c.req.query('path');
11
11
  if (!repoPath) {
12
12
  return c.json({ error: 'Missing required query parameter: path' }, 400);
13
13
  }
14
- const local = listBranches(repoPath);
15
- const remote = listRemoteBranches(repoPath);
14
+ const [local, remote] = await Promise.all([listBranchesAsync(repoPath), listRemoteBranchesAsync(repoPath)]);
16
15
  return c.json({ local, remote });
17
16
  }
18
17
  catch (err) {
@@ -20,12 +19,12 @@ app.get('/branches', (c) => {
20
19
  return c.json({ error: message }, 500);
21
20
  }
22
21
  });
23
- /** Hard ceiling on the worktree listing. `listWorktreeFiles` already truncates
22
+ /** Hard ceiling on the worktree listing. `listWorktreeFilesAsync` already truncates
24
23
  * at this value; exposing it lets a caller ask for LESS, never for more. */
25
24
  const MAX_WORKTREE_FILES = 5000;
26
25
  // GET /api/git/files?path=<worktreePath>&limit=<n> — list a worktree's files
27
26
  // (tracked + untracked-but-not-ignored) for the chat's `@file` autocomplete.
28
- app.get('/files', (c) => {
27
+ app.get('/files', async (c) => {
29
28
  try {
30
29
  const repoPath = c.req.query('path');
31
30
  if (!repoPath) {
@@ -33,7 +32,7 @@ app.get('/files', (c) => {
33
32
  }
34
33
  const requested = Number.parseInt(c.req.query('limit') ?? '', 10);
35
34
  const limit = Number.isFinite(requested) && requested > 0 ? Math.min(requested, MAX_WORKTREE_FILES) : MAX_WORKTREE_FILES;
36
- return c.json({ files: listWorktreeFiles(repoPath, limit) });
35
+ return c.json({ files: await listWorktreeFilesAsync(repoPath, limit) });
37
36
  }
38
37
  catch (err) {
39
38
  const message = err instanceof Error ? err.message : String(err);
@@ -11,8 +11,8 @@ import { DEFAULT_CHANGE_SOURCE_BRANCH_SCRIPT } from '../services/settings-defaul
11
11
  import * as settingsService from '../services/settings-service.js';
12
12
  import { DEFAULT_CI_FIX_PROMPT_TEMPLATE, DEFAULT_FINALIZATION_PROMPT, DEFAULT_GIT_CONVENTIONS, DEFAULT_PR_PROMPT_TEMPLATE, } from '../services/settings-service.js';
13
13
  import { getSuitePrompts } from '../services/skill-suite-prompts.js';
14
- import { listTemplates, replaceAllTemplates } from '../services/templates-service.js';
15
- import { countPrunableWsEvents } from '../services/ws-events-retention-service.js';
14
+ import { listTemplates, replaceAllTemplates, validateTemplatesForReplacement } from '../services/templates-service.js';
15
+ import { previewWsEventsRetention } from '../services/ws-events-retention-worker-service.js';
16
16
  /** Hono sub-router for global and per-project settings CRUD. */
17
17
  const app = new Hono();
18
18
  // GET /api/settings — return full settings, credentials masked
@@ -67,17 +67,15 @@ app.get('/defaults', (c) => {
67
67
  // the confirmation dialog: enabling retention on a year-old database destroys
68
68
  // months of conversation in one go, and the user is entitled to the number
69
69
  // before agreeing to it.
70
- app.get('/ws-events-retention-preview', (c) => {
70
+ app.get('/ws-events-retention-preview', async (c) => {
71
71
  try {
72
72
  const days = Number.parseInt(c.req.query('days') ?? '', 10);
73
73
  const keep = Number.parseInt(c.req.query('keep') ?? '', 10);
74
74
  if (!Number.isInteger(days) || days < 0 || !Number.isInteger(keep) || keep < 0) {
75
75
  return c.json({ error: 'days and keep must be non-negative integers' }, 400);
76
76
  }
77
- const db = getDb();
78
- const total = db.prepare('SELECT COUNT(*) AS c FROM ws_events').get().c;
79
- const deletable = countPrunableWsEvents(db, { retentionDays: days, keepPerWorkspace: keep });
80
- return c.json({ deletable, total });
77
+ const preview = await previewWsEventsRetention({ retentionDays: days, keepPerWorkspace: keep });
78
+ return c.json(preview);
81
79
  }
82
80
  catch (err) {
83
81
  const message = err instanceof Error ? err.message : String(err);
@@ -252,11 +250,23 @@ app.get('/export', (c) => {
252
250
  app.post('/import', async (c) => {
253
251
  try {
254
252
  const body = (await c.req.json());
255
- // Validate settings first — throws on malformed payload before we touch disk.
253
+ // Validate templates before settings import writes anything. An invalid
254
+ // row must not leave an otherwise rejected bundle partially applied.
255
+ let validatedTemplates;
256
+ if (body?.templates !== undefined) {
257
+ try {
258
+ validatedTemplates = validateTemplatesForReplacement(body.templates);
259
+ }
260
+ catch (error) {
261
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
262
+ }
263
+ }
264
+ // Settings validates its section before writing. Each file is replaced
265
+ // atomically; this is not a transaction spanning the two files.
256
266
  settingsService.importConfigBundle(body);
257
- if (body.templates !== undefined) {
258
- // Accept missing templates (backward-compatible). Otherwise validate and replace.
259
- replaceAllTemplates(body.templates);
267
+ if (validatedTemplates !== undefined) {
268
+ // Accept missing templates for backward-compatible bundles.
269
+ replaceAllTemplates(validatedTemplates);
260
270
  }
261
271
  return c.json({ ok: true });
262
272
  }
@@ -6,7 +6,7 @@ import { getSearchIndexStatus, searchEvents } from '../services/search-service.j
6
6
  import { createSessionHandoff, decideSessionHandoff, getCurrentSessionHandoff, SessionHandoffError, submitSessionHandoff, waitForSessionHandoff, } from '../services/session-handoff-service.js';
7
7
  import { AgentStopError, assertAgentStopped } from '../utils/agent-stop-result.js';
8
8
  import { withGitRepoLock } from '../utils/git-repo-lock.js';
9
- import { isWorkspaceLifecycleBusy, workspaceLifecycleReason } from '../utils/workspace-lifecycle-guard.js';
9
+ import { assertWorkspaceLifecycleAvailable, isWorkspaceLifecycleBusy, workspaceLifecycleReason, } from '../utils/workspace-lifecycle-guard.js';
10
10
  import autoLoopMessagesRoutes from './auto-loop-messages.js';
11
11
  const execFileAsync = promisify(execFileCb);
12
12
  import fs from 'node:fs';
@@ -73,7 +73,11 @@ function realPathOrResolved(p) {
73
73
  }
74
74
  }
75
75
  function workspaceErrorStatus(err) {
76
- return err instanceof AgentStopError || err instanceof WorkspaceLifecycleBusyError ? 409 : 500;
76
+ return err instanceof AgentStopError ||
77
+ err instanceof WorkspaceLifecycleBusyError ||
78
+ err instanceof terminalService.TerminalStopError
79
+ ? 409
80
+ : 500;
77
81
  }
78
82
  /** Hono sub-router for workspace CRUD, tasks, agents, Git and PR creation. */
79
83
  const app = new Hono();
@@ -381,7 +385,7 @@ app.post('/:id/engine-handoff-preview', migrationGuard, async (c) => {
381
385
  const engine = listEngines().find((item) => item.id === body.engine);
382
386
  if (!engine)
383
387
  return c.json({ error: `Unknown engine '${body.engine ?? ''}'` }, 400);
384
- return c.json({ handoff: buildEngineHandoff(workspace, workspace.engine, engine.id) });
388
+ return c.json({ handoff: await buildEngineHandoff(workspace, workspace.engine, engine.id) });
385
389
  }
386
390
  catch (err) {
387
391
  return c.json({ error: err instanceof Error ? err.message : 'Failed to build engine handoff' }, 500);
@@ -414,7 +418,7 @@ app.post('/:id/switch-engine', migrationGuard, async (c) => {
414
418
  const effort = typeof body.reasoningEffort === 'string' ? body.reasoningEffort : 'auto';
415
419
  const handoff = typeof body.handoff === 'string' && body.handoff.trim()
416
420
  ? body.handoff.trim().slice(0, 30_000)
417
- : buildEngineHandoff(workspace, workspace.engine, body.engine);
421
+ : await buildEngineHandoff(workspace, workspace.engine, body.engine);
418
422
  if (workspaceService.getWorkspace(workspace.id)?.status === 'compacting') {
419
423
  return c.json({
420
424
  code: 'compacting',
@@ -1914,13 +1918,19 @@ app.post('/:id/pending-wakeup', async (c) => {
1914
1918
  if (rawMode !== 'fresh' && rawMode !== 'resume') {
1915
1919
  return c.json({ error: "mode must be 'fresh' or 'resume'" }, 400);
1916
1920
  }
1921
+ const workspace = workspaceService.getWorkspace(id);
1922
+ if (!workspace)
1923
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
1924
+ if (workspace.archivedAt || workspace.worktreePurgedAt) {
1925
+ return c.json({ error: 'Restore the workspace before scheduling a wakeup' }, 409);
1926
+ }
1927
+ assertWorkspaceLifecycleAvailable(id);
1917
1928
  // 'resume' pins the active session so the wakeup resumes that conversation;
1918
1929
  // 'fresh' (default) — or 'resume' with no active session — leaves it unpinned
1919
1930
  // so the wakeup fires a brand-new session running `prompt`. This is what makes
1920
1931
  // manual scheduling on an idle workspace possible (no more hard 409).
1921
1932
  const agentSessionId = rawMode === 'resume' ? (agentManager.getActiveSessionId(id) ?? undefined) : undefined;
1922
- wakeupService.schedule(id, delaySeconds, prompt, reason, agentSessionId);
1923
- const pending = wakeupService.getPending(id);
1933
+ const pending = wakeupService.schedule(id, delaySeconds, prompt, reason, agentSessionId);
1924
1934
  return c.json({ ok: true, pending });
1925
1935
  }
1926
1936
  catch (err) {
@@ -3018,12 +3028,7 @@ app.post('/:id/archive', migrationGuard, async (c) => {
3018
3028
  console.error(`[workspaces] stopDevServer during archive failed: ${message}`);
3019
3029
  throw err;
3020
3030
  }
3021
- try {
3022
- terminalService.destroyTerminal(id);
3023
- }
3024
- catch {
3025
- // Terminal may not exist — ignore
3026
- }
3031
+ await terminalService.destroyTerminal(id);
3027
3032
  const updated = workspaceService.archiveWorkspace(id);
3028
3033
  wsService.emitEphemeral(id, 'workspace:archived', { workspace: updated });
3029
3034
  // Run the project's archive script (best-effort — never blocks the archive).
@@ -3140,12 +3145,7 @@ async function deleteWorkspaceWithSideEffectsUnlocked(workspace, opts) {
3140
3145
  console.error(`[workspaces] stopDevServer during delete failed for '${workspace.name}':`, err);
3141
3146
  throw err;
3142
3147
  }
3143
- try {
3144
- terminalService.destroyTerminal(workspace.id);
3145
- }
3146
- catch {
3147
- // Terminal may not exist — ignore
3148
- }
3148
+ await terminalService.destroyTerminal(workspace.id);
3149
3149
  // Collected best-effort warnings: the DB deletion always proceeds, but
3150
3150
  // side-effects (worktree, local/remote branches) can fail independently.
3151
3151
  // We surface a user-friendly message per failure so the UI can show a
@@ -3178,7 +3178,7 @@ async function deleteWorkspaceWithSideEffectsUnlocked(workspace, opts) {
3178
3178
  // Delete local branch if requested
3179
3179
  if (opts.deleteLocalBranch) {
3180
3180
  try {
3181
- gitOps.deleteLocalBranch(workspace.projectPath, workspace.workingBranch);
3181
+ await gitOps.deleteLocalBranchAsync(workspace.projectPath, workspace.workingBranch);
3182
3182
  }
3183
3183
  catch (err) {
3184
3184
  const message = err instanceof Error ? err.message : String(err);
@@ -3191,7 +3191,7 @@ async function deleteWorkspaceWithSideEffectsUnlocked(workspace, opts) {
3191
3191
  // Delete remote branch if requested
3192
3192
  if (opts.deleteRemoteBranch) {
3193
3193
  try {
3194
- gitOps.deleteRemoteBranch(workspace.projectPath, workspace.workingBranch);
3194
+ await gitOps.deleteRemoteBranchAsync(workspace.projectPath, workspace.workingBranch);
3195
3195
  }
3196
3196
  catch (err) {
3197
3197
  const message = err instanceof Error ? err.message : String(err);
@@ -3405,7 +3405,7 @@ app.get('/:id/diff', async (c) => {
3405
3405
  if (!from || !to) {
3406
3406
  return c.json({ error: 'mode=commits requires from and to query params' }, 400);
3407
3407
  }
3408
- if (!gitOps.commitExists(worktreePath, to)) {
3408
+ if (!(await gitOps.commitExistsAsync(worktreePath, to))) {
3409
3409
  return c.json({ error: `Invalid commit ref '${to}'` }, 400);
3410
3410
  }
3411
3411
  // `to` is strict (400 above). `from` is intentionally lenient: any ref it
@@ -3413,8 +3413,8 @@ app.get('/:id/diff', async (c) => {
3413
3413
  // only unresolved `from` the UI ever sends is a root commit's `<sha>^`
3414
3414
  // (single-commit diff of the first commit); the compare dialog otherwise
3415
3415
  // only offers refs that resolve.
3416
- const fromRef = gitOps.commitExists(worktreePath, from) ? from : gitOps.EMPTY_TREE_SHA;
3417
- const files = gitOps.getChangedFilesBetween(worktreePath, fromRef, to);
3416
+ const fromRef = (await gitOps.commitExistsAsync(worktreePath, from)) ? from : gitOps.EMPTY_TREE_SHA;
3417
+ const files = await gitOps.getChangedFilesBetweenAsync(worktreePath, fromRef, to);
3418
3418
  c.header('Cache-Control', 'no-store');
3419
3419
  return c.json({ files, mode: 'commits', from: fromRef, to });
3420
3420
  }
@@ -3426,8 +3426,8 @@ app.get('/:id/diff', async (c) => {
3426
3426
  await gitOps.fetchSourceBranchAsync(worktreePath, workspace.sourceBranch);
3427
3427
  }
3428
3428
  const files = mode === 'unpushed'
3429
- ? gitOps.getUnpushedChangedFiles(worktreePath, workspace.workingBranch)
3430
- : gitOps.getChangedFiles(worktreePath, workspace.sourceBranch, includeUntracked);
3429
+ ? await gitOps.getUnpushedChangedFilesAsync(worktreePath, workspace.workingBranch)
3430
+ : await gitOps.getChangedFilesAsync(worktreePath, workspace.sourceBranch, includeUntracked);
3431
3431
  c.header('Cache-Control', 'no-store');
3432
3432
  return c.json({
3433
3433
  files,
@@ -3446,7 +3446,7 @@ app.get('/:id/diff', async (c) => {
3446
3446
  // - `branch` → sourceBranch
3447
3447
  // - `unpushed` → origin/<workingBranch>
3448
3448
  // `modified` is always the current worktree content.
3449
- app.get('/:id/diff-file', (c) => {
3449
+ app.get('/:id/diff-file', async (c) => {
3450
3450
  try {
3451
3451
  const id = c.req.param('id');
3452
3452
  const filePath = c.req.query('path');
@@ -3472,14 +3472,16 @@ app.get('/:id/diff-file', (c) => {
3472
3472
  if (!from || !to) {
3473
3473
  return c.json({ error: 'mode=commits requires from and to query params' }, 400);
3474
3474
  }
3475
- if (!gitOps.commitExists(worktreePath, to)) {
3475
+ if (!(await gitOps.commitExistsAsync(worktreePath, to))) {
3476
3476
  return c.json({ error: `Invalid commit ref '${to}'` }, 400);
3477
3477
  }
3478
3478
  // `from` is lenient (empty-tree fallback) for a root commit's `<sha>^` —
3479
3479
  // see the matching note in the `/diff` commits branch above.
3480
- const fromRef = gitOps.commitExists(worktreePath, from) ? from : gitOps.EMPTY_TREE_SHA;
3481
- const original = gitOps.getFileAtRef(worktreePath, fromRef, filePath);
3482
- const modified = gitOps.getFileAtRef(worktreePath, to, filePath);
3480
+ const fromRef = (await gitOps.commitExistsAsync(worktreePath, from)) ? from : gitOps.EMPTY_TREE_SHA;
3481
+ const [original, modified] = await Promise.all([
3482
+ gitOps.getFileAtRefAsync(worktreePath, fromRef, filePath),
3483
+ gitOps.getFileAtRefAsync(worktreePath, to, filePath),
3484
+ ]);
3483
3485
  c.header('Cache-Control', 'no-store');
3484
3486
  return c.json({
3485
3487
  original: original ?? '',
@@ -3491,7 +3493,7 @@ app.get('/:id/diff-file', (c) => {
3491
3493
  });
3492
3494
  }
3493
3495
  const baseRef = mode === 'unpushed' ? `origin/${workspace.workingBranch}` : workspace.sourceBranch;
3494
- const original = gitOps.getFileAtRef(worktreePath, baseRef, filePath);
3496
+ const original = await gitOps.getFileAtRefAsync(worktreePath, baseRef, filePath);
3495
3497
  const modified = gitOps.getFileContent(worktreePath, filePath);
3496
3498
  c.header('Cache-Control', 'no-store');
3497
3499
  return c.json({
@@ -3555,7 +3557,7 @@ app.post('/:id/rollback-file', async (c) => {
3555
3557
  // GET /api/workspaces/:id/branch-divergence?limit=50
3556
3558
  // Returns commits on the working branch ahead of `origin/<sourceBranch>`
3557
3559
  // (`ahead`) and commits on `origin/<sourceBranch>` not yet on the working
3558
- // branch (`behind`). Refreshes `origin/<sourceBranch>` synchronously so the
3560
+ // branch (`behind`). Awaits refreshing `origin/<sourceBranch>` so the
3559
3561
  // counts and lists are never computed against a stale local source ref.
3560
3562
  app.get('/:id/branch-divergence', async (c) => {
3561
3563
  try {
@@ -3569,8 +3571,8 @@ app.get('/:id/branch-divergence', async (c) => {
3569
3571
  const limit = Math.min(Math.max(1, Number.isNaN(parsed) ? 50 : parsed), 200);
3570
3572
  const worktreePath = workspace.worktreePath;
3571
3573
  await gitOps.fetchSourceBranchAsync(worktreePath, workspace.sourceBranch);
3572
- const ahead = gitOps.listBranchCommits(worktreePath, workspace.sourceBranch, workspace.workingBranch, limit);
3573
- const behind = gitOps.listCommitsBehind(worktreePath, workspace.sourceBranch, workspace.workingBranch, limit);
3574
+ const ahead = await gitOps.listBranchCommitsAsync(worktreePath, workspace.sourceBranch, workspace.workingBranch, limit);
3575
+ const behind = await gitOps.listCommitsBehindAsync(worktreePath, workspace.sourceBranch, workspace.workingBranch, limit);
3574
3576
  c.header('Cache-Control', 'no-store');
3575
3577
  return c.json({
3576
3578
  ahead,
@@ -3586,7 +3588,7 @@ app.get('/:id/branch-divergence', async (c) => {
3586
3588
  });
3587
3589
  // GET /api/workspaces/:id/commits?limit=50 — list commits between
3588
3590
  // `origin/<sourceBranch>` and HEAD, each tagged with whether it's already
3589
- // pushed to origin/<branch>. Refreshes `origin/<sourceBranch>` synchronously
3591
+ // pushed to origin/<branch>. Awaits refreshing `origin/<sourceBranch>`
3590
3592
  // so the list is not computed against a stale local source ref.
3591
3593
  app.get('/:id/commits', async (c) => {
3592
3594
  try {
@@ -3599,7 +3601,7 @@ app.get('/:id/commits', async (c) => {
3599
3601
  const limit = Math.min(Math.max(1, parseInt(limitRaw ?? '50', 10) || 50), 200);
3600
3602
  const worktreePath = workspace.worktreePath;
3601
3603
  await gitOps.fetchSourceBranchAsync(worktreePath, workspace.sourceBranch);
3602
- const commits = gitOps.listBranchCommits(worktreePath, workspace.sourceBranch, workspace.workingBranch, limit);
3604
+ const commits = await gitOps.listBranchCommitsAsync(worktreePath, workspace.sourceBranch, workspace.workingBranch, limit);
3603
3605
  c.header('Cache-Control', 'no-store');
3604
3606
  return c.json({ commits, sourceBranch: workspace.sourceBranch, workingBranch: workspace.workingBranch });
3605
3607
  }
@@ -3609,13 +3611,13 @@ app.get('/:id/commits', async (c) => {
3609
3611
  }
3610
3612
  });
3611
3613
  // GET /:id/working-tree-files — list uncommitted working-tree files (read-only)
3612
- app.get('/:id/working-tree-files', (c) => {
3614
+ app.get('/:id/working-tree-files', async (c) => {
3613
3615
  try {
3614
3616
  const id = c.req.param('id');
3615
3617
  const workspace = workspaceService.getWorkspace(id);
3616
3618
  if (!workspace)
3617
3619
  return c.json({ error: `Workspace '${id}' not found` }, 404);
3618
- const files = gitOps.getWorkingTreeFiles(workspace.worktreePath);
3620
+ const files = await gitOps.getWorkingTreeFilesAsync(workspace.worktreePath);
3619
3621
  return c.json({ files });
3620
3622
  }
3621
3623
  catch (err) {
@@ -3954,7 +3956,7 @@ app.post('/:id/git/commit-with-agent', migrationGuard, async (c) => {
3954
3956
  const workspace = workspaceService.getWorkspace(id);
3955
3957
  if (!workspace)
3956
3958
  return c.json({ error: `Workspace '${id}' not found` }, 404);
3957
- const workingTree = gitOps.getWorkingTreeStatus(workspace.worktreePath);
3959
+ const workingTree = await gitOps.getWorkingTreeStatusAsync(workspace.worktreePath);
3958
3960
  if (workingTree.staged + workingTree.modified + workingTree.untracked === 0) {
3959
3961
  return c.json({ error: 'No uncommitted changes to commit' }, 400);
3960
3962
  }
@@ -4372,8 +4374,8 @@ app.post('/:id/open-pr', async (c) => {
4372
4374
  return c.json({ ok: true, prNumber, prUrl, messageSent: false });
4373
4375
  }
4374
4376
  // Build context and render the PR prompt template
4375
- const commits = gitOps.getCommitsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
4376
- const diffStats = gitOps.getDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
4377
+ const commits = await gitOps.getCommitsBetweenAsync(worktreePath, workspace.sourceBranch, workspace.workingBranch);
4378
+ const diffStats = await gitOps.getDiffStatsBetweenAsync(worktreePath, workspace.sourceBranch, workspace.workingBranch);
4377
4379
  const tasks = workspaceService.listTasks(workspace.id);
4378
4380
  const rendered = renderPrTemplate(effective.prPromptTemplate, {
4379
4381
  workspace,
@@ -4621,4 +4623,43 @@ app.post('/:id/interrupt', migrationGuard, async (c) => {
4621
4623
  return c.json({ error: message }, workspaceErrorStatus(err));
4622
4624
  }
4623
4625
  });
4626
+ // POST /api/workspaces/:id/subagents/stop - stop one running sub-agent (body
4627
+ // `{ id }`, its canonical card id) or all of them, without interrupting the turn
4628
+ app.post('/:id/subagents/stop', migrationGuard, async (c) => {
4629
+ try {
4630
+ const id = c.req.param('id');
4631
+ const workspace = workspaceService.getWorkspace(id);
4632
+ if (!workspace) {
4633
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
4634
+ }
4635
+ const rawBody = await c.req.text();
4636
+ let body = {};
4637
+ if (rawBody.trim().length > 0) {
4638
+ let parsed;
4639
+ try {
4640
+ parsed = JSON.parse(rawBody);
4641
+ }
4642
+ catch {
4643
+ return c.json({ error: 'Request body must be valid JSON' }, 400);
4644
+ }
4645
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
4646
+ return c.json({ error: 'Request body must be a JSON object' }, 400);
4647
+ }
4648
+ body = parsed;
4649
+ }
4650
+ if (body.id !== undefined && (typeof body.id !== 'string' || body.id.trim().length === 0)) {
4651
+ return c.json({ error: 'id must be a non-empty string when provided' }, 400);
4652
+ }
4653
+ const stopped = agentManager.stopSubagents(id, body.id);
4654
+ return c.json({ stopped });
4655
+ }
4656
+ catch (err) {
4657
+ const message = err instanceof Error ? err.message : String(err);
4658
+ if (err instanceof agentManager.StopSubagentsError) {
4659
+ const status = err.code === 'subagent_not_running' ? 404 : 409;
4660
+ return c.json({ error: message, code: err.code }, status);
4661
+ }
4662
+ return c.json({ error: message }, workspaceErrorStatus(err));
4663
+ }
4664
+ });
4624
4665
  export default app;