@pheem49/mint 1.15.0 → 1.16.0

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 (644) hide show
  1. package/Cargo.lock +1012 -69
  2. package/Cargo.toml +5 -2
  3. package/LICENSE +21 -654
  4. package/README.md +92 -817
  5. package/Release_Note.md +53 -560
  6. package/apps/mint-companion/src-tauri/Cargo.toml +20 -0
  7. package/apps/mint-companion/src-tauri/build.rs +3 -0
  8. package/apps/mint-companion/src-tauri/src/main.rs +468 -0
  9. package/apps/mint-companion/tsconfig.json +20 -0
  10. package/apps/mint-companion/vite.config.ts +3 -0
  11. package/components.json +19 -0
  12. package/crates/mint-cli/Cargo.toml +6 -0
  13. package/crates/mint-cli/src/agent/live_status.rs +187 -39
  14. package/crates/mint-cli/src/agent.rs +323 -95
  15. package/crates/mint-cli/src/commands/agent.rs +69 -55
  16. package/crates/mint-cli/src/commands/integrations.rs +7 -0
  17. package/crates/mint-cli/src/commands/knowledge.rs +12 -32
  18. package/crates/mint-cli/src/commands/mod.rs +44 -0
  19. package/crates/mint-cli/src/image.rs +31 -33
  20. package/crates/mint-cli/src/interactive/chat_tui.rs +3594 -483
  21. package/crates/mint-cli/src/interactive/input_box.rs +56 -6
  22. package/crates/mint-cli/src/interactive/slash_commands.rs +208 -56
  23. package/crates/mint-cli/src/interactive/tool_activity.rs +328 -0
  24. package/crates/mint-cli/src/interactive.rs +64 -40
  25. package/crates/mint-cli/src/main.rs +13 -7
  26. package/crates/mint-cli/src/mcp.rs +5 -0
  27. package/crates/mint-cli/src/mod_host.rs +6 -0
  28. package/crates/mint-cli/src/mods_cli.rs +62 -0
  29. package/crates/mint-cli/src/mods_ui.rs +187 -0
  30. package/crates/mint-cli/src/onboard.rs +5 -31
  31. package/crates/mint-cli/src/terminal_theme.rs +3 -1
  32. package/crates/mint-cli/src/updater.rs +56 -2
  33. package/crates/mint-cli/tests/library_intake.rs +77 -0
  34. package/crates/mint-cli/tests/mods_testing.rs +133 -0
  35. package/crates/mint-companion-protocol/Cargo.toml +10 -0
  36. package/crates/mint-companion-protocol/src/lib.rs +163 -0
  37. package/crates/mint-core/Cargo.toml +6 -3
  38. package/crates/mint-core/build.rs +26 -0
  39. package/crates/mint-core/src/agent/chat.rs +167 -39
  40. package/crates/mint-core/src/agent/code_tools.rs +15 -0
  41. package/crates/mint-core/src/agent/memory/sources.rs +240 -0
  42. package/crates/mint-core/src/agent/memory.rs +147 -13
  43. package/crates/mint-core/src/agent/skill_inventory.rs +325 -0
  44. package/crates/mint-core/src/agent/skills.rs +437 -90
  45. package/crates/mint-core/src/agent.rs +1 -0
  46. package/crates/mint-core/src/api_server/routes/background_jobs.rs +310 -0
  47. package/crates/mint-core/src/api_server/routes/chat.rs +216 -199
  48. package/crates/mint-core/src/api_server/routes/html_preview.rs +77 -0
  49. package/crates/mint-core/src/api_server/routes/library.rs +97 -0
  50. package/crates/mint-core/src/api_server/routes/media_gen.rs +10 -57
  51. package/crates/mint-core/src/api_server/routes/misc.rs +104 -107
  52. package/crates/mint-core/src/api_server/routes/mod.rs +6 -0
  53. package/crates/mint-core/src/api_server/routes/mods.rs +31 -0
  54. package/crates/mint-core/src/api_server/routes/sessions.rs +41 -0
  55. package/crates/mint-core/src/api_server/routes/skills_subagents.rs +12 -42
  56. package/crates/mint-core/src/api_server/routes/slash.rs +50 -1
  57. package/crates/mint-core/src/api_server/routes/sources.rs +101 -0
  58. package/crates/mint-core/src/api_server.rs +118 -9
  59. package/crates/mint-core/src/avatar_bridge.rs +11 -2
  60. package/crates/mint-core/src/browser/session.rs +52 -0
  61. package/crates/mint-core/src/companion.rs +716 -0
  62. package/crates/mint-core/src/cron/scheduler.rs +34 -2
  63. package/crates/mint-core/src/integrations/channels.rs +87 -345
  64. package/crates/mint-core/src/integrations/mcp.rs +397 -50
  65. package/crates/mint-core/src/integrations/mcp_calls.rs +823 -0
  66. package/crates/mint-core/src/integrations/mcp_catalog.rs +267 -0
  67. package/crates/mint-core/src/integrations/mcp_device.rs +319 -0
  68. package/crates/mint-core/src/integrations/mcp_result.rs +356 -0
  69. package/crates/mint-core/src/integrations/webhooks.rs +539 -0
  70. package/crates/mint-core/src/integrations.rs +2 -0
  71. package/crates/mint-core/src/lib.rs +85 -26
  72. package/crates/mint-core/src/media/gemini_live.rs +134 -14
  73. package/crates/mint-core/src/media/image_models.rs +3 -27
  74. package/crates/mint-core/src/media/image_workflow.rs +216 -0
  75. package/crates/mint-core/src/media/library/intake.rs +144 -0
  76. package/crates/mint-core/src/media/library/migration.rs +56 -0
  77. package/crates/mint-core/src/media/library/removal.rs +55 -0
  78. package/crates/mint-core/src/media/library/tests.rs +730 -0
  79. package/crates/mint-core/src/media/library/thumbnails.rs +72 -0
  80. package/crates/mint-core/src/media/library.rs +545 -0
  81. package/crates/mint-core/src/media/model_catalog.rs +375 -0
  82. package/crates/mint-core/src/media/pictures.rs +79 -222
  83. package/crates/mint-core/src/media/video_models.rs +3 -22
  84. package/crates/mint-core/src/media.rs +3 -0
  85. package/crates/mint-core/src/mods/manager.rs +1124 -0
  86. package/crates/mint-core/src/mods/mod.rs +569 -0
  87. package/crates/mint-core/src/mods/operations.rs +204 -0
  88. package/crates/mint-core/src/mods/session.rs +146 -0
  89. package/crates/mint-core/src/mods/slash.rs +59 -0
  90. package/crates/mint-core/src/mods/telemetry.rs +306 -0
  91. package/crates/mint-core/src/orchestration/approvals.rs +344 -0
  92. package/crates/mint-core/src/orchestration/context_render.rs +104 -0
  93. package/crates/mint-core/src/orchestration/mcp_review.rs +153 -0
  94. package/crates/mint-core/src/orchestration/memory_skill.rs +104 -32
  95. package/crates/mint-core/src/orchestration/mod.rs +2139 -244
  96. package/crates/mint-core/src/orchestration/mod_authoring_regressions.rs +209 -0
  97. package/crates/mint-core/src/orchestration/progress_clock.rs +80 -0
  98. package/crates/mint-core/src/orchestration/repeated_failures.rs +116 -0
  99. package/crates/mint-core/src/orchestration/review_regressions.rs +449 -0
  100. package/crates/mint-core/src/orchestration/run_control.rs +196 -0
  101. package/crates/mint-core/src/orchestration/tools/files.rs +161 -14
  102. package/crates/mint-core/src/orchestration/tools/media.rs +37 -41
  103. package/crates/mint-core/src/orchestration/tools/misc.rs +3 -2
  104. package/crates/mint-core/src/orchestration/tools/mod.rs +2 -0
  105. package/crates/mint-core/src/orchestration/tools/mods.rs +161 -0
  106. package/crates/mint-core/src/orchestration/tools/plugins_mcp.rs +303 -71
  107. package/crates/mint-core/src/orchestration/tools/shell.rs +95 -2
  108. package/crates/mint-core/src/orchestration/workspace_helpers.rs +4 -1
  109. package/crates/mint-core/src/prompts/agent.rs +17 -2
  110. package/crates/mint-core/src/prompts/tool_catalog.rs +28 -2
  111. package/crates/mint-core/src/search/knowledge.rs +7 -1
  112. package/crates/mint-core/src/search/linked_folders/index.rs +177 -13
  113. package/crates/mint-core/src/search/linked_folders.rs +83 -2
  114. package/crates/mint-core/src/slash/catalog.rs +1 -1
  115. package/crates/mint-core/src/slash/mod.rs +174 -11
  116. package/crates/mint-core/src/slash/mods.rs +155 -0
  117. package/crates/mint-core/src/system/bg_shell.rs +1129 -95
  118. package/crates/mint-core/src/system/config.rs +5 -10
  119. package/crates/mint-core/src/system/html_preview.rs +398 -0
  120. package/crates/mint-core/src/system/mint_web.rs +164 -0
  121. package/crates/mint-core/src/system/release_notes.rs +18 -0
  122. package/crates/mint-core/src/system/shell.rs +21 -6
  123. package/crates/mint-core/src/system/workspace.rs +72 -8
  124. package/crates/mint-core/src/system/workspace_history.rs +604 -0
  125. package/crates/mint-core/src/system.rs +4 -0
  126. package/crates/mint-core/tests/background_retention.rs +34 -0
  127. package/crates/mint-core/tests/background_shutdown.rs +24 -0
  128. package/crates/mint-core/tests/library_http.rs +209 -0
  129. package/crates/mint-core/tests/mcp_contract.rs +283 -0
  130. package/crates/mint-core/tests/mcp_stdio.rs +11 -10
  131. package/crates/mint-core/tests/media_model_catalog_http.rs +403 -0
  132. package/crates/mint-core/tests/mods_http.rs +215 -0
  133. package/crates/mint-core/tests/terminal_shutdown.rs +109 -0
  134. package/crates/mint-mod-runtime/Cargo.toml +18 -0
  135. package/crates/mint-mod-runtime/src/lib.rs +507 -0
  136. package/crates/mint-mod-runtime/src/sdk.js +56 -0
  137. package/crates/mint-mod-runtime/src/test_sdk.js +32 -0
  138. package/crates/mint-mod-runtime/src/testing.rs +94 -0
  139. package/crates/mint-mod-runtime/tests/runtime.rs +156 -0
  140. package/docs/mods/AUTHORING.md +61 -0
  141. package/docs/mods/README.md +139 -0
  142. package/docs/mods/TESTING.md +56 -0
  143. package/docs/mods/mint-mods.d.ts +66 -0
  144. package/examples/device-mcp/README.md +22 -0
  145. package/examples/device-mcp/contract.md +49 -0
  146. package/examples/device-mcp/mock-device.mjs +66 -0
  147. package/examples/device-mcp/server.mjs +48 -0
  148. package/examples/device-mcp/server.test.mjs +78 -0
  149. package/examples/mods/task-checklist/README.md +1 -0
  150. package/examples/mods/task-checklist/main.ts +17 -0
  151. package/examples/mods/task-checklist/manifest.json +1 -0
  152. package/examples/mods/task-checklist/tests/checklist.test.ts +15 -0
  153. package/examples/mods/token-meter/README.md +1 -0
  154. package/examples/mods/token-meter/main.ts +8 -0
  155. package/examples/mods/token-meter/manifest.json +1 -0
  156. package/examples/mods/tool-guard/README.md +1 -0
  157. package/examples/mods/tool-guard/main.ts +9 -0
  158. package/examples/mods/tool-guard/manifest.json +1 -0
  159. package/out/web/assets/CliSessionsView-CtzUJpK_.css +1 -0
  160. package/out/web/assets/CliSessionsView-D7hw0Mgj.js +1 -0
  161. package/out/web/assets/CodeReviewPage-C7bEOJfd.js +4 -0
  162. package/out/web/assets/CodeReviewPage-Dte9uLiL.css +1 -0
  163. package/out/web/assets/CommandPalette-0RrF-AOT.js +1 -0
  164. package/out/web/assets/ImageStudioPanel-CdxkAf_r.js +1 -0
  165. package/out/web/assets/LibraryPage--Qfisp6D.js +2 -0
  166. package/out/web/assets/LibraryPage-DAxUTAg7.css +1 -0
  167. package/out/web/assets/LinkedFoldersView-2nfZbDgN.js +1 -0
  168. package/out/web/assets/McpRegistryPicker-h9uS8_wT.js +1 -0
  169. package/out/web/assets/McpServersView-slfQJzNB.js +1 -0
  170. package/out/web/assets/MintDashboard-BSR5XpXh.js +25 -0
  171. package/out/web/assets/MintDashboard-CWUcCGhd.css +1 -0
  172. package/out/web/assets/PluginsView-l7LSm503.js +1 -0
  173. package/out/web/assets/ScheduledTasksView-CttmLbq1.js +1 -0
  174. package/out/web/assets/SettingsWindow-BCMLYvEV.js +4 -0
  175. package/out/web/assets/SettingsWindow-DcMVmU9u.css +1 -0
  176. package/out/web/assets/SkillsView-rNs1qr_U.js +2 -0
  177. package/out/web/assets/ToolSurfacePage-B9DzEPWN.js +37 -0
  178. package/out/web/assets/ToolSurfacePage-CTfgggF2.css +1 -0
  179. package/out/web/assets/VeoStudioPanel-DiJs_Cg2.js +4 -0
  180. package/out/web/assets/WorkspaceSelector-B2xIp04D.js +1 -0
  181. package/out/web/assets/WorkspaceSelector-Ca8uJOsS.css +1 -0
  182. package/out/web/assets/{abnfDiagram-VCTEODGH-ByMyfanq.js → abnfDiagram-VCTEODGH-CD0CjoGl.js} +1 -1
  183. package/out/web/assets/{architecture-7GRP2DOG-CLq2kEOB.js → architecture-7GRP2DOG-RKV83OeZ.js} +1 -1
  184. package/out/web/assets/{architectureDiagram-5GKGNRK7-6PoVSmNG.js → architectureDiagram-5GKGNRK7-nN_HAFct.js} +1 -1
  185. package/out/web/assets/{blockDiagram-NRAW4CY4-cKw9mAKi.js → blockDiagram-NRAW4CY4-BrrqzHW5.js} +1 -1
  186. package/out/web/assets/{c4Diagram-UCG6FXSJ-Cqy8N5Ge.js → c4Diagram-UCG6FXSJ-DTdV8_zE.js} +1 -1
  187. package/out/web/assets/{chunk-75Z2AOVW-3qybijF5.js → chunk-75Z2AOVW-_M0z5olV.js} +1 -1
  188. package/out/web/assets/chunk-CKBBP62Z-BpQEgeHF.js +2 -0
  189. package/out/web/assets/{chunk-CLGD4ZFX-CndDQkww.js → chunk-CLGD4ZFX-D54yGvGS.js} +1 -1
  190. package/out/web/assets/{chunk-DSNCTWBM-Cc4NxvgD.js → chunk-DSNCTWBM-C-gvT2ru.js} +1 -1
  191. package/out/web/assets/{chunk-DU6HZSFF-drzh6GsU.js → chunk-DU6HZSFF-CnFngIhW.js} +1 -1
  192. package/out/web/assets/{chunk-F27PBJKO-FiY35l4v.js → chunk-F27PBJKO-BqtWrnEO.js} +1 -1
  193. package/out/web/assets/{chunk-G27WJ6UU-Bjt9Il4A.js → chunk-G27WJ6UU-Ckoq5mi2.js} +1 -1
  194. package/out/web/assets/{chunk-GMAD6QVW-Bppi3Ctd.js → chunk-GMAD6QVW-BuhEcgHq.js} +1 -1
  195. package/out/web/assets/{chunk-JQ64N6SF-DGg5kcEV.js → chunk-JQ64N6SF-CF8T7GiZ.js} +1 -1
  196. package/out/web/assets/{chunk-LCL6LL3I-CCYcjd7p.js → chunk-LCL6LL3I-BhXV9zyH.js} +1 -1
  197. package/out/web/assets/{chunk-OBVCFTLP-DJeNil5M.js → chunk-OBVCFTLP-CRIkunUB.js} +1 -1
  198. package/out/web/assets/{chunk-P2QGCYS3-lRzkRjBB.js → chunk-P2QGCYS3-UFEU6BxZ.js} +1 -1
  199. package/out/web/assets/{chunk-POPQ4Y6H-DouASMBW.js → chunk-POPQ4Y6H-CQcrNbjr.js} +1 -1
  200. package/out/web/assets/{chunk-PWAF6VOD-DMmD1vVf.js → chunk-PWAF6VOD-Cc2uVDsG.js} +1 -1
  201. package/out/web/assets/{chunk-R7TYR2AO-CYLA5UC5.js → chunk-R7TYR2AO-DiCxzdAY.js} +1 -1
  202. package/out/web/assets/{chunk-RHFEMEQ7-BiXwEcUT.js → chunk-RHFEMEQ7-b7hnPmrR.js} +1 -1
  203. package/out/web/assets/{chunk-SVP7TREG-BV-zPslF.js → chunk-SVP7TREG-Bu_yYb-V.js} +1 -1
  204. package/out/web/assets/{chunk-TEH6E4GO-B2RGEohZ.js → chunk-TEH6E4GO-G4Cdlyrt.js} +1 -1
  205. package/out/web/assets/classDiagram-DTDB5LWJ-RnC2mYO4.js +1 -0
  206. package/out/web/assets/classDiagram-v2-JRS7N3AN-RnC2mYO4.js +1 -0
  207. package/out/web/assets/config-CnvCChUM.js +2 -0
  208. package/out/web/assets/config-DM6oIVO3.css +1 -0
  209. package/out/web/assets/core-DsKkQ7ig.js +1 -0
  210. package/out/web/assets/{cynefin-OW5HDTMX-Cas4csHM.js → cynefin-OW5HDTMX-CnzQfOGp.js} +1 -1
  211. package/out/web/assets/{cynefinDiagram-5FMLGOSQ-Bbpj66Y3.js → cynefinDiagram-5FMLGOSQ-DVtQnfpm.js} +1 -1
  212. package/out/web/assets/{dagre-3AP2YEHR-B0-2RDEd.js → dagre-3AP2YEHR-DG76_k5_.js} +1 -1
  213. package/out/web/assets/{diagram-S7CK7UJ4-BdzEjUyH.js → diagram-S7CK7UJ4-CaouUoze.js} +1 -1
  214. package/out/web/assets/{diagram-UQ7AKVKN-CJDwyPxF.js → diagram-UQ7AKVKN-B4bEc26B.js} +1 -1
  215. package/out/web/assets/{diagram-VSXAHHWV-BxaG1r_B.js → diagram-VSXAHHWV-BJ86Ov2Y.js} +1 -1
  216. package/out/web/assets/{diagram-VX7I27RA-BMmUqm8f.js → diagram-VX7I27RA-7hH5IPYf.js} +1 -1
  217. package/out/web/assets/{diagram-Z3DM3KII-CFtd03eE.js → diagram-Z3DM3KII-CCM4clo8.js} +1 -1
  218. package/out/web/assets/dist-js-BkYNznHX.js +1 -0
  219. package/out/web/assets/{ebnfDiagram-PWID7BFC-DwAen1hO.js → ebnfDiagram-PWID7BFC-Da7WAM3k.js} +1 -1
  220. package/out/web/assets/{erDiagram-SSCWMZ5O-BCZSHACj.js → erDiagram-SSCWMZ5O-CzzmnZWT.js} +1 -1
  221. package/out/web/assets/{event-ChBVpRy0.js → event-Cdt7Byyj.js} +1 -1
  222. package/out/web/assets/{eventmodeling-NTZA5JFV-XHStE4E_.js → eventmodeling-NTZA5JFV-xcfsA0XD.js} +1 -1
  223. package/out/web/assets/flowDiagram-A5DVABFB-JdtH1673.js +1 -0
  224. package/out/web/assets/{ganttDiagram-EL5Y4UJY-BAEu1Cql.js → ganttDiagram-EL5Y4UJY-B3ClFGmL.js} +1 -1
  225. package/out/web/assets/{gitGraph-4MIJSDKK-r32Vyper.js → gitGraph-4MIJSDKK-BZ_y_cDU.js} +1 -1
  226. package/out/web/assets/{gitGraphDiagram-WWUBYQGX-z9o9qt6V.js → gitGraphDiagram-WWUBYQGX-drhRSYm3.js} +1 -1
  227. package/out/web/assets/index-BZ9xgmMP.css +1 -0
  228. package/out/web/assets/index-Ca8cCS4h.js +168 -0
  229. package/out/web/assets/{info-A6RAGUB7-BvLaC2cw.js → info-A6RAGUB7-B7I3K0F1.js} +1 -1
  230. package/out/web/assets/{infoDiagram-RXCK75RN-Be6WAXXw.js → infoDiagram-RXCK75RN-deX_1MPh.js} +1 -1
  231. package/out/web/assets/{ishikawaDiagram-5VMMS53U-DZjoHJHC.js → ishikawaDiagram-5VMMS53U-s38yo28y.js} +1 -1
  232. package/out/web/assets/{journeyDiagram-EYS64GPL-CtVkjkGm.js → journeyDiagram-EYS64GPL-CZn2kB8b.js} +1 -1
  233. package/out/web/assets/jsx-runtime-DN4NhQUK.js +1 -0
  234. package/out/web/assets/{kanban-definition-3QL26DDD-CW5FMv1w.js → kanban-definition-3QL26DDD-DodjgySL.js} +1 -1
  235. package/out/web/assets/markdown-CMhYIcWB.js +39 -0
  236. package/out/web/assets/{mermaid-parser.core-aUgCqsZ5.js → mermaid-parser.core-Whu2rlJC.js} +3 -3
  237. package/out/web/assets/{mermaid.core-D57sEiSL.js → mermaid.core-CKyPo8VH.js} +4 -4
  238. package/out/web/assets/{mindmap-definition-FBJOCRG2-DAmSpM3a.js → mindmap-definition-FBJOCRG2-260WBDxZ.js} +1 -1
  239. package/out/web/assets/modelManager-CUaGWMWu.js +2 -0
  240. package/out/web/assets/{oauthManager-9jxicDWJ.js → oauthManager-DlNhXJr6.js} +1 -1
  241. package/out/web/assets/{packet-AYTQ26CC-DJ27iIoJ.js → packet-AYTQ26CC-CEeIRKRG.js} +1 -1
  242. package/out/web/assets/{pegDiagram-XKGWAZYB-Bj3ii-gS.js → pegDiagram-XKGWAZYB-BYadI-KO.js} +1 -1
  243. package/out/web/assets/{pie-WAS4IAKB-DG5mCUbc.js → pie-WAS4IAKB-D8uf4dh5.js} +1 -1
  244. package/out/web/assets/{pieDiagram-E7YTZNPT-BrJLPnDA.js → pieDiagram-E7YTZNPT-CjSDy4hO.js} +1 -1
  245. package/out/web/assets/{quadrantDiagram-AXDQQJYC-3x_TmF3j.js → quadrantDiagram-AXDQQJYC-B8D3gU5d.js} +1 -1
  246. package/out/web/assets/{radar-RG4KPBEZ-CrXaTTuV.js → radar-RG4KPBEZ-DZJxnokY.js} +1 -1
  247. package/out/web/assets/{railroad-74A4TZTK-BpqMH9BE.js → railroad-74A4TZTK-DBysUiUi.js} +1 -1
  248. package/out/web/assets/{railroad-abnf-HS5TGJTU-DHI0uLlV.js → railroad-abnf-HS5TGJTU-CHWxoJYg.js} +1 -1
  249. package/out/web/assets/{railroad-ebnf-LZEXJU2U-CQCb316y.js → railroad-ebnf-LZEXJU2U-DDv7X6bY.js} +1 -1
  250. package/out/web/assets/{railroad-peg-WCYAUIDC-Ph1giKkH.js → railroad-peg-WCYAUIDC-DpfcYYNJ.js} +1 -1
  251. package/out/web/assets/{railroadDiagram-O6MQD6OU-BRq6wIxx.js → railroadDiagram-O6MQD6OU-CYtyv0t3.js} +1 -1
  252. package/out/web/assets/{requirementDiagram-EFPCY7ZU-DIJj6G1g.js → requirementDiagram-EFPCY7ZU-D2X-Ewik.js} +1 -1
  253. package/out/web/assets/{sankeyDiagram-P5KCCOFB-DZajSryq.js → sankeyDiagram-P5KCCOFB-Bn2x6C4Y.js} +1 -1
  254. package/out/web/assets/{sequenceDiagram-WJ2MYXX4-DxJCWyZS.js → sequenceDiagram-WJ2MYXX4-BvFLKtPd.js} +1 -1
  255. package/out/web/assets/{stateDiagram-HBIQ2CUA-k6voi8Vf.js → stateDiagram-HBIQ2CUA-Bx0GWeaY.js} +1 -1
  256. package/out/web/assets/stateDiagram-v2-4QOOHH4V--hol1yg_.js +1 -0
  257. package/out/web/assets/{swimlanes-XN3QIQJK-igYhTjXB.js → swimlanes-XN3QIQJK-CCma0T7L.js} +1 -1
  258. package/out/web/assets/swimlanesDiagram-VK2B7HYN-BkohoVsU.js +8 -0
  259. package/out/web/assets/{timeline-definition-24CTP7MA-Dke2p93p.js → timeline-definition-24CTP7MA-iCRhhJ7C.js} +1 -1
  260. package/out/web/assets/{treeView-Q6P3EWNA-hNELnXUs.js → treeView-Q6P3EWNA-DZ-y08qp.js} +1 -1
  261. package/out/web/assets/{treemap-WGGIJYW6-D2B6wDsx.js → treemap-WGGIJYW6-De0CxzMx.js} +1 -1
  262. package/out/web/assets/{vennDiagram-4TSXK5OY-CDkW7rUP.js → vennDiagram-4TSXK5OY-w_7aZ6xe.js} +1 -1
  263. package/out/web/assets/{wardley-WFR3VGLG-BECdXETi.js → wardley-WFR3VGLG-DT0y0xJ4.js} +1 -1
  264. package/out/web/assets/{wardleyDiagram-VM6X3IG4-B5BycpQ4.js → wardleyDiagram-VM6X3IG4-DAdXELbQ.js} +1 -1
  265. package/out/web/assets/window-BJCVtyET.js +1 -0
  266. package/out/web/assets/{xychartDiagram-S5SC5T6Z-CtJS3b--.js → xychartDiagram-S5SC5T6Z-IZIXf3Vo.js} +1 -1
  267. package/out/web/index-web.html +3 -2
  268. package/out/web/index.html +3 -2
  269. package/package.json +71 -22
  270. package/shared-modules/.github/workflows/ci.yml +120 -0
  271. package/shared-modules/.github/workflows/update.yaml +28 -0
  272. package/slash-commands.json +7 -2
  273. package/src/bin/auto-build.js +66 -0
  274. package/src/bin/desktop.js +113 -0
  275. package/src/bin/desktop.test.js +113 -0
  276. package/src/bin/index.js +18 -25
  277. package/src/bin/index.test.js +89 -0
  278. package/src/bin/measure-effects.mjs +53 -0
  279. package/src/bin/package.test.js +28 -0
  280. package/src/bin/release-notes.test.js +84 -0
  281. package/src/bin/styles.test.js +20 -0
  282. package/src/bin/web.js +5 -0
  283. package/src/bin/web.test.js +39 -0
  284. package/src/renderer/index.html +0 -2
  285. package/src/renderer/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  286. package/src/renderer/shared/agentProgress.ts +33 -0
  287. package/src/renderer/shared/components/AgentActivityDrawer.tsx +3 -2
  288. package/src/renderer/shared/components/AgentActivityTable.tsx +1 -1
  289. package/src/renderer/shared/components/ApprovalCard.test.tsx +24 -0
  290. package/src/renderer/shared/components/ApprovalCard.tsx +10 -3
  291. package/src/renderer/shared/components/ArtifactPreviewPanel.tsx +88 -37
  292. package/src/renderer/shared/components/AuthGate.tsx +75 -39
  293. package/src/renderer/shared/components/BackgroundTerminals.tsx +181 -0
  294. package/src/renderer/shared/components/ChatCodeBlock.tsx +8 -4
  295. package/src/renderer/shared/components/ChatMessageItem.test.tsx +68 -0
  296. package/src/renderer/shared/components/ChatMessageItem.tsx +36 -16
  297. package/src/renderer/shared/components/ChatPanel.test.tsx +21 -0
  298. package/src/renderer/shared/components/ChatPanel.tsx +294 -272
  299. package/src/renderer/shared/components/ChunkErrorBoundary.tsx +4 -102
  300. package/src/renderer/shared/components/CliSessionsView.test.tsx +125 -0
  301. package/src/renderer/shared/components/CliSessionsView.tsx +61 -13
  302. package/src/renderer/shared/components/CodeReviewPage.tsx +111 -8
  303. package/src/renderer/shared/components/CollapsiblePasteInput.tsx +90 -0
  304. package/src/renderer/shared/components/ContextCompactionStatus.tsx +17 -0
  305. package/src/renderer/shared/components/DashboardSidebar.tsx +15 -128
  306. package/src/renderer/shared/components/DesktopTitlebar.tsx +45 -19
  307. package/src/renderer/shared/components/ErrorNotice.tsx +29 -0
  308. package/src/renderer/shared/components/FormattedMessage.tsx +11 -0
  309. package/src/renderer/shared/components/GeminiLiveOverlay.css +86 -0
  310. package/src/renderer/shared/components/GeminiLiveOverlay.test.tsx +24 -0
  311. package/src/renderer/shared/components/GeminiLiveOverlay.tsx +85 -101
  312. package/src/renderer/shared/components/GlideSelect.css +138 -0
  313. package/src/renderer/shared/components/GlideSelect.d.ts +31 -0
  314. package/src/renderer/shared/components/GlideSelect.jsx +266 -0
  315. package/src/renderer/shared/components/ImageSearchCard.tsx +46 -56
  316. package/src/renderer/shared/components/ImageStudioPanel.tsx +9 -8
  317. package/src/renderer/shared/components/LibraryItemCard.tsx +72 -0
  318. package/src/renderer/shared/components/LibraryPage.tsx +165 -0
  319. package/src/renderer/shared/components/LibraryPreview.tsx +59 -0
  320. package/src/renderer/shared/components/McpServersView.tsx +27 -8
  321. package/src/renderer/shared/components/McpToolAllowlist.tsx +2 -4
  322. package/src/renderer/shared/components/McpToolResults.tsx +99 -0
  323. package/src/renderer/shared/components/MintDashboard.tsx +308 -770
  324. package/src/renderer/shared/components/ModSessionControls.tsx +8 -0
  325. package/src/renderer/shared/components/ModUi.test.tsx +65 -0
  326. package/src/renderer/shared/components/ModUi.tsx +26 -0
  327. package/src/renderer/shared/components/ModelSelectorPopover.tsx +14 -7
  328. package/src/renderer/shared/components/ModsPanel.tsx +156 -0
  329. package/src/renderer/shared/components/ProgressActivity.tsx +17 -0
  330. package/src/renderer/shared/components/ReviewHistory.tsx +42 -0
  331. package/src/renderer/shared/components/RewindModal.tsx +5 -12
  332. package/src/renderer/shared/components/RunSummaryDashboard.tsx +6 -12
  333. package/src/renderer/shared/components/ScheduledTasksView.tsx +7 -6
  334. package/src/renderer/shared/components/SelectField.test.tsx +27 -0
  335. package/src/renderer/shared/components/SelectField.tsx +38 -0
  336. package/src/renderer/shared/components/SessionSources.tsx +48 -0
  337. package/src/renderer/shared/components/SessionSourcesPopover.tsx +61 -0
  338. package/src/renderer/shared/components/Settings/AgentsTab.tsx +7 -6
  339. package/src/renderer/shared/components/Settings/AudioTab.tsx +8 -7
  340. package/src/renderer/shared/components/Settings/AutomationTab.tsx +2 -2
  341. package/src/renderer/shared/components/Settings/GeneralTab.tsx +33 -32
  342. package/src/renderer/shared/components/Settings/MemoryTab.test.tsx +37 -0
  343. package/src/renderer/shared/components/Settings/MemoryTab.tsx +71 -31
  344. package/src/renderer/shared/components/Settings/NotificationsTab.test.tsx +15 -0
  345. package/src/renderer/shared/components/Settings/NotificationsTab.tsx +84 -0
  346. package/src/renderer/shared/components/Settings/ThemeTab.tsx +19 -11
  347. package/src/renderer/shared/components/SlashChoicePanel.tsx +88 -0
  348. package/src/renderer/shared/components/SlashSuggestions.test.tsx +33 -0
  349. package/src/renderer/shared/components/SlashSuggestions.tsx +38 -10
  350. package/src/renderer/shared/components/SourcesBlock.tsx +12 -20
  351. package/src/renderer/shared/components/ToolSurfacePage.test.tsx +32 -0
  352. package/src/renderer/shared/components/ToolSurfacePage.tsx +59 -11
  353. package/src/renderer/shared/components/ToolsPanelToggle.tsx +13 -0
  354. package/src/renderer/shared/components/VeoStudioPanel.tsx +9 -8
  355. package/src/renderer/shared/components/ViewedImages.tsx +36 -0
  356. package/src/renderer/shared/components/VirtualChatHistory.tsx +82 -0
  357. package/src/renderer/shared/components/VoicePill.css +85 -0
  358. package/src/renderer/shared/components/VoicePill.d.ts +12 -0
  359. package/src/renderer/shared/components/VoicePill.jsx +190 -0
  360. package/src/renderer/shared/components/VoicePill.test.tsx +22 -0
  361. package/src/renderer/shared/components/WorkspaceContextPopover.tsx +20 -15
  362. package/src/renderer/shared/components/WorkspacePaneResizer.tsx +92 -0
  363. package/src/renderer/shared/components/WorkspacePreviewActions.test.tsx +44 -0
  364. package/src/renderer/shared/components/WorkspacePreviewActions.tsx +19 -0
  365. package/src/renderer/shared/components/WorkspaceSelector.tsx +13 -8
  366. package/src/renderer/shared/components/background-terminals.css +292 -0
  367. package/src/renderer/shared/components/ui/README.md +52 -0
  368. package/src/renderer/shared/components/ui/button.tsx +37 -0
  369. package/src/renderer/shared/components/ui/demo.tsx +26 -0
  370. package/src/renderer/shared/components/ui/error-recovery.css +35 -0
  371. package/src/renderer/shared/components/ui/error-recovery.tsx +35 -0
  372. package/src/renderer/shared/components/ui/voice-powered-orb.css +6 -0
  373. package/src/renderer/shared/components/ui/voice-powered-orb.tsx +374 -0
  374. package/src/renderer/shared/components/workspace-selector.css +248 -0
  375. package/src/renderer/shared/constants/config.ts +30 -1
  376. package/src/renderer/shared/constants/slashCommands.ts +1 -1
  377. package/src/renderer/shared/conversation/lifecycle.test.ts +362 -0
  378. package/src/renderer/shared/conversation/lifecycle.ts +418 -0
  379. package/src/renderer/shared/conversation/projectNavigation.test.ts +105 -0
  380. package/src/renderer/shared/conversation/projectNavigation.ts +39 -0
  381. package/src/renderer/shared/conversation/sourcesTransport.ts +27 -0
  382. package/src/renderer/shared/conversation/syncView.test.ts +1 -34
  383. package/src/renderer/shared/conversation/syncView.ts +0 -37
  384. package/src/renderer/shared/conversation/transport.test.ts +163 -0
  385. package/src/renderer/shared/conversation/transport.ts +159 -0
  386. package/src/renderer/shared/conversation/useConversationCoordinator.ts +30 -72
  387. package/src/renderer/shared/css/agent-observability.css +20 -21
  388. package/src/renderer/shared/css/auth-gate.css +112 -13
  389. package/src/renderer/shared/css/code-review.css +33 -0
  390. package/src/renderer/shared/css/code-sessions.css +27 -0
  391. package/src/renderer/shared/css/collapsed-paste.css +35 -0
  392. package/src/renderer/shared/css/effects.css +15 -0
  393. package/src/renderer/shared/css/error-notice.css +60 -0
  394. package/src/renderer/shared/css/library.css +476 -0
  395. package/src/renderer/shared/css/management-views.css +138 -0
  396. package/src/renderer/shared/css/mcp-results.css +11 -0
  397. package/src/renderer/shared/css/model-selector.css +163 -105
  398. package/src/renderer/shared/css/mods.css +77 -0
  399. package/src/renderer/shared/css/notification-notice.css +28 -0
  400. package/src/renderer/shared/css/session-sources-popover.css +9 -0
  401. package/src/renderer/shared/css/session-sources.css +23 -0
  402. package/src/renderer/shared/css/settings/memory.css +154 -0
  403. package/src/renderer/shared/css/settings/notifications.css +11 -0
  404. package/src/renderer/shared/css/settings/theme.css +5 -0
  405. package/src/renderer/shared/css/slash-choice.css +95 -0
  406. package/src/renderer/shared/css/slash-suggestions.css +68 -0
  407. package/src/renderer/shared/css/sources-block.css +91 -0
  408. package/src/renderer/shared/css/thinking-status.css +46 -0
  409. package/src/renderer/shared/css/tools-panel.css +7 -0
  410. package/src/renderer/shared/css/ui-tailwind.css +19 -0
  411. package/src/renderer/shared/css/viewed-images.css +15 -0
  412. package/src/renderer/shared/fonts.css +46 -0
  413. package/src/renderer/shared/fonts.ts +3 -12
  414. package/src/renderer/shared/lib/utils.ts +6 -0
  415. package/src/renderer/shared/library/controller.test.ts +61 -0
  416. package/src/renderer/shared/library/controller.ts +57 -0
  417. package/src/renderer/shared/library/transport.test.ts +26 -0
  418. package/src/renderer/shared/library/transport.ts +60 -0
  419. package/src/renderer/shared/library/useLibraryFile.ts +38 -0
  420. package/src/renderer/shared/mods.ts +58 -0
  421. package/src/renderer/shared/platform.test.ts +57 -1
  422. package/src/renderer/shared/platform.ts +46 -1
  423. package/src/renderer/shared/theme/themeManager.test.ts +43 -0
  424. package/src/renderer/shared/theme/themeManager.ts +17 -4
  425. package/src/renderer/shared/types.ts +56 -2
  426. package/src/renderer/shared/utils/agentActivity.test.ts +75 -0
  427. package/src/renderer/shared/utils/agentActivity.ts +42 -3
  428. package/src/renderer/shared/utils/approval.tsx +7 -0
  429. package/src/renderer/shared/utils/artifactBrowser.test.ts +80 -0
  430. package/src/renderer/shared/utils/artifactBrowser.ts +41 -0
  431. package/src/renderer/shared/utils/contextCompaction.test.tsx +48 -0
  432. package/src/renderer/shared/utils/contextCompaction.ts +21 -0
  433. package/src/renderer/shared/utils/effectVisibility.ts +9 -0
  434. package/src/renderer/shared/utils/markdown.tsx +3 -3
  435. package/src/renderer/shared/utils/mcpResults.test.tsx +91 -0
  436. package/src/renderer/shared/utils/modViewport.ts +7 -0
  437. package/src/renderer/shared/utils/nativeRecordingSession.test.ts +61 -0
  438. package/src/renderer/shared/utils/nativeRecordingSession.ts +46 -0
  439. package/src/renderer/shared/utils/notificationSettings.test.ts +14 -0
  440. package/src/renderer/shared/utils/notificationSettings.ts +23 -0
  441. package/src/renderer/shared/utils/notifications.test.ts +51 -0
  442. package/src/renderer/shared/utils/notifications.ts +54 -0
  443. package/src/renderer/shared/utils/pastedText.test.ts +20 -0
  444. package/src/renderer/shared/utils/pastedText.ts +13 -0
  445. package/src/renderer/shared/utils/progressCache.test.ts +32 -0
  446. package/src/renderer/shared/utils/sessionSources.test.ts +31 -0
  447. package/src/renderer/shared/utils/sessionSources.ts +34 -0
  448. package/src/renderer/shared/utils/slashCommandProcessor.ts +1 -1
  449. package/src/renderer/shared/utils/sourceAttachments.ts +28 -0
  450. package/src/renderer/shared/utils/speech.ts +51 -9
  451. package/src/renderer/shared/utils/syntaxHighlight.test.tsx +22 -0
  452. package/src/renderer/shared/utils/syntaxHighlight.tsx +26 -0
  453. package/src/renderer/shared/utils/useGeminiLiveVoice.ts +49 -5
  454. package/src/renderer/shared/utils/useLatestCallback.ts +9 -0
  455. package/src/renderer/shared/utils/useNativeVoiceInput.ts +80 -72
  456. package/src/renderer/shared/utils/useSessionSources.ts +33 -0
  457. package/src/renderer/shared/utils/viewedImages.ts +26 -0
  458. package/src/renderer/shared/utils/workspacePane.test.ts +33 -0
  459. package/src/renderer/shared/utils/workspacePane.ts +20 -0
  460. package/src/renderer/shared/utils/workspacePreview.test.ts +24 -0
  461. package/src/renderer/shared/utils/workspacePreview.ts +16 -0
  462. package/src/renderer/shared/utils/workspaceSnapshot.test.ts +49 -0
  463. package/src/renderer/shared/utils/workspaceSnapshot.ts +26 -0
  464. package/src/renderer/shared/utils/workspaces.ts +10 -0
  465. package/src/renderer/shared/workspaceUndo.ts +88 -0
  466. package/src/renderer/src/App.tsx +9 -1
  467. package/src/renderer/src/components/LiveTranslateControls.tsx +65 -0
  468. package/src/renderer/src/components/PicturesLibrary.tsx +2 -288
  469. package/src/renderer/src/components/ScreenPicker.css +67 -30
  470. package/src/renderer/src/components/ScreenPicker.tsx +201 -60
  471. package/src/renderer/src/components/SettingsWindow.tsx +10 -5
  472. package/src/renderer/src/components/WorkspacePanel.tsx +222 -32
  473. package/src/renderer/src/components/browser-shell.css +1 -1
  474. package/src/renderer/src/components/browser-suggestions-popup.css +1 -1
  475. package/src/renderer/src/components/liveTranslateFrames.test.ts +26 -5
  476. package/src/renderer/src/components/liveTranslateFrames.ts +18 -7
  477. package/src/renderer/src/components/liveTranslateLanguage.ts +6 -0
  478. package/src/renderer/src/css/app-shell.css +52 -137
  479. package/src/renderer/src/css/chat.css +21 -495
  480. package/src/renderer/src/css/desktop-tools.css +199 -1
  481. package/src/renderer/src/css/misc.css +0 -52
  482. package/src/renderer/src/css/pictures.css +2 -415
  483. package/src/renderer/src/css/settings/base.css +50 -47
  484. package/src/renderer/src/css/sidebar-workspace.css +151 -94
  485. package/src/renderer/src/css/tool-surfaces.css +65 -9
  486. package/src/renderer/src/css/widget.css +1 -1
  487. package/src/renderer/src/env.d.ts +1 -9
  488. package/src/renderer/src/index.css +1 -1
  489. package/src/renderer/src/main.tsx +2 -1
  490. package/src/renderer/src/proactiveSuggestions.ts +29 -0
  491. package/src/renderer/src/tauri.ts +122 -330
  492. package/src/renderer/src/voiceInput.ts +2 -1
  493. package/src/renderer/src-web/components/PicturesLibrary.tsx +2 -285
  494. package/src/renderer/src-web/components/PullToRefresh.tsx +1 -1
  495. package/src/renderer/src-web/components/SettingsWindow.tsx +10 -5
  496. package/src/renderer/src-web/css/app-shell.css +24 -121
  497. package/src/renderer/src-web/css/chat.css +21 -495
  498. package/src/renderer/src-web/css/misc.css +0 -52
  499. package/src/renderer/src-web/css/mobile.css +15 -14
  500. package/src/renderer/src-web/css/pictures.css +2 -462
  501. package/src/renderer/src-web/css/settings/base.css +50 -47
  502. package/src/renderer/src-web/css/sidebar-workspace.css +9 -95
  503. package/src/renderer/src-web/css/widget.css +1 -1
  504. package/src/renderer/src-web/env.d.ts +1 -12
  505. package/src/renderer/src-web/index.css +1 -1
  506. package/src/renderer/src-web/main.tsx +2 -1
  507. package/src/renderer/src-web/proactiveSuggestions.ts +3 -0
  508. package/src/renderer/src-web/tauri.ts +129 -354
  509. package/src/renderer/tests/backgroundTerminals.browser.html +81 -0
  510. package/src/renderer/tests/chatPanelFixture.ts +23 -0
  511. package/src/renderer/tests/chatPerformance.browser.html +71 -0
  512. package/src/renderer/tests/cliSessions.browser.html +82 -0
  513. package/src/renderer/tests/collapsedPaste.browser.html +70 -0
  514. package/src/renderer/tests/composerMenus.browser.html +116 -0
  515. package/src/renderer/tests/contextCompaction.browser.html +17 -0
  516. package/src/renderer/tests/conversationLifecycle.browser.html +70 -0
  517. package/src/renderer/tests/effectsPerformance.browser.html +13 -0
  518. package/src/renderer/tests/errorRecovery.browser.html +21 -0
  519. package/src/renderer/tests/glideSelect.browser.html +71 -0
  520. package/src/renderer/tests/glideSelect.preview.html +27 -0
  521. package/src/renderer/tests/library.browser.html +1 -0
  522. package/src/renderer/tests/library.browser.tsx +78 -0
  523. package/src/renderer/tests/liveOrb.browser.html +49 -0
  524. package/src/renderer/tests/liveVoice.browser.html +29 -0
  525. package/src/renderer/tests/modInput.browser.html +22 -0
  526. package/src/renderer/tests/sessionSources.browser.html +1 -0
  527. package/src/renderer/tests/sessionSources.browser.tsx +107 -0
  528. package/src/renderer/tests/skills.browser.html +24 -0
  529. package/src/renderer/tests/toolsPanel.browser.html +1 -0
  530. package/src/renderer/tests/toolsPanel.browser.tsx +113 -0
  531. package/src/renderer/tests/viewedImages.browser.html +1 -0
  532. package/src/renderer/tests/viewedImages.browser.tsx +93 -0
  533. package/src/renderer/tests/voicePill.browser.html +80 -0
  534. package/src/renderer/tests/voicePill.preview.html +34 -0
  535. package/src/renderer/tests/webSources.browser.html +1 -0
  536. package/src/renderer/tests/webSources.browser.tsx +98 -0
  537. package/src/renderer/tests/workspaceUndo.browser.html +92 -0
  538. package/src-tauri/Cargo.toml +40 -0
  539. package/src-tauri/build.rs +3 -0
  540. package/src-tauri/src/app/lifecycle.rs +277 -0
  541. package/src-tauri/src/app/mod.rs +4 -0
  542. package/src-tauri/src/app/shortcuts.rs +40 -0
  543. package/src-tauri/src/app/tray.rs +52 -0
  544. package/src-tauri/src/background/events.rs +70 -0
  545. package/src-tauri/src/background/headless.rs +230 -0
  546. package/src-tauri/src/background/mod.rs +4 -0
  547. package/src-tauri/src/background/proactive.rs +262 -0
  548. package/src-tauri/src/commands/approvals.rs +20 -0
  549. package/src-tauri/src/commands/auth.rs +127 -0
  550. package/src-tauri/src/commands/conversation.rs +642 -0
  551. package/src-tauri/src/commands/integrations.rs +157 -0
  552. package/src-tauri/src/commands/media.rs +179 -0
  553. package/src-tauri/src/commands/mod.rs +11 -0
  554. package/src-tauri/src/commands/mods.rs +6 -0
  555. package/src-tauri/src/commands/settings.rs +141 -0
  556. package/src-tauri/src/commands/voice.rs +153 -0
  557. package/src-tauri/src/commands/workspace.rs +501 -0
  558. package/src-tauri/src/lib.rs +10 -0
  559. package/src-tauri/src/main.rs +3 -0
  560. package/src-tauri/src/native/actions.rs +206 -0
  561. package/src-tauri/src/native/browser.rs +252 -0
  562. package/src-tauri/src/native/capture.rs +695 -0
  563. package/src-tauri/src/native/discord_rpc.rs +99 -0
  564. package/src-tauri/src/native/integrations.rs +126 -0
  565. package/src-tauri/src/native/mod.rs +12 -0
  566. package/src-tauri/src/native/notifications.rs +51 -0
  567. package/src-tauri/src/native/plugins.rs +14 -0
  568. package/src-tauri/src/native/system.rs +250 -0
  569. package/src-tauri/src/native/terminal.rs +201 -0
  570. package/src-tauri/src/native/updater.rs +148 -0
  571. package/src-tauri/src/native/windows.rs +143 -0
  572. package/tsconfig.json +2 -0
  573. package/vite.config.ts +4 -1
  574. package/vite.config.web.ts +4 -0
  575. package/.cargo/config.toml +0 -13
  576. package/.claude/settings.local.json +0 -182
  577. package/.codex +0 -0
  578. package/CLAUDE.md +0 -102
  579. package/ROADMAP.md +0 -30
  580. package/mint-presentation-demo.mp4 +0 -0
  581. package/out/web/Live2DCubismCore.js +0 -9
  582. package/out/web/assets/CliSessionsView-BxUVWEEL.js +0 -1
  583. package/out/web/assets/CliSessionsView-CBDTXuQv.css +0 -1
  584. package/out/web/assets/CodeReviewPage-DkMgfcos.css +0 -1
  585. package/out/web/assets/CodeReviewPage-jKlmLJwT.js +0 -3
  586. package/out/web/assets/CommandPalette-DnwPA5hy.js +0 -1
  587. package/out/web/assets/ImageStudioPanel-BR1XFwhR.js +0 -1
  588. package/out/web/assets/LinkedFoldersView-bi-CWQ-I.js +0 -1
  589. package/out/web/assets/McpRegistryPicker-D5tEFnyq.js +0 -1
  590. package/out/web/assets/McpServersView-BGG1dhFM.js +0 -1
  591. package/out/web/assets/MintDashboard-BIglDEWX.js +0 -20
  592. package/out/web/assets/MintDashboard-DSMywSgs.css +0 -1
  593. package/out/web/assets/ModelPanel-CbJ1PFoZ.js +0 -1
  594. package/out/web/assets/PicturesLibrary-CQP0fLP1.css +0 -1
  595. package/out/web/assets/PicturesLibrary-DoYN7T1p.js +0 -1
  596. package/out/web/assets/PluginsView-CU1HWXGk.js +0 -1
  597. package/out/web/assets/ScheduledTasksView-Bnl36y2N.js +0 -1
  598. package/out/web/assets/SettingsWindow-DhWLAn9T.css +0 -1
  599. package/out/web/assets/SettingsWindow-Dw8L01pw.js +0 -2
  600. package/out/web/assets/SkillsView-cnl8aY5p.js +0 -2
  601. package/out/web/assets/ToolSurfacePage-CgsQw6l5.css +0 -1
  602. package/out/web/assets/ToolSurfacePage-Cz2VJAPZ.js +0 -37
  603. package/out/web/assets/VeoStudioPanel-D4JnERWR.js +0 -4
  604. package/out/web/assets/chunk-CKBBP62Z-cIrXq6jN.js +0 -2
  605. package/out/web/assets/classDiagram-DTDB5LWJ-Dq0smRWk.js +0 -1
  606. package/out/web/assets/classDiagram-v2-JRS7N3AN-Dq0smRWk.js +0 -1
  607. package/out/web/assets/config-C-ZFtipU.css +0 -1
  608. package/out/web/assets/config-eEDngJXO.js +0 -1
  609. package/out/web/assets/core-hQ2VA9Ti.js +0 -1
  610. package/out/web/assets/dist-js-Bf2bhoku.js +0 -1
  611. package/out/web/assets/flowDiagram-A5DVABFB-BLg7nEcd.js +0 -1
  612. package/out/web/assets/index-Btsf4L8M.css +0 -1
  613. package/out/web/assets/index-D8D9MeRA.js +0 -11
  614. package/out/web/assets/lucide-react-Ip3L6Jtn.js +0 -1
  615. package/out/web/assets/markdown-BSeeUbJH.js +0 -38
  616. package/out/web/assets/modelManager-tMVmJVAW.js +0 -2
  617. package/out/web/assets/prompt-latin-400-normal-BQ9zjSN8.woff2 +0 -0
  618. package/out/web/assets/prompt-latin-400-normal-CxU4ec_r.woff +0 -0
  619. package/out/web/assets/prompt-latin-500-normal-B1edBDQ9.woff +0 -0
  620. package/out/web/assets/prompt-latin-500-normal-CxzxEHZc.woff2 +0 -0
  621. package/out/web/assets/prompt-latin-600-normal-CSDDLorv.woff +0 -0
  622. package/out/web/assets/prompt-latin-600-normal-hKZWXsc1.woff2 +0 -0
  623. package/out/web/assets/prompt-latin-700-normal-BfbftDSq.woff +0 -0
  624. package/out/web/assets/prompt-latin-700-normal-I2gc831J.woff2 +0 -0
  625. package/out/web/assets/prompt-thai-400-normal-BrkKv8cO.woff2 +0 -0
  626. package/out/web/assets/prompt-thai-400-normal-DJypnfr3.woff +0 -0
  627. package/out/web/assets/prompt-thai-500-normal-C18pDUoL.woff2 +0 -0
  628. package/out/web/assets/prompt-thai-500-normal-D7YNqUJD.woff +0 -0
  629. package/out/web/assets/prompt-thai-600-normal-CYPViXds.woff +0 -0
  630. package/out/web/assets/prompt-thai-600-normal-MrdfU7zR.woff2 +0 -0
  631. package/out/web/assets/prompt-thai-700-normal-Cb5ilR-c.woff +0 -0
  632. package/out/web/assets/prompt-thai-700-normal-Cg4aQ0Nn.woff2 +0 -0
  633. package/out/web/assets/stateDiagram-v2-4QOOHH4V-DeEc0lGZ.js +0 -1
  634. package/out/web/assets/swimlanesDiagram-VK2B7HYN-DpVik9v7.js +0 -8
  635. package/out/web/assets/window-5qYXKoW7.js +0 -1
  636. package/skills-lock.json +0 -77
  637. package/src/renderer/public/Live2DCubismCore.js +0 -9
  638. package/src/renderer/src/companionWidget.ts +0 -95
  639. package/src/renderer/src/components/Live2DStage.tsx +0 -412
  640. package/src/renderer/src/components/ModelPanel.tsx +0 -155
  641. package/src/renderer/src/css/model.css +0 -628
  642. package/src/renderer/src-web/companionWidget.ts +0 -37
  643. package/src/renderer/src-web/components/ModelPanel.tsx +0 -12
  644. package/src/renderer/src-web/css/model.css +0 -628
@@ -8,8 +8,9 @@
8
8
  //! tools wired up in `orchestration.rs`.
9
9
 
10
10
  use std::{
11
+ cell::RefCell,
11
12
  collections::HashMap,
12
- io::Read,
13
+ io::{Read, Write},
13
14
  path::{Path, PathBuf},
14
15
  process::{Child, Command, Stdio},
15
16
  sync::{Arc, LazyLock, Mutex},
@@ -29,6 +30,7 @@ const MAX_BUF_BYTES: usize = 256 * 1024;
29
30
  #[derive(Debug, Clone)]
30
31
  pub enum JobStatus {
31
32
  Running,
33
+ Stopping,
32
34
  Exited(i32),
33
35
  Killed,
34
36
  Failed(String),
@@ -37,14 +39,14 @@ pub enum JobStatus {
37
39
  /// Growable buffer that tracks how much of itself has already been handed
38
40
  /// back by [`JobRecord`] polling, and caps total retained size so a chatty
39
41
  /// background process can't grow memory unbounded.
40
- struct CappedBuf {
41
- data: String,
42
+ pub(crate) struct CappedBuf {
43
+ pub(crate) data: String,
42
44
  read_offset: usize,
43
45
  truncated: bool,
44
46
  }
45
47
 
46
48
  impl CappedBuf {
47
- fn new() -> Self {
49
+ pub(crate) fn new() -> Self {
48
50
  Self {
49
51
  data: String::new(),
50
52
  read_offset: 0,
@@ -52,7 +54,7 @@ impl CappedBuf {
52
54
  }
53
55
  }
54
56
 
55
- fn push(&mut self, chunk: &str) {
57
+ pub(crate) fn push(&mut self, chunk: &str) {
56
58
  self.data.push_str(chunk);
57
59
  if self.data.len() > MAX_BUF_BYTES {
58
60
  let mut cut = self.data.len() - MAX_BUF_BYTES;
@@ -80,9 +82,18 @@ pub struct JobRecord {
80
82
  cwd: PathBuf,
81
83
  pid: Option<u32>,
82
84
  status: Mutex<JobStatus>,
83
- stdout: Mutex<CappedBuf>,
84
- stderr: Mutex<CappedBuf>,
85
+ stdout: Arc<Mutex<CappedBuf>>,
86
+ stderr: Arc<Mutex<CappedBuf>>,
85
87
  started_at: Instant,
88
+ started_ms: i64,
89
+ ended_ms: Mutex<Option<i64>>,
90
+ context: JobContext,
91
+ server_url: Mutex<Option<String>>,
92
+ stop_complete: std::sync::atomic::AtomicBool,
93
+ stop_error: Mutex<Option<String>>,
94
+ managed_stop: Option<Arc<std::sync::atomic::AtomicBool>>,
95
+ // Held by the backend, so the owned child observes EOF when its owner dies.
96
+ owner_stdin: Mutex<Option<std::process::ChildStdin>>,
86
97
  }
87
98
 
88
99
  pub struct StartedJob {
@@ -99,6 +110,9 @@ pub struct JobSummary {
99
110
  pub status: JobStatus,
100
111
  pub pid: Option<u32>,
101
112
  pub elapsed_secs: u64,
113
+ pub cwd: PathBuf,
114
+ pub workspace: PathBuf,
115
+ pub server_url: Option<String>,
102
116
  }
103
117
 
104
118
  /// Full accumulated stdout/stderr for one job, for `/shells show <id>`.
@@ -113,6 +127,9 @@ pub struct JobSnapshot {
113
127
  pub pid: Option<u32>,
114
128
  pub stdout: String,
115
129
  pub stderr: String,
130
+ pub truncated: bool,
131
+ pub elapsed_secs: u64,
132
+ pub server_url: Option<String>,
116
133
  }
117
134
 
118
135
  static BG_SHELL_JOBS: LazyLock<Mutex<HashMap<String, Arc<JobRecord>>>> =
@@ -123,7 +140,182 @@ static NEXT_JOB_ID: LazyLock<Mutex<u32>> = LazyLock::new(|| Mutex::new(0));
123
140
  fn next_job_id() -> String {
124
141
  let mut n = NEXT_JOB_ID.lock().unwrap();
125
142
  *n += 1;
126
- format!("bg-{n}")
143
+ format!("bg-{}-{n}", &BACKEND_ID[..8])
144
+ }
145
+
146
+ static SHUTTING_DOWN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
147
+ pub struct ShutdownGuard;
148
+ impl Drop for ShutdownGuard {
149
+ fn drop(&mut self) {
150
+ shutdown();
151
+ }
152
+ }
153
+ pub fn is_shutting_down() -> bool {
154
+ SHUTTING_DOWN.load(std::sync::atomic::Ordering::Acquire)
155
+ }
156
+ static BACKEND_ID: LazyLock<String> = LazyLock::new(|| uuid::Uuid::new_v4().to_string());
157
+ static CHAT_OWNERS: LazyLock<Mutex<HashMap<String, Option<String>>>> =
158
+ LazyLock::new(|| Mutex::new(HashMap::new()));
159
+ #[derive(Clone, Default)]
160
+ pub struct JobContext {
161
+ pub workspace: PathBuf,
162
+ pub chat_id: Option<String>,
163
+ pub owner: Option<String>,
164
+ }
165
+ thread_local! { static JOB_CONTEXT: RefCell<JobContext> = RefCell::new(JobContext::default()); }
166
+ pub fn with_context<T>(context: JobContext, run: impl FnOnce() -> T) -> T {
167
+ struct Restore(JobContext);
168
+ impl Drop for Restore {
169
+ fn drop(&mut self) {
170
+ JOB_CONTEXT.with(|c| c.replace(self.0.clone()));
171
+ }
172
+ }
173
+ let previous = JOB_CONTEXT.with(|c| c.replace(context));
174
+ let _restore = Restore(previous);
175
+ run()
176
+ }
177
+ fn normalized_chat(chat: &str) -> &str {
178
+ let chat = chat.trim();
179
+ if chat.is_empty() {
180
+ crate::DEFAULT_CONVERSATION_ID
181
+ } else {
182
+ chat
183
+ }
184
+ }
185
+ pub fn bind_chat_owner(chat: &str, owner: Option<String>) -> bool {
186
+ let chat = normalized_chat(chat);
187
+ let mut owners = CHAT_OWNERS.lock().unwrap();
188
+ let parent = chat.split("::subagent::").next().unwrap_or(chat);
189
+ if let Some(existing) = owners.get(parent).or_else(|| owners.get(chat)) {
190
+ return existing == &owner;
191
+ }
192
+ owners.insert(chat.to_owned(), owner);
193
+ true
194
+ }
195
+ pub fn chat_context(root: &Path, chat: &str) -> JobContext {
196
+ let chat = normalized_chat(chat);
197
+ let owners = CHAT_OWNERS.lock().unwrap();
198
+ let parent = chat.split("::subagent::").next().unwrap_or(chat);
199
+ let owner = owners
200
+ .get(parent)
201
+ .or_else(|| owners.get(chat))
202
+ .cloned()
203
+ .flatten();
204
+ JobContext {
205
+ workspace: root.to_path_buf(),
206
+ chat_id: Some(chat.to_owned()),
207
+ owner,
208
+ }
209
+ }
210
+ pub fn accessible(job_id: &str, owner: &Option<String>) -> bool {
211
+ BG_SHELL_JOBS
212
+ .lock()
213
+ .unwrap()
214
+ .get(job_id)
215
+ .is_some_and(|job| &job.context.owner == owner)
216
+ }
217
+ fn context_for(cwd: &Path) -> JobContext {
218
+ let mut context = JOB_CONTEXT.with(|c| c.borrow().clone());
219
+ if context.workspace.as_os_str().is_empty() {
220
+ context.workspace = cwd.to_path_buf();
221
+ }
222
+ context
223
+ }
224
+ fn register(job: Arc<JobRecord>) {
225
+ let mut jobs = BG_SHELL_JOBS.lock().unwrap();
226
+ jobs.insert(job.id.clone(), job);
227
+ let mut finished: Vec<_> = jobs
228
+ .values()
229
+ .filter(|j| {
230
+ !matches!(
231
+ *j.status.lock().unwrap(),
232
+ JobStatus::Running | JobStatus::Stopping
233
+ )
234
+ })
235
+ .map(|j| (j.started_at, j.id.clone()))
236
+ .collect();
237
+ finished.sort();
238
+ let remove = finished.len().saturating_sub(100);
239
+ for (_, id) in finished.into_iter().take(remove) {
240
+ jobs.remove(&id);
241
+ }
242
+ }
243
+ /// Immutable metadata and output for clients; never consumes the agent's cursor.
244
+ pub fn job_json(id: &str, include_output: bool) -> Result<serde_json::Value, ShellError> {
245
+ let job = BG_SHELL_JOBS
246
+ .lock()
247
+ .unwrap()
248
+ .get(id)
249
+ .cloned()
250
+ .ok_or_else(|| ShellError::JobNotFound(id.into()))?;
251
+ let status = job.status.lock().unwrap().clone();
252
+ let (state, code, error) = match status {
253
+ JobStatus::Running => ("running", None, None),
254
+ JobStatus::Stopping => ("stopping", None, None),
255
+ JobStatus::Exited(0) => ("completed", Some(0), None),
256
+ JobStatus::Exited(code) => ("failed", Some(code), None),
257
+ JobStatus::Killed => ("stopped", None, None),
258
+ JobStatus::Failed(error) => ("failed", None, Some(error)),
259
+ };
260
+ let ended = *job.ended_ms.lock().unwrap();
261
+ let mut value = serde_json::json!({ "id": job.id, "managedService": job.managed_stop.is_some(), "command": job.command, "cwd": job.cwd, "workspacePath": job.context.workspace, "chatId": job.context.chat_id, "pid": job.pid, "status": state, "exitCode": code, "error": error, "startedAt": job.started_ms, "endedAt": ended, "elapsedSeconds": (ended.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()) - job.started_ms).max(0) / 1000, "serverUrl": if state == "running" { job.server_url.lock().unwrap().clone() } else { None } });
262
+ if include_output {
263
+ let stdout = job.stdout.lock().unwrap();
264
+ let stderr = job.stderr.lock().unwrap();
265
+ value["stdout"] = serde_json::json!(stdout.data);
266
+ value["stderr"] = serde_json::json!(stderr.data);
267
+ value["truncated"] = serde_json::json!(stdout.truncated || stderr.truncated);
268
+ }
269
+ Ok(value)
270
+ }
271
+ pub fn list_json(owner: &Option<String>, workspace: Option<&Path>) -> Vec<serde_json::Value> {
272
+ let ids: Vec<_> = BG_SHELL_JOBS
273
+ .lock()
274
+ .unwrap()
275
+ .values()
276
+ .filter(|j| &j.context.owner == owner && workspace.is_none_or(|w| j.context.workspace == w))
277
+ .map(|j| j.id.clone())
278
+ .collect();
279
+ let mut jobs: Vec<_> = ids
280
+ .iter()
281
+ .filter_map(|id| job_json(id, false).ok())
282
+ .collect();
283
+ jobs.sort_by_key(|j| {
284
+ (
285
+ j["startedAt"].as_i64().unwrap_or(0),
286
+ j["id"]
287
+ .as_str()
288
+ .and_then(|id| id.rsplit('-').next())
289
+ .and_then(|n| n.parse::<u64>().ok())
290
+ .unwrap_or(0),
291
+ )
292
+ });
293
+ jobs
294
+ }
295
+ pub fn running_count_for(workspace: &Path) -> usize {
296
+ BG_SHELL_JOBS
297
+ .lock()
298
+ .unwrap()
299
+ .values()
300
+ .filter(|j| {
301
+ j.context.workspace == workspace
302
+ && matches!(
303
+ *j.status.lock().unwrap(),
304
+ JobStatus::Running | JobStatus::Stopping
305
+ )
306
+ })
307
+ .count()
308
+ }
309
+ pub fn shutdown() {
310
+ SHUTTING_DOWN.store(true, std::sync::atomic::Ordering::Release);
311
+ let ids: Vec<_> = BG_SHELL_JOBS.lock().unwrap().keys().cloned().collect();
312
+ for id in &ids {
313
+ let _ = kill_job(id);
314
+ }
315
+ let deadline = Instant::now() + Duration::from_secs(3);
316
+ while running_count() > 0 && Instant::now() < deadline {
317
+ std::thread::sleep(Duration::from_millis(25));
318
+ }
127
319
  }
128
320
 
129
321
  /// Plain-text notices ("job X exited with code 0") a UI surface can drain,
@@ -146,7 +338,7 @@ fn push_finished_notice(job: &JobRecord, status: &JobStatus) {
146
338
  ),
147
339
  // Killed jobs were stopped intentionally (via kill_shell), and
148
340
  // Running is not a terminal state — neither needs a surprise notice.
149
- JobStatus::Killed | JobStatus::Running => return,
341
+ JobStatus::Killed | JobStatus::Running | JobStatus::Stopping => return,
150
342
  };
151
343
  FINISHED_NOTICES.lock().unwrap().push(message);
152
344
  }
@@ -156,22 +348,169 @@ enum StreamKind {
156
348
  Stderr,
157
349
  }
158
350
 
159
- fn spawn_pump(mut pipe: impl Read + Send + 'static, job: Arc<JobRecord>, kind: StreamKind) {
351
+ pub(crate) fn read_stream(mut pipe: impl Read, buffer: &Arc<Mutex<CappedBuf>>) {
352
+ let mut chunk = [0u8; 4096];
353
+ let mut pending = Vec::new();
354
+ loop {
355
+ match pipe.read(&mut chunk) {
356
+ Ok(0) | Err(_) => break,
357
+ Ok(n) => pending.extend_from_slice(&chunk[..n]),
358
+ }
359
+ loop {
360
+ match std::str::from_utf8(&pending) {
361
+ Ok(text) => {
362
+ buffer.lock().unwrap().push(text);
363
+ pending.clear();
364
+ break;
365
+ }
366
+ Err(error) => {
367
+ let valid = error.valid_up_to();
368
+ buffer
369
+ .lock()
370
+ .unwrap()
371
+ .push(std::str::from_utf8(&pending[..valid]).unwrap());
372
+ pending.drain(..valid);
373
+ if let Some(invalid) = error.error_len() {
374
+ buffer.lock().unwrap().push("�");
375
+ pending.drain(..invalid);
376
+ } else {
377
+ break;
378
+ }
379
+ }
380
+ }
381
+ }
382
+ }
383
+ if !pending.is_empty() {
384
+ buffer
385
+ .lock()
386
+ .unwrap()
387
+ .push(&String::from_utf8_lossy(&pending));
388
+ }
389
+ }
390
+ fn spawn_pump(pipe: impl Read + Send + 'static, job: Arc<JobRecord>, kind: StreamKind) {
391
+ std::thread::spawn(move || {
392
+ let buffer = match kind {
393
+ StreamKind::Stdout => &job.stdout,
394
+ StreamKind::Stderr => &job.stderr,
395
+ };
396
+ read_stream(pipe, buffer);
397
+ });
398
+ }
399
+
400
+ /// Track a built-in local service using the same lifetime and Stop controls as shell jobs.
401
+ pub(crate) fn start_local_service(
402
+ root: PathBuf,
403
+ owner: Option<String>,
404
+ url: String,
405
+ run: impl FnOnce(Arc<std::sync::atomic::AtomicBool>) -> Result<(), String> + Send + 'static,
406
+ ) -> Result<String, String> {
407
+ if is_shutting_down() {
408
+ return Err("Mint backend is shutting down".into());
409
+ }
410
+ let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
411
+ let job = Arc::new(JobRecord {
412
+ id: next_job_id(),
413
+ command: format!("HTML preview server · {}", root.display()),
414
+ cwd: root.clone(),
415
+ pid: None,
416
+ status: Mutex::new(JobStatus::Running),
417
+ stdout: Arc::new(Mutex::new(CappedBuf::new())),
418
+ stderr: Arc::new(Mutex::new(CappedBuf::new())),
419
+ started_at: Instant::now(),
420
+ started_ms: chrono::Utc::now().timestamp_millis(),
421
+ ended_ms: Mutex::new(None),
422
+ context: JobContext {
423
+ workspace: root,
424
+ chat_id: None,
425
+ owner,
426
+ },
427
+ server_url: Mutex::new(Some(url.clone())),
428
+ stop_complete: std::sync::atomic::AtomicBool::new(false),
429
+ stop_error: Mutex::new(None),
430
+ managed_stop: Some(Arc::clone(&stop)),
431
+ owner_stdin: Mutex::new(None),
432
+ });
433
+ job.stdout
434
+ .lock()
435
+ .unwrap()
436
+ .push(&format!("Serving HTML preview at {url}\n"));
437
+ let id = job.id.clone();
438
+ register(Arc::clone(&job));
439
+ if is_shutting_down() {
440
+ stop.store(true, std::sync::atomic::Ordering::Release);
441
+ }
160
442
  std::thread::spawn(move || {
161
- let mut chunk = [0u8; 4096];
443
+ let result =
444
+ std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(Arc::clone(&stop))))
445
+ .unwrap_or_else(|_| Err("HTML preview server panicked".into()));
446
+ let final_status = if stop.load(std::sync::atomic::Ordering::Acquire) {
447
+ JobStatus::Killed
448
+ } else {
449
+ result.map_or_else(JobStatus::Failed, |_| JobStatus::Exited(0))
450
+ };
451
+ *job.status.lock().unwrap() = final_status.clone();
452
+ *job.ended_ms.lock().unwrap() = Some(chrono::Utc::now().timestamp_millis());
453
+ job.stop_complete
454
+ .store(true, std::sync::atomic::Ordering::Release);
455
+ push_finished_notice(&job, &final_status);
456
+ register(job);
457
+ });
458
+ Ok(id)
459
+ }
460
+
461
+ /// Start an application-owned program with a lifetime pipe on stdin.
462
+ pub(crate) fn start_owned_background(
463
+ cwd: &Path,
464
+ config: &MintConfig,
465
+ command: &str,
466
+ mut program: Command,
467
+ ) -> Result<StartedJob, ShellError> {
468
+ #[cfg(unix)]
469
+ {
470
+ use std::os::unix::process::CommandExt;
471
+ program.process_group(0);
472
+ }
473
+ program.env("MINT_WEB_LIFETIME_PIPE", "1");
474
+ spawn_tracked(cwd, config, command, program, true)
475
+ }
476
+
477
+ /// Launch Mint's known frontend scripts as tracked children of the web backend.
478
+ pub fn start_web_frontend(
479
+ cwd: &Path,
480
+ config: &MintConfig,
481
+ script: &str,
482
+ ) -> Result<StartedJob, ShellError> {
483
+ if !matches!(script, "dev:web" | "preview:web") {
484
+ return Err(ShellError::Execute(std::io::Error::other(
485
+ "Unknown Mint Web frontend script",
486
+ )));
487
+ }
488
+ let mut program = Command::new(if cfg!(windows) { "npm.cmd" } else { "npm" });
489
+ program.args(["run", script]);
490
+ #[cfg(unix)]
491
+ {
492
+ use std::os::unix::process::CommandExt;
493
+ program.process_group(0);
494
+ }
495
+ spawn_tracked(cwd, config, &format!("npm run {script}"), program, false)
496
+ }
497
+
498
+ /// Called by the owned Mint Web child. EOF also covers abrupt owner termination.
499
+ pub fn watch_web_owner() {
500
+ if std::env::var_os("MINT_WEB_LIFETIME_PIPE").is_none() {
501
+ return;
502
+ }
503
+ std::thread::spawn(|| {
504
+ let mut byte = [0u8; 1];
505
+ let mut stdin = std::io::stdin().lock();
162
506
  loop {
163
- match pipe.read(&mut chunk) {
507
+ match stdin.read(&mut byte) {
164
508
  Ok(0) | Err(_) => break,
165
- Ok(n) => {
166
- let text = String::from_utf8_lossy(&chunk[..n]);
167
- let mut buf = match kind {
168
- StreamKind::Stdout => job.stdout.lock().unwrap(),
169
- StreamKind::Stderr => job.stderr.lock().unwrap(),
170
- };
171
- buf.push(&text);
172
- }
509
+ Ok(_) => {}
173
510
  }
174
511
  }
512
+ shutdown();
513
+ std::process::exit(0);
175
514
  });
176
515
  }
177
516
 
@@ -185,6 +524,11 @@ pub fn start_background(
185
524
  config: &MintConfig,
186
525
  command: &str,
187
526
  ) -> Result<StartedJob, ShellError> {
527
+ if is_shutting_down() {
528
+ return Err(ShellError::Execute(std::io::Error::other(
529
+ "Mint backend is shutting down",
530
+ )));
531
+ }
188
532
  let classification = classify_shell_command(command);
189
533
  if classification.tier == SafetyTier::Blocked {
190
534
  return Err(ShellError::Blocked {
@@ -199,19 +543,39 @@ pub fn start_background(
199
543
  });
200
544
  }
201
545
 
546
+ spawn_tracked(
547
+ cwd,
548
+ config,
549
+ command,
550
+ crate::shell::shell_command(command),
551
+ false,
552
+ )
553
+ }
554
+
555
+ fn spawn_tracked(
556
+ cwd: &Path,
557
+ config: &MintConfig,
558
+ command: &str,
559
+ mut cmd: Command,
560
+ owned: bool,
561
+ ) -> Result<StartedJob, ShellError> {
562
+ if is_shutting_down() {
563
+ return Err(ShellError::Execute(std::io::Error::other(
564
+ "Mint backend is shutting down",
565
+ )));
566
+ }
202
567
  let cwd = assert_path_capability(cwd, Capability::Write, config)?;
203
568
  if !cwd.is_dir() {
204
569
  return Err(ShellError::InvalidWorkingDirectory(cwd));
205
570
  }
206
-
207
- let mut cmd = crate::shell::shell_command(command);
208
571
  cmd.current_dir(&cwd)
209
- .stdin(Stdio::null())
572
+ .stdin(if owned { Stdio::piped() } else { Stdio::null() })
210
573
  .stdout(Stdio::piped())
211
574
  .stderr(Stdio::piped());
212
575
 
213
576
  let mut child = cmd.spawn()?;
214
577
  let pid = child.id();
578
+ let owner_stdin = child.stdin.take();
215
579
  let stdout = child.stdout.take().expect("stdout was piped");
216
580
  let stderr = child.stderr.take().expect("stderr was piped");
217
581
 
@@ -221,9 +585,17 @@ pub fn start_background(
221
585
  cwd: cwd.clone(),
222
586
  pid: Some(pid),
223
587
  status: Mutex::new(JobStatus::Running),
224
- stdout: Mutex::new(CappedBuf::new()),
225
- stderr: Mutex::new(CappedBuf::new()),
588
+ stdout: Arc::new(Mutex::new(CappedBuf::new())),
589
+ stderr: Arc::new(Mutex::new(CappedBuf::new())),
226
590
  started_at: Instant::now(),
591
+ started_ms: chrono::Utc::now().timestamp_millis(),
592
+ ended_ms: Mutex::new(None),
593
+ context: context_for(&cwd),
594
+ server_url: Mutex::new(None),
595
+ stop_complete: std::sync::atomic::AtomicBool::new(false),
596
+ stop_error: Mutex::new(None),
597
+ managed_stop: None,
598
+ owner_stdin: Mutex::new(owner_stdin),
227
599
  });
228
600
 
229
601
  spawn_pump(stdout, Arc::clone(&job), StreamKind::Stdout);
@@ -242,25 +614,37 @@ pub fn start_background(
242
614
  // Always wait, even if `kill_job` already marked this Killed —
243
615
  // otherwise the child becomes a zombie.
244
616
  let exit_status = child.wait();
617
+ wait_stop_completion(&job_for_wait);
245
618
  let mut status = job_for_wait.status.lock().unwrap();
246
- let was_running = matches!(*status, JobStatus::Running);
619
+ let was_running = matches!(*status, JobStatus::Running | JobStatus::Stopping);
620
+ let stopping = matches!(*status, JobStatus::Stopping);
247
621
  if was_running {
248
622
  *status = match exit_status {
623
+ Ok(_) if stopping => job_for_wait
624
+ .stop_error
625
+ .lock()
626
+ .unwrap()
627
+ .clone()
628
+ .map_or(JobStatus::Killed, JobStatus::Failed),
249
629
  Ok(code) => JobStatus::Exited(code.code().unwrap_or(-1)),
250
630
  Err(e) => JobStatus::Failed(e.to_string()),
251
631
  };
252
632
  }
633
+ *job_for_wait.ended_ms.lock().unwrap() =
634
+ Some(chrono::Utc::now().timestamp_millis());
253
635
  let final_status = status.clone();
254
636
  drop(status);
255
637
  if was_running {
256
638
  push_finished_notice(&job_for_wait, &final_status);
639
+ register(Arc::clone(&job_for_wait));
257
640
  }
258
641
  }));
259
642
  if let Err(payload) = result {
260
643
  let message = crate::channels::panic_payload_message(&payload);
261
644
  eprintln!("[mint] bg_shell job reaper thread panicked: {message}");
262
645
  let mut status = job.status.lock().unwrap();
263
- if matches!(*status, JobStatus::Running) {
646
+ if matches!(*status, JobStatus::Running | JobStatus::Stopping) {
647
+ *job.ended_ms.lock().unwrap() = Some(chrono::Utc::now().timestamp_millis());
264
648
  *status = JobStatus::Failed(format!("reaper thread panicked: {message}"));
265
649
  }
266
650
  }
@@ -272,10 +656,11 @@ pub fn start_background(
272
656
  pid: job.pid,
273
657
  cwd,
274
658
  };
275
- BG_SHELL_JOBS
276
- .lock()
277
- .unwrap()
278
- .insert(started.id.clone(), job);
659
+ spawn_readiness(Arc::clone(&job));
660
+ register(job);
661
+ if is_shutting_down() {
662
+ let _ = kill_job(&started.id);
663
+ }
279
664
  Ok(started)
280
665
  }
281
666
 
@@ -289,12 +674,14 @@ pub fn start_background(
289
674
  /// via `start_background`, there is no live streaming during this handoff.
290
675
  /// The job is registered as `Running` immediately regardless, so it's never
291
676
  /// invisible to `/shells` or the status bar even before that happens.
292
- pub fn promote_timed_out(
677
+ pub(crate) fn promote_timed_out(
293
678
  command: &str,
294
679
  cwd: PathBuf,
295
680
  mut child: Child,
296
681
  stdout_handle: std::thread::JoinHandle<Vec<u8>>,
297
682
  stderr_handle: std::thread::JoinHandle<Vec<u8>>,
683
+ stdout_buffer: Arc<Mutex<CappedBuf>>,
684
+ stderr_buffer: Arc<Mutex<CappedBuf>>,
298
685
  ) -> StartedJob {
299
686
  let pid = child.id();
300
687
  let job = Arc::new(JobRecord {
@@ -303,9 +690,17 @@ pub fn promote_timed_out(
303
690
  cwd: cwd.clone(),
304
691
  pid: Some(pid),
305
692
  status: Mutex::new(JobStatus::Running),
306
- stdout: Mutex::new(CappedBuf::new()),
307
- stderr: Mutex::new(CappedBuf::new()),
693
+ stdout: stdout_buffer,
694
+ stderr: stderr_buffer,
308
695
  started_at: Instant::now(),
696
+ started_ms: chrono::Utc::now().timestamp_millis(),
697
+ ended_ms: Mutex::new(None),
698
+ context: context_for(&cwd),
699
+ server_url: Mutex::new(None),
700
+ stop_complete: std::sync::atomic::AtomicBool::new(false),
701
+ stop_error: Mutex::new(None),
702
+ managed_stop: None,
703
+ owner_stdin: Mutex::new(None),
309
704
  });
310
705
 
311
706
  {
@@ -316,41 +711,42 @@ pub fn promote_timed_out(
316
711
  // panic here marks the job Failed instead of leaving it stuck as
317
712
  // `Running` forever with a zombie child left un-`wait()`-ed.
318
713
  let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
319
- let stdout = stdout_handle.join().unwrap_or_default();
320
- let stderr = stderr_handle.join().unwrap_or_default();
321
- job_for_wait
322
- .stdout
323
- .lock()
324
- .unwrap()
325
- .push(&String::from_utf8_lossy(&stdout));
326
- job_for_wait
327
- .stderr
328
- .lock()
329
- .unwrap()
330
- .push(&String::from_utf8_lossy(&stderr));
331
-
332
714
  // Always wait, even if `kill_job` already marked this Killed —
333
715
  // otherwise the child becomes a zombie.
334
716
  let exit_status = child.wait();
717
+ wait_stop_completion(&job_for_wait);
718
+ let _ = stdout_handle.join();
719
+ let _ = stderr_handle.join();
335
720
  let mut status = job_for_wait.status.lock().unwrap();
336
- let was_running = matches!(*status, JobStatus::Running);
721
+ let was_running = matches!(*status, JobStatus::Running | JobStatus::Stopping);
722
+ let stopping = matches!(*status, JobStatus::Stopping);
337
723
  if was_running {
338
724
  *status = match exit_status {
725
+ Ok(_) if stopping => job_for_wait
726
+ .stop_error
727
+ .lock()
728
+ .unwrap()
729
+ .clone()
730
+ .map_or(JobStatus::Killed, JobStatus::Failed),
339
731
  Ok(code) => JobStatus::Exited(code.code().unwrap_or(-1)),
340
732
  Err(e) => JobStatus::Failed(e.to_string()),
341
733
  };
342
734
  }
735
+ *job_for_wait.ended_ms.lock().unwrap() =
736
+ Some(chrono::Utc::now().timestamp_millis());
343
737
  let final_status = status.clone();
344
738
  drop(status);
345
739
  if was_running {
346
740
  push_finished_notice(&job_for_wait, &final_status);
741
+ register(Arc::clone(&job_for_wait));
347
742
  }
348
743
  }));
349
744
  if let Err(payload) = result {
350
745
  let message = crate::channels::panic_payload_message(&payload);
351
746
  eprintln!("[mint] bg_shell job reaper thread panicked: {message}");
352
747
  let mut status = job.status.lock().unwrap();
353
- if matches!(*status, JobStatus::Running) {
748
+ if matches!(*status, JobStatus::Running | JobStatus::Stopping) {
749
+ *job.ended_ms.lock().unwrap() = Some(chrono::Utc::now().timestamp_millis());
354
750
  *status = JobStatus::Failed(format!("reaper thread panicked: {message}"));
355
751
  }
356
752
  }
@@ -362,10 +758,11 @@ pub fn promote_timed_out(
362
758
  pid: job.pid,
363
759
  cwd,
364
760
  };
365
- BG_SHELL_JOBS
366
- .lock()
367
- .unwrap()
368
- .insert(started.id.clone(), job);
761
+ spawn_readiness(Arc::clone(&job));
762
+ register(job);
763
+ if is_shutting_down() {
764
+ let _ = kill_job(&started.id);
765
+ }
369
766
  started
370
767
  }
371
768
 
@@ -389,6 +786,10 @@ pub fn poll_output(job_id: &str) -> Result<String, ShellError> {
389
786
  };
390
787
 
391
788
  Ok(match status {
789
+ JobStatus::Stopping => format!(
790
+ "job_id: {}\nstatus: stopping\nstdout (new):\n{}\nstderr (new):\n{}{}",
791
+ job.id, stdout_new, stderr_new, truncated_note
792
+ ),
392
793
  JobStatus::Running => format!(
393
794
  "job_id: {}\nstatus: running (elapsed {}s)\ncwd: {}\nstdout (new):\n{}\nstderr (new):\n{}{}",
394
795
  job.id,
@@ -420,15 +821,38 @@ pub fn list_jobs() -> Vec<JobSummary> {
420
821
  .lock()
421
822
  .unwrap()
422
823
  .values()
423
- .map(|job| JobSummary {
424
- id: job.id.clone(),
425
- command: job.command.clone(),
426
- status: job.status.lock().unwrap().clone(),
427
- pid: job.pid,
428
- elapsed_secs: job.started_at.elapsed().as_secs(),
824
+ .map(|job| {
825
+ let status = job.status.lock().unwrap().clone();
826
+ JobSummary {
827
+ id: job.id.clone(),
828
+ command: job.command.clone(),
829
+ status: status.clone(),
830
+ pid: job.pid,
831
+ elapsed_secs: (job
832
+ .ended_ms
833
+ .lock()
834
+ .unwrap()
835
+ .unwrap_or_else(|| chrono::Utc::now().timestamp_millis())
836
+ - job.started_ms)
837
+ .max(0) as u64
838
+ / 1000,
839
+ cwd: job.cwd.clone(),
840
+ workspace: job.context.workspace.clone(),
841
+ server_url: if matches!(status, JobStatus::Running) {
842
+ job.server_url.lock().unwrap().clone()
843
+ } else {
844
+ None
845
+ },
846
+ }
429
847
  })
430
848
  .collect();
431
- jobs.sort_by(|a, b| a.id.cmp(&b.id));
849
+ jobs.sort_by_key(|job| {
850
+ job.id
851
+ .rsplit('-')
852
+ .next()
853
+ .and_then(|n| n.parse::<u64>().ok())
854
+ .unwrap_or(0)
855
+ });
432
856
  jobs
433
857
  }
434
858
 
@@ -439,7 +863,12 @@ pub fn running_count() -> usize {
439
863
  .lock()
440
864
  .unwrap()
441
865
  .values()
442
- .filter(|job| matches!(*job.status.lock().unwrap(), JobStatus::Running))
866
+ .filter(|job| {
867
+ matches!(
868
+ *job.status.lock().unwrap(),
869
+ JobStatus::Running | JobStatus::Stopping
870
+ )
871
+ })
443
872
  .count()
444
873
  }
445
874
 
@@ -454,35 +883,197 @@ pub fn snapshot(job_id: &str) -> Result<JobSnapshot, ShellError> {
454
883
  .cloned()
455
884
  .ok_or_else(|| ShellError::JobNotFound(job_id.to_owned()))?;
456
885
 
886
+ let status = job.status.lock().unwrap().clone();
887
+ let stdout = job.stdout.lock().unwrap();
888
+ let stderr = job.stderr.lock().unwrap();
457
889
  Ok(JobSnapshot {
458
890
  id: job.id.clone(),
459
891
  command: job.command.clone(),
460
892
  cwd: job.cwd.clone(),
461
- status: job.status.lock().unwrap().clone(),
893
+ status: status.clone(),
462
894
  pid: job.pid,
463
- stdout: job.stdout.lock().unwrap().data.clone(),
464
- stderr: job.stderr.lock().unwrap().data.clone(),
895
+ stdout: stdout.data.clone(),
896
+ stderr: stderr.data.clone(),
897
+ truncated: stdout.truncated || stderr.truncated,
898
+ elapsed_secs: (job
899
+ .ended_ms
900
+ .lock()
901
+ .unwrap()
902
+ .unwrap_or_else(|| chrono::Utc::now().timestamp_millis())
903
+ - job.started_ms)
904
+ .max(0) as u64
905
+ / 1000,
906
+ server_url: if matches!(status, JobStatus::Running) {
907
+ job.server_url.lock().unwrap().clone()
908
+ } else {
909
+ None
910
+ },
465
911
  })
466
912
  }
467
913
 
468
- #[cfg(not(target_os = "windows"))]
469
- pub(crate) fn terminate_process_group(pid: u32) {
470
- let _ = Command::new("kill")
471
- .args(["-TERM", &format!("-{pid}")])
472
- .status();
914
+ /// Verify a listening socket belongs to the tracked process group before probing it.
915
+ #[cfg(target_os = "linux")]
916
+ fn owns_port(pid: u32, port: u16) -> bool {
917
+ let mut sockets = std::collections::HashSet::new();
918
+ for table in ["/proc/net/tcp", "/proc/net/tcp6"] {
919
+ for line in std::fs::read_to_string(table)
920
+ .unwrap_or_default()
921
+ .lines()
922
+ .skip(1)
923
+ {
924
+ let fields: Vec<_> = line.split_whitespace().collect();
925
+ if fields.len() > 9
926
+ && fields[3] == "0A"
927
+ && fields[1]
928
+ .rsplit(':')
929
+ .next()
930
+ .is_some_and(|p| u16::from_str_radix(p, 16).ok() == Some(port))
931
+ {
932
+ sockets.insert(format!("socket:[{}]", fields[9]));
933
+ }
934
+ }
935
+ }
936
+ if sockets.is_empty() {
937
+ return false;
938
+ }
939
+ for entry in std::fs::read_dir("/proc").into_iter().flatten().flatten() {
940
+ if entry.file_name().to_string_lossy().parse::<u32>().is_err() {
941
+ continue;
942
+ }
943
+ let stat = std::fs::read_to_string(entry.path().join("stat")).unwrap_or_default();
944
+ // After the parenthesized command: state, parent pid, process group.
945
+ if !stat.rsplit_once(')').is_some_and(|(_, rest)| {
946
+ rest.split_whitespace()
947
+ .nth(2)
948
+ .and_then(|g| g.parse::<u32>().ok())
949
+ == Some(pid)
950
+ }) {
951
+ continue;
952
+ }
953
+ for fd in std::fs::read_dir(entry.path().join("fd"))
954
+ .into_iter()
955
+ .flatten()
956
+ .flatten()
957
+ {
958
+ if std::fs::read_link(fd.path())
959
+ .ok()
960
+ .is_some_and(|target| sockets.contains(target.to_string_lossy().as_ref()))
961
+ {
962
+ return true;
963
+ }
964
+ }
965
+ }
966
+ false
967
+ }
968
+ #[cfg(target_os = "macos")]
969
+ fn owns_port(pid: u32, port: u16) -> bool {
970
+ Command::new("lsof")
971
+ .args([
972
+ "-a",
973
+ "-g",
974
+ &pid.to_string(),
975
+ &format!("-iTCP:{port}"),
976
+ "-sTCP:LISTEN",
977
+ "-t",
978
+ ])
979
+ .output()
980
+ .is_ok_and(|r| r.status.success() && !r.stdout.is_empty())
981
+ }
982
+ #[cfg(target_os = "windows")]
983
+ fn owns_port(pid: u32, port: u16) -> bool {
984
+ let script = format!(
985
+ "$ids=@({pid}); $processes=Get-CimInstance Win32_Process; do {{ $previous=$ids.Count; $ids+=@($processes | Where-Object {{ $ids -contains $_.ParentProcessId -and $ids -notcontains $_.ProcessId }} | ForEach-Object {{ $_.ProcessId }}); }} while ($ids.Count -gt $previous); if (Get-NetTCPConnection -LocalPort {port} -State Listen -ErrorAction SilentlyContinue | Where-Object {{ $ids -contains $_.OwningProcess }}) {{ 'owned' }}"
986
+ );
987
+ Command::new("powershell")
988
+ .args(["-NoProfile", "-NonInteractive", "-Command", &script])
989
+ .output()
990
+ .is_ok_and(|r| r.status.success() && String::from_utf8_lossy(&r.stdout).contains("owned"))
991
+ }
992
+ #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
993
+ fn owns_port(_pid: u32, _port: u16) -> bool {
994
+ false
995
+ }
996
+
997
+ fn spawn_readiness(job: Arc<JobRecord>) {
473
998
  std::thread::spawn(move || {
474
- std::thread::sleep(Duration::from_millis(500));
475
- let _ = Command::new("kill")
476
- .args(["-KILL", &format!("-{pid}")])
477
- .status();
999
+ let pattern =
1000
+ regex::Regex::new(r"http://(?:localhost|127\.0\.0\.1|0\.0\.0\.0):([0-9]{1,5})")
1001
+ .unwrap();
1002
+ let python = regex::Regex::new(r"-m\s+http\.server(?:\s+(\d+))?").unwrap();
1003
+ let serving = regex::Regex::new(r"Serving HTTP on .* port (\d+)").unwrap();
1004
+ let command_port = python.captures(&job.command).and_then(|c| {
1005
+ c.get(1)
1006
+ .map_or(Some(8000), |p| p.as_str().parse::<u16>().ok())
1007
+ });
1008
+ loop {
1009
+ std::thread::sleep(Duration::from_secs(1));
1010
+ if !matches!(*job.status.lock().unwrap(), JobStatus::Running) {
1011
+ return;
1012
+ }
1013
+ let text = format!(
1014
+ "{}\n{}",
1015
+ job.stdout.lock().unwrap().data,
1016
+ job.stderr.lock().unwrap().data
1017
+ );
1018
+ let port = pattern
1019
+ .captures(&text)
1020
+ .and_then(|c| c[1].parse::<u16>().ok())
1021
+ .or_else(|| {
1022
+ serving
1023
+ .captures(&text)
1024
+ .and_then(|c| c[1].parse::<u16>().ok())
1025
+ })
1026
+ .or(command_port);
1027
+ let Some(port) = port.filter(|p| *p > 0) else {
1028
+ continue;
1029
+ };
1030
+ let owned = job.pid.is_some_and(|pid| owns_port(pid, port));
1031
+ let mut verified = false;
1032
+ if owned {
1033
+ let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
1034
+ if let Ok(mut stream) =
1035
+ std::net::TcpStream::connect_timeout(&addr, Duration::from_millis(300))
1036
+ {
1037
+ let _ = stream.set_read_timeout(Some(Duration::from_millis(300)));
1038
+ let _ = stream.set_write_timeout(Some(Duration::from_millis(300)));
1039
+ if stream
1040
+ .write_all(b"HEAD / HTTP/1.0\r\nHost: localhost\r\n\r\n")
1041
+ .is_ok()
1042
+ {
1043
+ let mut response = [0; 128];
1044
+ verified = stream
1045
+ .read(&mut response)
1046
+ .is_ok_and(|n| response[..n].starts_with(b"HTTP/"));
1047
+ }
1048
+ }
1049
+ }
1050
+ *job.server_url.lock().unwrap() = verified.then(|| format!("http://127.0.0.1:{port}"));
1051
+ }
478
1052
  });
479
1053
  }
480
1054
 
481
- #[cfg(target_os = "windows")]
482
- pub(crate) fn terminate_process_group(pid: u32) {
483
- let _ = Command::new("taskkill")
484
- .args(["/F", "/T", "/PID", &pid.to_string()])
485
- .status();
1055
+ fn wait_stop_completion(job: &JobRecord) {
1056
+ if matches!(*job.status.lock().unwrap(), JobStatus::Stopping) {
1057
+ while !job.stop_complete.load(std::sync::atomic::Ordering::Acquire) {
1058
+ std::thread::sleep(Duration::from_millis(10));
1059
+ }
1060
+ }
1061
+ }
1062
+
1063
+ /// Remove a finished job and its buffered output from the registry.
1064
+ pub fn remove_job(job_id: &str) -> Result<(), ShellError> {
1065
+ let mut jobs = BG_SHELL_JOBS.lock().unwrap();
1066
+ let job = jobs
1067
+ .get(job_id)
1068
+ .ok_or_else(|| ShellError::JobNotFound(job_id.to_owned()))?;
1069
+ if matches!(
1070
+ *job.status.lock().unwrap(),
1071
+ JobStatus::Running | JobStatus::Stopping
1072
+ ) {
1073
+ return Err(ShellError::JobStillRunning(job_id.to_owned()));
1074
+ }
1075
+ jobs.remove(job_id);
1076
+ Ok(())
486
1077
  }
487
1078
 
488
1079
  /// Sends a termination signal to a running job's whole process group
@@ -498,28 +1089,84 @@ pub fn kill_job(job_id: &str) -> Result<String, ShellError> {
498
1089
  .cloned()
499
1090
  .ok_or_else(|| ShellError::JobNotFound(job_id.to_owned()))?;
500
1091
 
501
- let current = job.status.lock().unwrap().clone();
502
- if !matches!(current, JobStatus::Running) {
1092
+ let mut status = job.status.lock().unwrap();
1093
+ if !matches!(*status, JobStatus::Running) {
503
1094
  return Ok(format!(
504
- "job_id: {} is already {:?}, nothing to kill",
505
- job.id, current
1095
+ "job_id: {} is already {:?}, nothing to stop",
1096
+ job.id, *status
506
1097
  ));
507
1098
  }
508
-
1099
+ if let Some(stop) = &job.managed_stop {
1100
+ *status = JobStatus::Stopping;
1101
+ stop.store(true, std::sync::atomic::Ordering::Release);
1102
+ return Ok(format!("job_id: {job_id} stop requested"));
1103
+ }
509
1104
  let Some(pid) = job.pid else {
510
- return Ok(format!("job_id: {} has no known pid to kill", job.id));
1105
+ return Err(ShellError::JobNotFound(job_id.into()));
511
1106
  };
512
-
513
- terminate_process_group(pid);
514
-
515
- {
516
- let mut status = job.status.lock().unwrap();
517
- if matches!(*status, JobStatus::Running) {
518
- *status = JobStatus::Killed;
519
- }
1107
+ // Closing the lifetime pipe lets owned Mint Web shut down its own tracked
1108
+ // frontend before it exits. Keep forceful group cleanup as a fallback.
1109
+ if job.owner_stdin.lock().unwrap().take().is_some() {
1110
+ *status = JobStatus::Stopping;
1111
+ drop(status);
1112
+ job.stop_complete
1113
+ .store(true, std::sync::atomic::Ordering::Release);
1114
+ std::thread::spawn(move || {
1115
+ std::thread::sleep(Duration::from_secs(2));
1116
+ if !matches!(*job.status.lock().unwrap(), JobStatus::Stopping) {
1117
+ return;
1118
+ }
1119
+ #[cfg(not(target_os = "windows"))]
1120
+ let _ = Command::new("kill")
1121
+ .args(["-KILL", "--", &format!("-{pid}")])
1122
+ .output();
1123
+ #[cfg(target_os = "windows")]
1124
+ let _ = Command::new("taskkill")
1125
+ .args(["/T", "/F", "/PID", &pid.to_string()])
1126
+ .output();
1127
+ });
1128
+ return Ok(format!("job_id: {job_id} stop requested (pid {pid})"));
520
1129
  }
521
-
522
- Ok(format!("job_id: {} kill signal sent (pid {pid})", job.id))
1130
+ #[cfg(not(target_os = "windows"))]
1131
+ let result = Command::new("kill")
1132
+ .args(["-TERM", "--", &format!("-{pid}")])
1133
+ .output();
1134
+ #[cfg(target_os = "windows")]
1135
+ let result = Command::new("taskkill")
1136
+ .args(["/T", "/F", "/PID", &pid.to_string()])
1137
+ .output();
1138
+ let result = result?;
1139
+ if !result.status.success() {
1140
+ return Err(ShellError::Execute(std::io::Error::other(
1141
+ String::from_utf8_lossy(&result.stderr).to_string(),
1142
+ )));
1143
+ }
1144
+ *status = JobStatus::Stopping;
1145
+ drop(status);
1146
+ #[cfg(target_os = "windows")]
1147
+ job.stop_complete
1148
+ .store(true, std::sync::atomic::Ordering::Release);
1149
+ #[cfg(not(target_os = "windows"))]
1150
+ std::thread::spawn(move || {
1151
+ std::thread::sleep(Duration::from_millis(500));
1152
+ // The shell may already have exited while a child ignores TERM.
1153
+ // Escalate the original process group before the reaper reports Stopped.
1154
+ let result = Command::new("kill")
1155
+ .args(["-KILL", "--", &format!("-{pid}")])
1156
+ .output();
1157
+ if result.as_ref().map_or(true, |r| !r.status.success())
1158
+ && Command::new("kill")
1159
+ .args(["-0", "--", &format!("-{pid}")])
1160
+ .output()
1161
+ .is_ok_and(|r| r.status.success())
1162
+ {
1163
+ *job.stop_error.lock().unwrap() =
1164
+ Some("Unable to terminate the background process group".into());
1165
+ }
1166
+ job.stop_complete
1167
+ .store(true, std::sync::atomic::Ordering::Release);
1168
+ });
1169
+ Ok(format!("job_id: {job_id} stop requested (pid {pid})"))
523
1170
  }
524
1171
 
525
1172
  #[cfg(test)]
@@ -549,6 +1196,123 @@ mod tests {
549
1196
  }
550
1197
  }
551
1198
 
1199
+ #[cfg(unix)]
1200
+ #[test]
1201
+ fn owned_background_keeps_stdin_open_and_stop_kills_the_program() {
1202
+ let mut program = Command::new("bash");
1203
+ program.args(["-c", "echo ready; cat; echo eof"]);
1204
+ let job = start_owned_background(Path::new("."), &local_config(), "owned fixture", program)
1205
+ .unwrap();
1206
+ let _cleanup = StopOnDrop(job.id.clone());
1207
+ let ready = wait_json(&job.id, |v| {
1208
+ v["stdout"].as_str().is_some_and(|s| s.contains("ready"))
1209
+ });
1210
+ assert_eq!(ready["status"], "running");
1211
+ assert!(!ready["stdout"].as_str().unwrap().contains("eof"));
1212
+ kill_job(&job.id).unwrap();
1213
+ let stopped = wait_json(&job.id, |v| v["status"] == "stopped");
1214
+ assert!(stopped["stdout"].as_str().unwrap().contains("eof"));
1215
+ }
1216
+
1217
+ #[cfg(target_os = "linux")]
1218
+ #[test]
1219
+ fn owned_web_stops_frontend_when_owner_is_killed() {
1220
+ const TEST: &str = "system::bg_shell::tests::owned_web_stops_frontend_when_owner_is_killed";
1221
+ let fixture = |role: &str| {
1222
+ let mut p = Command::new(std::env::current_exe().unwrap());
1223
+ p.args(["--exact", TEST, "--nocapture"])
1224
+ .env("MINT_WEB_FIXTURE_ROLE", role);
1225
+ p
1226
+ };
1227
+ match std::env::var("MINT_WEB_FIXTURE_ROLE").as_deref() {
1228
+ Ok("web") => {
1229
+ let frontend =
1230
+ start_background(Path::new("."), &local_config(), "sleep 30").unwrap();
1231
+ println!("frontend:{}", frontend.pid.unwrap());
1232
+ std::io::stdout().flush().unwrap();
1233
+ watch_web_owner();
1234
+ loop {
1235
+ std::thread::sleep(Duration::from_millis(100));
1236
+ }
1237
+ }
1238
+ Ok("owner") => {
1239
+ let web = start_owned_background(
1240
+ Path::new("."),
1241
+ &local_config(),
1242
+ "web fixture",
1243
+ fixture("web"),
1244
+ )
1245
+ .unwrap();
1246
+ println!("web:{}", web.pid.unwrap());
1247
+ let ready = wait_json(&web.id, |v| {
1248
+ v["stdout"]
1249
+ .as_str()
1250
+ .is_some_and(|s| s.contains("frontend:"))
1251
+ });
1252
+ println!("{}", ready["stdout"].as_str().unwrap());
1253
+ std::io::stdout().flush().unwrap();
1254
+ loop {
1255
+ std::thread::sleep(Duration::from_millis(100));
1256
+ }
1257
+ }
1258
+ _ => {}
1259
+ }
1260
+ struct Cleanup {
1261
+ owner: Child,
1262
+ groups: Vec<u32>,
1263
+ }
1264
+ impl Drop for Cleanup {
1265
+ fn drop(&mut self) {
1266
+ let _ = self.owner.kill();
1267
+ let _ = self.owner.wait();
1268
+ for pid in &self.groups {
1269
+ let _ = Command::new("kill")
1270
+ .args(["-KILL", "--", &format!("-{pid}")])
1271
+ .output();
1272
+ }
1273
+ }
1274
+ }
1275
+ use std::io::{BufRead, BufReader};
1276
+ let mut owner = fixture("owner").stdout(Stdio::piped()).spawn().unwrap();
1277
+ let stdout = owner.stdout.take().unwrap();
1278
+ let mut cleanup = Cleanup {
1279
+ owner,
1280
+ groups: vec![],
1281
+ };
1282
+ for line in BufReader::new(stdout).lines() {
1283
+ let line = line.unwrap();
1284
+ if let Some(pid) = line
1285
+ .strip_prefix("web:")
1286
+ .or_else(|| line.strip_prefix("frontend:"))
1287
+ {
1288
+ cleanup.groups.push(pid.parse().unwrap());
1289
+ if cleanup.groups.len() == 2 {
1290
+ break;
1291
+ }
1292
+ }
1293
+ }
1294
+ assert_eq!(cleanup.groups.len(), 2);
1295
+ cleanup.owner.kill().unwrap();
1296
+ cleanup.owner.wait().unwrap();
1297
+ let deadline = Instant::now() + Duration::from_secs(5);
1298
+ for pid in &cleanup.groups {
1299
+ loop {
1300
+ let live = std::fs::read_to_string(format!("/proc/{pid}/stat")).is_ok_and(|stat| {
1301
+ stat.rsplit_once(')')
1302
+ .is_some_and(|(_, rest)| !rest.trim_start().starts_with('Z'))
1303
+ });
1304
+ if !live {
1305
+ break;
1306
+ }
1307
+ assert!(
1308
+ Instant::now() < deadline,
1309
+ "owned process {pid} survived its owner"
1310
+ );
1311
+ std::thread::sleep(Duration::from_millis(20));
1312
+ }
1313
+ }
1314
+ }
1315
+
552
1316
  #[test]
553
1317
  fn background_job_reports_running_then_exit() {
554
1318
  let config = local_config();
@@ -568,7 +1332,7 @@ mod tests {
568
1332
  let started = start_background(Path::new("."), &config, "sleep 30").unwrap();
569
1333
 
570
1334
  let result = kill_job(&started.id).unwrap();
571
- assert!(result.contains("kill signal sent"));
1335
+ assert!(result.contains("stop requested"));
572
1336
 
573
1337
  let after = poll_until(&started.id, Duration::from_secs(10), |out| {
574
1338
  out.contains("status: killed") || out.contains("status: exited")
@@ -576,6 +1340,198 @@ mod tests {
576
1340
  assert!(after.contains("status: killed") || after.contains("status: exited"));
577
1341
  }
578
1342
 
1343
+ struct TestDir(PathBuf);
1344
+ impl TestDir {
1345
+ fn new() -> Self {
1346
+ let path = std::env::temp_dir().join(format!("mint-bg-test-{}", uuid::Uuid::new_v4()));
1347
+ std::fs::create_dir(&path).unwrap();
1348
+ Self(path)
1349
+ }
1350
+ fn path(&self) -> &Path {
1351
+ &self.0
1352
+ }
1353
+ }
1354
+ impl Drop for TestDir {
1355
+ fn drop(&mut self) {
1356
+ let _ = std::fs::remove_dir_all(&self.0);
1357
+ }
1358
+ }
1359
+ struct StopOnDrop(String);
1360
+ impl Drop for StopOnDrop {
1361
+ fn drop(&mut self) {
1362
+ let _ = kill_job(&self.0);
1363
+ }
1364
+ }
1365
+ fn wait_json(id: &str, predicate: impl Fn(&serde_json::Value) -> bool) -> serde_json::Value {
1366
+ let deadline = Instant::now() + Duration::from_secs(8);
1367
+ loop {
1368
+ let value = job_json(id, true).unwrap();
1369
+ if predicate(&value) {
1370
+ return value;
1371
+ }
1372
+ assert!(
1373
+ Instant::now() < deadline,
1374
+ "job did not reach expected state: {value}"
1375
+ );
1376
+ std::thread::sleep(Duration::from_millis(25));
1377
+ }
1378
+ }
1379
+ #[test]
1380
+ fn normalized_and_subagent_chats_keep_parent_ownership() {
1381
+ let chat = uuid::Uuid::new_v4().to_string();
1382
+ let owner = Some("parent-owner".to_owned());
1383
+ assert!(bind_chat_owner(&format!(" {chat} "), owner.clone()));
1384
+ let child = format!("{chat}::subagent::helper");
1385
+ assert_eq!(chat_context(Path::new("."), &child).owner, owner);
1386
+ assert!(!bind_chat_owner(&child, Some("other-owner".into())));
1387
+ assert_eq!(
1388
+ chat_context(Path::new("."), &format!(" {chat} ")).chat_id,
1389
+ Some(chat)
1390
+ );
1391
+ }
1392
+
1393
+ #[test]
1394
+ fn counts_and_context_are_workspace_and_owner_scoped() {
1395
+ let root = TestDir::new();
1396
+ let other = TestDir::new();
1397
+ let chat = uuid::Uuid::new_v4().to_string();
1398
+ assert!(bind_chat_owner(&chat, Some("owner-test".into())));
1399
+ assert!(!bind_chat_owner(&chat, Some("different-owner".into())));
1400
+ let start = Instant::now();
1401
+ let job = with_context(chat_context(root.path(), &chat), || {
1402
+ start_background(other.path(), &local_config(), "sleep 30")
1403
+ })
1404
+ .unwrap();
1405
+ let _cleanup = StopOnDrop(job.id.clone());
1406
+ assert!(start.elapsed() < Duration::from_secs(2));
1407
+ assert_eq!(running_count_for(root.path()), 1);
1408
+ assert_eq!(running_count_for(other.path()), 0);
1409
+ assert!(accessible(&job.id, &Some("owner-test".into())));
1410
+ assert!(!accessible(&job.id, &None));
1411
+ assert_eq!(
1412
+ list_json(&Some("owner-test".into()), Some(root.path())).len(),
1413
+ 1
1414
+ );
1415
+ let info = job_json(&job.id, false).unwrap();
1416
+ assert_eq!(info["cwd"], other.path().to_string_lossy().as_ref());
1417
+ assert_eq!(info["chatId"], chat);
1418
+ assert_eq!(info["id"].as_str().unwrap().split('-').count(), 3);
1419
+ kill_job(&job.id).unwrap();
1420
+ kill_job(&job.id).unwrap();
1421
+ let value = wait_json(&job.id, |v| v["status"] == "stopped");
1422
+ assert!(value["endedAt"].is_i64());
1423
+ assert_eq!(running_count_for(root.path()), 0);
1424
+ }
1425
+ #[test]
1426
+ fn snapshots_never_consume_incremental_output_and_exit_codes_are_retained() {
1427
+ let job = start_background(
1428
+ Path::new("."),
1429
+ &local_config(),
1430
+ "printf hello; printf problem >&2; exit 7",
1431
+ )
1432
+ .unwrap();
1433
+ let value = wait_json(&job.id, |v| {
1434
+ v["status"] == "failed" && v["stdout"] == "hello" && v["stderr"] == "problem"
1435
+ });
1436
+ assert_eq!(value["exitCode"], 7);
1437
+ assert_eq!(snapshot(&job.id).unwrap().stdout, "hello");
1438
+ assert!(poll_output(&job.id).unwrap().contains("hello"));
1439
+ assert!(!poll_output(&job.id).unwrap().contains("hello"));
1440
+ assert_eq!(job_json(&job.id, true).unwrap()["stdout"], "hello");
1441
+ }
1442
+ #[test]
1443
+ fn capped_output_preserves_utf8_and_reports_truncation() {
1444
+ let mut buffer = CappedBuf::new();
1445
+ buffer.push(&"ก".repeat(MAX_BUF_BYTES));
1446
+ assert!(buffer.data.len() <= MAX_BUF_BYTES);
1447
+ assert!(buffer.truncated);
1448
+ assert!(buffer.drain_new().1);
1449
+ }
1450
+ #[test]
1451
+ fn promoted_output_streams_before_exit_and_retains_context() {
1452
+ let root = TestDir::new();
1453
+ let command = "printf promoted-ready; sleep 30";
1454
+ let context = JobContext {
1455
+ workspace: root.path().into(),
1456
+ chat_id: Some("promoted-test".into()),
1457
+ owner: Some("promotion-owner".into()),
1458
+ };
1459
+ let result = with_context(context, || {
1460
+ crate::shell::run_with_timeout(
1461
+ crate::shell::shell_command(command),
1462
+ Duration::from_millis(100),
1463
+ command,
1464
+ root.path(),
1465
+ )
1466
+ })
1467
+ .unwrap();
1468
+ assert!(result.status_code.is_none());
1469
+ let job = list_json(&Some("promotion-owner".into()), Some(root.path()))
1470
+ .pop()
1471
+ .unwrap();
1472
+ let id = job["id"].as_str().unwrap();
1473
+ let _cleanup = StopOnDrop(id.to_string());
1474
+ let value = wait_json(id, |v| v["stdout"] == "promoted-ready");
1475
+ assert_eq!(value["status"], "running");
1476
+ }
1477
+ #[cfg(target_os = "linux")]
1478
+ #[test]
1479
+ fn stop_terminates_children_in_the_job_group() {
1480
+ let job = start_background(
1481
+ Path::new("."),
1482
+ &local_config(),
1483
+ "sh -c 'trap \"\" TERM; echo $$; sleep 30' & wait",
1484
+ )
1485
+ .unwrap();
1486
+ let _cleanup = StopOnDrop(job.id.clone());
1487
+ let value = wait_json(&job.id, |v| {
1488
+ v["stdout"].as_str().is_some_and(|s| !s.trim().is_empty())
1489
+ });
1490
+ let pid: u32 = value["stdout"].as_str().unwrap().trim().parse().unwrap();
1491
+ kill_job(&job.id).unwrap();
1492
+ wait_json(&job.id, |v| v["status"] == "stopped");
1493
+ let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).unwrap_or_default();
1494
+ assert!(
1495
+ stat.is_empty()
1496
+ || stat
1497
+ .rsplit_once(')')
1498
+ .unwrap()
1499
+ .1
1500
+ .trim_start()
1501
+ .starts_with('Z')
1502
+ );
1503
+ }
1504
+ #[cfg(target_os = "linux")]
1505
+ #[test]
1506
+ fn local_server_readiness_is_verified_and_occupied_ports_are_rejected() {
1507
+ if !crate::config::which("python3") {
1508
+ return;
1509
+ }
1510
+ let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1511
+ let port = listener.local_addr().unwrap().port();
1512
+ let job = start_background(
1513
+ Path::new("."),
1514
+ &local_config(),
1515
+ &format!("python3 -u -m http.server {port} --bind 127.0.0.1"),
1516
+ )
1517
+ .unwrap();
1518
+ let failed = wait_json(&job.id, |v| v["status"] == "failed");
1519
+ assert!(failed["serverUrl"].is_null());
1520
+ drop(listener);
1521
+ let job = start_background(
1522
+ Path::new("."),
1523
+ &local_config(),
1524
+ &format!("sleep 0.2; python3 -u -m http.server {port} --bind 127.0.0.1"),
1525
+ )
1526
+ .unwrap();
1527
+ let _cleanup = StopOnDrop(job.id.clone());
1528
+ let ready = wait_json(&job.id, |v| v["serverUrl"].is_string());
1529
+ assert_eq!(ready["serverUrl"], format!("http://127.0.0.1:{port}"));
1530
+ kill_job(&job.id).unwrap();
1531
+ let stopped = wait_json(&job.id, |v| v["status"] == "stopped");
1532
+ assert!(stopped["serverUrl"].is_null());
1533
+ }
1534
+
579
1535
  #[test]
580
1536
  fn poll_unknown_job_errors() {
581
1537
  assert!(matches!(
@@ -584,3 +1540,81 @@ mod tests {
584
1540
  ));
585
1541
  }
586
1542
  }
1543
+
1544
+ /// Human-facing slash output shared by Desktop and Web; CLI keeps its styled renderer.
1545
+ pub fn shells_command(
1546
+ rest: &str,
1547
+ workspace: &Path,
1548
+ owner: &Option<String>,
1549
+ config: &MintConfig,
1550
+ ) -> Result<String, ShellError> {
1551
+ if rest.is_empty() || rest == "all" {
1552
+ assert_path_capability(workspace, Capability::Read, config)?;
1553
+ let jobs = list_json(owner, if rest == "all" { None } else { Some(workspace) });
1554
+ let mut lines = vec!["Background terminals".to_string()];
1555
+ for job in jobs {
1556
+ let path = job["workspacePath"].as_str().unwrap_or("");
1557
+ if assert_path_capability(Path::new(path), Capability::Read, config).is_err() {
1558
+ continue;
1559
+ }
1560
+ lines.push(format!(
1561
+ "{} · {} · {}s · {}\n{}",
1562
+ job["id"].as_str().unwrap_or(""),
1563
+ job["status"].as_str().unwrap_or(""),
1564
+ job["elapsedSeconds"],
1565
+ path,
1566
+ job["command"].as_str().unwrap_or("")
1567
+ ));
1568
+ if let Some(url) = job["serverUrl"].as_str() {
1569
+ lines.push(format!("Ready URL: {url}"));
1570
+ }
1571
+ }
1572
+ if lines.len() == 1 {
1573
+ lines.push("No background terminals in this workspace.".into());
1574
+ }
1575
+ return Ok(lines.join("\n\n"));
1576
+ }
1577
+ let (action, id) = rest
1578
+ .split_once(char::is_whitespace)
1579
+ .map(|(a, i)| (a, i.trim()))
1580
+ .unwrap_or((rest, ""));
1581
+ if !matches!(action, "show" | "stop" | "kill") || id.is_empty() {
1582
+ return Ok("Usage: /shells [all|show <id>|stop <id>|kill <id>]".into());
1583
+ }
1584
+ if !accessible(id, owner) {
1585
+ return Err(ShellError::JobNotFound(id.into()));
1586
+ }
1587
+ let job = job_json(id, action == "show")?;
1588
+ let path = Path::new(job["workspacePath"].as_str().unwrap_or(""));
1589
+ assert_path_capability(
1590
+ path,
1591
+ if action == "show" || job["managedService"] == true {
1592
+ Capability::Read
1593
+ } else {
1594
+ Capability::Write
1595
+ },
1596
+ config,
1597
+ )?;
1598
+ if action != "show" {
1599
+ return kill_job(id);
1600
+ }
1601
+ let readiness = job["serverUrl"]
1602
+ .as_str()
1603
+ .map(|url| format!("Ready URL: {url}\n"))
1604
+ .unwrap_or_default();
1605
+ Ok(format!(
1606
+ "{readiness}{} · {} · {}s\nWorkspace: {}\nCommand: {}\n{}\nstdout:\n{}\nstderr:\n{}",
1607
+ id,
1608
+ job["status"].as_str().unwrap_or(""),
1609
+ job["elapsedSeconds"],
1610
+ path.display(),
1611
+ job["command"].as_str().unwrap_or(""),
1612
+ if job["truncated"] == true {
1613
+ "[Older output truncated]"
1614
+ } else {
1615
+ ""
1616
+ },
1617
+ job["stdout"].as_str().unwrap_or(""),
1618
+ job["stderr"].as_str().unwrap_or("")
1619
+ ))
1620
+ }