@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
@@ -124,9 +124,52 @@ export function createClaudeCodeEngine() {
124
124
  async start(options, onEvent) {
125
125
  const abortController = new AbortController();
126
126
  const mapperState = createMapperState();
127
- // UI/tool ids can differ from the SDK task id. Key by the stable UI id
128
- // so a terminal notification that omits task_id still clears the task.
129
- const activeSubagentTaskIds = new Map();
127
+ // Active sub-agents keyed by their canonical id (SDK task id when known,
128
+ // else the tool call id), which is also the id passed to `q.stopTask`.
129
+ // One task can surface under both ids (relaunch, kill/restart), so
130
+ // running and terminal events must resolve to the same entry.
131
+ const activeSubagentTaskIds = new Set();
132
+ // tool call id -> SDK task id, learned from events carrying both.
133
+ const subagentTaskIdByToolCallId = new Map();
134
+ // Ambient tasks (SDK `ambient`, e.g. Monitor watchers) are not activity:
135
+ // they never enter `activeSubagentTaskIds`, so they never block
136
+ // turn:completed, keep the input open or arm the stall watchdog. They are
137
+ // tracked apart only so a user interrupt still stops them.
138
+ const ambientSubagentTaskIds = new Set();
139
+ // Canonical ids whose last lifecycle event was terminal: a late
140
+ // `progress` for them is ignored, only a `started` relaunch revives them.
141
+ const terminalSubagentTaskIds = new Set();
142
+ const trackSubagentProgress = (ev) => {
143
+ if (ev.taskId) {
144
+ subagentTaskIdByToolCallId.set(ev.toolCallId, ev.taskId);
145
+ // Merge an entry first tracked under its tool call id alone.
146
+ if (ev.toolCallId !== ev.taskId) {
147
+ activeSubagentTaskIds.delete(ev.toolCallId);
148
+ ambientSubagentTaskIds.delete(ev.toolCallId);
149
+ if (terminalSubagentTaskIds.delete(ev.toolCallId))
150
+ terminalSubagentTaskIds.add(ev.taskId);
151
+ }
152
+ }
153
+ const taskId = ev.taskId ?? subagentTaskIdByToolCallId.get(ev.toolCallId);
154
+ const key = taskId ?? ev.toolCallId;
155
+ if (ev.status !== 'running') {
156
+ activeSubagentTaskIds.delete(key);
157
+ ambientSubagentTaskIds.delete(key);
158
+ terminalSubagentTaskIds.add(key);
159
+ return;
160
+ }
161
+ if (ev.phase === 'progress' && terminalSubagentTaskIds.has(key))
162
+ return;
163
+ terminalSubagentTaskIds.delete(key);
164
+ if (ev.ambient) {
165
+ activeSubagentTaskIds.delete(key);
166
+ ambientSubagentTaskIds.add(key);
167
+ }
168
+ else {
169
+ ambientSubagentTaskIds.delete(key);
170
+ activeSubagentTaskIds.add(key);
171
+ }
172
+ };
130
173
  // Foreground tool calls currently awaiting their tool_result. Cleared on
131
174
  // every `result` message: a turn cannot end with a tool still in flight.
132
175
  const pendingToolCallIds = new Set();
@@ -260,6 +303,17 @@ export function createClaudeCodeEngine() {
260
303
  sdkOptions.pathToClaudeCodeExecutable = explicitBinary;
261
304
  const inputStream = new ClaudeInputStream(effectivePrompt);
262
305
  const q = query({ prompt: inputStream, options: sdkOptions });
306
+ // Best-effort SDK stop of one background task; failures are ignored.
307
+ const requestStopTask = (taskId) => {
308
+ try {
309
+ void q.stopTask(taskId).catch(() => {
310
+ /* best-effort */
311
+ });
312
+ }
313
+ catch {
314
+ /* best-effort */
315
+ }
316
+ };
263
317
  let resolveReady;
264
318
  let rejectReady;
265
319
  const ready = new Promise((resolve, reject) => {
@@ -507,15 +561,14 @@ export function createClaudeCodeEngine() {
507
561
  clearResultDrainWatchdog();
508
562
  const events = mapSdkMessage(msg, mapperState);
509
563
  for (const ev of events) {
510
- if (ev.kind !== 'subagent:progress')
511
- continue;
512
- if (ev.status === 'running')
513
- activeSubagentTaskIds.set(ev.toolCallId, ev.taskId ?? ev.toolCallId);
514
- else
515
- activeSubagentTaskIds.delete(ev.toolCallId);
564
+ if (ev.kind === 'subagent:progress')
565
+ trackSubagentProgress(ev);
516
566
  }
517
567
  if (activeSubagentTaskIds.size > 0 &&
518
- (events.some((ev) => ev.kind === 'subagent:progress' ||
568
+ (events.some((ev) =>
569
+ // An ambient watcher's progress is not proof that a real
570
+ // sub-agent advanced: it must not reset the stall bound.
571
+ (ev.kind === 'subagent:progress' && !ev.ambient) ||
519
572
  ev.kind === 'message:text' ||
520
573
  ev.kind === 'message:thinking' ||
521
574
  ev.kind === 'tool:call' ||
@@ -689,6 +742,28 @@ export function createClaudeCodeEngine() {
689
742
  armSubagentStallWatchdog();
690
743
  return true;
691
744
  },
745
+ stopSubagents(ids) {
746
+ const isRunning = (taskId) => activeSubagentTaskIds.has(taskId) || ambientSubagentTaskIds.has(taskId);
747
+ const targets = new Set();
748
+ if (ids === undefined) {
749
+ for (const taskId of [...activeSubagentTaskIds, ...ambientSubagentTaskIds])
750
+ targets.add(taskId);
751
+ }
752
+ else {
753
+ for (const id of ids) {
754
+ // A card id is the SDK task id, or the tool call id when the
755
+ // task id was not known yet: resolve both to the tracked entry.
756
+ const taskId = isRunning(id) ? id : subagentTaskIdByToolCallId.get(id);
757
+ if (taskId && isRunning(taskId))
758
+ targets.add(taskId);
759
+ }
760
+ }
761
+ // The main turn keeps running: only the targeted tasks are stopped.
762
+ // Their terminal notification clears the tracking as usual.
763
+ for (const taskId of targets)
764
+ requestStopTask(taskId);
765
+ return targets.size;
766
+ },
692
767
  interrupt() {
693
768
  userInterrupted = true;
694
769
  // The SDK ends an interrupted run by emitting a `result` with
@@ -699,16 +774,8 @@ export function createClaudeCodeEngine() {
699
774
  // background subagent task keeps running unless told to stop via
700
775
  // its own API, which would otherwise leave the Stop button
701
776
  // appearing to do nothing for up to SUBAGENT_STALL_TIMEOUT_MS.
702
- for (const taskId of activeSubagentTaskIds.values()) {
703
- try {
704
- void q.stopTask(taskId).catch(() => {
705
- /* best-effort */
706
- });
707
- }
708
- catch {
709
- /* best-effort */
710
- }
711
- }
777
+ for (const taskId of [...activeSubagentTaskIds, ...ambientSubagentTaskIds])
778
+ requestStopTask(taskId);
712
779
  const qq = q;
713
780
  if (typeof qq.interrupt === 'function') {
714
781
  try {
@@ -77,6 +77,21 @@ export const QUOTA_PATTERN = /\b429\b|out of extra usage|rate[_ ]limit|usage lim
77
77
  * of a previous attempt, a sub-agent failure, or application HTTP handling.
78
78
  */
79
79
  const ASSISTANT_QUOTA_NOTICE = /^(?:you(?:'|’)re out of extra usage|you(?:'|’)ve hit your (?:session|weekly|usage) limit|claude ai usage limit reached|usage limit reached|quota exceeded|rate limit exceeded)(?:[.!]?(?:[ \t]*[·—-]?[ \t]*resets?\b[^\r\n]*)?)?[.!]?$/i;
80
+ /** SDKTaskNotificationMessage.status -> subagent:progress terminal status. */
81
+ const TASK_NOTIFICATION_STATUS = {
82
+ completed: 'done',
83
+ failed: 'failed',
84
+ stopped: 'stopped',
85
+ };
86
+ /**
87
+ * SDKTaskUpdatedMessage.patch.status values that end a task. `pending`,
88
+ * `running` and `paused` are not lifecycle edges and are ignored.
89
+ */
90
+ const TASK_UPDATED_TERMINAL_STATUS = {
91
+ completed: 'done',
92
+ failed: 'failed',
93
+ killed: 'stopped',
94
+ };
80
95
  export function createMapperState() {
81
96
  return {
82
97
  sessionStartedEmitted: false,
@@ -84,6 +99,8 @@ export function createMapperState() {
84
99
  sawErrorResult: false,
85
100
  quotaErrorEmitted: false,
86
101
  userInterrupted: false,
102
+ taskToolUseIds: new Map(),
103
+ taskFlags: new Map(),
87
104
  };
88
105
  }
89
106
  /** Known SDK `result` subtypes that indicate the run failed. */
@@ -191,30 +208,79 @@ export function mapSdkMessage(msg, state) {
191
208
  events.push({ kind: 'session:compacting', active: sdkStatus === 'compacting' });
192
209
  return events;
193
210
  }
194
- if (subtype === 'task_started' || subtype === 'task_progress' || subtype === 'task_notification') {
211
+ if (subtype === 'task_started' ||
212
+ subtype === 'task_progress' ||
213
+ subtype === 'task_notification' ||
214
+ subtype === 'task_updated') {
195
215
  const taskId = typeof parsed.task_id === 'string' ? parsed.task_id : undefined;
196
- const toolCallId = typeof parsed.tool_use_id === 'string' ? parsed.tool_use_id : taskId;
197
- if (toolCallId) {
198
- const usage = parsed.usage;
199
- // Unlike task_started/task_progress (in-flight signals), task_notification
200
- // is only ever emitted once a task settles — its `status` value is a
201
- // reason, not a liveness signal. Treat every task_notification as
202
- // terminal instead of matching against a status whitelist, so an SDK
203
- // status value added later doesn't leave the subagent stuck 'running'.
204
- const isDone = subtype === 'task_notification';
205
- events.push({
206
- kind: 'subagent:progress',
207
- toolCallId,
208
- ...(taskId ? { taskId } : {}),
209
- status: isDone ? 'done' : 'running',
210
- description: typeof parsed.description === 'string' ? parsed.description : undefined,
211
- taskType: typeof parsed.task_type === 'string' ? parsed.task_type : undefined,
212
- lastToolName: typeof parsed.last_tool_name === 'string' ? parsed.last_tool_name : undefined,
213
- totalTokens: typeof usage?.total_tokens === 'number' ? usage.total_tokens : undefined,
214
- toolUses: typeof usage?.tool_uses === 'number' ? usage.tool_uses : undefined,
215
- durationMs: typeof usage?.duration_ms === 'number' ? usage.duration_ms : undefined,
216
- });
216
+ const toolUseId = typeof parsed.tool_use_id === 'string' ? parsed.tool_use_id : undefined;
217
+ if (taskId && toolUseId)
218
+ state.taskToolUseIds.set(taskId, toolUseId);
219
+ const toolCallId = toolUseId ?? (taskId ? (state.taskToolUseIds.get(taskId) ?? taskId) : undefined);
220
+ if (!toolCallId)
221
+ return events;
222
+ let status;
223
+ let phase;
224
+ if (subtype === 'task_updated') {
225
+ // Only a terminal patch status is a lifecycle signal; other patches
226
+ // (pending/running/paused, description, is_backgrounded...) are ignored.
227
+ const patch = parsed.patch;
228
+ const patchStatus = TASK_UPDATED_TERMINAL_STATUS[String(patch?.status)];
229
+ if (!patchStatus)
230
+ return events;
231
+ status = patchStatus;
232
+ }
233
+ else if (subtype === 'task_notification') {
234
+ // task_notification is only ever emitted once a task settles: an
235
+ // unknown status value is still terminal (`done`), so a future SDK
236
+ // value never leaves the sub-agent stuck 'running'.
237
+ status = TASK_NOTIFICATION_STATUS[String(parsed.status)] ?? 'done';
238
+ }
239
+ else {
240
+ status = 'running';
241
+ phase = subtype === 'task_started' ? 'started' : 'progress';
242
+ }
243
+ if (taskId) {
244
+ const hasAmbient = typeof parsed.ambient === 'boolean';
245
+ const hasSkip = typeof parsed.skip_transcript === 'boolean';
246
+ if (subtype === 'task_started') {
247
+ // A (re)launch defines the task's classification afresh.
248
+ state.taskFlags.set(taskId, {
249
+ ...(hasAmbient ? { ambient: parsed.ambient } : {}),
250
+ ...(hasSkip ? { skipTranscript: parsed.skip_transcript } : {}),
251
+ });
252
+ }
253
+ else if (hasAmbient || hasSkip) {
254
+ state.taskFlags.set(taskId, {
255
+ ...state.taskFlags.get(taskId),
256
+ ...(hasAmbient ? { ambient: parsed.ambient } : {}),
257
+ ...(hasSkip ? { skipTranscript: parsed.skip_transcript } : {}),
258
+ });
259
+ }
217
260
  }
261
+ const flags = taskId ? state.taskFlags.get(taskId) : undefined;
262
+ const usage = parsed.usage;
263
+ const description = typeof parsed.description === 'string'
264
+ ? parsed.description
265
+ : subtype === 'task_updated' &&
266
+ typeof parsed.patch?.description === 'string'
267
+ ? parsed.patch.description
268
+ : undefined;
269
+ events.push({
270
+ kind: 'subagent:progress',
271
+ toolCallId,
272
+ ...(taskId ? { taskId } : {}),
273
+ status,
274
+ ...(phase ? { phase } : {}),
275
+ ...(flags?.ambient ? { ambient: true } : {}),
276
+ ...(flags?.skipTranscript ? { skipTranscript: true } : {}),
277
+ description,
278
+ taskType: typeof parsed.task_type === 'string' ? parsed.task_type : undefined,
279
+ lastToolName: typeof parsed.last_tool_name === 'string' ? parsed.last_tool_name : undefined,
280
+ totalTokens: typeof usage?.total_tokens === 'number' ? usage.total_tokens : undefined,
281
+ toolUses: typeof usage?.tool_uses === 'number' ? usage.tool_uses : undefined,
282
+ durationMs: typeof usage?.duration_ms === 'number' ? usage.duration_ms : undefined,
283
+ });
218
284
  return events;
219
285
  }
220
286
  if (subtype === 'init') {
@@ -14,6 +14,7 @@ const KOBO_MCP_BRIEF = [
14
14
  '• `kobo__set_workspace_status` when the mission is done or irrecoverably blocked. Never set `idle` while this session is active; use AskUserQuestion for clarification, or end the turn.',
15
15
  '• `kobo__schedule_wakeup` / `kobo__cancel_wakeup` to schedule (or cancel) a follow-up session — prefer these over the built-in `ScheduleWakeup` tool.',
16
16
  'Each tool carries its own "WHEN to use" guidance in its description — follow it.',
17
+ 'Only one agent edits the worktree at a time: do not run background subagents that modify files while you or another subagent modify files too. Parallel read-only subagents (search, analysis, review) are fine. Wait for a file-editing subagent to finish before editing or running tests yourself, and remember that changes appearing in the worktree may come from your own subagents.',
17
18
  ].join('\n');
18
19
  /**
19
20
  * Appended to the Claude Code preset system prompt so the rule is always in
@@ -340,13 +340,35 @@ export function createCodexEngine() {
340
340
  if (n.item.type === 'fileChange')
341
341
  fileChanges.set(itemKey(n.threadId, n.turnId, n.item.id), n.item);
342
342
  const events = mapTurnEvents(n, (state) => handleItemCompleted(n.item, state));
343
+ const collabItem = n.item.type === 'collabAgentToolCall' ? n.item : undefined;
344
+ const isActiveAgent = (status) => status === 'pendingInit' || status === 'running';
345
+ const progressIndex = events.findIndex((event) => event.kind === 'subagent:progress');
346
+ if (collabItem && progressIndex >= 0) {
347
+ // The card that first tracked a thread (normally its spawnAgent)
348
+ // owns it until it finishes. A later wait/sendInput/resumeAgent
349
+ // on an already-owned thread (e.g. a wait that timed out) must
350
+ // not steal it, or the owner card is orphaned in `running`; such
351
+ // a card is done as soon as its own call completes.
352
+ const progress = events[progressIndex];
353
+ const ownsActiveThread = Object.entries(collabItem.agentsStates).some(([threadId, agentState]) => {
354
+ if (!isActiveAgent(agentState.status))
355
+ return false;
356
+ const owner = activeSubagentThreads.get(threadId);
357
+ return !owner || owner.toolCallId === progress.toolCallId;
358
+ });
359
+ if (progress.status === 'running' && !ownsActiveThread) {
360
+ events[progressIndex] = { ...progress, status: 'done' };
361
+ }
362
+ }
343
363
  for (const ev of events)
344
364
  safeEmit(ev);
345
- if (n.item.type === 'collabAgentToolCall') {
346
- const progress = events.find((event) => event.kind === 'subagent:progress');
347
- for (const [threadId, agentState] of Object.entries(n.item.agentsStates)) {
348
- if (agentState.status === 'pendingInit' || agentState.status === 'running') {
349
- if (progress) {
365
+ if (collabItem) {
366
+ const progress = progressIndex >= 0
367
+ ? events[progressIndex]
368
+ : undefined;
369
+ for (const [threadId, agentState] of Object.entries(collabItem.agentsStates)) {
370
+ if (isActiveAgent(agentState.status)) {
371
+ if (progress && !activeSubagentThreads.has(threadId)) {
350
372
  activeSubagentThreads.set(threadId, {
351
373
  toolCallId: progress.toolCallId,
352
374
  description: progress.description,
@@ -11,6 +11,7 @@ const CODEX_KOBO_MCP_BRIEF = [
11
11
  '• `mcp__kobo-tasks__set_workspace_status` when the mission is done or irrecoverably blocked. Never set `idle` while this session is active; ask the user or end the turn instead.',
12
12
  '• `mcp__kobo-tasks__schedule_wakeup` / `mcp__kobo-tasks__cancel_wakeup` to schedule (or cancel) a follow-up session.',
13
13
  'Each tool carries its own "WHEN to use" guidance in its description — follow it.',
14
+ 'Only one agent edits the worktree at a time: do not run background subagents that modify files while you or another subagent modify files too. Parallel read-only subagents (search, analysis, review) are fine. Wait for a file-editing subagent to finish before editing or running tests yourself, and remember that changes appearing in the worktree may come from your own subagents.',
14
15
  ].join('\n');
15
16
  export function buildCodexOptions(input) {
16
17
  const isResume = input.resumeFromEngineSessionId !== undefined;
@@ -14,10 +14,11 @@ export function hasIntegrationMcpConfig(integration, selectedKey) {
14
14
  return Boolean(readClaudeMcpEntry((name) => key ? name === key : integration === 'notion' ? name === 'notion' : /sentry/i.test(name)));
15
15
  }
16
16
  /** Resolve at every launch/resume; never write credentials into a project file. */
17
- export function buildIntegrationMcpServers(settings, onUnavailable) {
17
+ export function buildIntegrationMcpServers(settings, engineId, onUnavailable) {
18
18
  const servers = [];
19
- // Fresh names avoid Codex recursively merging stale native config fields.
20
- const launch = nanoid(12);
19
+ // Codex needs fresh names to avoid recursively merging stale native config.
20
+ // Claude needs stable tool names to preserve its prompt cache across resumes.
21
+ const launch = engineId === 'codex' ? nanoid(12) : 'managed';
21
22
  for (const integration of ['notion', 'sentry']) {
22
23
  if (!settings[`${integration}Enabled`])
23
24
  continue;
@@ -34,6 +34,14 @@ export class InterruptAgentError extends Error {
34
34
  this.name = 'InterruptAgentError';
35
35
  }
36
36
  }
37
+ export class StopSubagentsError extends Error {
38
+ code;
39
+ constructor(message, code) {
40
+ super(message);
41
+ this.code = code;
42
+ this.name = 'StopSubagentsError';
43
+ }
44
+ }
37
45
  // ── State ──────────────────────────────────────────────────────────────────────
38
46
  /** Actual bound port of the running backend — set at startup via setBackendPort() */
39
47
  let backendPort = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000;
@@ -1377,7 +1385,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1377
1385
  // enforce read-only plan mode here, not only through its prompt.
1378
1386
  const readOnly = getReviewReturn(workspaceId, agentSessionId) !== null;
1379
1387
  const unavailableIntegrations = [];
1380
- const integrationServers = buildIntegrationMcpServers(getGlobalSettings(), (name) => unavailableIntegrations.push(name));
1388
+ const integrationServers = buildIntegrationMcpServers(getGlobalSettings(), engine.id, (name) => unavailableIntegrations.push(name));
1381
1389
  const options = {
1382
1390
  workspaceId,
1383
1391
  workingDir,
@@ -1494,6 +1502,33 @@ export function interruptAgent(workspaceId, options = {}) {
1494
1502
  autoLoopService.disable(workspaceId, 'user-action');
1495
1503
  }
1496
1504
  }
1505
+ /**
1506
+ * Stop one running sub-agent (by its canonical id: task id or tool call id)
1507
+ * or, without id, every running sub-agent. The main turn keeps running.
1508
+ * Returns the number of sub-agents a stop was requested for.
1509
+ */
1510
+ export function stopSubagents(workspaceId, id) {
1511
+ const ctrl = controllers.get(workspaceId);
1512
+ if (!ctrl) {
1513
+ throw new StopSubagentsError(`No agent running for workspace '${workspaceId}'`, 'no_agent_running');
1514
+ }
1515
+ let stopped;
1516
+ try {
1517
+ stopped = ctrl.stopSubagents(id === undefined ? undefined : [id]);
1518
+ }
1519
+ catch (err) {
1520
+ // A controller whose engine has not started yet has nothing to stop.
1521
+ const message = err instanceof Error ? err.message : String(err);
1522
+ throw new StopSubagentsError(`No agent running for workspace '${workspaceId}': ${message}`, 'no_agent_running');
1523
+ }
1524
+ if (stopped === undefined) {
1525
+ throw new StopSubagentsError(`The agent engine of workspace '${workspaceId}' cannot stop a single sub-agent`, 'unsupported');
1526
+ }
1527
+ if (id !== undefined && stopped === 0) {
1528
+ throw new StopSubagentsError(`Sub-agent '${id}' is not running for workspace '${workspaceId}'`, 'subagent_not_running');
1529
+ }
1530
+ return stopped;
1531
+ }
1497
1532
  /** Bound the caller's wait, retaining ownership until actual engine shutdown. */
1498
1533
  export const STOP_AGENT_TIMEOUT_MS = 15_000;
1499
1534
  async function stopController(workspaceId, ctrl, cause = 'user') {
@@ -121,6 +121,18 @@ export class SessionController {
121
121
  throw new Error('SessionController not started');
122
122
  this._engineProcess.interrupt();
123
123
  }
124
+ /**
125
+ * Stop running sub-agents without interrupting the main turn. Returns the
126
+ * number of sub-agents a stop was requested for, or `undefined` when the
127
+ * engine cannot stop a single sub-agent.
128
+ */
129
+ stopSubagents(ids) {
130
+ if (!this._engineProcess)
131
+ throw new Error('SessionController not started');
132
+ if (typeof this._engineProcess.stopSubagents !== 'function')
133
+ return undefined;
134
+ return this._engineProcess.stopSubagents(ids);
135
+ }
124
136
  stop() {
125
137
  if (!this._stopPromise) {
126
138
  this._stopPromise = this.stopEngine().catch((err) => {
@@ -1,41 +1,137 @@
1
- import { execFileSync } from 'node:child_process';
2
1
  import { getDb } from '../db/index.js';
3
- function runGit(worktreePath, args) {
2
+ import { getWorkingTreeDiffStatsAsync, getWorkingTreePorcelainAsync } from '../utils/git-ops.js';
3
+ const MAX_RECENT_MESSAGES = 6;
4
+ const MAX_HISTORY_ROWS = 10_000;
5
+ const MAX_MESSAGE_CHARS = 2_000;
6
+ const MAX_CONVERSATION_CHARS = 12_000;
7
+ const TRUNCATED = '[Earlier text omitted]\n';
8
+ /** Normalized text events are deltas; a non-streaming snapshot is complete. */
9
+ function readableFragment(row) {
4
10
  try {
5
- return execFileSync('git', args.split(' '), { cwd: worktreePath, encoding: 'utf8', timeout: 10_000 }).trim();
11
+ const payload = JSON.parse(row.payload);
12
+ if (!payload || typeof payload !== 'object')
13
+ return null;
14
+ if (row.type === 'user:message' &&
15
+ typeof payload.content === 'string' &&
16
+ (payload.sender === undefined || payload.sender === 'user')) {
17
+ return { key: `user:${row.id}`, role: 'user', text: payload.content, complete: true };
18
+ }
19
+ if (row.type === 'agent:event' && payload.kind === 'message:text' && typeof payload.text === 'string') {
20
+ return {
21
+ // A resumed/native session may reuse the same message id elsewhere.
22
+ key: JSON.stringify([row.session_id, typeof payload.messageId === 'string' ? payload.messageId : row.id]),
23
+ role: 'assistant',
24
+ text: payload.text,
25
+ complete: payload.streaming === false,
26
+ };
27
+ }
6
28
  }
7
29
  catch {
8
- return '';
30
+ // Historical damaged rows are not evidence of a conversation message.
9
31
  }
32
+ return null;
33
+ }
34
+ function boundedText(text, limit) {
35
+ if (text.length <= limit)
36
+ return text;
37
+ const marker = '\n[Middle text omitted]\n';
38
+ const available = Math.max(0, limit - marker.length);
39
+ const first = Math.ceil(available / 2);
40
+ return `${text.slice(0, first)}${marker}${text.slice(-(available - first))}`.slice(0, limit);
10
41
  }
11
42
  function recentConversation(workspaceId, sessionId) {
12
- const rows = getDb()
13
- .prepare(`SELECT type, payload FROM ws_events
14
- WHERE workspace_id = ? AND (? IS NULL OR session_id = ?)
15
- AND (type = 'user:message' OR (type = 'agent:event' AND json_extract(payload, '$.kind') = 'message:text'))
16
- ORDER BY rowid DESC LIMIT 6`)
17
- .all(workspaceId, sessionId ?? null, sessionId ?? null);
18
- return rows
19
- .reverse()
20
- .flatMap((row) => {
21
- try {
22
- const payload = JSON.parse(row.payload);
23
- const text = typeof payload.content === 'string' ? payload.content : typeof payload.text === 'string' ? payload.text : '';
24
- return text ? [text.slice(0, 1600)] : [];
43
+ const db = getDb();
44
+ const filter = 'workspace_id = ? AND (? IS NULL OR session_id = ?)';
45
+ const parameters = [workspaceId, sessionId ?? null, sessionId ?? null];
46
+ // Pin the most recent real user message independently of streaming volume.
47
+ const userRows = db
48
+ .prepare(`SELECT rowid AS ordinal, id, type, payload, session_id FROM ws_events
49
+ WHERE ${filter} AND type = 'user:message' ORDER BY rowid DESC`)
50
+ .iterate(...parameters);
51
+ let latestUser;
52
+ for (const row of userRows) {
53
+ const fragment = readableFragment(row);
54
+ if (!fragment?.text)
55
+ continue;
56
+ latestUser = { ...fragment, ordinal: row.ordinal, truncated: false };
57
+ break;
58
+ }
59
+ const rows = db
60
+ .prepare(`SELECT rowid AS ordinal, id, type, payload, session_id FROM ws_events
61
+ WHERE ${filter} AND type IN ('user:message', 'agent:event')
62
+ ORDER BY rowid DESC LIMIT ?`)
63
+ .iterate(...parameters, MAX_HISTORY_ROWS + 1);
64
+ const messages = new Map();
65
+ let scanned = 0;
66
+ let historyTruncated = false;
67
+ for (const row of rows) {
68
+ if (++scanned > MAX_HISTORY_ROWS) {
69
+ historyTruncated = true;
70
+ break;
25
71
  }
26
- catch {
27
- return [];
72
+ const fragment = readableFragment(row);
73
+ if (!fragment?.text)
74
+ continue;
75
+ const existing = messages.get(fragment.key);
76
+ if (existing) {
77
+ existing.ordinal = row.ordinal;
78
+ if (existing.complete)
79
+ continue;
80
+ const merged = fragment.text + existing.text;
81
+ existing.text = merged.slice(-MAX_MESSAGE_CHARS);
82
+ existing.truncated ||= merged.length > MAX_MESSAGE_CHARS;
83
+ existing.complete = fragment.complete;
28
84
  }
29
- })
30
- .join('\n\n');
85
+ else if (messages.size < MAX_RECENT_MESSAGES) {
86
+ messages.set(fragment.key, {
87
+ ...fragment,
88
+ ordinal: row.ordinal,
89
+ text: fragment.text.slice(-MAX_MESSAGE_CHARS),
90
+ truncated: fragment.text.length > MAX_MESSAGE_CHARS,
91
+ });
92
+ }
93
+ }
94
+ const selected = [];
95
+ const warning = historyTruncated
96
+ ? '\n\n[Conversation history truncated; recover older context from the source session.]'
97
+ : '';
98
+ let remaining = MAX_CONVERSATION_CHARS - warning.length;
99
+ if (latestUser) {
100
+ const pinned = `Latest user instruction:\n${boundedText(latestUser.text, 6_000)}`;
101
+ selected.push({ ordinal: latestUser.ordinal, text: pinned });
102
+ remaining -= pinned.length + 2;
103
+ messages.delete(latestUser.key);
104
+ }
105
+ const newestFirst = [...messages.values()].sort((a, b) => b.ordinal - a.ordinal);
106
+ for (const message of newestFirst) {
107
+ const label = message.role === 'assistant' ? 'Assistant:\n' : 'User:\n';
108
+ if (remaining <= label.length + TRUNCATED.length + 2)
109
+ break;
110
+ const text = message.truncated ? TRUNCATED + message.text : message.text;
111
+ const rendered = label + boundedText(text, Math.min(MAX_MESSAGE_CHARS, remaining - label.length - 2));
112
+ selected.push({ ordinal: message.ordinal, text: rendered });
113
+ remaining -= rendered.length + 2;
114
+ }
115
+ return (selected
116
+ .sort((a, b) => a.ordinal - b.ordinal)
117
+ .map((message) => message.text)
118
+ .join('\n\n') + warning);
31
119
  }
32
120
  /** Build a deterministic, secret-free handoff for a fresh session on another engine. */
33
- export function buildEngineHandoff(workspace, sourceEngine, targetEngine, sourceSessionId) {
121
+ export async function buildEngineHandoff(workspace, sourceEngine, targetEngine, sourceSessionId) {
34
122
  const taskLines = workspace.tasks.length
35
123
  ? workspace.tasks.map((task) => `- [${task.status === 'done' ? 'x' : ' '}] ${task.title}`).join('\n')
36
124
  : '- No Kōbō task recorded.';
37
- const changedFiles = runGit(workspace.worktreePath, 'status --short') || 'No uncommitted changes detected.';
38
- const diffStat = runGit(workspace.worktreePath, 'diff --stat') || 'No uncommitted diff stat available.';
125
+ const [status, diff] = await Promise.allSettled([
126
+ getWorkingTreePorcelainAsync(workspace.worktreePath),
127
+ getWorkingTreeDiffStatsAsync(workspace.worktreePath),
128
+ ]);
129
+ const changedFiles = status.status === 'fulfilled'
130
+ ? status.value.trim() || 'No uncommitted changes detected.'
131
+ : 'Git state unavailable. Verify the working tree before making changes.';
132
+ const diffStat = diff.status === 'fulfilled'
133
+ ? diff.value.trim() || 'No uncommitted diff stat available.'
134
+ : 'Git state unavailable. Run git diff --stat to recover the summary.';
39
135
  const history = recentConversation(workspace.id, sourceSessionId) || 'No recent conversation message available.';
40
136
  return `# Kōbō engine handoff\n\nYou are taking over this workspace from ${sourceEngine} using ${targetEngine}. Work in the existing worktree and verify the repository state before making changes.\n\n## Objective\n${workspace.description ?? workspace.name}\n\n## Tasks\n${taskLines}\n\n## Git state\n- Branch: ${workspace.workingBranch}\n- Base: ${workspace.sourceBranch}\n- Worktree: ${workspace.worktreePath}\n\nChanged files:\n\`\`\`\n${changedFiles}\n\`\`\`\n\nDiff summary:\n\`\`\`\n${diffStat}\n\`\`\`\n\n## Recent conversation\n${history}\n\n## Recover more context\nIf this handoff is insufficient, use \`kobo__read_workspace_events_csv\` to read the previous workspace conversation in pages (optionally filter by \`session_id\`). Use \`kobo__search_codebase\` to find a precise past decision or user request. Read only the context needed, then continue the task.\n`;
41
137
  }