@loicngr/kobo 2.1.28 → 2.1.30

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 (207) hide show
  1. package/AGENTS.md +60 -11
  2. package/CHANGELOG.md +20 -1
  3. package/CONFIGURATION.md +140 -35
  4. package/CONTRIBUTING.md +2 -2
  5. package/DESIGN.md +1 -1
  6. package/README.md +12 -9
  7. package/SECURITY.md +7 -2
  8. package/dist/server/db/memory-schema.js +5 -5
  9. package/dist/server/db/migrations.js +2 -0
  10. package/dist/server/db/mistral-memory-migration.js +40 -0
  11. package/dist/server/routes/engines.js +14 -0
  12. package/dist/server/routes/environment.js +1 -1
  13. package/dist/server/routes/health.js +14 -3
  14. package/dist/server/routes/settings.js +7 -1
  15. package/dist/server/routes/workspaces.js +116 -9
  16. package/dist/server/services/agent/engines/claude-code/capabilities.js +2 -0
  17. package/dist/server/services/agent/engines/claude-code/engine.js +3 -0
  18. package/dist/server/services/agent/engines/claude-code/resolve-binary.js +1 -1
  19. package/dist/server/services/agent/engines/codex/capabilities.js +2 -0
  20. package/dist/server/services/agent/engines/codex/engine.js +3 -0
  21. package/dist/server/services/agent/engines/configuration.js +14 -0
  22. package/dist/server/services/agent/engines/mistral-vibe/capabilities.js +13 -0
  23. package/dist/server/services/agent/engines/mistral-vibe/client.js +64 -0
  24. package/dist/server/services/agent/engines/mistral-vibe/configuration.js +76 -0
  25. package/dist/server/services/agent/engines/mistral-vibe/engine.js +361 -0
  26. package/dist/server/services/agent/engines/mistral-vibe/event-mapper.js +297 -0
  27. package/dist/server/services/agent/engines/mistral-vibe/options-builder.js +35 -0
  28. package/dist/server/services/agent/engines/mistral-vibe/server-requests.js +278 -0
  29. package/dist/server/services/agent/engines/mistral-vibe/spawn.js +176 -0
  30. package/dist/server/services/agent/engines/registry.js +2 -0
  31. package/dist/server/services/agent/engines/types.js +6 -0
  32. package/dist/server/services/agent/orchestrator.js +88 -25
  33. package/dist/server/services/agent/session-controller.js +5 -0
  34. package/dist/server/services/auto-loop-final-review-service.js +10 -3
  35. package/dist/server/services/dev-server-service.js +68 -4
  36. package/dist/server/services/environment-check-service.js +9 -3
  37. package/dist/server/services/memory-service.js +1 -1
  38. package/dist/server/services/mistral-options-service.js +145 -0
  39. package/dist/server/services/pr-watcher-service.js +40 -11
  40. package/dist/server/services/review-service.js +33 -9
  41. package/dist/server/services/session-handoff-service.js +28 -2
  42. package/dist/server/services/settings-defaults.js +1 -0
  43. package/dist/server/services/settings-service.js +19 -1
  44. package/dist/server/services/workspace-creation-mcp-service.js +50 -8
  45. package/dist/server/services/workspace-dialogue-service.js +2 -0
  46. package/dist/server/services/workspace-message-service.js +19 -1
  47. package/dist/server/services/workspace-permission-policy-service.js +24 -0
  48. package/dist/server/utils/bounded-process.js +1 -1
  49. package/dist/shared/launch-instructions.js +18 -7
  50. package/dist/shared/memory.js +1 -1
  51. package/dist/shared/mistral-models.js +9 -0
  52. package/dist/shared/mistral-options.js +1 -0
  53. package/dist/shared/question-selections.js +16 -0
  54. package/dist/shared/workspace-creation-tools.js +8 -2
  55. package/dist/shared/workspace-dialogue-tools.js +15 -4
  56. package/docs/deployment-vps.md +10 -3
  57. package/docs/getting-started.md +4 -3
  58. package/docs/troubleshooting.md +2 -2
  59. package/package.json +20 -14
  60. package/src/client/dist/pwa/assets/ActivityFeed-C5stmHXP.js +2 -0
  61. package/src/client/dist/pwa/assets/AutoLoopFinalReviewSettings-CIDSX32d.css +1 -0
  62. package/src/client/dist/pwa/assets/AutoLoopFinalReviewSettings-DG32W_0G.js +1 -0
  63. package/src/client/dist/pwa/assets/{ChangelogPage-CAAUEn5X.js → ChangelogPage-_vItY8vA.js} +1 -1
  64. package/src/client/dist/pwa/assets/{ClosePopup-CRV2hxHS.js → ClosePopup-rU8Bm-mC.js} +1 -1
  65. package/src/client/dist/pwa/assets/{ComparisonPanel-BCb7vB5E.js → ComparisonPanel-B3BMzQH0.js} +2 -2
  66. package/src/client/dist/pwa/assets/CreatePage-FlGrfmjX.js +2 -0
  67. package/src/client/dist/pwa/assets/CreatePage-oh8ihVC4.css +1 -0
  68. package/src/client/dist/pwa/assets/{DashboardPage-XE52EABP.js → DashboardPage-lJGwWQYQ.js} +1 -1
  69. package/src/client/dist/pwa/assets/DiffViewer-DIaQH3lA.js +8 -0
  70. package/src/client/dist/pwa/assets/{DocumentsPanel-L4pbQfz1.js → DocumentsPanel-Ct-nm4fX.js} +1 -1
  71. package/src/client/dist/pwa/assets/{DrawerToggleButton-DiOJm5he.js → DrawerToggleButton-M5qv4Io-.js} +1 -1
  72. package/src/client/dist/pwa/assets/GitPanel-BaHCgb3p.js +3 -0
  73. package/src/client/dist/pwa/assets/GitPanel-C8vw2qvW.css +1 -0
  74. package/src/client/dist/pwa/assets/HealthPage-DR3wa6iD.js +1 -0
  75. package/src/client/dist/pwa/assets/HealthPage-Dbn4J_DT.css +1 -0
  76. package/src/client/dist/pwa/assets/{MainLayout-DoAEgANU.css → MainLayout-CHi8qP1D.css} +1 -1
  77. package/src/client/dist/pwa/assets/MainLayout-WJabICsA.js +2 -0
  78. package/src/client/dist/pwa/assets/{MemoryPanel-DtEvh_b7.js → MemoryPanel-CRpWdfc0.js} +1 -1
  79. package/src/client/dist/pwa/assets/MemoryProposalList-DGz3lDbL.js +1 -0
  80. package/src/client/dist/pwa/assets/{QBadge-CW3Lw-kL.js → QBadge-DiwbRpte.js} +1 -1
  81. package/src/client/dist/pwa/assets/{QBanner-BT2ZemYu.js → QBanner-Cw89HSjk.js} +1 -1
  82. package/src/client/dist/pwa/assets/QBtn-kfhHFy7Q.js +1 -0
  83. package/src/client/dist/pwa/assets/{QBtnDropdown-CWPIugs8.js → QBtnDropdown-tt8rUNNR.js} +1 -1
  84. package/src/client/dist/pwa/assets/{QBtnGroup-CnM4vSJA.js → QBtnGroup-Bioxjp9q.js} +1 -1
  85. package/src/client/dist/pwa/assets/{QBtnToggle-DccOOtbh.js → QBtnToggle-Bod-x9im.js} +1 -1
  86. package/src/client/dist/pwa/assets/{QChip-Be16M4Ig.js → QChip-DvhtPv_L.js} +1 -1
  87. package/src/client/dist/pwa/assets/{QExpansionItem-D-EGL2b3.js → QExpansionItem-BkrI3UPL.js} +1 -1
  88. package/src/client/dist/pwa/assets/{QItemSection-MdJ1_ZK7.js → QItemSection-ahqRK4JF.js} +1 -1
  89. package/src/client/dist/pwa/assets/{QList-Cx1UfaUm.js → QList-CAUl0v66.js} +1 -1
  90. package/src/client/dist/pwa/assets/{QMarkupTable-UhP7fVUp.js → QMarkupTable-B4LLAly-.js} +1 -1
  91. package/src/client/dist/pwa/assets/{QPage-CqkGHokx.js → QPage-DIy_mRoT.js} +1 -1
  92. package/src/client/dist/pwa/assets/{QScrollArea-BMrqK-xd.js → QScrollArea-BvgnY-oA.js} +1 -1
  93. package/src/client/dist/pwa/assets/QSelect-BtZB1wrB.js +1 -0
  94. package/src/client/dist/pwa/assets/{QSpinnerDots-xdWyaE1i.js → QSpinnerDots-Bea6hAuJ.js} +1 -1
  95. package/src/client/dist/pwa/assets/{QTooltip-Bk8dLUtU.js → QTooltip-CDH6p745.js} +1 -1
  96. package/src/client/dist/pwa/assets/{SchedulePanel-CuxmxCKi.js → SchedulePanel-C2rWlh7l.js} +1 -1
  97. package/src/client/dist/pwa/assets/{SearchPage-DKnQPUqY.js → SearchPage-BFd4NQwJ.js} +1 -1
  98. package/src/client/dist/pwa/assets/{SettingsPage-B4rmd2WX.css → SettingsPage-Bm0TcarK.css} +1 -1
  99. package/src/client/dist/pwa/assets/SettingsPage-DDQCgnjL.js +9 -0
  100. package/src/client/dist/pwa/assets/{SplitWorkspacePage-WhQyX8fn.js → SplitWorkspacePage-DKCtOZO4.js} +1 -1
  101. package/src/client/dist/pwa/assets/TerminalPanel-BE_MSZF0.js +16 -0
  102. package/src/client/dist/pwa/assets/{ToolCallItem-DYubi-J0.js → ToolCallItem-BEYaOtgh.js} +4 -4
  103. package/src/client/dist/pwa/assets/{TouchPan-DDotyYLN.js → TouchPan-DeYoOjX5.js} +1 -1
  104. package/src/client/dist/pwa/assets/{TourReplayButton-kAnEMcy2.js → TourReplayButton-D5lMwIKt.js} +1 -1
  105. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-BHEAsIoo.js +1 -0
  106. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-v7ein__H.css +1 -0
  107. package/src/client/dist/pwa/assets/WorkspacePage-5_wsJPaJ.js +4 -0
  108. package/src/client/dist/pwa/assets/WorkspacePage-CtUXz0Cq.css +1 -0
  109. package/src/client/dist/pwa/assets/{browser-DVYLH8vl.js → browser-ogy_9LVv.js} +2 -2
  110. package/src/client/dist/pwa/assets/build-path-tree-CRXxaYkA.js +1 -0
  111. package/src/client/dist/pwa/assets/custom-sounds-iLTkCd_i.js +1 -0
  112. package/src/client/dist/pwa/assets/de-BqDU7iZH.js +17 -0
  113. package/src/client/dist/pwa/assets/detached-fullscreen-CHqMxB-8.js +1 -0
  114. package/src/client/dist/pwa/assets/dom-CRPVbmiy.js +2 -0
  115. package/src/client/dist/pwa/assets/ecl-94qehhXe.js +1 -0
  116. package/src/client/dist/pwa/assets/{editor.api-BC38eXAS.js → editor.api-Dx6TbfZG.js} +9 -9
  117. package/src/client/dist/pwa/assets/editor.worker-xrHNhPSq.js +33 -0
  118. package/src/client/dist/pwa/assets/efforts-DnJh2fW_.js +9 -0
  119. package/src/client/dist/pwa/assets/es-D4VMXQRk.js +17 -0
  120. package/src/client/dist/pwa/assets/expand-template-PUEVWny3.js +1 -0
  121. package/src/client/dist/pwa/assets/{format-OwxXBnDJ.js → format-yf9XM3dV.js} +48 -48
  122. package/src/client/dist/pwa/assets/{formatters-YgaCZcwJ.js → formatters-D0sXu9Oe.js} +1 -1
  123. package/src/client/dist/pwa/assets/fr-DIyaCFB3.js +17 -0
  124. package/src/client/dist/pwa/assets/{freemarker2-l7R42fI2.js → freemarker2-l_xIHxRf.js} +1 -1
  125. package/src/client/dist/pwa/assets/{handlebars-DFmPrgjH.js → handlebars-CYgq99yQ.js} +1 -1
  126. package/src/client/dist/pwa/assets/{html-CERIAlPM.js → html-C28GatN_.js} +1 -1
  127. package/src/client/dist/pwa/assets/i18n-2AmHIUy0.js +1 -0
  128. package/src/client/dist/pwa/assets/index-C0MApoXN.css +1 -0
  129. package/src/client/dist/pwa/assets/index-MMYMztJ_.js +20 -0
  130. package/src/client/dist/pwa/assets/it-CF6_4OS0.js +17 -0
  131. package/src/client/dist/pwa/assets/{javascript-BBYncplF.js → javascript-YCSueyyk.js} +1 -1
  132. package/src/client/dist/pwa/assets/{liquid-C1HqYyYU.js → liquid-CUBn90Gs.js} +1 -1
  133. package/src/client/dist/pwa/assets/{mdx-E74dsP22.js → mdx-C5WOHShJ.js} +1 -1
  134. package/src/client/dist/pwa/assets/monaco-contributions-DYwqbh7m.js +53 -0
  135. package/src/client/dist/pwa/assets/{monaco.contribution-DYdn9CwR.js → monaco.contribution-CoFzrPQl.js} +2 -2
  136. package/src/client/dist/pwa/assets/network-auth-BpJ6Noax.js +1 -0
  137. package/src/client/dist/pwa/assets/notify-theme-NPq4-Tup.js +1 -0
  138. package/src/client/dist/pwa/assets/permissionModes-Bbl901Hx.js +36 -0
  139. package/src/client/dist/pwa/assets/{private.use-form-DY1BkePL.js → private.use-form-BcspuzM9.js} +1 -1
  140. package/src/client/dist/pwa/assets/{project-color-_snXWYUp.js → project-color-gNyhKJlV.js} +1 -1
  141. package/src/client/dist/pwa/assets/{python-DNAAGBcX.js → python-lhKUZdPj.js} +1 -1
  142. package/src/client/dist/pwa/assets/{razor-CB3g5p_T.js → razor-Chi44rEL.js} +1 -1
  143. package/src/client/dist/pwa/assets/reactivity.esm-bundler-Dplx1ILy.js +1 -0
  144. package/src/client/dist/pwa/assets/render-chat-markdown-C9ihAYBt.js +67 -0
  145. package/src/client/dist/pwa/assets/runtime-core.esm-bundler-BAUgNzMK.js +1 -0
  146. package/src/client/dist/pwa/assets/{typescript-0kpz8bqP.js → typescript-CU2TXVm6.js} +1 -1
  147. package/src/client/dist/pwa/assets/{use-checkbox-v4aAbE6K.js → use-checkbox-DUxgXR4e.js} +1 -1
  148. package/src/client/dist/pwa/assets/{use-id-BrdYXce0.js → use-id-B7ieAx2m.js} +1 -1
  149. package/src/client/dist/pwa/assets/use-is-mobile-CHmaewI1.js +1 -0
  150. package/src/client/dist/pwa/assets/{use-search-index-status-D38yFOD_.js → use-search-index-status-CQ9S05G7.js} +1 -1
  151. package/src/client/dist/pwa/assets/use-slide-transition-BJh9xGMa.js +1 -0
  152. package/src/client/dist/pwa/assets/use-tours-BmrZbHjz.js +2 -0
  153. package/src/client/dist/pwa/assets/use-tours-CU5oA7cz.css +1 -0
  154. package/src/client/dist/pwa/assets/use-worktree-restore-DcucRlLh.js +1 -0
  155. package/src/client/dist/pwa/assets/{workspace-templates-D-lPJQ4G.js → workspace-templates-DaG42EEt.js} +1 -1
  156. package/src/client/dist/pwa/assets/{xml-BbOuqdUe.js → xml-CPO5LI6F.js} +1 -1
  157. package/src/client/dist/pwa/assets/{yaml-gasFQtfk.js → yaml-CoYs82jI.js} +1 -1
  158. package/src/client/dist/pwa/index.html +10 -10
  159. package/src/client/dist/pwa/sw.js +1 -1
  160. package/src/mcp-server/README.md +18 -6
  161. package/src/client/dist/pwa/assets/ActivityFeed-D58VS9o2.js +0 -2
  162. package/src/client/dist/pwa/assets/AutoLoopFinalReviewSettings-CK3r_fVB.js +0 -1
  163. package/src/client/dist/pwa/assets/AutoLoopFinalReviewSettings-Q-OlSQ1c.css +0 -1
  164. package/src/client/dist/pwa/assets/CreatePage-DasH1Uk4.js +0 -2
  165. package/src/client/dist/pwa/assets/CreatePage-DxOes_cF.css +0 -1
  166. package/src/client/dist/pwa/assets/DiffViewer-CncKlXne.js +0 -8
  167. package/src/client/dist/pwa/assets/GitPanel-WJlzy2VJ.js +0 -3
  168. package/src/client/dist/pwa/assets/GitPanel-yHvU38dp.css +0 -1
  169. package/src/client/dist/pwa/assets/HealthPage-Cnlhrn--.css +0 -1
  170. package/src/client/dist/pwa/assets/HealthPage-hJY5MurK.js +0 -1
  171. package/src/client/dist/pwa/assets/MainLayout-DgWElK7w.js +0 -2
  172. package/src/client/dist/pwa/assets/MemoryProposalList-kgFCm9zz.js +0 -1
  173. package/src/client/dist/pwa/assets/QBtn-BUSdPaf4.js +0 -1
  174. package/src/client/dist/pwa/assets/QSelect-DFWyDWCE.js +0 -1
  175. package/src/client/dist/pwa/assets/SettingsPage-CO3vIpPd.js +0 -9
  176. package/src/client/dist/pwa/assets/TerminalPanel-BY0lPq6k.js +0 -16
  177. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-B9LE8HtU.css +0 -1
  178. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-DiYt3rP3.js +0 -1
  179. package/src/client/dist/pwa/assets/WorkspacePage-BEBwwr81.css +0 -1
  180. package/src/client/dist/pwa/assets/WorkspacePage-K-1ce50d.js +0 -4
  181. package/src/client/dist/pwa/assets/build-path-tree-DEuOdIIk.js +0 -1
  182. package/src/client/dist/pwa/assets/custom-sounds-Ggw6V9iF.js +0 -1
  183. package/src/client/dist/pwa/assets/de-Bk3zQrin.js +0 -17
  184. package/src/client/dist/pwa/assets/detached-fullscreen-Du3TcYdX.js +0 -1
  185. package/src/client/dist/pwa/assets/dom-BtYTvLeN.js +0 -2
  186. package/src/client/dist/pwa/assets/ecl-D05T4iGw.js +0 -1
  187. package/src/client/dist/pwa/assets/editor.worker-BLJF_oQx.js +0 -33
  188. package/src/client/dist/pwa/assets/efforts-TGacUPcJ.js +0 -9
  189. package/src/client/dist/pwa/assets/es-f3xuAWA9.js +0 -17
  190. package/src/client/dist/pwa/assets/expand-template-DM8gq3Mo.js +0 -1
  191. package/src/client/dist/pwa/assets/fr-B6c21SG3.js +0 -17
  192. package/src/client/dist/pwa/assets/i18n-WaE4cZK9.js +0 -1
  193. package/src/client/dist/pwa/assets/index-C8zAZ-k-.css +0 -1
  194. package/src/client/dist/pwa/assets/index-CpWBGKdM.js +0 -20
  195. package/src/client/dist/pwa/assets/it-DUstDpC2.js +0 -17
  196. package/src/client/dist/pwa/assets/monaco-contributions-DHvm-kp5.js +0 -53
  197. package/src/client/dist/pwa/assets/network-auth-DTZUKbny.js +0 -1
  198. package/src/client/dist/pwa/assets/notify-theme-FuTL7HuS.js +0 -1
  199. package/src/client/dist/pwa/assets/permissionModes-D-Nexnoo.js +0 -36
  200. package/src/client/dist/pwa/assets/reactivity.esm-bundler-DY1JkAEA.js +0 -1
  201. package/src/client/dist/pwa/assets/render-chat-markdown-CafzCcmu.js +0 -69
  202. package/src/client/dist/pwa/assets/runtime-core.esm-bundler-Bqg71Vll.js +0 -1
  203. package/src/client/dist/pwa/assets/use-is-mobile-CDB_rzM9.js +0 -1
  204. package/src/client/dist/pwa/assets/use-slide-transition-B_Nu6u-t.js +0 -1
  205. package/src/client/dist/pwa/assets/use-tours-BWkHp34X.js +0 -2
  206. package/src/client/dist/pwa/assets/use-tours-CTZuBZOi.css +0 -1
  207. package/src/client/dist/pwa/assets/use-worktree-restore-DUjZr44N.js +0 -1
@@ -1,5 +1,7 @@
1
1
  import { execFile, spawn } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
+ import { StringDecoder } from 'node:string_decoder';
4
+ import { stripVTControlCharacters } from 'node:util';
3
5
  import { resolveInstance, sanitizeBranchName } from '../utils/dev-server-instance.js';
4
6
  import { assertWorkspaceLifecycleAvailable } from '../utils/workspace-lifecycle-guard.js';
5
7
  import { getProjectSettings } from './settings-service.js';
@@ -25,6 +27,7 @@ function runCommand(command, args, options = {}) {
25
27
  // ── State ──────────────────────────────────────────────────────────────────────
26
28
  /** workspaceId -> spawned dev-server process */
27
29
  const trackedProcesses = new Map();
30
+ const directServerStatuses = new Map();
28
31
  const stoppingProcesses = new Map();
29
32
  const generations = new Map();
30
33
  let shuttingDown = false;
@@ -34,6 +37,7 @@ export function beginDevServerShutdown() {
34
37
  /** Test-only: clear the tracked-processes map between tests. */
35
38
  export function _resetTrackedProcessesForTests() {
36
39
  trackedProcesses.clear();
40
+ directServerStatuses.clear();
37
41
  stoppingProcesses.clear();
38
42
  generations.clear();
39
43
  shuttingDown = false;
@@ -74,14 +78,18 @@ export async function listRunningContainers(projectName) {
74
78
  * still running (e.g. `docker compose up -d` is pulling/building images), the
75
79
  * status is reported as `'starting'` even if no matching container is visible
76
80
  * in `docker ps` yet. This prevents the UI from flashing to `'stopped'` during
77
- * long build phases.
81
+ * long build phases. Direct processes report running and their URL after a
82
+ * recognized local readiness announcement on stdout or stderr.
78
83
  */
79
84
  export async function getStatus(projectPath, workingBranch, workspaceId) {
80
85
  const config = resolveInstance(projectPath, workingBranch);
86
+ const directStatus = workspaceId ? directServerStatuses.get(workspaceId) : undefined;
87
+ if (workspaceId && directStatus && trackedProcesses.has(workspaceId))
88
+ return { ...directStatus };
81
89
  if (!config) {
82
90
  return {
83
- status: 'unknown',
84
- instanceName: '',
91
+ status: workspaceId && trackedProcesses.has(workspaceId) ? 'starting' : 'unknown',
92
+ instanceName: workspaceId && trackedProcesses.has(workspaceId) ? sanitizeBranchName(workingBranch) : '',
85
93
  projectName: '',
86
94
  httpPort: '',
87
95
  url: '',
@@ -163,12 +171,64 @@ export function startDevServer(workspaceId) {
163
171
  const generation = (generations.get(workspaceId) ?? 0) + 1;
164
172
  generations.set(workspaceId, generation);
165
173
  trackedProcesses.set(workspaceId, proc);
166
- // Log stdout/stderr for debugging
174
+ // Parse complete lines separately per stream: CLI output and ANSI sequences
175
+ // can span arbitrary pipe chunks. Bound the unfinished line retained in memory.
176
+ const observeOutput = () => {
177
+ const decoder = new StringDecoder('utf8');
178
+ let pending = '';
179
+ return (data) => {
180
+ if (shuttingDown ||
181
+ trackedProcesses.get(workspaceId) !== proc ||
182
+ generations.get(workspaceId) !== generation ||
183
+ stoppingProcesses.has(workspaceId) ||
184
+ directServerStatuses.has(workspaceId))
185
+ return;
186
+ pending += decoder.write(data);
187
+ const lines = pending.split(/[\r\n]/);
188
+ pending = lines.pop().slice(-8192);
189
+ for (const line of lines) {
190
+ // Quasar's "App URL....." and Vite/Next's "Local:" are explicit
191
+ // readiness announcements; unrelated links in build output are not.
192
+ const match = stripVTControlCharacters(line).match(/^\s*(?:[»➜-]\s*)?(?:App URL[.\s]*|Local:\s*)(https?:\/\/\S+)\s*$/i);
193
+ if (!match)
194
+ continue;
195
+ let url;
196
+ try {
197
+ url = new URL(match[1]);
198
+ }
199
+ catch {
200
+ continue;
201
+ }
202
+ if (url.username ||
203
+ url.password ||
204
+ !['localhost', '127.0.0.1', '[::1]', '0.0.0.0', '[::]'].includes(url.hostname))
205
+ continue;
206
+ if (url.hostname === '0.0.0.0' || url.hostname === '[::]')
207
+ url.hostname = 'localhost';
208
+ const status = {
209
+ status: 'running',
210
+ instanceName,
211
+ projectName: '',
212
+ httpPort: url.port || (url.protocol === 'https:' ? '443' : '80'),
213
+ url: url.href,
214
+ containers: [],
215
+ };
216
+ directServerStatuses.set(workspaceId, status);
217
+ updateDevServerStatus(workspaceId, 'running');
218
+ emitEphemeral(workspaceId, 'devserver:status', status);
219
+ break;
220
+ }
221
+ };
222
+ };
223
+ const observeStdout = observeOutput();
224
+ const observeStderr = observeOutput();
167
225
  proc.stdout?.on('data', (data) => {
168
226
  console.log(`[dev-server:${instanceName}] ${data.toString().trim()}`);
227
+ observeStdout(data);
169
228
  });
170
229
  proc.stderr?.on('data', (data) => {
171
230
  console.error(`[dev-server:${instanceName}] ${data.toString().trim()}`);
231
+ observeStderr(data);
172
232
  });
173
233
  proc.on('exit', (code) => {
174
234
  // Shell exit does not imply that its background children have stopped.
@@ -181,6 +241,7 @@ export function startDevServer(workspaceId) {
181
241
  if (trackedProcesses.get(workspaceId) !== proc || stoppingProcesses.has(workspaceId))
182
242
  return;
183
243
  trackedProcesses.delete(workspaceId);
244
+ directServerStatuses.delete(workspaceId);
184
245
  const currentStatus = await getStatus(workspace.projectPath, workspace.workingBranch);
185
246
  if (shuttingDown || generations.get(workspaceId) !== generation || stoppingProcesses.has(workspaceId))
186
247
  return;
@@ -200,6 +261,7 @@ export function startDevServer(workspaceId) {
200
261
  // A signal failure is also an 'error' event; it does not prove process exit.
201
262
  if (!proc.pid)
202
263
  trackedProcesses.delete(workspaceId);
264
+ directServerStatuses.delete(workspaceId);
203
265
  updateDevServerStatus(workspaceId, 'error');
204
266
  console.error(`[dev-server] Process error for workspace ${workspaceId}:`, err);
205
267
  emitEphemeral(workspaceId, 'devserver:status', {
@@ -233,6 +295,7 @@ export async function stopDevServer(workspaceId) {
233
295
  if (pending)
234
296
  return pending;
235
297
  generations.set(workspaceId, (generations.get(workspaceId) ?? 0) + 1);
298
+ directServerStatuses.delete(workspaceId);
236
299
  const stopping = stopDevServerOwned(workspaceId);
237
300
  stoppingProcesses.set(workspaceId, stopping);
238
301
  try {
@@ -333,6 +396,7 @@ async function stopTrackedProcess(workspaceId) {
333
396
  throw new Error('Dev server stop is not confirmed; retry after the process exits');
334
397
  if (trackedProcesses.get(workspaceId) === tracked)
335
398
  trackedProcesses.delete(workspaceId);
399
+ directServerStatuses.delete(workspaceId);
336
400
  }
337
401
  async function stopDevServerOwned(workspaceId) {
338
402
  const workspace = getWorkspace(workspaceId);
@@ -66,13 +66,15 @@ function liveProbe() {
66
66
  },
67
67
  worktreesDirectory: (project) => resolveWorktreesRoot(project, getGlobalSettings().worktreesPath),
68
68
  codexBinary: resolveCodexBinary,
69
+ mistralBinary: () => 'vibe-acp',
69
70
  };
70
71
  }
71
72
  /** No credential files, provider calls, generated prompts, or raw process errors in this report. */
72
73
  export async function checkEnvironment(request, probe = liveProbe()) {
73
- const [major, minor] = probe.nodeVersion.split('.').map(Number);
74
+ const [major, minor, patch] = probe.nodeVersion.split('.').map(Number);
75
+ const supportedNode = major > 26 || (major === 26 && (minor > 12 || (minor === 12 && patch >= 1)));
74
76
  const checks = [
75
- { code: 'node', status: major > 24 || (major === 24 && minor >= 15) ? 'ok' : 'error' },
77
+ { code: 'node', status: supportedNode ? 'ok' : 'error' },
76
78
  { code: 'platform', status: ['linux', 'darwin'].includes(probe.platform) ? 'ok' : 'error' },
77
79
  ];
78
80
  const commandAvailable = async (command, args) => {
@@ -84,7 +86,11 @@ export async function checkEnvironment(request, probe = liveProbe()) {
84
86
  return error.code === 'ENOENT' ? 'missing' : 'error';
85
87
  }
86
88
  };
87
- const binary = request.engine === 'codex' ? probe.codexBinary() : probe.claudeBinary();
89
+ const binary = request.engine === 'mistral-vibe'
90
+ ? probe.mistralBinary()
91
+ : request.engine === 'codex'
92
+ ? probe.codexBinary()
93
+ : probe.claudeBinary();
88
94
  const [git, shell, storage, runtime] = await Promise.all([
89
95
  commandAvailable('git', ['--version']),
90
96
  commandAvailable('bash', ['--version']),
@@ -154,7 +154,7 @@ function assertInternalActorCanAccessScope(actor, scope) {
154
154
  if (!source || source.workspace_id !== actor.workspaceId) {
155
155
  throw new MemoryNotFoundError('Agent session is not bound to the stated workspace');
156
156
  }
157
- if (source.engine !== 'claude-code' && source.engine !== 'codex') {
157
+ if (source.engine !== 'claude-code' && source.engine !== 'codex' && source.engine !== 'mistral-vibe') {
158
158
  throw new MemoryNotFoundError('Bound agent session has no valid engine');
159
159
  }
160
160
  if (source.engine !== actor.engine)
@@ -0,0 +1,145 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { resolve } from 'node:path';
4
+ import * as acp from '@agentclientprotocol/sdk';
5
+ import { closeVibeAcpSession, connectVibeAcp, requestVibeAcp, } from './agent/engines/mistral-vibe/client.js';
6
+ import { assertMistralSelection, MISTRAL_MODES, readMistralSelect, requireMistralOption, } from './agent/engines/mistral-vibe/configuration.js';
7
+ import { spawnVibeAcp } from './agent/engines/mistral-vibe/spawn.js';
8
+ import { getWorkspace } from './workspace-service.js';
9
+ const pending = new Map();
10
+ const cache = new Map();
11
+ let owned = 0;
12
+ export async function getMistralOptions(input) {
13
+ for (const [key, value] of Object.entries(input)) {
14
+ if (value !== undefined &&
15
+ (typeof value !== 'string' ||
16
+ !value.trim() ||
17
+ value.length > (key === 'projectPath' ? 4096 : 256) ||
18
+ value.includes('\0')))
19
+ throw new Error(`Invalid Mistral Vibe ${key}`);
20
+ }
21
+ if (input.workspaceId && input.projectPath)
22
+ throw new Error('Choose workspaceId or projectPath, not both');
23
+ const permission = input.agentPermissionMode ?? 'interactive';
24
+ if (!Object.hasOwn(MISTRAL_MODES, permission))
25
+ throw new Error('Mistral Vibe requires a supported native permission mode');
26
+ assertMistralSelection(input.model, 'model');
27
+ const workspace = input.workspaceId ? getWorkspace(input.workspaceId) : undefined;
28
+ if (input.workspaceId && !workspace)
29
+ throw new Error('Workspace not found');
30
+ if (workspace &&
31
+ (typeof workspace.worktreePath !== 'string' || !workspace.worktreePath || workspace.worktreePurgedAt))
32
+ throw new Error('Restore the workspace context before discovering Vibe options');
33
+ const cwd = resolve(workspace?.worktreePath ?? input.projectPath ?? homedir());
34
+ if (!(await stat(cwd).catch(() => undefined))?.isDirectory())
35
+ throw new Error('Mistral Vibe context directory does not exist');
36
+ const mode = MISTRAL_MODES[permission];
37
+ const model = input.model && input.model !== 'auto' ? input.model : undefined;
38
+ const key = JSON.stringify([cwd, mode, model]);
39
+ const cached = cache.get(key);
40
+ if (cached && cached.expires > Date.now())
41
+ return cached.options;
42
+ const existing = pending.get(key);
43
+ if (existing)
44
+ return existing;
45
+ if (owned >= 4)
46
+ throw new Error('Mistral Vibe discovery is busy or awaiting native closure; try again after closure');
47
+ owned++;
48
+ const probe = (async () => {
49
+ let runtime;
50
+ try {
51
+ const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !/^KOBO_.*TOKEN$/.test(name)));
52
+ runtime = spawnVibeAcp({
53
+ cwd,
54
+ env: { ...env, VIBE_DEFAULT_AGENT: mode, ...(model ? { VIBE_ACTIVE_MODEL: model } : {}) },
55
+ });
56
+ }
57
+ catch {
58
+ owned--;
59
+ throw new Error('Cannot start native Vibe discovery; check vibe-acp on PATH');
60
+ }
61
+ let connection;
62
+ let sessionId;
63
+ let canClose = false;
64
+ // Failed shutdown never releases a slot while native ownership remains unresolved.
65
+ void runtime.closed.then(() => {
66
+ owned--;
67
+ connection?.close();
68
+ });
69
+ try {
70
+ connection = connectVibeAcp(runtime.child, {
71
+ requestPermission: () => ({ outcome: { outcome: 'cancelled' } }),
72
+ createElicitation: () => ({ action: 'cancel' }),
73
+ });
74
+ const initialized = await requestVibeAcp(connection, acp.methods.agent.initialize, {
75
+ protocolVersion: acp.PROTOCOL_VERSION,
76
+ clientCapabilities: {},
77
+ });
78
+ const capabilities = initialized.agentCapabilities;
79
+ if (!capabilities || typeof capabilities !== 'object' || Array.isArray(capabilities))
80
+ throw new Error('Invalid Vibe ACP capabilities');
81
+ if (capabilities.sessionCapabilities != null &&
82
+ (typeof capabilities.sessionCapabilities !== 'object' || Array.isArray(capabilities.sessionCapabilities)))
83
+ throw new Error('Invalid Vibe ACP session capabilities');
84
+ const close = capabilities.sessionCapabilities?.close;
85
+ if (close != null && (typeof close !== 'object' || Array.isArray(close)))
86
+ throw new Error('Invalid Vibe ACP close capability');
87
+ canClose = close != null;
88
+ const session = await requestVibeAcp(connection, acp.methods.agent.session.new, { cwd, mcpServers: [] });
89
+ sessionId = session.sessionId;
90
+ if (sessionId.length > 256 || sessionId.includes('\0'))
91
+ throw new Error('Invalid Vibe ACP identity');
92
+ requireMistralOption(session, 'mode', mode, true);
93
+ if (model)
94
+ requireMistralOption(session, 'model', model, true);
95
+ const options = { model: readMistralSelect(session, 'model'), thinking: readMistralSelect(session, 'thinking') };
96
+ await runtime.stop(() => closeVibeAcpSession(connection, { sessionId: sessionId, active: false, canClose }));
97
+ await runtime.closed;
98
+ if (cache.size >= 32)
99
+ cache.delete(cache.keys().next().value);
100
+ cache.set(key, { options, expires: Date.now() + 30_000 });
101
+ return options;
102
+ }
103
+ catch {
104
+ throw new Error('Native Vibe options unavailable; check the executable, authentication, project/profile and selected model, then retry');
105
+ }
106
+ finally {
107
+ await runtime
108
+ .stop(connection && sessionId
109
+ ? () => closeVibeAcpSession(connection, { sessionId: sessionId, active: false, canClose })
110
+ : undefined)
111
+ .catch(() => {
112
+ throw new Error('Native Vibe discovery shutdown unconfirmed; ownership retained until confirmed closure or server restart');
113
+ });
114
+ }
115
+ })();
116
+ pending.set(key, probe);
117
+ try {
118
+ return await probe;
119
+ }
120
+ finally {
121
+ pending.delete(key);
122
+ }
123
+ }
124
+ /** External mutation preflight; the actual new/load session is still authoritative. */
125
+ export async function preflightMistralConfiguration(engine, context, selection, resume = false) {
126
+ if (engine !== 'mistral-vibe')
127
+ return;
128
+ assertMistralSelection(selection.model, 'model');
129
+ assertMistralSelection(selection.reasoningEffort, 'thinking');
130
+ // New-session defaults cannot validate thinking for an Auto model pinned in a saved session.
131
+ if (resume && (selection.model === undefined || selection.model === 'auto'))
132
+ return;
133
+ if ((selection.model === undefined || selection.model === 'auto') &&
134
+ (selection.reasoningEffort === undefined || selection.reasoningEffort === 'auto'))
135
+ return;
136
+ const options = await getMistralOptions({
137
+ ...context,
138
+ model: selection.model,
139
+ agentPermissionMode: selection.agentPermissionMode,
140
+ });
141
+ if (selection.reasoningEffort !== undefined &&
142
+ selection.reasoningEffort !== 'auto' &&
143
+ !options.thinking.options.some((option) => option.value === selection.reasoningEffort))
144
+ throw new Error('Mistral Vibe thinking selection is unavailable for this model; refresh native options');
145
+ }
@@ -6,6 +6,7 @@ import { hasController } from './agent/orchestrator.js';
6
6
  import { stopDevServer } from './dev-server-service.js';
7
7
  import { getForgeProvider } from './forge/registry.js';
8
8
  import { resolveForge } from './forge/resolve.js';
9
+ import { deriveReadyToMerge } from './forge/types.js';
9
10
  import { computeGitStats } from './git-stats-service.js';
10
11
  import * as lifecycleHookService from './lifecycle-hook-service.js';
11
12
  import { getGlobalSettings } from './settings-service.js';
@@ -38,15 +39,31 @@ async function runBounded(items, concurrency, worker) {
38
39
  });
39
40
  await Promise.all(workers);
40
41
  }
41
- /** Tracks the last known PR snapshot per workspace, used to detect transitions
42
- * (state, base, reviewDecision). */
42
+ /** Latest snapshot for the UI, including explicit refreshes. */
43
43
  const lastKnownPr = new Map();
44
+ /** Only the watcher consumes transitions; refreshing the UI must not consume a merge. */
45
+ const lastProcessedPr = new Map();
46
+ function recordProcessedPr(workspaceId, snapshot, displayedAtStart) {
47
+ const previous = lastProcessedPr.get(workspaceId);
48
+ // A refresh completed during this tick's Git work may already have newer data.
49
+ if (lastKnownPr.get(workspaceId) === displayedAtStart)
50
+ lastKnownPr.set(workspaceId, snapshot);
51
+ // Keep notification history through a recalculation, while the public snapshot
52
+ // still blocks merging until the forge has actually confirmed mergeability.
53
+ const keepReadiness = previous?.number === snapshot.number &&
54
+ previous.url === snapshot.url &&
55
+ previous.readyToMerge &&
56
+ snapshot.mergeable === 'UNKNOWN' &&
57
+ deriveReadyToMerge({ ...snapshot, mergeable: 'MERGEABLE' });
58
+ lastProcessedPr.set(workspaceId, keepReadiness ? { ...snapshot, readyToMerge: true } : snapshot);
59
+ }
44
60
  /** Latest git-stats snapshot per workspace, refreshed each watcher tick. */
45
61
  const lastKnownGitStats = new Map();
46
62
  const activeChecks = new Map();
47
63
  /** Also cancels stale results from checks that started before restoration. */
48
64
  export function invalidateWorkspacePrCaches(workspaceId) {
49
65
  lastKnownPr.delete(workspaceId);
66
+ lastProcessedPr.delete(workspaceId);
50
67
  lastKnownGitStats.delete(workspaceId);
51
68
  for (const check of activeChecks.get(workspaceId) ?? [])
52
69
  check.invalidated = true;
@@ -77,6 +94,9 @@ export function getAllGitStats() {
77
94
  * workspace's entry, so this must be done explicitly by the caller). */
78
95
  export function clearPrSnapshotCache(workspaceId) {
79
96
  lastKnownPr.delete(workspaceId);
97
+ lastProcessedPr.delete(workspaceId);
98
+ for (const check of activeChecks.get(workspaceId) ?? [])
99
+ check.invalidated = true;
80
100
  }
81
101
  /**
82
102
  * Test-only escape hatch — drops the in-memory cache so each test starts
@@ -84,6 +104,7 @@ export function clearPrSnapshotCache(workspaceId) {
84
104
  */
85
105
  export function _resetForTest() {
86
106
  lastKnownPr.clear();
107
+ lastProcessedPr.clear();
87
108
  lastKnownGitStats.clear();
88
109
  }
89
110
  /**
@@ -127,9 +148,10 @@ export async function checkPrStatuses() {
127
148
  await autoRestoreManuallyRecreatedWorktrees();
128
149
  const workspaces = listWorkspaces(false); // non-archived only
129
150
  // Clean up entries for workspaces that no longer exist
130
- for (const id of lastKnownPr.keys()) {
151
+ for (const id of new Set([...lastKnownPr.keys(), ...lastProcessedPr.keys()])) {
131
152
  if (!workspaces.some((ws) => ws.id === id)) {
132
153
  lastKnownPr.delete(id);
154
+ lastProcessedPr.delete(id);
133
155
  }
134
156
  }
135
157
  for (const id of lastKnownGitStats.keys()) {
@@ -142,6 +164,7 @@ export async function checkPrStatuses() {
142
164
  // floods the logs when a worktree was deleted externally.
143
165
  if (isWorkspaceLifecycleBusy(ws.id) || !fs.existsSync(ws.worktreePath))
144
166
  return;
167
+ const displayedAtStart = lastKnownPr.get(ws.id);
145
168
  const check = { invalidated: false };
146
169
  const checks = activeChecks.get(ws.id) ?? new Set();
147
170
  checks.add(check);
@@ -172,7 +195,7 @@ export async function checkPrStatuses() {
172
195
  // `gh pr edit --base …`, the lag was painful and confusing.
173
196
  let baseTransitionedFrom = null;
174
197
  if (pr?.state === 'OPEN' && pr.base) {
175
- const prevBase = lastKnownPr.get(ws.id)?.base ?? ws.sourceBranch;
198
+ const prevBase = ws.sourceBranch;
176
199
  if (prevBase !== pr.base) {
177
200
  try {
178
201
  updateWorkspaceSourceBranch(ws.id, pr.base);
@@ -211,7 +234,8 @@ export async function checkPrStatuses() {
211
234
  }
212
235
  if (stale() || !pr)
213
236
  return;
214
- const prev = lastKnownPr.get(ws.id);
237
+ const processed = lastProcessedPr.get(ws.id);
238
+ const prev = processed?.number === pr.number && processed.url === pr.url ? processed : undefined;
215
239
  // We delay updating `lastKnownPr` until after the actions succeed.
216
240
  // Setting it eagerly would poison the cache: if updateWorkspaceSourceBranch
217
241
  // throws (transient DB issue, race with workspace deletion), the cache
@@ -242,7 +266,7 @@ export async function checkPrStatuses() {
242
266
  // manual archive route and purge-worktree, this path never stops the
243
267
  // agent, so archiving under one orphans its pending question and
244
268
  // leaves it writing into a workspace the UI now shows as archived.
245
- lastKnownPr.set(ws.id, pr);
269
+ recordProcessedPr(ws.id, pr, displayedAtStart);
246
270
  return;
247
271
  }
248
272
  console.log(`[pr-watcher] PR ${pr.state.toLowerCase()} for workspace '${ws.name}' — archiving`);
@@ -275,6 +299,7 @@ export async function checkPrStatuses() {
275
299
  if (!archived)
276
300
  return;
277
301
  lastKnownPr.delete(ws.id);
302
+ lastProcessedPr.delete(ws.id);
278
303
  emitEphemeral(ws.id, 'workspace:archived', {
279
304
  reason: `PR ${pr.state.toLowerCase()}`,
280
305
  prUrl: pr.url,
@@ -344,10 +369,10 @@ export async function checkPrStatuses() {
344
369
  // missing one — keeps the OPEN→CLOSED/MERGED archiving logic stable.
345
370
  if (pr.state !== 'OPEN' || !pr.base) {
346
371
  const next = pr.base ? pr : { ...pr, base: prev?.base ?? pr.base };
347
- lastKnownPr.set(ws.id, next);
372
+ recordProcessedPr(ws.id, next, displayedAtStart);
348
373
  return;
349
374
  }
350
- lastKnownPr.set(ws.id, pr);
375
+ recordProcessedPr(ws.id, pr, displayedAtStart);
351
376
  // Emit the base-change event AFTER the snapshot is committed so a
352
377
  // sync:response replay on reconnect sees a consistent state. The
353
378
  // DB update + ws.sourceBranch mutation already happened above
@@ -372,9 +397,8 @@ export async function checkPrStatuses() {
372
397
  }
373
398
  /**
374
399
  * On-demand refresh of a single workspace's PR snapshot. Bypasses the 30s tick.
375
- * No side effects beyond cache update — no archive, no transition emits. The
376
- * user is watching the UI; we don't replay events for state they're already
377
- * looking at.
400
+ * Publishes the snapshot and synchronizes the PR base. Lifecycle transitions
401
+ * remain pending for the watcher, including OPEN → MERGED after a UI merge.
378
402
  *
379
403
  * Returns the fresh snapshot, or null if the workspace has no PR (cache entry
380
404
  * cleared in that case). Throws if the workspace doesn't exist.
@@ -421,6 +445,11 @@ export async function refreshPrSnapshot(workspaceId) {
421
445
  }
422
446
  }
423
447
  lastKnownPr.set(workspaceId, snap);
448
+ const processed = lastProcessedPr.get(workspaceId);
449
+ if (!ws.prWatchDisabledAt &&
450
+ !current.prWatchDisabledAt &&
451
+ (processed?.number !== snap.number || processed.url !== snap.url))
452
+ lastProcessedPr.set(workspaceId, snap);
424
453
  return snap;
425
454
  }
426
455
  finally {
@@ -9,6 +9,7 @@ import { listEngines } from './agent/engines/registry.js';
9
9
  import * as agentManager from './agent/orchestrator.js';
10
10
  import { bindReviewSession, getFinalReviewSourceSession } from './auto-loop-final-review-service.js';
11
11
  import { canStartAutomatically } from './auto-loop-service.js';
12
+ import { preflightMistralConfiguration } from './mistral-options-service.js';
12
13
  import { beginReviewLaunch, finishReviewLaunch } from './review-launch-runtime.js';
13
14
  import { getReviewReturn, registerReviewReturn, restoreReviewConfiguration } from './review-return-service.js';
14
15
  import { getActiveReviewTemplate, renderReviewTemplate } from './review-template-service.js';
@@ -47,20 +48,28 @@ function resolveConfiguration(workspace, body, allowCustomModel = false) {
47
48
  const engine = listEngines().find((e) => e.id === engineId);
48
49
  if (!engine)
49
50
  throw new ReviewRequestError(`Unknown engine '${engineId}'`, 400);
51
+ if (!engine.capabilities.supportsReadOnly && engineId !== 'mistral-vibe')
52
+ throw new ReviewRequestError(`Engine '${engineId}' does not support read-only reviews`, 400);
50
53
  const changedEngine = engineId !== workspace.engine;
51
54
  const config = {
52
55
  engine: engineId,
53
56
  model: body.model ?? (changedEngine ? 'auto' : workspace.model),
54
57
  reasoningEffort: body.reasoningEffort ?? (changedEngine ? 'auto' : workspace.reasoningEffort),
55
- // A review that hands its report back must end by itself: always read-only.
56
- agentPermissionMode: body.returnToSession ? 'plan' : (body.agentPermissionMode ?? workspace.agentPermissionMode),
58
+ // Native reviews have independent permissions; other returning reviewers use Plan.
59
+ agentPermissionMode: engineId === 'mistral-vibe'
60
+ ? (body.agentPermissionMode ?? 'interactive')
61
+ : body.returnToSession
62
+ ? 'plan'
63
+ : (body.agentPermissionMode ?? workspace.agentPermissionMode),
57
64
  };
58
65
  // Preserve an existing custom model when unchanged; reject incompatible selections.
59
- if (!allowCustomModel &&
66
+ if (engineId !== 'mistral-vibe' &&
67
+ !allowCustomModel &&
60
68
  (changedEngine || config.model !== workspace.model) &&
61
69
  !engine.capabilities.models.some((m) => m.id === config.model))
62
70
  throw new ReviewRequestError(`Model '${config.model}' is not supported by '${engineId}'`, 400);
63
- if ((changedEngine || config.reasoningEffort !== workspace.reasoningEffort) &&
71
+ if (engineId !== 'mistral-vibe' &&
72
+ (changedEngine || config.reasoningEffort !== workspace.reasoningEffort) &&
64
73
  !engine.capabilities.effortLevels?.some((e) => e.id === config.reasoningEffort))
65
74
  throw new ReviewRequestError(`Reasoning effort '${config.reasoningEffort}' is not supported by '${engineId}'`, 400);
66
75
  if (!engine.capabilities.permissionModes.includes(config.agentPermissionMode))
@@ -81,6 +90,15 @@ export async function startWorkspaceReview(id, input, internal) {
81
90
  throw new ReviewRequestError(`Workspace '${id}' not found`, 404);
82
91
  const body = parseReviewRequest(input);
83
92
  const configuration = resolveConfiguration(workspace, body, internal?.autoLoopFinalReview);
93
+ const nativeReview = configuration.engine === 'mistral-vibe';
94
+ if (nativeReview && internal?.autoLoopFinalReview)
95
+ configuration.agentPermissionMode = 'interactive';
96
+ try {
97
+ await preflightMistralConfiguration(configuration.engine, { workspaceId: id }, configuration);
98
+ }
99
+ catch (error) {
100
+ throw new ReviewRequestError(error instanceof Error ? error.message : 'Invalid native review configuration', 400);
101
+ }
84
102
  assertWorkspaceLifecycleAvailable(id);
85
103
  if (workspace.archivedAt || workspace.worktreePurgedAt)
86
104
  throw new ReviewRequestError('Restore the workspace before starting a review', 409);
@@ -94,7 +112,7 @@ export async function startWorkspaceReview(id, input, internal) {
94
112
  const differsFromSession = !!originalSession &&
95
113
  ((!!originalSession.engine && originalSession.engine !== configuration.engine) ||
96
114
  (configuration.model !== 'auto' && !!originalSession.model && originalSession.model !== configuration.model));
97
- const newSession = body.newSession === true || changed || differsFromSession || body.returnToSession === true;
115
+ const newSession = nativeReview || body.newSession === true || changed || differsFromSession || body.returnToSession === true;
98
116
  if (body.returnToSession &&
99
117
  (!originalSession?.engineSessionId || (originalSession.engine && originalSession.engine !== workspace.engine)))
100
118
  throw new ReviewRequestError('The current session cannot be resumed; start it before requesting an automatic return', 400);
@@ -134,7 +152,10 @@ export async function startWorkspaceReview(id, input, internal) {
134
152
  });
135
153
  // The return fires only when the review session ends by itself: a reviewer
136
154
  // that starts fixing, or waits on a question, never hands back.
137
- if (body.returnToSession)
155
+ if (nativeReview)
156
+ rendered +=
157
+ '\n\nThis is a report-only review under native Vibe permissions, without guaranteed filesystem read-only enforcement. These rules override any template or skill suggesting fixes: do not modify, create or delete files, commit, push, or mutate tasks, workspace state or memory. Report findings and recommended fixes only. Respect native approval requests; never bypass or auto-approve them. If approval is required, wait for the user. Finish with a standalone report including severity, file/line references and remaining uncertainty.';
158
+ else if (body.returnToSession)
138
159
  rendered +=
139
160
  '\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.' +
140
161
  '\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.';
@@ -177,7 +198,10 @@ export async function startWorkspaceReview(id, input, internal) {
177
198
  original: {
178
199
  engine: workspace.engine,
179
200
  model: workspace.model,
180
- ...(originalSession.model ? { sessionModel: originalSession.model } : {}),
201
+ ...((workspace.engine !== 'mistral-vibe' || workspace.model !== 'auto') && originalSession.model
202
+ ? { sessionModel: originalSession.model }
203
+ : {}),
204
+ // Restore iteration intent separately; native review returns retain saved session thinking.
181
205
  reasoningEffort: workspace.reasoningEffort,
182
206
  agentPermissionMode: workspace.agentPermissionMode,
183
207
  },
@@ -204,9 +228,9 @@ export async function startWorkspaceReview(id, input, internal) {
204
228
  pendingSessionId,
205
229
  configuration.reasoningEffort,
206
230
  ];
207
- if (reviewLaunch) {
231
+ if (nativeReview || reviewLaunch) {
208
232
  startArgs[8] = undefined;
209
- startArgs[9] = { mcpEnv: reviewLaunch.mcpEnv };
233
+ startArgs[9] = { mcpEnv: { ...reviewLaunch?.mcpEnv, ...(nativeReview ? { KOBO_REVIEW_ONLY: '1' } : {}) } };
210
234
  }
211
235
  const agent = agentManager.startAgent(...startArgs);
212
236
  workspaceService.updateWorkspaceStatus(id, 'executing');
@@ -6,9 +6,11 @@ import { getDb } from '../db/index.js';
6
6
  import { assertAgentStopped } from '../utils/agent-stop-result.js';
7
7
  import { ensureDirectoryInside } from '../utils/safe-path.js';
8
8
  import { reserveWorkspaceLifecycle } from '../utils/workspace-lifecycle-guard.js';
9
+ import { assertEngineConfigurationSupported } from './agent/engines/configuration.js';
9
10
  import { listEngines } from './agent/engines/registry.js';
10
11
  import * as agents from './agent/orchestrator.js';
11
12
  import { buildEngineHandoff } from './engine-handoff-service.js';
13
+ import { preflightMistralConfiguration } from './mistral-options-service.js';
12
14
  import { getReviewReturn } from './review-return-service.js';
13
15
  import { activateSession, deactivateSession } from './session-activity-service.js';
14
16
  import { registerHandoffStopHandler } from './session-handoff-runtime.js';
@@ -180,6 +182,15 @@ function parseRequest(input) {
180
182
  },
181
183
  };
182
184
  }
185
+ export async function preflightSessionHandoff(workspaceId, input) {
186
+ const body = parseRequest(input);
187
+ // Existing requests are replayed by createSessionHandoff with fingerprint validation.
188
+ if (getDb()
189
+ .prepare('SELECT 1 FROM session_handoffs WHERE workspace_id = ? AND request_id = ?')
190
+ .get(workspaceId, body.requestId))
191
+ return;
192
+ await preflightMistralConfiguration(body.target.engine, { workspaceId }, body.target);
193
+ }
183
194
  export function createSessionHandoff(workspaceId, input, suppliedReport) {
184
195
  if (agents.isShuttingDown())
185
196
  throw new SessionHandoffError('Kōbō is shutting down');
@@ -207,11 +218,22 @@ export function createSessionHandoff(workspaceId, input, suppliedReport) {
207
218
  throw new SessionHandoffError('The current session changed. Reopen the transfer dialog.');
208
219
  const engine = listEngines().find((item) => item.id === body.target.engine);
209
220
  if (!engine?.capabilities.permissionModes.includes(body.target.agentPermissionMode) ||
210
- (!engine.capabilities.models.some((model) => model.id === body.target.model) &&
221
+ (engine.id !== 'mistral-vibe' &&
222
+ !engine.capabilities.models.some((model) => model.id === body.target.model) &&
211
223
  !(workspace.engine === body.target.engine && workspace.model === body.target.model)) ||
212
- (body.target.reasoningEffort !== 'auto' &&
224
+ (engine.id !== 'mistral-vibe' &&
225
+ body.target.reasoningEffort !== 'auto' &&
213
226
  !engine.capabilities.effortLevels?.some((effort) => effort.id === body.target.reasoningEffort)))
214
227
  throw new SessionHandoffError('The target engine configuration is not supported', 400);
228
+ try {
229
+ assertEngineConfigurationSupported(engine, body.target);
230
+ }
231
+ catch (error) {
232
+ throw new SessionHandoffError(error instanceof Error ? error.message : 'Invalid engine configuration', 400);
233
+ }
234
+ if (body.generateSummary &&
235
+ !listEngines().find((item) => item.id === workspace.engine)?.capabilities.supportsReadOnly)
236
+ throw new SessionHandoffError('The source engine does not support read-only handoff summaries', 400);
215
237
  const id = nanoid();
216
238
  const runtime = reserve(workspaceId, id);
217
239
  try {
@@ -289,6 +311,10 @@ async function run(runtime) {
289
311
  runtime.running = true;
290
312
  const row = read(runtime.id);
291
313
  try {
314
+ const target = map(row).target;
315
+ await preflightMistralConfiguration(target.engine, { workspaceId: row.workspace_id }, target);
316
+ if (runtime.cancelled)
317
+ return;
292
318
  state(row.id, 'stopping');
293
319
  assertAgentStopped(await agents.stopAgentAndWait(row.workspace_id, undefined, 'handoff'));
294
320
  if (runtime.cancelled)
@@ -89,4 +89,5 @@ export const DEFAULT_MODEL_BY_ENGINE = {
89
89
  export const FRESH_MODEL_BY_ENGINE = {
90
90
  'claude-code': 'claude-sonnet-5-5',
91
91
  codex: 'auto',
92
+ 'mistral-vibe': 'auto',
92
93
  };