@loicngr/kobo 2.1.1 → 2.1.4

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 (147) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/server/routes/workspaces.js +39 -0
  3. package/dist/server/services/agent/engines/claude-code/engine.js +116 -29
  4. package/dist/server/services/agent/engines/claude-code/event-mapper.js +88 -22
  5. package/dist/server/services/agent/engines/claude-code/options-builder.js +1 -0
  6. package/dist/server/services/agent/engines/codex/engine.js +28 -5
  7. package/dist/server/services/agent/engines/codex/options-builder.js +7 -1
  8. package/dist/server/services/agent/orchestrator.js +40 -1
  9. package/dist/server/services/agent/session-controller.js +12 -0
  10. package/dist/server/services/review-service.js +6 -2
  11. package/package.json +13 -13
  12. package/src/client/dist/pwa/assets/{ActivityFeed-BYR5x7aa.css → ActivityFeed-DCmiJP-z.css} +1 -1
  13. package/src/client/dist/pwa/assets/ActivityFeed-DdGARu8o.js +8 -0
  14. package/src/client/dist/pwa/assets/{ChangelogPage-OoOnn4q9.js → ChangelogPage-CGenjiI5.js} +1 -1
  15. package/src/client/dist/pwa/assets/{ClosePopup-CWjIRHxu.js → ClosePopup-C1a82pL9.js} +1 -1
  16. package/src/client/dist/pwa/assets/{ComparisonPanel-BYpe6Nch.js → ComparisonPanel-aXgjEulK.js} +2 -2
  17. package/src/client/dist/pwa/assets/{CreatePage-COQwHyqL.js → CreatePage-BaB_30xJ.js} +2 -2
  18. package/src/client/dist/pwa/assets/{DashboardPage-CZ2036MC.js → DashboardPage-DsbwmO2W.js} +1 -1
  19. package/src/client/dist/pwa/assets/{DiffViewer-CXBmMV9R.js → DiffViewer--dUMf7b3.js} +4 -4
  20. package/src/client/dist/pwa/assets/{DocumentsPanel-mLGU5aTb.js → DocumentsPanel-DTjEUYUd.js} +1 -1
  21. package/src/client/dist/pwa/assets/{DrawerToggleButton-C39pfWBD.js → DrawerToggleButton-Bh4FXdtJ.js} +1 -1
  22. package/src/client/dist/pwa/assets/GitPanel-PUPLj0M8.js +3 -0
  23. package/src/client/dist/pwa/assets/{HealthPage-CnJWXfa8.js → HealthPage-BHgucifz.js} +1 -1
  24. package/src/client/dist/pwa/assets/{MainLayout-Cc4XzCAy.css → MainLayout-CNmwCnDD.css} +1 -1
  25. package/src/client/dist/pwa/assets/MainLayout-CQVLnTX3.js +2 -0
  26. package/src/client/dist/pwa/assets/QBadge-rGILsUYl.js +1 -0
  27. package/src/client/dist/pwa/assets/QBanner-XE0EMe9L.js +1 -0
  28. package/src/client/dist/pwa/assets/QBtn-b_baQtAt.js +1 -0
  29. package/src/client/dist/pwa/assets/QBtnDropdown-DVbxGTNb.js +1 -0
  30. package/src/client/dist/pwa/assets/QBtnGroup-CUmAgBV8.js +1 -0
  31. package/src/client/dist/pwa/assets/QBtnToggle-COL46j6W.js +1 -0
  32. package/src/client/dist/pwa/assets/QChip-Bt_JvnHb.js +1 -0
  33. package/src/client/dist/pwa/assets/QExpansionItem-CxEBY3jY.js +1 -0
  34. package/src/client/dist/pwa/assets/QItemSection-Cf6OZEH4.js +1 -0
  35. package/src/client/dist/pwa/assets/QList-Z90BspT4.js +1 -0
  36. package/src/client/dist/pwa/assets/QMarkupTable-Bwjknbmw.js +1 -0
  37. package/src/client/dist/pwa/assets/QPage-BCDXjfVj.js +1 -0
  38. package/src/client/dist/pwa/assets/QSelect-CMzJDhi-.js +1 -0
  39. package/src/client/dist/pwa/assets/QSpinnerDots-BsKmccpX.js +1 -0
  40. package/src/client/dist/pwa/assets/QTooltip-BLhkNT4s.js +1 -0
  41. package/src/client/dist/pwa/assets/{SchedulePanel-DIMzNIO3.js → SchedulePanel-DTVwe7sX.js} +1 -1
  42. package/src/client/dist/pwa/assets/{SearchPage-COWnMxRZ.js → SearchPage-CtrR7dNr.js} +1 -1
  43. package/src/client/dist/pwa/assets/SettingsPage-Bl_RNTGi.js +9 -0
  44. package/src/client/dist/pwa/assets/SplitWorkspacePage-Bac_Pzmm.js +1 -0
  45. package/src/client/dist/pwa/assets/{TerminalPanel-C_HNpN1X.js → TerminalPanel-D2j5AA8S.js} +1 -1
  46. package/src/client/dist/pwa/assets/TouchPan-C0EqFcrr.js +1 -0
  47. package/src/client/dist/pwa/assets/{TourReplayButton-Cp2UnxWa.js → TourReplayButton-DlMvPblb.js} +1 -1
  48. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-BAk5l4Ik.js +1 -0
  49. package/src/client/dist/pwa/assets/WorkspacePage-FScR5YAD.js +4 -0
  50. package/src/client/dist/pwa/assets/{browser-uy3hmwKo.js → browser-DNEbLF-4.js} +1 -1
  51. package/src/client/dist/pwa/assets/build-path-tree-C0b61nxI.js +1 -0
  52. package/src/client/dist/pwa/assets/{codicon-Brq4_Ui5.ttf → codicon-DF1abBS2.ttf} +0 -0
  53. package/src/client/dist/pwa/assets/custom-sounds-DE4JLdGG.js +1 -0
  54. package/src/client/dist/pwa/assets/{de-CTE3Xb7Z.js → de-DgZ9jKq_.js} +2 -2
  55. package/src/client/dist/pwa/assets/documents-CALwmao-.js +1 -0
  56. package/src/client/dist/pwa/assets/editor-CmVjQp1O.css +1 -0
  57. package/src/client/dist/pwa/assets/editor.api-PENN0io6.js +420 -0
  58. package/src/client/dist/pwa/assets/editor.worker-BLJF_oQx.js +33 -0
  59. package/src/client/dist/pwa/assets/efforts-Detdm-FH.js +9 -0
  60. package/src/client/dist/pwa/assets/{es-CZxg1xjh.js → es-zvlgzw9H.js} +2 -2
  61. package/src/client/dist/pwa/assets/expand-template-ZIVLPdW2.js +1 -0
  62. package/src/client/dist/pwa/assets/format-CVI7mr7G.js +489 -0
  63. package/src/client/dist/pwa/assets/format-DoyK4IAn.css +1 -0
  64. package/src/client/dist/pwa/assets/{formatters-BtG6lb4d.js → formatters-C_zcfJBt.js} +1 -1
  65. package/src/client/dist/pwa/assets/{fr-C7HCclxj.js → fr-BICzq59I.js} +2 -2
  66. package/src/client/dist/pwa/assets/{freemarker2-CDYXi2tK.js → freemarker2-BxN_Aq6C.js} +1 -1
  67. package/src/client/dist/pwa/assets/{handlebars-AVkJuN-_.js → handlebars-C9UHXtO1.js} +1 -1
  68. package/src/client/dist/pwa/assets/{html-CpvEZlmO.js → html-CTH9wSJr.js} +1 -1
  69. package/src/client/dist/pwa/assets/i18n-DG_vChZ3.js +1 -0
  70. package/src/client/dist/pwa/assets/index-C8zAZ-k-.css +1 -0
  71. package/src/client/dist/pwa/assets/index-fuLr-ZVa.js +20 -0
  72. package/src/client/dist/pwa/assets/{it-DoOWwqZz.js → it-BIGArQrS.js} +2 -2
  73. package/src/client/dist/pwa/assets/{javascript-BYnnBLH9.js → javascript-WT4BS8p4.js} +1 -1
  74. package/src/client/dist/pwa/assets/{liquid-CzqlB3Um.js → liquid-D6dfe-gU.js} +1 -1
  75. package/src/client/dist/pwa/assets/{mdx-hrYFnJVZ.js → mdx-B8pNXNeC.js} +1 -1
  76. package/src/client/dist/pwa/assets/monaco-contributions-COLfslyi.js +53 -0
  77. package/src/client/dist/pwa/assets/monaco-contributions-DvnCTJI2.css +1 -0
  78. package/src/client/dist/pwa/assets/{monaco.contribution-Dl22KqTL.js → monaco.contribution-CDhuQbzi.js} +2 -2
  79. package/src/client/dist/pwa/assets/{network-auth-Dq0I5kjv.js → network-auth-QouPCx6A.js} +1 -1
  80. package/src/client/dist/pwa/assets/{notify-theme-vV3Rm4_v.js → notify-theme-7MsG-MyN.js} +1 -1
  81. package/src/client/dist/pwa/assets/pending-diff-open-CX24u9gW.js +1 -0
  82. package/src/client/dist/pwa/assets/{pinia-CTJ3OfP3.js → pinia-BdFjQEqa.js} +1 -1
  83. package/src/client/dist/pwa/assets/{private.use-form-Bk8ti264.js → private.use-form-vxvbh_VN.js} +1 -1
  84. package/src/client/dist/pwa/assets/{project-color-W5mMgIKK.js → project-color-Bo6iEcqN.js} +1 -1
  85. package/src/client/dist/pwa/assets/{python-DafgCozG.js → python-D2gb6Jjz.js} +1 -1
  86. package/src/client/dist/pwa/assets/{razor-CWYrTLCm.js → razor-dksrCQF2.js} +1 -1
  87. package/src/client/dist/pwa/assets/{reactivity.esm-bundler-Bo9anJjg.js → reactivity.esm-bundler-tuEvQ47f.js} +1 -1
  88. package/src/client/dist/pwa/assets/render-chat-markdown-px_6WDIX.js +69 -0
  89. package/src/client/dist/pwa/assets/runtime-core.esm-bundler-DOiZcasU.js +1 -0
  90. package/src/client/dist/pwa/assets/{typescript-DLSIuW9u.js → typescript-DTPyRTrW.js} +1 -1
  91. package/src/client/dist/pwa/assets/{use-checkbox-CnvHBhSK.js → use-checkbox-Bh9EQhkV.js} +1 -1
  92. package/src/client/dist/pwa/assets/use-id-b4A2I8LB.js +1 -0
  93. package/src/client/dist/pwa/assets/use-is-mobile-DLIZxEo9.js +1 -0
  94. package/src/client/dist/pwa/assets/{use-search-index-status-Bluf9L99.js → use-search-index-status-_nCyACc7.js} +1 -1
  95. package/src/client/dist/pwa/assets/{use-slide-transition-CiaDm-tR.js → use-slide-transition-C5eDGOmu.js} +1 -1
  96. package/src/client/dist/pwa/assets/{use-tours-CDdnvIaG.js → use-tours-b00RruX_.js} +1 -1
  97. package/src/client/dist/pwa/assets/use-worktree-restore-BDOZc9fk.js +1 -0
  98. package/src/client/dist/pwa/assets/{workspace-templates-C88VmFdQ.js → workspace-templates-DV9Njz0T.js} +1 -1
  99. package/src/client/dist/pwa/assets/{xml-BAmdYarn.js → xml-mxUZCM4r.js} +1 -1
  100. package/src/client/dist/pwa/assets/{yaml-RQqNMXWh.js → yaml-BuN8sUWC.js} +1 -1
  101. package/src/client/dist/pwa/index.html +9 -9
  102. package/src/client/dist/pwa/sw.js +1 -1
  103. package/src/client/dist/pwa/assets/ActivityFeed-DeJ0V6CW.js +0 -8
  104. package/src/client/dist/pwa/assets/GitPanel-BQk7kJO-.js +0 -3
  105. package/src/client/dist/pwa/assets/MainLayout-D3zfT61Y.js +0 -2
  106. package/src/client/dist/pwa/assets/QBadge-FWC1zDCq.js +0 -1
  107. package/src/client/dist/pwa/assets/QBanner-CcRI_QIy.js +0 -1
  108. package/src/client/dist/pwa/assets/QBtn-DEdx_Y1V.js +0 -1
  109. package/src/client/dist/pwa/assets/QBtnDropdown-DGLLlBf3.js +0 -1
  110. package/src/client/dist/pwa/assets/QBtnGroup-CAoD6G0Q.js +0 -1
  111. package/src/client/dist/pwa/assets/QBtnToggle-C_hQ2UCQ.js +0 -1
  112. package/src/client/dist/pwa/assets/QChip-DCPUhqXX.js +0 -1
  113. package/src/client/dist/pwa/assets/QExpansionItem-CQ2LzM8P.js +0 -1
  114. package/src/client/dist/pwa/assets/QItemSection-BhqphaH6.js +0 -1
  115. package/src/client/dist/pwa/assets/QList-CHm4JNP0.js +0 -1
  116. package/src/client/dist/pwa/assets/QMarkupTable-CNpiA9oq.js +0 -1
  117. package/src/client/dist/pwa/assets/QPage-BXBw2Alx.js +0 -1
  118. package/src/client/dist/pwa/assets/QSelect-DoTWgQAO.js +0 -1
  119. package/src/client/dist/pwa/assets/QSpinnerDots-BkFiCsH3.js +0 -1
  120. package/src/client/dist/pwa/assets/QTooltip-DKvUeiR_.js +0 -1
  121. package/src/client/dist/pwa/assets/SettingsPage-B9cLhFnO.js +0 -9
  122. package/src/client/dist/pwa/assets/SplitWorkspacePage-BJKssJyI.js +0 -1
  123. package/src/client/dist/pwa/assets/TouchPan-CCllyaAF.js +0 -1
  124. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-B-SuQuS_.js +0 -1
  125. package/src/client/dist/pwa/assets/WorkspacePage-6lN4iF5W.js +0 -4
  126. package/src/client/dist/pwa/assets/build-path-tree-DZrp6Aji.js +0 -1
  127. package/src/client/dist/pwa/assets/custom-sounds-Du8ReMlU.js +0 -1
  128. package/src/client/dist/pwa/assets/documents-BtNrB6d0.js +0 -1
  129. package/src/client/dist/pwa/assets/editor-BJU3Xb3h.css +0 -1
  130. package/src/client/dist/pwa/assets/editor.api-P-eJRcVK.js +0 -413
  131. package/src/client/dist/pwa/assets/editor.worker-iO5iN0DB.js +0 -33
  132. package/src/client/dist/pwa/assets/efforts-ChUC4xtk.js +0 -9
  133. package/src/client/dist/pwa/assets/engineFeatures-D-0NPZBE.js +0 -1
  134. package/src/client/dist/pwa/assets/expand-template-ByoC_kxX.js +0 -1
  135. package/src/client/dist/pwa/assets/format-CX6Cv1cD.css +0 -1
  136. package/src/client/dist/pwa/assets/format-CeidAPFn.js +0 -435
  137. package/src/client/dist/pwa/assets/i18n-KVkTJG-4.js +0 -1
  138. package/src/client/dist/pwa/assets/index-0wtBh1Xt.css +0 -1
  139. package/src/client/dist/pwa/assets/index-DsaWUT_Z.js +0 -20
  140. package/src/client/dist/pwa/assets/monaco-contributions-Cb0afGfW.css +0 -1
  141. package/src/client/dist/pwa/assets/monaco-contributions-D2YXqNaH.js +0 -51
  142. package/src/client/dist/pwa/assets/pending-diff-open-BJYqBOUG.js +0 -1
  143. package/src/client/dist/pwa/assets/render-chat-markdown-CE5RIL0s.js +0 -69
  144. package/src/client/dist/pwa/assets/runtime-core.esm-bundler-CNFKm4ij.js +0 -1
  145. package/src/client/dist/pwa/assets/use-id-wXd4c8FA.js +0 -1
  146. package/src/client/dist/pwa/assets/use-is-mobile-DpKI3XMy.js +0 -1
  147. /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,19 @@ 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.4
8
+
9
+ - chore(deps): update server client and pwa packages
10
+
11
+ ## 2.1.3
12
+
13
+ - fix(subagents): reliable lifecycle tracking and a stop button
14
+
15
+ ## 2.1.2
16
+
17
+ - fix: read-only returning reviews, resumed-turn stdin, feed and tag filter
18
+ - chore: npm deps update
19
+
7
20
  ## 2.1.1
8
21
 
9
22
  - feat(dashboard): allow resetting the model reliability table
@@ -4621,4 +4621,43 @@ app.post('/:id/interrupt', migrationGuard, async (c) => {
4621
4621
  return c.json({ error: message }, workspaceErrorStatus(err));
4622
4622
  }
4623
4623
  });
4624
+ // POST /api/workspaces/:id/subagents/stop - stop one running sub-agent (body
4625
+ // `{ id }`, its canonical card id) or all of them, without interrupting the turn
4626
+ app.post('/:id/subagents/stop', migrationGuard, async (c) => {
4627
+ try {
4628
+ const id = c.req.param('id');
4629
+ const workspace = workspaceService.getWorkspace(id);
4630
+ if (!workspace) {
4631
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
4632
+ }
4633
+ const rawBody = await c.req.text();
4634
+ let body = {};
4635
+ if (rawBody.trim().length > 0) {
4636
+ let parsed;
4637
+ try {
4638
+ parsed = JSON.parse(rawBody);
4639
+ }
4640
+ catch {
4641
+ return c.json({ error: 'Request body must be valid JSON' }, 400);
4642
+ }
4643
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
4644
+ return c.json({ error: 'Request body must be a JSON object' }, 400);
4645
+ }
4646
+ body = parsed;
4647
+ }
4648
+ if (body.id !== undefined && (typeof body.id !== 'string' || body.id.trim().length === 0)) {
4649
+ return c.json({ error: 'id must be a non-empty string when provided' }, 400);
4650
+ }
4651
+ const stopped = agentManager.stopSubagents(id, body.id);
4652
+ return c.json({ stopped });
4653
+ }
4654
+ catch (err) {
4655
+ const message = err instanceof Error ? err.message : String(err);
4656
+ if (err instanceof agentManager.StopSubagentsError) {
4657
+ const status = err.code === 'subagent_not_running' ? 404 : 409;
4658
+ return c.json({ error: message, code: err.code }, status);
4659
+ }
4660
+ return c.json({ error: message }, workspaceErrorStatus(err));
4661
+ }
4662
+ });
4624
4663
  export default app;
@@ -48,6 +48,11 @@ export const SUBAGENT_STALL_TIMEOUT_MS = 10 * 60_000;
48
48
  * every permission request of it fail with "Stream closed", so wait this long
49
49
  * for a parent message before draining a stream that did not continue. */
50
50
  export const BACKGROUND_CONTINUATION_GRACE_MS = 60_000;
51
+ /** A `result` does not always end the stream: resuming a session killed
52
+ * mid-turn first settles the interrupted turn with an empty result, then
53
+ * runs the new prompt right away. Wait this long for a follow-up before
54
+ * closing the input it still needs for permission requests. */
55
+ export const RESULT_CONTINUATION_GRACE_MS = 3_000;
51
56
  const MAX_PENDING_USER_MESSAGES = 20;
52
57
  function toMcpServersMap(specs) {
53
58
  if (!specs || specs.length === 0)
@@ -119,9 +124,52 @@ export function createClaudeCodeEngine() {
119
124
  async start(options, onEvent) {
120
125
  const abortController = new AbortController();
121
126
  const mapperState = createMapperState();
122
- // UI/tool ids can differ from the SDK task id. Key by the stable UI id
123
- // so a terminal notification that omits task_id still clears the task.
124
- 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
+ };
125
173
  // Foreground tool calls currently awaiting their tool_result. Cleared on
126
174
  // every `result` message: a turn cannot end with a tool still in flight.
127
175
  const pendingToolCallIds = new Set();
@@ -130,6 +178,16 @@ export function createClaudeCodeEngine() {
130
178
  const isInteractive = options.agentPermissionMode === 'interactive';
131
179
  const canUseTool = (toolName, input, ctx) => {
132
180
  const toolCallId = typeof ctx.toolUseID === 'string' && ctx.toolUseID.length > 0 ? ctx.toolUseID : `tu_${nanoid()}`;
181
+ // Plan mode alone is not read-only here: the non-interactive allow below
182
+ // would approve ExitPlanMode and then every edit. Anything that needs a
183
+ // permission, a question included, is refused outright instead.
184
+ if (options.readOnly) {
185
+ return Promise.resolve({
186
+ behavior: 'deny',
187
+ message: 'This is a read-only review session: do not modify anything or ask the user. Put the finding in your final report instead.',
188
+ interrupt: false,
189
+ });
190
+ }
133
191
  // Non-interactive modes: the SDK has already applied its permissionMode
134
192
  // rules before reaching us, so allow through unchanged. AskUserQuestion
135
193
  // is the exception — it always defers to the user.
@@ -245,6 +303,17 @@ export function createClaudeCodeEngine() {
245
303
  sdkOptions.pathToClaudeCodeExecutable = explicitBinary;
246
304
  const inputStream = new ClaudeInputStream(effectivePrompt);
247
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
+ };
248
317
  let resolveReady;
249
318
  let rejectReady;
250
319
  const ready = new Promise((resolve, reject) => {
@@ -411,9 +480,9 @@ export function createClaudeCodeEngine() {
411
480
  clearTimeout(resultDrainTimer);
412
481
  resultDrainTimer = undefined;
413
482
  };
414
- // Drain for a stream whose last background subagent completed without
415
- // a parent continuation. Any parent message cancels it: that
416
- // continuation's own `result` then closes the input.
483
+ // Drain for a stream that may still continue: after a result, or after
484
+ // the last background subagent completed. Any new init or parent
485
+ // message cancels it, and that continuation's own `result` re-arms it.
417
486
  let continuationGraceTimer;
418
487
  const clearContinuationGrace = () => {
419
488
  if (!continuationGraceTimer)
@@ -421,7 +490,7 @@ export function createClaudeCodeEngine() {
421
490
  clearTimeout(continuationGraceTimer);
422
491
  continuationGraceTimer = undefined;
423
492
  };
424
- const armContinuationGrace = () => {
493
+ const armContinuationGrace = (delayMs) => {
425
494
  clearContinuationGrace();
426
495
  continuationGraceTimer = setTimeout(() => {
427
496
  continuationGraceTimer = undefined;
@@ -430,9 +499,12 @@ export function createClaudeCodeEngine() {
430
499
  pendingToolCallIds.size > 0 ||
431
500
  inputStream.hasUnansweredInput(completedResponses))
432
501
  return;
502
+ // Settled only now: a result the CLI follows up on (e.g. a resumed
503
+ // turn's empty result) must not hide the busy banner meanwhile.
504
+ emitTurnCompletedIfSettled();
433
505
  inputStream.close();
434
506
  armResultDrainWatchdog();
435
- }, BACKGROUND_CONTINUATION_GRACE_MS);
507
+ }, delayMs);
436
508
  continuationGraceTimer.unref?.();
437
509
  };
438
510
  // Safety net for the "waiting on a background subagent" branch below:
@@ -478,6 +550,10 @@ export function createClaudeCodeEngine() {
478
550
  waitingForBackground = false;
479
551
  clearContinuationGrace();
480
552
  }
553
+ else if (msg.type === 'system' && msg.subtype === 'init') {
554
+ // A new run starts on this stream (e.g. after a resumed turn's empty result).
555
+ clearContinuationGrace();
556
+ }
481
557
  // This SDK message proves that any drain condition armed by a
482
558
  // *previous* message is no longer current. A drain armed while
483
559
  // processing this message remains active if the generator then
@@ -485,15 +561,14 @@ export function createClaudeCodeEngine() {
485
561
  clearResultDrainWatchdog();
486
562
  const events = mapSdkMessage(msg, mapperState);
487
563
  for (const ev of events) {
488
- if (ev.kind !== 'subagent:progress')
489
- continue;
490
- if (ev.status === 'running')
491
- activeSubagentTaskIds.set(ev.toolCallId, ev.taskId ?? ev.toolCallId);
492
- else
493
- activeSubagentTaskIds.delete(ev.toolCallId);
564
+ if (ev.kind === 'subagent:progress')
565
+ trackSubagentProgress(ev);
494
566
  }
495
567
  if (activeSubagentTaskIds.size > 0 &&
496
- (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) ||
497
572
  ev.kind === 'message:text' ||
498
573
  ev.kind === 'message:thinking' ||
499
574
  ev.kind === 'tool:call' ||
@@ -539,7 +614,7 @@ export function createClaudeCodeEngine() {
539
614
  activeSubagentTaskIds.size === 0 &&
540
615
  !inputStream.hasUnansweredInput(completedResponses)) {
541
616
  clearSubagentStallWatchdog();
542
- armContinuationGrace();
617
+ armContinuationGrace(BACKGROUND_CONTINUATION_GRACE_MS);
543
618
  }
544
619
  for (const ev of events) {
545
620
  if (ev.kind === 'session:started')
@@ -561,13 +636,11 @@ export function createClaudeCodeEngine() {
561
636
  clearContinuationGrace();
562
637
  pendingToolCallIds.clear();
563
638
  completedResponses++;
564
- emitTurnCompletedIfSettled();
565
639
  // A queued forced message starts the next response on this same SDK stream.
566
640
  if (!inputStream.hasUnansweredInput(completedResponses)) {
567
641
  if (activeSubagentTaskIds.size === 0) {
568
642
  clearSubagentStallWatchdog();
569
- inputStream.close();
570
- armResultDrainWatchdog();
643
+ armContinuationGrace(RESULT_CONTINUATION_GRACE_MS);
571
644
  }
572
645
  else {
573
646
  waitingForBackground = true;
@@ -669,6 +742,28 @@ export function createClaudeCodeEngine() {
669
742
  armSubagentStallWatchdog();
670
743
  return true;
671
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
+ },
672
767
  interrupt() {
673
768
  userInterrupted = true;
674
769
  // The SDK ends an interrupted run by emitting a `result` with
@@ -679,16 +774,8 @@ export function createClaudeCodeEngine() {
679
774
  // background subagent task keeps running unless told to stop via
680
775
  // its own API, which would otherwise leave the Stop button
681
776
  // appearing to do nothing for up to SUBAGENT_STALL_TIMEOUT_MS.
682
- for (const taskId of activeSubagentTaskIds.values()) {
683
- try {
684
- void q.stopTask(taskId).catch(() => {
685
- /* best-effort */
686
- });
687
- }
688
- catch {
689
- /* best-effort */
690
- }
691
- }
777
+ for (const taskId of [...activeSubagentTaskIds, ...ambientSubagentTaskIds])
778
+ requestStopTask(taskId);
692
779
  const qq = q;
693
780
  if (typeof qq.interrupt === 'function') {
694
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
@@ -77,6 +77,7 @@ export function createCodexEngine() {
77
77
  model: options.model,
78
78
  effort: options.effort,
79
79
  agentPermissionMode: options.agentPermissionMode ?? 'bypass',
80
+ readOnly: options.readOnly,
80
81
  resumeFromEngineSessionId: options.resumeFromEngineSessionId,
81
82
  workingDir: options.workingDir,
82
83
  mcpServers: options.mcpServers,
@@ -339,13 +340,35 @@ export function createCodexEngine() {
339
340
  if (n.item.type === 'fileChange')
340
341
  fileChanges.set(itemKey(n.threadId, n.turnId, n.item.id), n.item);
341
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
+ }
342
363
  for (const ev of events)
343
364
  safeEmit(ev);
344
- if (n.item.type === 'collabAgentToolCall') {
345
- const progress = events.find((event) => event.kind === 'subagent:progress');
346
- for (const [threadId, agentState] of Object.entries(n.item.agentsStates)) {
347
- if (agentState.status === 'pendingInit' || agentState.status === 'running') {
348
- 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)) {
349
372
  activeSubagentThreads.set(threadId, {
350
373
  toolCallId: progress.toolCallId,
351
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;
@@ -38,6 +39,10 @@ export function buildCodexOptions(input) {
38
39
  threadParams.approvalPolicy = 'unless-trusted';
39
40
  break;
40
41
  }
42
+ if (input.readOnly) {
43
+ threadParams.sandbox = 'read-only';
44
+ threadParams.approvalPolicy = 'never';
45
+ }
41
46
  // Model: omit when undefined or 'auto', let Codex use its default
42
47
  if (input.model && input.model !== 'auto') {
43
48
  threadParams.model = input.model;
@@ -70,7 +75,8 @@ export function buildCodexOptions(input) {
70
75
  // model/effort because collaborationMode takes precedence over them. Automatic
71
76
  // model selection is filled from thread/start or thread/resume by the engine.
72
77
  const collaborationMode = {
73
- mode: input.agentPermissionMode === 'plan' ? 'plan' : 'default',
78
+ // A read-only review must not stop on a plan-mode question (see readOnly).
79
+ mode: input.agentPermissionMode === 'plan' && !input.readOnly ? 'plan' : 'default',
74
80
  settings: {
75
81
  ...(threadParams.model ? { model: threadParams.model } : {}),
76
82
  reasoning_effort: threadParams.modelReasoningEffort ?? null,
@@ -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;
@@ -1373,6 +1381,9 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1373
1381
  activateSession(workspaceId, agentSessionId);
1374
1382
  captureTaskProgressBaseline(workspaceId, agentSessionId);
1375
1383
  const settings = ws ? readEffectiveSettingsSafe(ws.projectPath) : readEffectiveSettingsSafe(workingDir);
1384
+ // A review whose report returns to the original session must end by itself:
1385
+ // enforce read-only plan mode here, not only through its prompt.
1386
+ const readOnly = getReviewReturn(workspaceId, agentSessionId) !== null;
1376
1387
  const unavailableIntegrations = [];
1377
1388
  const integrationServers = buildIntegrationMcpServers(getGlobalSettings(), (name) => unavailableIntegrations.push(name));
1378
1389
  const options = {
@@ -1382,7 +1393,8 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1382
1393
  model,
1383
1394
  effort: reasoningEffort,
1384
1395
  // Cascade: explicit caller override → workspace setting → 'bypass'.
1385
- agentPermissionMode: agentPermissionMode ?? ws?.agentPermissionMode ?? 'bypass',
1396
+ agentPermissionMode: readOnly ? 'plan' : (agentPermissionMode ?? ws?.agentPermissionMode ?? 'bypass'),
1397
+ readOnly,
1386
1398
  resumeFromEngineSessionId,
1387
1399
  backendUrl: `http://127.0.0.1:${backendPort}`,
1388
1400
  koboHome: (() => {
@@ -1490,6 +1502,33 @@ export function interruptAgent(workspaceId, options = {}) {
1490
1502
  autoLoopService.disable(workspaceId, 'user-action');
1491
1503
  }
1492
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
+ }
1493
1532
  /** Bound the caller's wait, retaining ownership until actual engine shutdown. */
1494
1533
  export const STOP_AGENT_TIMEOUT_MS = 15_000;
1495
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) => {
@@ -44,7 +44,8 @@ function resolveConfiguration(workspace, body) {
44
44
  engine: engineId,
45
45
  model: body.model ?? (changedEngine ? 'auto' : workspace.model),
46
46
  reasoningEffort: body.reasoningEffort ?? (changedEngine ? 'auto' : workspace.reasoningEffort),
47
- agentPermissionMode: body.agentPermissionMode ?? workspace.agentPermissionMode,
47
+ // A review that hands its report back must end by itself: always read-only.
48
+ agentPermissionMode: body.returnToSession ? 'plan' : (body.agentPermissionMode ?? workspace.agentPermissionMode),
48
49
  };
49
50
  // Preserve an existing custom model when unchanged; reject incompatible selections.
50
51
  if ((changedEngine || config.model !== workspace.model) &&
@@ -115,9 +116,12 @@ export async function startWorkspaceReview(id, input) {
115
116
  baseCommit,
116
117
  additionalInstructions: (body.additionalInstructions ?? '').trim(),
117
118
  });
119
+ // The return fires only when the review session ends by itself: a reviewer
120
+ // that starts fixing, or waits on a question, never hands back.
118
121
  if (body.returnToSession)
119
122
  rendered +=
120
- '\n\nFinish with a standalone summary of your review findings, including severity, file/line references, recommended fixes and any remaining uncertainty. This final message will be handed back to the original agent session. Do not rely on earlier messages to explain your findings.';
123
+ '\n\nThis is a read-only review session; these rules override any template instruction or skill that suggests fixing issues. Do not modify, create or delete files, and do not commit or push: the original agent session applies the fixes. Running read-only checks such as tests or linters is fine. Do not ask the user questions or wait for input: note any decision that needs the user in your summary instead.' +
124
+ '\n\nFinish with a standalone summary of your review findings, including severity, file/line references, recommended fixes and any remaining uncertainty. This final message will be handed back to the original agent session. Do not rely on earlier messages to explain your findings.';
121
125
  assertWorkspaceLifecycleAvailable(id);
122
126
  const current = workspaceService.getWorkspace(id);
123
127
  if (!current || current.archivedAt || current.worktreePurgedAt || current.status === 'compacting')