specrails-desktop 2.0.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 (455) hide show
  1. package/.claude/commands/specrails/batch-implement.md +287 -0
  2. package/.claude/commands/specrails/compat-check.md +271 -0
  3. package/.claude/commands/specrails/doctor.md +62 -0
  4. package/.claude/commands/specrails/enrich.md +1635 -0
  5. package/.claude/commands/specrails/explore-spec.md +173 -0
  6. package/.claude/commands/specrails/health-check.md +527 -0
  7. package/.claude/commands/specrails/implement.md +1457 -0
  8. package/.claude/commands/specrails/memory-inspect.md +259 -0
  9. package/.claude/commands/specrails/opsx-diff.md +419 -0
  10. package/.claude/commands/specrails/propose-spec.md +102 -0
  11. package/.claude/commands/specrails/reconfig.md +89 -0
  12. package/.claude/commands/specrails/refactor-recommender.md +212 -0
  13. package/.claude/commands/specrails/retry.md +363 -0
  14. package/.claude/commands/specrails/telemetry.md +552 -0
  15. package/.claude/commands/specrails/why.md +96 -0
  16. package/LICENSE +21 -0
  17. package/README.md +290 -0
  18. package/cli/dist/specrails-desktop.js +1098 -0
  19. package/client/dist/assets/ActivityFeedPage-Gy4x8dBt.js +1 -0
  20. package/client/dist/assets/AgentsPage-CPgu--Fb.js +86 -0
  21. package/client/dist/assets/AnalyticsPage-B5sJEee2.js +1 -0
  22. package/client/dist/assets/BarChart-7IMQ8HY1.js +33 -0
  23. package/client/dist/assets/CodePage-CBdFvbwe.js +2 -0
  24. package/client/dist/assets/DesktopAnalyticsPage-w0rdTq4w.js +1 -0
  25. package/client/dist/assets/DocsDialog-BZUYM7wm.js +11 -0
  26. package/client/dist/assets/DocsPage-9QglWl46.js +11 -0
  27. package/client/dist/assets/ExportDropdown-BLZFXtNi.js +1 -0
  28. package/client/dist/assets/IntegrationsPage-BxBE4y99.js +3 -0
  29. package/client/dist/assets/JobDetailPage-DydWx_5S.js +16 -0
  30. package/client/dist/assets/JobsPage-20ibw0IO.js +1 -0
  31. package/client/dist/assets/abap-Bw6f2wDG.js +1 -0
  32. package/client/dist/assets/activity-BEIp_Y1A.js +1 -0
  33. package/client/dist/assets/activity-BdrPln96.js +1 -0
  34. package/client/dist/assets/activity-CpkRS8Sx.js +1 -0
  35. package/client/dist/assets/activity-DKCpESPt.js +1 -0
  36. package/client/dist/assets/activity-DOUVEjJi.js +1 -0
  37. package/client/dist/assets/activity-DRwkql_y.js +1 -0
  38. package/client/dist/assets/activity-DcDQ7tjw.js +1 -0
  39. package/client/dist/assets/activity-Dv6H7wEr.js +1 -0
  40. package/client/dist/assets/addon-image-3WCl5Vhd.js +1 -0
  41. package/client/dist/assets/addon-ligatures-C5OdliKs.js +2 -0
  42. package/client/dist/assets/addon-webgl-BbX6pSjl.js +44 -0
  43. package/client/dist/assets/addspec-B5yl4Loj.js +1 -0
  44. package/client/dist/assets/addspec-BEeF5-zc.js +1 -0
  45. package/client/dist/assets/addspec-D33ocMxf.js +1 -0
  46. package/client/dist/assets/addspec-DFswZ0jK.js +1 -0
  47. package/client/dist/assets/addspec-DRE-jZv7.js +1 -0
  48. package/client/dist/assets/addspec-DVZ15Jp8.js +1 -0
  49. package/client/dist/assets/addspec-Fkv91Opc.js +1 -0
  50. package/client/dist/assets/addspec-GWm4ffKl.js +1 -0
  51. package/client/dist/assets/agents-1nCDWRmP.js +1 -0
  52. package/client/dist/assets/agents-Bm9rPqnt.js +1 -0
  53. package/client/dist/assets/agents-CMxtJMLD.js +1 -0
  54. package/client/dist/assets/agents-DK-Dlc0i.js +1 -0
  55. package/client/dist/assets/agents-Q6Ldfpxx.js +1 -0
  56. package/client/dist/assets/agents-TeOSy-ax.js +1 -0
  57. package/client/dist/assets/agents-iTqjRajS.js +1 -0
  58. package/client/dist/assets/agents-s87sMGzL.js +1 -0
  59. package/client/dist/assets/agentstudio-B6Wb59E7.js +1 -0
  60. package/client/dist/assets/agentstudio-BADhZ41e.js +1 -0
  61. package/client/dist/assets/agentstudio-BSnWLR63.js +1 -0
  62. package/client/dist/assets/agentstudio-BdidyBzZ.js +1 -0
  63. package/client/dist/assets/agentstudio-CxlUllqI.js +1 -0
  64. package/client/dist/assets/agentstudio-D3I62TLJ.js +1 -0
  65. package/client/dist/assets/agentstudio-DuH9TogZ.js +1 -0
  66. package/client/dist/assets/agentstudio-Kw88_dUF.js +1 -0
  67. package/client/dist/assets/aiedit-BWxHGsYA.js +1 -0
  68. package/client/dist/assets/aiedit-D2ji6Qy0.js +1 -0
  69. package/client/dist/assets/aiedit-DAhZTvtk.js +1 -0
  70. package/client/dist/assets/aiedit-DJMny-D5.js +1 -0
  71. package/client/dist/assets/aiedit-DOcxERkU.js +1 -0
  72. package/client/dist/assets/aiedit-DvrcbwGv.js +1 -0
  73. package/client/dist/assets/aiedit-TTwzL1TS.js +1 -0
  74. package/client/dist/assets/aiedit-WBSjT_C1.js +1 -0
  75. package/client/dist/assets/analytics-BIdr0YfL.js +1 -0
  76. package/client/dist/assets/analytics-C6EzgtdE.js +1 -0
  77. package/client/dist/assets/analytics-C9Zc-rkM.js +1 -0
  78. package/client/dist/assets/analytics-CVx3YOc0.js +1 -0
  79. package/client/dist/assets/analytics-CYj0tfj7.js +1 -0
  80. package/client/dist/assets/analytics-CnY4kNG3.js +1 -0
  81. package/client/dist/assets/analytics-CrPCZRJ-.js +1 -0
  82. package/client/dist/assets/analytics-DMCto-TF.js +1 -0
  83. package/client/dist/assets/apex-Cw8_REBo.js +1 -0
  84. package/client/dist/assets/atom-one-dark-B-oHczHB.css +1 -0
  85. package/client/dist/assets/attachments-BIsSSnHJ.js +1 -0
  86. package/client/dist/assets/attachments-BW4L3l2L.js +1 -0
  87. package/client/dist/assets/attachments-Bcf6BG6V.js +1 -0
  88. package/client/dist/assets/attachments-Bke8sCU4.js +1 -0
  89. package/client/dist/assets/attachments-COcrGRFz.js +1 -0
  90. package/client/dist/assets/attachments-DYHGA2Dj.js +1 -0
  91. package/client/dist/assets/attachments-Dd92KpUH.js +1 -0
  92. package/client/dist/assets/attachments-DzdU6DV6.js +1 -0
  93. package/client/dist/assets/azcli-Cz6HAoOw.js +1 -0
  94. package/client/dist/assets/bat-CcJ-xyqL.js +1 -0
  95. package/client/dist/assets/bicep-z1WDCKYz.js +2 -0
  96. package/client/dist/assets/browser-5ErDlJoR.js +1 -0
  97. package/client/dist/assets/browser-Bc-YdlVg.js +1 -0
  98. package/client/dist/assets/browser-BlYF4OOq.js +1 -0
  99. package/client/dist/assets/browser-CT-ReZGt.js +1 -0
  100. package/client/dist/assets/browser-DGITz3fC.js +1 -0
  101. package/client/dist/assets/browser-JsAIGCEW.js +1 -0
  102. package/client/dist/assets/browser-M5-rbPlw.js +1 -0
  103. package/client/dist/assets/browser-Qya9cARy.js +1 -0
  104. package/client/dist/assets/cameligo-BRewOpfa.js +1 -0
  105. package/client/dist/assets/chat-BEGuC03z.js +1 -0
  106. package/client/dist/assets/chat-BEW60P_u.js +1 -0
  107. package/client/dist/assets/chat-BQNMD0PL.js +1 -0
  108. package/client/dist/assets/chat-BsbNGPW9.js +1 -0
  109. package/client/dist/assets/chat-CboQguCi.js +1 -0
  110. package/client/dist/assets/chat-DRCa9pOt.js +1 -0
  111. package/client/dist/assets/chat-DwUm6W9z.js +1 -0
  112. package/client/dist/assets/chat-yoXwguQu.js +1 -0
  113. package/client/dist/assets/chunk-CilyBKbf.js +1 -0
  114. package/client/dist/assets/clojure-DBjRWN6g.js +1 -0
  115. package/client/dist/assets/clsx-DnqN-uhr.js +1 -0
  116. package/client/dist/assets/code-AL1rVIMb.js +1 -0
  117. package/client/dist/assets/code-C0BKpkht.js +1 -0
  118. package/client/dist/assets/code-C0FTS3ew.js +1 -0
  119. package/client/dist/assets/code-CPcHxzxw.js +1 -0
  120. package/client/dist/assets/code-D3ryDniw.js +1 -0
  121. package/client/dist/assets/code-D3zVVQTj.js +1 -0
  122. package/client/dist/assets/code-PCmfS3dn.js +1 -0
  123. package/client/dist/assets/code-exI0G5Wd.js +1 -0
  124. package/client/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
  125. package/client/dist/assets/coffee-Cfk_XHGR.js +1 -0
  126. package/client/dist/assets/commands-B772IyDa.js +1 -0
  127. package/client/dist/assets/commands-BDDp6xFG.js +1 -0
  128. package/client/dist/assets/commands-CJxCry-o.js +1 -0
  129. package/client/dist/assets/commands-CfgY-_of.js +1 -0
  130. package/client/dist/assets/commands-DLrvnPNg.js +1 -0
  131. package/client/dist/assets/commands-IXMOKBYt.js +1 -0
  132. package/client/dist/assets/commands-UD1NzmwX.js +1 -0
  133. package/client/dist/assets/commands-sqrqsxyE.js +1 -0
  134. package/client/dist/assets/common-DCr6VzJ7.js +1 -0
  135. package/client/dist/assets/common-Dard9UNH.js +1 -0
  136. package/client/dist/assets/common-DeDELLZJ.js +1 -0
  137. package/client/dist/assets/common-DltqHaAe.js +1 -0
  138. package/client/dist/assets/common-Dmm1GhdD.js +1 -0
  139. package/client/dist/assets/common-DnjcgkPH.js +1 -0
  140. package/client/dist/assets/common-GbpxfPG8.js +1 -0
  141. package/client/dist/assets/common-wA36jmj1.js +1 -0
  142. package/client/dist/assets/cpp-BVob6BaP.js +1 -0
  143. package/client/dist/assets/csharp-C4fbRuOu.js +1 -0
  144. package/client/dist/assets/csp-DthFP_vT.js +1 -0
  145. package/client/dist/assets/css-CGMH0hcW.js +3 -0
  146. package/client/dist/assets/css.worker-Wv5dxAWO.js +89 -0
  147. package/client/dist/assets/cssMode-Cc6ozl-J.js +1 -0
  148. package/client/dist/assets/cypher-Pnf68BRV.js +1 -0
  149. package/client/dist/assets/dart-PMMOtxZX.js +1 -0
  150. package/client/dist/assets/dashboard-B4ixDVk8.js +1 -0
  151. package/client/dist/assets/dashboard-BZBADHSj.js +1 -0
  152. package/client/dist/assets/dashboard-C1MfeUHs.js +1 -0
  153. package/client/dist/assets/dashboard-C7SK6xu5.js +1 -0
  154. package/client/dist/assets/dashboard-CB6Le1yN.js +1 -0
  155. package/client/dist/assets/dashboard-CoTpMOBM.js +1 -0
  156. package/client/dist/assets/dashboard-Duo4DDCW.js +1 -0
  157. package/client/dist/assets/dashboard-I19DXBxw.js +1 -0
  158. package/client/dist/assets/dist-js-BY-Fv_fg.js +1 -0
  159. package/client/dist/assets/dist-js-Bakc4uxT.js +1 -0
  160. package/client/dist/assets/dockerfile-di1nsJCc.js +1 -0
  161. package/client/dist/assets/ecl-D_WVcB5M.js +1 -0
  162. package/client/dist/assets/editor-Br_kD0ds.css +1 -0
  163. package/client/dist/assets/editor.api2-XLGzZfbc.js +872 -0
  164. package/client/dist/assets/editor.main-CfXxHimg.js +6 -0
  165. package/client/dist/assets/editor.worker-Bd9IXS8d.js +26 -0
  166. package/client/dist/assets/elixir-OAdJEMOn.js +1 -0
  167. package/client/dist/assets/explore-4mFpnrKU.js +1 -0
  168. package/client/dist/assets/explore-A8Ltoblq.js +1 -0
  169. package/client/dist/assets/explore-B9A3iN2W.js +1 -0
  170. package/client/dist/assets/explore-BV5Xxlsn.js +1 -0
  171. package/client/dist/assets/explore-BrBJvfjP.js +1 -0
  172. package/client/dist/assets/explore-C3FSE42C.js +1 -0
  173. package/client/dist/assets/explore-D2EFgt8J.js +1 -0
  174. package/client/dist/assets/explore-hFc3HFcp.js +1 -0
  175. package/client/dist/assets/flow9-D3QEZjgn.js +1 -0
  176. package/client/dist/assets/format-command-CwGuwzGA.js +1 -0
  177. package/client/dist/assets/freemarker2-DP7J1gG3.js +3 -0
  178. package/client/dist/assets/fsharp-BF0k_8N8.js +1 -0
  179. package/client/dist/assets/go-BAQO5Jsz.js +1 -0
  180. package/client/dist/assets/graphql-hdFVFkiV.js +1 -0
  181. package/client/dist/assets/handlebars-BjRlucw6.js +1 -0
  182. package/client/dist/assets/hcl-DWnl1o-X.js +1 -0
  183. package/client/dist/assets/html-OumBQJ-U.js +1 -0
  184. package/client/dist/assets/html.worker-CQP8QQsS.js +502 -0
  185. package/client/dist/assets/htmlMode-CStc3zXM.js +1 -0
  186. package/client/dist/assets/index-CimDRRi7.css +2 -0
  187. package/client/dist/assets/index-XGZaKl_u.js +142 -0
  188. package/client/dist/assets/ini-CB-6OVu3.js +1 -0
  189. package/client/dist/assets/integrations-C3p12Ms6.js +1 -0
  190. package/client/dist/assets/integrations-Cr6hH7XR.js +1 -0
  191. package/client/dist/assets/integrations-Cublz3m6.js +1 -0
  192. package/client/dist/assets/integrations-D28q1kF6.js +1 -0
  193. package/client/dist/assets/integrations-DRdbki5W.js +1 -0
  194. package/client/dist/assets/integrations-DaC4SzzL.js +1 -0
  195. package/client/dist/assets/integrations-DmQYCUvN.js +1 -0
  196. package/client/dist/assets/integrations-HIlUxXVs.js +1 -0
  197. package/client/dist/assets/java-d1CmfiHX.js +1 -0
  198. package/client/dist/assets/javascript-CMk--e7g.js +1 -0
  199. package/client/dist/assets/jobs-BE1siB0M.js +1 -0
  200. package/client/dist/assets/jobs-BHcQ_Faf.js +1 -0
  201. package/client/dist/assets/jobs-CFfc2dNX.js +1 -0
  202. package/client/dist/assets/jobs-CSi5n8X_.js +1 -0
  203. package/client/dist/assets/jobs-Dc3X86PY.js +1 -0
  204. package/client/dist/assets/jobs-De5tASex.js +1 -0
  205. package/client/dist/assets/jobs-DsoXEdo7.js +1 -0
  206. package/client/dist/assets/jobs-Wl-ApPMb.js +1 -0
  207. package/client/dist/assets/json.worker-DzV-CpCQ.js +58 -0
  208. package/client/dist/assets/jsonMode-C2h3ZcjZ.js +7 -0
  209. package/client/dist/assets/julia-Bgv08lKa.js +1 -0
  210. package/client/dist/assets/kotlin-u98kaVTf.js +1 -0
  211. package/client/dist/assets/less-CjYwpgg5.js +2 -0
  212. package/client/dist/assets/lexon-YTjaAFBB.js +1 -0
  213. package/client/dist/assets/lib-CPxTMOAq.js +7 -0
  214. package/client/dist/assets/liquid-mI3KJrBE.js +1 -0
  215. package/client/dist/assets/lspLanguageFeatures-DU09ggWi.js +4 -0
  216. package/client/dist/assets/lua-BzmkWv27.js +1 -0
  217. package/client/dist/assets/m3-CFwk9fw0.js +1 -0
  218. package/client/dist/assets/markdown-CR5iMpSZ.js +1 -0
  219. package/client/dist/assets/mdx-C41VDTR_.js +1 -0
  220. package/client/dist/assets/mips-CcEalc17.js +1 -0
  221. package/client/dist/assets/monaco.contribution-CPObAXMC.js +2 -0
  222. package/client/dist/assets/msdax-BQbkawnr.js +1 -0
  223. package/client/dist/assets/mysql-GTlaaW_P.js +1 -0
  224. package/client/dist/assets/nav-0fwkrgHt.js +1 -0
  225. package/client/dist/assets/nav-BEL3MTwK.js +1 -0
  226. package/client/dist/assets/nav-B_G-TJDW.js +1 -0
  227. package/client/dist/assets/nav-C2YXcbZS.js +1 -0
  228. package/client/dist/assets/nav-ClzOE4mA.js +1 -0
  229. package/client/dist/assets/nav-CtYwmMgu.js +1 -0
  230. package/client/dist/assets/nav-D2bOGSEg.js +1 -0
  231. package/client/dist/assets/nav-iH1V5j6o.js +1 -0
  232. package/client/dist/assets/objective-c-Byu1T5if.js +1 -0
  233. package/client/dist/assets/pascal-BrfzBfRm.js +1 -0
  234. package/client/dist/assets/pascaligo-BXXKFUeo.js +1 -0
  235. package/client/dist/assets/perl-B3OikKq-.js +1 -0
  236. package/client/dist/assets/pgsql-CTsa0Acc.js +1 -0
  237. package/client/dist/assets/php-DiQh3FUW.js +1 -0
  238. package/client/dist/assets/pla-92uH8Fzm.js +1 -0
  239. package/client/dist/assets/postiats-BbeWkKUr.js +1 -0
  240. package/client/dist/assets/powerquery-DgDMzpsm.js +1 -0
  241. package/client/dist/assets/powershell-BfdUUzaG.js +1 -0
  242. package/client/dist/assets/preload-helper-DSXbuxSR.js +1 -0
  243. package/client/dist/assets/protobuf-BojW2ftW.js +2 -0
  244. package/client/dist/assets/pug-BxqTg3IU.js +1 -0
  245. package/client/dist/assets/python-Y27rKQtk.js +1 -0
  246. package/client/dist/assets/qsharp-BX_A-MW9.js +1 -0
  247. package/client/dist/assets/r-D9BMnxvJ.js +1 -0
  248. package/client/dist/assets/razor-Cd5-q9Bp.js +1 -0
  249. package/client/dist/assets/redis-5cJqEQJJ.js +1 -0
  250. package/client/dist/assets/redshift-d8BBqiwb.js +1 -0
  251. package/client/dist/assets/restructuredtext-C8a6yIcZ.js +1 -0
  252. package/client/dist/assets/ruby-egeh-6KX.js +1 -0
  253. package/client/dist/assets/rust-a3r9IInB.js +1 -0
  254. package/client/dist/assets/sb-y8iRIDei.js +1 -0
  255. package/client/dist/assets/scala-BPDK2AmK.js +1 -0
  256. package/client/dist/assets/scheme-BIWUEoOs.js +1 -0
  257. package/client/dist/assets/scss-CA-PSzwg.js +3 -0
  258. package/client/dist/assets/settings-55oDcbSh.js +1 -0
  259. package/client/dist/assets/settings-Bd4Tq1RB.js +1 -0
  260. package/client/dist/assets/settings-CCSM-Fhn.js +1 -0
  261. package/client/dist/assets/settings-D3e_bDoW.js +1 -0
  262. package/client/dist/assets/settings-DKbTkbn7.js +1 -0
  263. package/client/dist/assets/settings-Dxpo6_w7.js +1 -0
  264. package/client/dist/assets/settings-bt84e3Aa.js +1 -0
  265. package/client/dist/assets/settings-nu68QukM.js +1 -0
  266. package/client/dist/assets/setup-BMqwfbW9.js +1 -0
  267. package/client/dist/assets/setup-Bb5LcG28.js +1 -0
  268. package/client/dist/assets/setup-BeEx2_da.js +1 -0
  269. package/client/dist/assets/setup-CCCrB53Q.js +1 -0
  270. package/client/dist/assets/setup-CJA0ATmd.js +1 -0
  271. package/client/dist/assets/setup-CeiDbZcb.js +1 -0
  272. package/client/dist/assets/setup-Cus7TApA.js +1 -0
  273. package/client/dist/assets/setup-D9qOs2Xo.js +1 -0
  274. package/client/dist/assets/shell--LiT1Bja.js +1 -0
  275. package/client/dist/assets/solidity-DdqZccZg.js +1 -0
  276. package/client/dist/assets/sophia-S6-YxNG_.js +1 -0
  277. package/client/dist/assets/sparql-BSf5kMp2.js +1 -0
  278. package/client/dist/assets/specs-BFfu3u-a.js +1 -0
  279. package/client/dist/assets/specs-B__C8-8a.js +1 -0
  280. package/client/dist/assets/specs-CZ1PsXsC.js +1 -0
  281. package/client/dist/assets/specs-D2FzlLn9.js +1 -0
  282. package/client/dist/assets/specs-DaUTrNF9.js +1 -0
  283. package/client/dist/assets/specs-Dyc5hYeE.js +1 -0
  284. package/client/dist/assets/specs-cKEh2LXt.js +1 -0
  285. package/client/dist/assets/specs-k0PyLDVt.js +1 -0
  286. package/client/dist/assets/sql-D7KgjR8G.js +1 -0
  287. package/client/dist/assets/st-BnoDa-Ml.js +1 -0
  288. package/client/dist/assets/swift-DEUHTkUX.js +1 -0
  289. package/client/dist/assets/systemverilog-Tqb_KPnW.js +1 -0
  290. package/client/dist/assets/tcl-BmBFS2qq.js +1 -0
  291. package/client/dist/assets/terminal-80yDMgMF.js +1 -0
  292. package/client/dist/assets/terminal-Bje4ziIa.js +1 -0
  293. package/client/dist/assets/terminal-C2WYcFHF.js +1 -0
  294. package/client/dist/assets/terminal-CSONJOex.js +1 -0
  295. package/client/dist/assets/terminal-DEqzGtcr.js +1 -0
  296. package/client/dist/assets/terminal-DeWzh6ys.js +1 -0
  297. package/client/dist/assets/terminal-YOlsJCQj.js +1 -0
  298. package/client/dist/assets/terminal-lkZYR4wJ.js +1 -0
  299. package/client/dist/assets/tickets-CB7N30gm.js +1 -0
  300. package/client/dist/assets/tickets-CF2PYelu.js +1 -0
  301. package/client/dist/assets/tickets-DNOANUXr.js +1 -0
  302. package/client/dist/assets/tickets-DU1aqsbr.js +1 -0
  303. package/client/dist/assets/tickets-DYvafSaY.js +1 -0
  304. package/client/dist/assets/tickets-DlpC_iTg.js +1 -0
  305. package/client/dist/assets/tickets-DucYgtdl.js +1 -0
  306. package/client/dist/assets/tickets-clefmXLv.js +1 -0
  307. package/client/dist/assets/ts.worker-METxwbDZ.js +67719 -0
  308. package/client/dist/assets/tsMode-B0y_xEci.js +11 -0
  309. package/client/dist/assets/twig-BQV8igWC.js +1 -0
  310. package/client/dist/assets/typescript-BzK0OgwW.js +1 -0
  311. package/client/dist/assets/typespec-DlFroUGY.js +1 -0
  312. package/client/dist/assets/useProjectCache-DSaiGFjV.js +1 -0
  313. package/client/dist/assets/vb-BlrJpIMX.js +1 -0
  314. package/client/dist/assets/wgsl-BWgIc6FZ.js +298 -0
  315. package/client/dist/assets/workers-rt--R2Qy.js +1 -0
  316. package/client/dist/assets/xml-eX9QXAmI.js +1 -0
  317. package/client/dist/assets/yaml-fcsNkpOt.js +1 -0
  318. package/client/dist/index.html +246 -0
  319. package/docs/README.md +54 -0
  320. package/docs/cli.md +198 -0
  321. package/docs/codex.md +210 -0
  322. package/docs/creating-specs.md +197 -0
  323. package/docs/customizing.md +197 -0
  324. package/docs/getting-started.md +140 -0
  325. package/docs/internals/README.md +25 -0
  326. package/docs/internals/adding-a-provider.md +238 -0
  327. package/docs/internals/api-reference.md +634 -0
  328. package/docs/internals/architecture.md +332 -0
  329. package/docs/internals/configuration.md +172 -0
  330. package/docs/internals/openspec-workflow.md +282 -0
  331. package/docs/internals/operations-runbook.md +198 -0
  332. package/docs/internals/profiles.md +152 -0
  333. package/docs/platforms/macos.md +130 -0
  334. package/docs/platforms/windows.md +81 -0
  335. package/docs/running-pipelines.md +240 -0
  336. package/docs/terminal.md +138 -0
  337. package/docs/tracking-cost.md +155 -0
  338. package/package.json +82 -0
  339. package/server/dist/agent-generator.js +232 -0
  340. package/server/dist/agent-refine-db.js +124 -0
  341. package/server/dist/agent-refine-manager.js +526 -0
  342. package/server/dist/ai-invocations.js +111 -0
  343. package/server/dist/attachment-manager.js +299 -0
  344. package/server/dist/auth.js +207 -0
  345. package/server/dist/binary-probe.js +35 -0
  346. package/server/dist/browser-capture-manager.js +576 -0
  347. package/server/dist/browser-capture-types.js +28 -0
  348. package/server/dist/browser-network.js +149 -0
  349. package/server/dist/browser-playwright.js +888 -0
  350. package/server/dist/build-dirs.js +44 -0
  351. package/server/dist/changes-reader.js +120 -0
  352. package/server/dist/chat-manager.js +1060 -0
  353. package/server/dist/chromium-resolver.js +311 -0
  354. package/server/dist/code-explorer-router.js +788 -0
  355. package/server/dist/codex-otel-bridge.js +235 -0
  356. package/server/dist/command-resolver.js +102 -0
  357. package/server/dist/config.js +306 -0
  358. package/server/dist/context-budget.js +113 -0
  359. package/server/dist/context-scope.js +279 -0
  360. package/server/dist/contract-refine-runner.js +521 -0
  361. package/server/dist/core-compat.js +207 -0
  362. package/server/dist/core-package.js +14 -0
  363. package/server/dist/db.js +1034 -0
  364. package/server/dist/desktop-analytics.js +156 -0
  365. package/server/dist/desktop-db.js +456 -0
  366. package/server/dist/desktop-router.js +735 -0
  367. package/server/dist/docs-router.js +207 -0
  368. package/server/dist/explore-contract-refine.js +421 -0
  369. package/server/dist/explore-cwd-manager.js +242 -0
  370. package/server/dist/explore-draft-title.js +47 -0
  371. package/server/dist/explore-smash.js +450 -0
  372. package/server/dist/feature-flags.js +17 -0
  373. package/server/dist/file-provenance.js +382 -0
  374. package/server/dist/file-summary-generator.js +221 -0
  375. package/server/dist/file-summary-manager.js +689 -0
  376. package/server/dist/hooks.js +102 -0
  377. package/server/dist/ids.js +7 -0
  378. package/server/dist/index.js +586 -0
  379. package/server/dist/metrics.js +136 -0
  380. package/server/dist/mobile/index.js +16 -0
  381. package/server/dist/mobile/mobile-admin-router.js +84 -0
  382. package/server/dist/mobile/mobile-auth.js +67 -0
  383. package/server/dist/mobile/mobile-devices.js +80 -0
  384. package/server/dist/mobile/mobile-event-bus.js +39 -0
  385. package/server/dist/mobile/mobile-gateway.js +285 -0
  386. package/server/dist/mobile/mobile-mdns.js +81 -0
  387. package/server/dist/mobile/mobile-pairing.js +179 -0
  388. package/server/dist/mobile/mobile-redact.js +53 -0
  389. package/server/dist/mobile/mobile-router.js +411 -0
  390. package/server/dist/mobile/mobile-tls.js +86 -0
  391. package/server/dist/mobile/mobile-types.js +9 -0
  392. package/server/dist/mobile/mobile-ws.js +275 -0
  393. package/server/dist/path-resolver.js +298 -0
  394. package/server/dist/plugin-manager.js +617 -0
  395. package/server/dist/plugins/claude-approval.js +179 -0
  396. package/server/dist/plugins/claude-md-mutation.js +146 -0
  397. package/server/dist/plugins/codex-mcp.js +108 -0
  398. package/server/dist/plugins/contributors.js +72 -0
  399. package/server/dist/plugins/drift.js +58 -0
  400. package/server/dist/plugins/index.js +14 -0
  401. package/server/dist/plugins/json-mutation.js +120 -0
  402. package/server/dist/plugins/manager.js +32 -0
  403. package/server/dist/plugins/ownership.js +86 -0
  404. package/server/dist/plugins/paths.js +37 -0
  405. package/server/dist/plugins/prereq-installer.js +104 -0
  406. package/server/dist/plugins/rail-integration.js +79 -0
  407. package/server/dist/plugins/serena/index.js +13 -0
  408. package/server/dist/plugins/serena/install.js +91 -0
  409. package/server/dist/plugins/serena/instructions-content.js +21 -0
  410. package/server/dist/plugins/serena/manifest.js +111 -0
  411. package/server/dist/plugins/serena/verify.js +78 -0
  412. package/server/dist/plugins-router.js +215 -0
  413. package/server/dist/pricing.js +89 -0
  414. package/server/dist/profile-manager.js +310 -0
  415. package/server/dist/profiles-router.js +759 -0
  416. package/server/dist/project-registry.js +443 -0
  417. package/server/dist/project-router.js +4016 -0
  418. package/server/dist/proposal-manager.js +291 -0
  419. package/server/dist/provider-selection.js +69 -0
  420. package/server/dist/providers/claude-adapter.js +281 -0
  421. package/server/dist/providers/codex-adapter.js +264 -0
  422. package/server/dist/providers/index.js +23 -0
  423. package/server/dist/providers/registry.js +37 -0
  424. package/server/dist/providers/types.js +22 -0
  425. package/server/dist/queue-manager.js +1511 -0
  426. package/server/dist/rails-router.js +362 -0
  427. package/server/dist/rails-store.js +116 -0
  428. package/server/dist/result-event.js +106 -0
  429. package/server/dist/schemas/profile.v1.json +151 -0
  430. package/server/dist/setup-manager.js +1165 -0
  431. package/server/dist/setup-prerequisites.js +372 -0
  432. package/server/dist/smash-runner.js +663 -0
  433. package/server/dist/spec-draft-parser.js +133 -0
  434. package/server/dist/spec-launcher-manager.js +174 -0
  435. package/server/dist/spec-models.js +32 -0
  436. package/server/dist/specrails-tech-client.js +82 -0
  437. package/server/dist/spending.js +448 -0
  438. package/server/dist/telemetry-compactor.js +180 -0
  439. package/server/dist/telemetry-export.js +317 -0
  440. package/server/dist/telemetry-receiver.js +224 -0
  441. package/server/dist/terminal-manager.js +633 -0
  442. package/server/dist/terminal-marks-store.js +117 -0
  443. package/server/dist/terminal-osc-parser.js +159 -0
  444. package/server/dist/terminal-settings.js +282 -0
  445. package/server/dist/terminal-shell-integration.js +196 -0
  446. package/server/dist/ticket-broadcast.js +47 -0
  447. package/server/dist/ticket-store.js +397 -0
  448. package/server/dist/ticket-watcher.js +117 -0
  449. package/server/dist/types.js +10 -0
  450. package/server/dist/user-mcp-config.js +117 -0
  451. package/server/dist/util/cli-prompt.js +181 -0
  452. package/server/dist/util/secure-fs.js +50 -0
  453. package/server/dist/util/win-spawn.js +43 -0
  454. package/server/dist/webhook-manager.js +89 -0
  455. package/server/dist/ws-routing.js +47 -0
@@ -0,0 +1,1060 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ChatManager = exports.EXPLORE_QUEUE_TIMEOUT_MS = exports.EXPLORE_MAX_CONCURRENCY = exports.EXPLORE_IDLE_KILL_MS = void 0;
7
+ exports.filterDraftBlocksLive = filterDraftBlocksLive;
8
+ const readline_1 = require("readline");
9
+ const tree_kill_1 = __importDefault(require("tree-kill"));
10
+ const db_1 = require("./db");
11
+ const command_resolver_1 = require("./command-resolver");
12
+ const cli_prompt_1 = require("./util/cli-prompt");
13
+ const explore_cwd_manager_1 = require("./explore-cwd-manager");
14
+ const ai_invocations_1 = require("./ai-invocations");
15
+ const result_event_1 = require("./result-event");
16
+ const crypto_1 = require("crypto");
17
+ const spec_draft_parser_1 = require("./spec-draft-parser");
18
+ const attachment_manager_1 = require("./attachment-manager");
19
+ const providers_1 = require("./providers");
20
+ const context_scope_1 = require("./context-scope");
21
+ const user_mcp_config_1 = require("./user-mcp-config");
22
+ const binary_probe_1 = require("./binary-probe");
23
+ const COMMAND_INSTRUCTION = 'When you want to suggest a SpecRails command for the user to execute, wrap it in a command block like this: ' +
24
+ ':::command\n/specrails:implement #42\n::: ' +
25
+ 'The user will be prompted to confirm before the command runs.';
26
+ function extractCommandProposals(text) {
27
+ const regex = /:::command\s*\n([\s\S]*?):::/g;
28
+ const results = [];
29
+ let match;
30
+ while ((match = regex.exec(text)) !== null) {
31
+ results.push(match[1].trim());
32
+ }
33
+ return results;
34
+ }
35
+ // ─── Explore lifecycle ────────────────────────────────────────────────────────
36
+ /** Tunables for Explore-spec acceleration lifecycle. Module-level constants
37
+ * rather than ChatManager statics so tests can override via vi.spyOn or
38
+ * redefine in fixtures. */
39
+ exports.EXPLORE_IDLE_KILL_MS = 2 * 60 * 1000;
40
+ exports.EXPLORE_MAX_CONCURRENCY = 5;
41
+ exports.EXPLORE_QUEUE_TIMEOUT_MS = 30 * 1000;
42
+ // ─── ChatManager ──────────────────────────────────────────────────────────────
43
+ class ChatManager {
44
+ _broadcast;
45
+ _db;
46
+ _activeProcesses;
47
+ /** M13: conversations with a turn in-flight but not yet spawned. Closes the
48
+ * TOCTOU window between sendMessage's initial guard and `_activeProcesses.set`
49
+ * across the explore-slot/attachment awaits, so a second concurrent POST for
50
+ * the same conversation is rejected instead of double-spawning. */
51
+ _reservedTurns = new Set();
52
+ _buffers;
53
+ _emittedProposals;
54
+ _abortingConversations;
55
+ _specDraftStates;
56
+ /** Per-conversation live-strip state for `\`\`\`spec-draft` fenced blocks. */
57
+ _streamFilters;
58
+ /** Per-Explore-conversation lifecycle state (idle timer, crash counter,
59
+ * streaming flag). See design.md D7. */
60
+ _exploreLifecycle;
61
+ /** FIFO queue of Explore turns waiting for a concurrency slot. */
62
+ _exploreQueue;
63
+ _cwd;
64
+ _projectName;
65
+ _adapter;
66
+ _projectId;
67
+ _projectSlug;
68
+ constructor(broadcast, db, cwd, projectName, provider, projectId, projectSlug) {
69
+ this._broadcast = broadcast;
70
+ this._db = db;
71
+ this._cwd = cwd;
72
+ this._projectName = projectName;
73
+ this._adapter = (0, providers_1.getAdapter)(provider ?? 'claude');
74
+ this._projectId = projectId;
75
+ this._projectSlug = projectSlug;
76
+ this._activeProcesses = new Map();
77
+ this._buffers = new Map();
78
+ this._emittedProposals = new Map();
79
+ this._abortingConversations = new Set();
80
+ this._specDraftStates = new Map();
81
+ this._streamFilters = new Map();
82
+ this._exploreLifecycle = new Map();
83
+ this._exploreQueue = [];
84
+ }
85
+ /** Compatibility accessor for tests that introspect the resolved provider. */
86
+ get provider() {
87
+ return this._adapter.id;
88
+ }
89
+ /**
90
+ * Resolve the adapter for a conversation. A conversation may carry its own
91
+ * `provider` (set at creation from the Add Spec AI Engine selector); when
92
+ * present and registered it wins, otherwise the project's primary adapter is
93
+ * used. Single-provider conversations always resolve to the primary.
94
+ */
95
+ _adapterForConversation(conversation) {
96
+ if (conversation.provider) {
97
+ try {
98
+ return (0, providers_1.getAdapter)(conversation.provider);
99
+ }
100
+ catch {
101
+ /* unknown id → fall back to primary */
102
+ }
103
+ }
104
+ return this._adapter;
105
+ }
106
+ // ─── Explore lifecycle helpers ──────────────────────────────────────────────
107
+ _getOrCreateExploreLifecycle(conversationId) {
108
+ let life = this._exploreLifecycle.get(conversationId);
109
+ if (!life) {
110
+ life = {
111
+ isMinimized: false,
112
+ isStreaming: false,
113
+ idleTimer: null,
114
+ crashCount: 0,
115
+ lastActivityAt: Date.now(),
116
+ };
117
+ this._exploreLifecycle.set(conversationId, life);
118
+ }
119
+ return life;
120
+ }
121
+ _clearIdleTimer(conversationId) {
122
+ const life = this._exploreLifecycle.get(conversationId);
123
+ if (life?.idleTimer) {
124
+ clearTimeout(life.idleTimer);
125
+ life.idleTimer = null;
126
+ }
127
+ }
128
+ _startIdleTimer(conversationId) {
129
+ const life = this._exploreLifecycle.get(conversationId);
130
+ if (!life)
131
+ return;
132
+ if (life.isStreaming)
133
+ return;
134
+ if (!life.isMinimized)
135
+ return;
136
+ this._clearIdleTimer(conversationId);
137
+ life.idleTimer = setTimeout(() => {
138
+ const child = this._activeProcesses.get(conversationId);
139
+ if (child?.pid) {
140
+ try {
141
+ (0, tree_kill_1.default)(child.pid, 'SIGTERM');
142
+ }
143
+ catch { /* best-effort */ }
144
+ }
145
+ }, exports.EXPLORE_IDLE_KILL_MS);
146
+ }
147
+ /**
148
+ * Mark an Explore conversation as minimized. Starts the idle-kill timer
149
+ * iff the conversation is not currently streaming. If a turn is in flight,
150
+ * the timer starts when the turn completes.
151
+ */
152
+ notifyMinimized(conversationId) {
153
+ const life = this._getOrCreateExploreLifecycle(conversationId);
154
+ life.isMinimized = true;
155
+ life.lastActivityAt = Date.now();
156
+ this._startIdleTimer(conversationId);
157
+ }
158
+ /** Mark an Explore conversation as restored (un-minimized). Cancels the
159
+ * pending idle-kill timer if any. */
160
+ notifyRestored(conversationId) {
161
+ const life = this._exploreLifecycle.get(conversationId);
162
+ if (!life)
163
+ return;
164
+ life.isMinimized = false;
165
+ life.lastActivityAt = Date.now();
166
+ this._clearIdleTimer(conversationId);
167
+ }
168
+ _countStreamingExplore() {
169
+ let n = 0;
170
+ for (const life of this._exploreLifecycle.values()) {
171
+ if (life.isStreaming)
172
+ n++;
173
+ }
174
+ return n;
175
+ }
176
+ _findIdleExploreVictim(excludeConvId) {
177
+ let oldest = null;
178
+ for (const [id, life] of this._exploreLifecycle.entries()) {
179
+ if (id === excludeConvId)
180
+ continue;
181
+ if (life.isStreaming)
182
+ continue;
183
+ if (life.idleTimer == null && !life.isMinimized)
184
+ continue;
185
+ if (!oldest || life.lastActivityAt < oldest.t) {
186
+ oldest = { id, t: life.lastActivityAt };
187
+ }
188
+ }
189
+ return oldest?.id ?? null;
190
+ }
191
+ _drainExploreQueue() {
192
+ // A released waiter does NOT flip its `isStreaming` flag synchronously — it
193
+ // does so only when its awaiting sendMessage continuation runs as a later
194
+ // microtask. So `_countStreamingExplore()` stays stale across this fully
195
+ // synchronous loop. Track the genuinely-free slots with a local counter so
196
+ // we release at most that many waiters per drain pass; otherwise a single
197
+ // freed slot could release every queued turn at once and blow past
198
+ // EXPLORE_MAX_CONCURRENCY (an unbounded burst of CLI processes).
199
+ let freed = exports.EXPLORE_MAX_CONCURRENCY - this._countStreamingExplore();
200
+ while (this._exploreQueue.length > 0 && freed > 0) {
201
+ const next = this._exploreQueue.shift();
202
+ clearTimeout(next.timeoutTimer);
203
+ freed--;
204
+ next.onSlot();
205
+ }
206
+ }
207
+ async _waitForExploreSlot(conversationId) {
208
+ if (this._countStreamingExplore() < exports.EXPLORE_MAX_CONCURRENCY)
209
+ return 'ok';
210
+ // M14: a streaming slot is freed only when a STREAMING turn ends. The old code
211
+ // evicted an idle (non-streaming) victim and immediately returned 'ok' — but
212
+ // _findIdleExploreVictim skips streaming entries, so the victim holds no live
213
+ // slot and the count is unchanged. That admitted a 6th concurrent turn (and,
214
+ // repeated per idle/minimized entry, made the effective cap 5 + idle-count =
215
+ // unbounded CLI spawning). Now: prune the idle entry (memory hygiene + kill any
216
+ // stray child) but only grant the slot if the streaming count actually dropped.
217
+ const victim = this._findIdleExploreVictim(conversationId);
218
+ if (victim) {
219
+ const child = this._activeProcesses.get(victim);
220
+ if (child?.pid) {
221
+ try {
222
+ (0, tree_kill_1.default)(child.pid, 'SIGTERM');
223
+ }
224
+ catch { /* best-effort */ }
225
+ }
226
+ this._clearIdleTimer(victim);
227
+ this._exploreLifecycle.delete(victim);
228
+ if (this._countStreamingExplore() < exports.EXPLORE_MAX_CONCURRENCY)
229
+ return 'ok';
230
+ }
231
+ // Still at cap — queue with timeout until a streaming turn completes.
232
+ return new Promise((resolve) => {
233
+ const timeoutTimer = setTimeout(() => {
234
+ const idx = this._exploreQueue.findIndex((q) => q.conversationId === conversationId);
235
+ if (idx >= 0)
236
+ this._exploreQueue.splice(idx, 1);
237
+ resolve('busy');
238
+ }, exports.EXPLORE_QUEUE_TIMEOUT_MS);
239
+ this._exploreQueue.push({
240
+ conversationId,
241
+ enqueuedAt: Date.now(),
242
+ timeoutTimer,
243
+ onSlot: () => resolve('ok'),
244
+ onTimeout: () => resolve('busy'),
245
+ });
246
+ });
247
+ }
248
+ /**
249
+ * Resolve the spawn cwd for a chat turn. Explore conversations spawn from
250
+ * an app-managed directory by default to skip auto-loading the project's
251
+ * `CLAUDE.md` (the dominant first-token cost); when the per-project MCP
252
+ * toggle is on, fall back to the project path so `.mcp.json` is honoured.
253
+ * Non-Explore conversations always use the project path.
254
+ *
255
+ * See openspec/changes/accelerate-spec-chat-first-token/design.md D1+D4.
256
+ */
257
+ _resolveSpawnCwd(kind, scope, providerId) {
258
+ if (kind !== 'explore')
259
+ return this._cwd;
260
+ if (!this._projectSlug || !this._cwd || !this._projectName)
261
+ return this._cwd;
262
+ // Per-conversation scope.mcp is the only source of truth. Legacy null
263
+ // scope is treated as mcp=false (spawn from app-managed cwd).
264
+ const mcpEnabled = scope ? !!scope.mcp : false;
265
+ if (mcpEnabled)
266
+ return this._cwd;
267
+ try {
268
+ const cwd = (0, explore_cwd_manager_1.ensureExploreCwd)({
269
+ slug: this._projectSlug,
270
+ projectPath: this._cwd,
271
+ projectName: this._projectName,
272
+ provider: (providerId ?? this._adapter.id),
273
+ });
274
+ console.log(`[chat-manager] explore spawn cwd=${cwd} (mcp=off)`);
275
+ return cwd;
276
+ }
277
+ catch (err) {
278
+ console.error('[chat-manager] ensureExploreCwd failed, falling back to project path:', err);
279
+ return this._cwd;
280
+ }
281
+ }
282
+ _resolveConversationScope(row) {
283
+ if (!row || row.kind !== 'explore')
284
+ return null;
285
+ const fallback = (0, context_scope_1.defaultBootScope)('explore');
286
+ if (!row.context_scope)
287
+ return fallback;
288
+ try {
289
+ return (0, context_scope_1.normalizeContextScope)(JSON.parse(row.context_scope), fallback);
290
+ }
291
+ catch {
292
+ return fallback;
293
+ }
294
+ }
295
+ /** Drop the per-conversation draft state (used on conversation deletion). */
296
+ forgetSpecDraft(conversationId) {
297
+ this._specDraftStates.delete(conversationId);
298
+ }
299
+ /** Snapshot of the current spec-draft state for a conversation, or null
300
+ * if no draft has accumulated yet. Used by the client to rehydrate after
301
+ * a refresh / minimize cycle so updates Claude pushed while no shell
302
+ * was subscribed don't get lost. */
303
+ getSpecDraftState(conversationId) {
304
+ return this._specDraftStates.get(conversationId) ?? null;
305
+ }
306
+ /**
307
+ * Sidebar system prompt. MUST stay byte-stable across consecutive
308
+ * invocations for the same project name so Anthropic's automatic prompt
309
+ * cache hits across turns within the 5-minute TTL window — the same
310
+ * constraint `_buildLightweightSystemPrompt` documents for Explore.
311
+ *
312
+ * DO NOT inject timestamps, live job stats, recent-job summaries, costs,
313
+ * or any per-invocation data here. The volatile dashboard snapshot is
314
+ * prepended to the user turn instead (see `_buildDashboardContextBlock`
315
+ * and its callsite in `sendMessage`).
316
+ */
317
+ _buildSystemPrompt() {
318
+ const name = this._projectName ?? 'this project';
319
+ return (`You are a project assistant for the "${name}" specrails project with full access to this repository via Claude Code. ` +
320
+ `You can help answer questions about the codebase, explain SpecRails concepts, and suggest commands to run.` +
321
+ `\n\nIMPORTANT: You have explicit permission to read and write .specrails/local-tickets.json — ` +
322
+ `this is the project's local ticket store managed by Specrails. It is NOT sensitive. ` +
323
+ `When creating or updating tickets, write directly to this JSON file.` +
324
+ `\n\nUser messages may begin with a "## Current Dashboard Context" section. It is injected by the dashboard, ` +
325
+ `not typed by the user — treat it as live, authoritative project state (active job, recent jobs, stats, costs) ` +
326
+ `when answering.` +
327
+ `\n\n` +
328
+ COMMAND_INSTRUCTION);
329
+ }
330
+ /**
331
+ * Volatile dashboard snapshot (active job, recent jobs, stats, costs) for
332
+ * sidebar turns. Prepended to the user turn rather than the system prompt
333
+ * so the cacheable `--system-prompt` prefix stays byte-stable.
334
+ * Returns '' when stats can't be read (context is best-effort).
335
+ */
336
+ _buildDashboardContextBlock() {
337
+ try {
338
+ const stats = (0, db_1.getStats)(this._db);
339
+ const { jobs: recentJobs } = (0, db_1.listJobs)(this._db, { limit: 5 });
340
+ // Active job (running or queued at top)
341
+ const activeJob = recentJobs.find((j) => j.status === 'running' || j.status === 'queued');
342
+ const activeLine = activeJob
343
+ ? `**${activeJob.status.toUpperCase()}**: \`${activeJob.command}\``
344
+ : 'No job currently running.';
345
+ // Recent terminal jobs
346
+ const terminalJobs = recentJobs.filter((j) => j.status === 'completed' || j.status === 'failed' || j.status === 'canceled');
347
+ const jobLines = terminalJobs.map((j) => {
348
+ const status = j.status === 'completed' ? '✓' : j.status === 'failed' ? '✗' : '○';
349
+ const dur = j.duration_ms != null ? `${Math.round(j.duration_ms / 1000)}s` : '—';
350
+ const cost = j.total_cost_usd != null ? `$${j.total_cost_usd.toFixed(3)}` : '—';
351
+ const cmd = j.command.length > 60 ? j.command.slice(0, 57) + '...' : j.command;
352
+ return `- ${status} \`${cmd}\` | ${dur} | ${cost}`;
353
+ });
354
+ const successRate = stats.totalJobs > 0
355
+ ? Math.round(((stats.totalJobs - stats.failedJobs) / stats.totalJobs) * 100)
356
+ : null;
357
+ return (`## Current Dashboard Context\n\n` +
358
+ `### Active Job\n${activeLine}\n\n` +
359
+ (jobLines.length > 0 ? `### Recent Jobs\n${jobLines.join('\n')}\n\n` : '') +
360
+ `### Project Stats\n` +
361
+ `- Total jobs: ${stats.totalJobs}\n` +
362
+ `- Jobs today: ${stats.jobsToday}\n` +
363
+ (successRate != null ? `- Overall success rate: ${successRate}%\n` : '') +
364
+ `- Total cost: $${stats.totalCostUsd.toFixed(3)}\n` +
365
+ `- Cost today: $${stats.costToday.toFixed(3)}`);
366
+ }
367
+ catch {
368
+ // Context is best-effort; fall back gracefully
369
+ return '';
370
+ }
371
+ }
372
+ /**
373
+ * Lightweight system prompt for Explore Spec turns. MUST stay byte-stable
374
+ * across consecutive invocations for the same project name so Anthropic's
375
+ * automatic prompt cache hits across turns within the 5-minute TTL window.
376
+ *
377
+ * DO NOT inject timestamps, live job stats, recent-job summaries, costs, or
378
+ * any per-invocation data here. Adding non-deterministic content silently
379
+ * breaks the cache and reverts the first-token-latency win.
380
+ *
381
+ * See openspec/changes/accelerate-spec-chat-first-token/design.md D5.
382
+ */
383
+ _buildLightweightSystemPrompt(scope) {
384
+ const name = this._projectName ?? 'this project';
385
+ const base = `You are a fast, focused assistant for the "${name}" specrails project. ` +
386
+ `You have explicit permission to read and write .specrails/local-tickets.json — ` +
387
+ `this is the project's local ticket store managed by Specrails. It is NOT sensitive. ` +
388
+ `When creating or updating tickets, write directly to this JSON file.\n\n` +
389
+ `IMPORTANT: Be efficient. Minimize tool calls. Only read files that are directly relevant. ` +
390
+ `Do not explore broadly — focus on the specific task.`;
391
+ const scopedBase = `${base}\n\n` +
392
+ `When "Specrails Tickets" or "OpenSpec Specs" sections are present below, treat them as authoritative project context. ` +
393
+ `For roadmap-style requests like "suggest the next best spec", ground the answer in that context, avoid duplicates, and propose one concrete next spec instead of generic directions.`;
394
+ if (!scope || !this._cwd)
395
+ return scopedBase;
396
+ const prefix = (0, context_scope_1.buildScopedSystemPromptPrefix)(scope, this._cwd);
397
+ if (!prefix)
398
+ return scopedBase;
399
+ return `${scopedBase}\n\n${prefix}`;
400
+ }
401
+ isActive(conversationId) {
402
+ return this._activeProcesses.has(conversationId);
403
+ }
404
+ async sendMessage(conversationId, userText, options) {
405
+ if (this._activeProcesses.has(conversationId) || this._reservedTurns.has(conversationId)) {
406
+ console.warn(`[ChatManager] conversation ${conversationId} already has an active or pending stream`);
407
+ return;
408
+ }
409
+ const conversation = (0, db_1.getConversation)(this._db, conversationId);
410
+ if (!conversation) {
411
+ console.warn(`[ChatManager] conversation ${conversationId} not found`);
412
+ return;
413
+ }
414
+ // Per-conversation adapter (multi-provider). The conversation's stored
415
+ // provider wins; null/legacy conversations fall back to the project
416
+ // primary (this._adapter). Resolved once and used for the whole turn.
417
+ const adapter = this._adapterForConversation(conversation);
418
+ if (!(0, binary_probe_1.binaryOnPath)(adapter.binary)) {
419
+ this._broadcast({
420
+ type: 'chat_error',
421
+ conversationId,
422
+ error: `${adapter.id.toUpperCase()}_NOT_FOUND`,
423
+ timestamp: new Date().toISOString(),
424
+ });
425
+ return;
426
+ }
427
+ // M13: reserve synchronously before the explore-slot / attachment awaits.
428
+ // Released in the finally at the end of the method — by then either
429
+ // _activeProcesses owns the guard (spawn succeeded) or the turn bailed out.
430
+ this._reservedTurns.add(conversationId);
431
+ try {
432
+ // Explore: enforce per-project concurrency cap before doing any work.
433
+ if (conversation.kind === 'explore') {
434
+ const slot = await this._waitForExploreSlot(conversationId);
435
+ if (slot === 'busy') {
436
+ this._broadcast({
437
+ type: 'chat_error',
438
+ conversationId,
439
+ error: 'busy',
440
+ timestamp: new Date().toISOString(),
441
+ });
442
+ return;
443
+ }
444
+ const life = this._getOrCreateExploreLifecycle(conversationId);
445
+ life.isStreaming = true;
446
+ life.lastActivityAt = Date.now();
447
+ this._clearIdleTimer(conversationId);
448
+ }
449
+ // Check if this is turn 1 (session_id was null before this message)
450
+ const isFirstTurn = conversation.session_id === null;
451
+ // Persist user message
452
+ (0, db_1.addMessage)(this._db, { conversation_id: conversationId, role: 'user', content: userText });
453
+ // Resolve slash commands (e.g. /specrails:propose-spec → prompt content)
454
+ let resolvedText = (0, command_resolver_1.resolveCommand)(userText, this._cwd ?? process.cwd());
455
+ // Fold attachments into the prompt as <user-attachment> text blocks under
456
+ // an "## Attached Resources" section, mirroring how /generate-spec wires
457
+ // them. Errors during extraction are logged and skipped — the chat turn
458
+ // proceeds without that attachment rather than failing.
459
+ let hasAttachments = false;
460
+ if (options?.attachments && options.attachments.ids.length > 0) {
461
+ try {
462
+ const { textBlocks } = await attachment_manager_1.attachmentManager.getClaudeArgs(options.attachments.slug, options.attachments.ticketKey, options.attachments.ids);
463
+ if (textBlocks.length > 0) {
464
+ resolvedText = `${resolvedText}\n\n## Attached Resources\n\n${textBlocks.join('\n\n')}`;
465
+ hasAttachments = true;
466
+ }
467
+ }
468
+ catch (err) {
469
+ console.error(`[chat-manager] attachment extraction failed (${conversationId}):`, err);
470
+ }
471
+ }
472
+ // Build spawn args via the resolved adapter. System prompt placement
473
+ // (--system-prompt flag vs prompt-fold) and resume vs fresh-turn are both
474
+ // adapter-driven via capability flags.
475
+ const lightweight = options?.lightweight ?? false;
476
+ const conversationScope = this._resolveConversationScope(conversation);
477
+ let systemPrompt = lightweight
478
+ ? this._buildLightweightSystemPrompt(conversationScope)
479
+ : this._buildSystemPrompt();
480
+ if (hasAttachments)
481
+ systemPrompt = `${systemPrompt}\n\n${attachment_manager_1.USER_ATTACHMENT_SYSTEM_NOTE}`;
482
+ const binary = adapter.binary;
483
+ const model = conversation.model || adapter.defaultModel();
484
+ const action = conversation.session_id && adapter.capabilities.nativeResume
485
+ ? 'chat-resume'
486
+ : 'chat-turn';
487
+ // Translate the per-conversation Explore scope into provider-native
488
+ // tool-gating flags. `toolFlagsForScope` emits claude-shape argv
489
+ // (`--disallowedTools …`); codex's `exec` would reject those with an
490
+ // "unexpected argument" error and crash the turn. The scope's tool
491
+ // gating is therefore claude-only today — codex inherits its sandbox
492
+ // and approval policy from the project's `.codex/config.toml` (or the
493
+ // `-c sandbox_mode=` override the adapter already attaches on resume).
494
+ const scopeFlags = conversationScope && adapter.id === 'claude'
495
+ ? (0, context_scope_1.toolFlagsForScope)(conversationScope).args
496
+ : [];
497
+ // Inject the user's OWN already-approved MCP servers when scope.userMcp is
498
+ // on. Claude-only via `--mcp-config` (codex reads ~/.codex natively, so
499
+ // buildUserMcpArgs returns []). Independent of the `mcp` toggle (project
500
+ // .mcp.json) and does not change the spawn cwd. See server/user-mcp-config.ts.
501
+ if (conversationScope?.userMcp && adapter.id === 'claude' && this._cwd && this._projectSlug) {
502
+ scopeFlags.push(...(0, user_mcp_config_1.buildUserMcpArgs)({
503
+ adapterId: adapter.id,
504
+ projectPath: this._cwd,
505
+ slug: this._projectSlug,
506
+ }));
507
+ }
508
+ let promptForAdapter = resolvedText;
509
+ if (conversation.kind === 'explore' && adapter.id === 'codex' && conversationScope && this._cwd) {
510
+ const scopedContext = (0, context_scope_1.buildScopedSystemPromptPrefix)(conversationScope, this._cwd);
511
+ if (scopedContext) {
512
+ promptForAdapter =
513
+ `Project context selected in Add Spec. Use it to avoid duplicate specs and to make project-specific recommendations.\n\n` +
514
+ `${scopedContext}\n\n` +
515
+ `## User turn\n\n${resolvedText}`;
516
+ }
517
+ }
518
+ // Sidebar turns: the volatile dashboard snapshot lives in the user turn
519
+ // (not --system-prompt) so the cacheable system-prompt prefix stays
520
+ // byte-stable across turns — same pattern as the codex scoped-context
521
+ // prepend above. Gated on systemPromptArg: adapters without it (codex)
522
+ // drop the system prompt for chat turns entirely (argv stays
523
+ // user-text-only by design), so they never saw the dashboard block and
524
+ // must not start receiving it here.
525
+ if (!lightweight && adapter.capabilities.systemPromptArg) {
526
+ const dashboardContext = this._buildDashboardContextBlock();
527
+ if (dashboardContext) {
528
+ promptForAdapter = `${dashboardContext}\n\n## User turn\n\n${promptForAdapter}`;
529
+ }
530
+ }
531
+ let args = adapter.buildArgs(action, {
532
+ prompt: promptForAdapter,
533
+ systemPrompt,
534
+ model,
535
+ sessionId: conversation.session_id ?? undefined,
536
+ maxTurns: options?.maxTurns,
537
+ extraArgs: scopeFlags,
538
+ });
539
+ if (conversationScope) {
540
+ console.log(`[chat-manager] scope=${JSON.stringify(conversationScope)} flags=${scopeFlags.join(' ')} promptBytes=${Buffer.byteLength(systemPrompt)}`);
541
+ }
542
+ // No OTEL env injection here — ChatManager spawns are interactive user sessions,
543
+ // not pipeline jobs. Telemetry is scoped to QueueManager pipeline runs only.
544
+ // spawnAiCli reroutes multi-line argv values through stdin on Windows.
545
+ const spawnCwd = this._resolveSpawnCwd(conversation.kind, conversationScope, adapter.id);
546
+ const child = (0, cli_prompt_1.spawnAiCli)(binary, args, {
547
+ env: process.env,
548
+ stdio: ['ignore', 'pipe', 'pipe'],
549
+ cwd: spawnCwd,
550
+ });
551
+ let stderrBuf = '';
552
+ // Drain stderr so the pipe buffer never fills up (child process would block otherwise)
553
+ child.stderr?.on('data', (chunk) => {
554
+ const text = chunk.toString();
555
+ stderrBuf += text;
556
+ console.error(`[chat-manager] ${binary} stderr (${conversationId}):`, text.trim());
557
+ });
558
+ this._activeProcesses.set(conversationId, child);
559
+ this._buffers.set(conversationId, '');
560
+ this._emittedProposals.set(conversationId, new Set());
561
+ this._streamFilters.set(conversationId, { inBlock: false, pendingTail: '' });
562
+ // Surface ENOENT (e.g. claude not on PATH) instead of crashing the app.
563
+ /* c8 ignore start -- spawn-failure path; exercised manually, not in CI */
564
+ child.on('error', (err) => {
565
+ console.error(`[chat-manager] spawn failed for ${conversationId}: ${err.message}`);
566
+ this._activeProcesses.delete(conversationId);
567
+ this._buffers.delete(conversationId);
568
+ this._emittedProposals.delete(conversationId);
569
+ this._broadcast({
570
+ type: 'chat_error',
571
+ conversationId,
572
+ error: `Failed to launch ${binary}: ${err.message}`,
573
+ timestamp: new Date().toISOString(),
574
+ });
575
+ });
576
+ /* c8 ignore stop */
577
+ let capturedSessionId = null;
578
+ // Accumulator of parsed events for finaliseInvocationResult at close.
579
+ const adapterEvents = [];
580
+ /** True iff a kind:'result' event has arrived; mirrors the legacy
581
+ * `lastResultEvent !== null` check that the crash-respawn guard uses. */
582
+ let sawResult = false;
583
+ const turnStartedAt = new Date().toISOString();
584
+ const stdoutReader = (0, readline_1.createInterface)({ input: child.stdout, crlfDelay: Infinity });
585
+ const emitDelta = (newText) => {
586
+ const prev = this._buffers.get(conversationId) ?? '';
587
+ const updated = prev + newText;
588
+ this._buffers.set(conversationId, updated);
589
+ // Live-strip any `​```spec-draft` fenced JSON from the broadcast so the
590
+ // user never sees the raw protocol payload mid-stream. The filter holds
591
+ // back partial fence markers and emits only the user-visible prose.
592
+ const filter = this._streamFilters.get(conversationId);
593
+ const visibleDelta = filter ? filterDraftBlocksLive(filter, newText) : newText;
594
+ if (visibleDelta) {
595
+ this._broadcast({
596
+ type: 'chat_stream',
597
+ conversationId,
598
+ delta: visibleDelta,
599
+ timestamp: new Date().toISOString(),
600
+ });
601
+ }
602
+ // Check for new command proposals
603
+ const proposals = extractCommandProposals(updated);
604
+ const emitted = this._emittedProposals.get(conversationId);
605
+ if (emitted) {
606
+ for (const proposal of proposals) {
607
+ if (!emitted.has(proposal)) {
608
+ emitted.add(proposal);
609
+ this._broadcast({
610
+ type: 'chat_command_proposal',
611
+ conversationId,
612
+ command: proposal,
613
+ timestamp: new Date().toISOString(),
614
+ });
615
+ }
616
+ }
617
+ }
618
+ };
619
+ const readerHandler = (line) => {
620
+ const ev = adapter.parseStreamLine(line);
621
+ if (!ev)
622
+ return;
623
+ adapterEvents.push(ev);
624
+ switch (ev.kind) {
625
+ case 'text-delta':
626
+ emitDelta(ev.text);
627
+ break;
628
+ case 'session-started':
629
+ // Last-wins: Claude rotates session ids across --resume, and only the
630
+ // id present at result-time is persisted on disk. Capturing the first
631
+ // one leaves DB with a ghost id that fails the next --resume.
632
+ if (ev.sessionId)
633
+ capturedSessionId = ev.sessionId;
634
+ break;
635
+ case 'result':
636
+ sawResult = true;
637
+ // Claude's result event carries the canonical (post-rotation)
638
+ // session_id; codex captures from thread.started but mirroring here
639
+ // is harmless.
640
+ {
641
+ const sid = ev.payload.session_id;
642
+ if (sid)
643
+ capturedSessionId = sid;
644
+ }
645
+ break;
646
+ case 'tool-use':
647
+ case 'other':
648
+ // No-op for ChatManager — adapter parses tool_use into the unified
649
+ // event shape but the chat UI does not currently surface them.
650
+ break;
651
+ }
652
+ };
653
+ stdoutReader.on('line', readerHandler);
654
+ let currentChild = child;
655
+ void currentChild; // keep reference live for crash respawn
656
+ return new Promise((resolve) => {
657
+ const onClose = (code) => {
658
+ console.log(`[chat-manager] ${adapter.id} exited code=${code} conv=${conversationId}`);
659
+ const fullText = this._buffers.get(conversationId) ?? '';
660
+ const wasAborting = this._abortingConversations.has(conversationId);
661
+ // Crash auto-respawn for Explore: if the child exited non-zero before
662
+ // emitting a `result` event, the user did not explicitly abort, and
663
+ // we have not yet retried, respawn the same turn once via chat-resume
664
+ // when the adapter supports it and a session id was captured.
665
+ // See design.md D7.
666
+ if (conversation.kind === 'explore' &&
667
+ !wasAborting &&
668
+ code !== 0 &&
669
+ !sawResult) {
670
+ const life = this._exploreLifecycle.get(conversationId);
671
+ if (life && life.crashCount === 0) {
672
+ life.crashCount = 1;
673
+ // Rebuild argv as chat-resume when the adapter supports native
674
+ // resume AND we captured a session id before the crash. Otherwise
675
+ // re-issue the original chat-turn argv so the spawn still happens.
676
+ const respawnArgs = capturedSessionId && adapter.capabilities.nativeResume
677
+ ? adapter.buildArgs('chat-resume', {
678
+ prompt: resolvedText,
679
+ systemPrompt,
680
+ model,
681
+ sessionId: capturedSessionId,
682
+ maxTurns: options?.maxTurns,
683
+ // Preserve scope-driven flags (tool gating + user MCP
684
+ // `--mcp-config`) on respawn; without this the resumed turn
685
+ // silently drops them.
686
+ extraArgs: scopeFlags,
687
+ })
688
+ : args;
689
+ console.warn(`[chat-manager] explore crash respawn for ${conversationId}`);
690
+ try {
691
+ const newChild = (0, cli_prompt_1.spawnAiCli)(binary, respawnArgs, {
692
+ env: process.env,
693
+ stdio: ['ignore', 'pipe', 'pipe'],
694
+ cwd: spawnCwd,
695
+ });
696
+ currentChild = newChild;
697
+ args = respawnArgs;
698
+ this._activeProcesses.set(conversationId, newChild);
699
+ newChild.stderr?.on('data', (chunk) => {
700
+ const text = chunk.toString();
701
+ stderrBuf += text;
702
+ console.error(`[chat-manager] ${binary} stderr (${conversationId}):`, text.trim());
703
+ });
704
+ // The respawn is a brand-new ChildProcess; it does NOT inherit
705
+ // the original child's 'error' listener. Without one, an async
706
+ // spawn 'error' (ENOENT/EAGAIN — the very class of failure that
707
+ // can recur right after a crash) would be an unhandled 'error'
708
+ // event and crash the entire app. Mirror the original handler.
709
+ /* c8 ignore start -- respawn spawn-failure path; exercised manually, not in CI */
710
+ newChild.on('error', (err) => {
711
+ console.error(`[chat-manager] explore crash-respawn spawn failed for ${conversationId}: ${err.message}`);
712
+ this._activeProcesses.delete(conversationId);
713
+ this._buffers.delete(conversationId);
714
+ this._emittedProposals.delete(conversationId);
715
+ this._abortingConversations.delete(conversationId);
716
+ this._streamFilters.delete(conversationId);
717
+ const life = this._exploreLifecycle.get(conversationId);
718
+ if (life) {
719
+ life.isStreaming = false;
720
+ life.lastActivityAt = Date.now();
721
+ if (life.isMinimized)
722
+ this._startIdleTimer(conversationId);
723
+ }
724
+ this._drainExploreQueue();
725
+ this._broadcast({
726
+ type: 'chat_error',
727
+ conversationId,
728
+ error: `Failed to launch ${binary}: ${err.message}`,
729
+ timestamp: new Date().toISOString(),
730
+ });
731
+ resolve();
732
+ });
733
+ /* c8 ignore stop */
734
+ const newReader = (0, readline_1.createInterface)({ input: newChild.stdout, crlfDelay: Infinity });
735
+ newReader.on('line', readerHandler);
736
+ newChild.on('close', onClose);
737
+ return;
738
+ }
739
+ catch (err) {
740
+ console.error('[chat-manager] crash respawn failed:', err);
741
+ /* fall through to normal close handling */
742
+ }
743
+ }
744
+ }
745
+ // Clean up tracking state
746
+ this._activeProcesses.delete(conversationId);
747
+ this._buffers.delete(conversationId);
748
+ this._emittedProposals.delete(conversationId);
749
+ this._abortingConversations.delete(conversationId);
750
+ this._streamFilters.delete(conversationId);
751
+ // Mark Explore turn as no longer streaming and drain any waiters.
752
+ if (conversation.kind === 'explore') {
753
+ const life = this._exploreLifecycle.get(conversationId);
754
+ if (life) {
755
+ life.isStreaming = false;
756
+ life.lastActivityAt = Date.now();
757
+ // Reset crash counter on a successful turn.
758
+ if (code === 0)
759
+ life.crashCount = 0;
760
+ if (life.isMinimized)
761
+ this._startIdleTimer(conversationId);
762
+ }
763
+ this._drainExploreQueue();
764
+ }
765
+ // ai_invocations capture (surface='explore-spec'). Gated on conversation kind.
766
+ if (this._projectId && conversation.kind === 'explore') {
767
+ try {
768
+ const invStatus = wasAborting
769
+ ? 'aborted'
770
+ : code === 0
771
+ ? 'success'
772
+ : 'failed';
773
+ const { result, estimated } = (0, result_event_1.finaliseInvocationResult)(adapter, adapterEvents, {
774
+ fallbackModel: model,
775
+ });
776
+ (0, ai_invocations_1.recordInvocation)(this._db, {
777
+ id: (0, crypto_1.randomUUID)(),
778
+ project_id: this._projectId,
779
+ provider: adapter.id,
780
+ surface: 'explore-spec',
781
+ surface_ref_id: conversationId,
782
+ conversation_id: conversationId,
783
+ status: invStatus,
784
+ started_at: turnStartedAt,
785
+ finished_at: new Date().toISOString(),
786
+ total_cost_usd_estimated: estimated,
787
+ ...result,
788
+ });
789
+ this._broadcast({ type: 'spending.invalidated', projectId: this._projectId });
790
+ }
791
+ catch (err) {
792
+ console.error('[chat-manager] recordInvocation failed:', err);
793
+ }
794
+ }
795
+ if (wasAborting) {
796
+ // abort already emitted chat_error
797
+ resolve();
798
+ return;
799
+ }
800
+ if (code === 0) {
801
+ // Parse out any spec-draft fenced blocks (Explore Spec protocol).
802
+ // No-op for non-Explore conversations (parser pre-checks for the fence
803
+ // marker and returns the original text unchanged).
804
+ const parsed = (0, spec_draft_parser_1.parseSpecDraftBlocks)(fullText);
805
+ const persistedText = parsed.blocks.length > 0 ? parsed.stripped : fullText;
806
+ if (parsed.blocks.length > 0) {
807
+ const prev = this._specDraftStates.get(conversationId);
808
+ const nextState = (0, spec_draft_parser_1.applyBlocks)(prev, parsed.blocks);
809
+ this._specDraftStates.set(conversationId, nextState);
810
+ this._broadcast({
811
+ type: 'spec_draft.update',
812
+ conversationId,
813
+ draft: nextState.draft,
814
+ ready: nextState.ready,
815
+ chips: nextState.chips,
816
+ changedFields: nextState.lastChangedFields,
817
+ timestamp: new Date().toISOString(),
818
+ });
819
+ }
820
+ // Persist assistant message (stripped of draft blocks for non-noisy DB).
821
+ if (persistedText) {
822
+ (0, db_1.addMessage)(this._db, { conversation_id: conversationId, role: 'assistant', content: persistedText });
823
+ }
824
+ // Update session_id from the real thread/session captured during
825
+ // streaming. No more synthetic codex-<convId>-<timestamp> fallback —
826
+ // codex's `thread.started` event already gives us a real UUID, and
827
+ // claude's `system`/`result` events carry the canonical session_id.
828
+ if (capturedSessionId) {
829
+ (0, db_1.updateConversation)(this._db, conversationId, { session_id: capturedSessionId });
830
+ }
831
+ this._broadcast({
832
+ type: 'chat_done',
833
+ conversationId,
834
+ fullText: persistedText,
835
+ timestamp: new Date().toISOString(),
836
+ });
837
+ // Auto-title on first turn (skip in lightweight mode — conversation is ephemeral)
838
+ if (isFirstTurn && fullText && !options?.lightweight) {
839
+ this._autoTitle(conversationId, userText, fullText);
840
+ }
841
+ }
842
+ else {
843
+ const stderrTail = stderrBuf.trim().slice(-500);
844
+ this._broadcast({
845
+ type: 'chat_error',
846
+ conversationId,
847
+ error: stderrTail
848
+ ? `${binary} exited with code ${code ?? 'unknown'}: ${stderrTail}`
849
+ : `Process exited with code ${code ?? 'unknown'}`,
850
+ timestamp: new Date().toISOString(),
851
+ });
852
+ }
853
+ resolve();
854
+ };
855
+ child.on('close', onClose);
856
+ });
857
+ }
858
+ finally {
859
+ // M13: release the synchronous reservation. After _activeProcesses.set the
860
+ // active-process map is the guard; on any early return / throw before that,
861
+ // this frees the conversation for a retry.
862
+ this._reservedTurns.delete(conversationId);
863
+ }
864
+ }
865
+ abort(conversationId) {
866
+ const child = this._activeProcesses.get(conversationId);
867
+ if (!child || !child.pid)
868
+ return;
869
+ this._abortingConversations.add(conversationId);
870
+ (0, tree_kill_1.default)(child.pid, 'SIGTERM');
871
+ this._broadcast({
872
+ type: 'chat_error',
873
+ conversationId,
874
+ error: 'aborted',
875
+ timestamp: new Date().toISOString(),
876
+ });
877
+ }
878
+ /**
879
+ * Drop all Explore-lifecycle bookkeeping for a conversation: cancel its
880
+ * pending idle-kill timer, remove it from the wait queue (clearing that
881
+ * waiter's timeout timer), and delete the lifecycle entry. Called when a
882
+ * conversation is deleted so minimized-but-never-resumed entries (and their
883
+ * armed timers) don't accumulate for the lifetime of the project.
884
+ */
885
+ forgetExploreLifecycle(conversationId) {
886
+ this._clearIdleTimer(conversationId);
887
+ const idx = this._exploreQueue.findIndex((q) => q.conversationId === conversationId);
888
+ if (idx >= 0) {
889
+ clearTimeout(this._exploreQueue[idx].timeoutTimer);
890
+ this._exploreQueue.splice(idx, 1);
891
+ }
892
+ this._exploreLifecycle.delete(conversationId);
893
+ }
894
+ /**
895
+ * Tear down the manager on shutdown / project removal: terminate every
896
+ * active chat child (SIGTERM), cancel all Explore idle timers and queued
897
+ * waiter timeouts, and clear all per-conversation tracking. Without this,
898
+ * in-flight claude/codex children are orphaned (reparented to init) when the
899
+ * app exits and keep consuming API quota/CPU. Idempotent.
900
+ */
901
+ shutdown() {
902
+ for (const child of this._activeProcesses.values()) {
903
+ if (child?.pid) {
904
+ try {
905
+ (0, tree_kill_1.default)(child.pid, 'SIGTERM');
906
+ }
907
+ catch { /* best-effort */ }
908
+ }
909
+ }
910
+ for (const id of this._exploreLifecycle.keys()) {
911
+ this._clearIdleTimer(id);
912
+ }
913
+ for (const q of this._exploreQueue) {
914
+ clearTimeout(q.timeoutTimer);
915
+ }
916
+ this._exploreQueue = [];
917
+ this._activeProcesses.clear();
918
+ this._buffers.clear();
919
+ this._emittedProposals.clear();
920
+ this._abortingConversations.clear();
921
+ this._streamFilters.clear();
922
+ this._exploreLifecycle.clear();
923
+ }
924
+ _autoTitle(conversationId, firstUserMsg, firstResponse) {
925
+ try {
926
+ // Title generation runs on the conversation's own provider.
927
+ const conv = (0, db_1.getConversation)(this._db, conversationId);
928
+ const adapter = this._adapterForConversation(conv ?? {});
929
+ const titlePrompt = `Generate a 4-6 word title for this conversation. Output ONLY the title text, no quotes or punctuation.\n\n` +
930
+ `User: ${firstUserMsg.slice(0, 200)}\nAssistant: ${firstResponse.slice(0, 300)}`;
931
+ const args = adapter.buildArgs('auto-title', {
932
+ prompt: titlePrompt,
933
+ model: adapter.defaultModel(),
934
+ });
935
+ const child = (0, cli_prompt_1.spawnAiCli)(adapter.binary, args, {
936
+ env: process.env,
937
+ stdio: ['ignore', 'pipe', 'pipe'],
938
+ cwd: this._cwd,
939
+ });
940
+ let titleText = '';
941
+ const reader = (0, readline_1.createInterface)({ input: child.stdout, crlfDelay: Infinity });
942
+ reader.on('line', (line) => {
943
+ if (titleText)
944
+ return;
945
+ const ev = adapter.parseStreamLine(line);
946
+ if (ev?.kind === 'text-delta') {
947
+ const trimmed = ev.text.trim();
948
+ if (trimmed)
949
+ titleText = trimmed;
950
+ }
951
+ });
952
+ child.on('close', (code) => {
953
+ if (code === 0 && titleText) {
954
+ (0, db_1.updateConversation)(this._db, conversationId, { title: titleText });
955
+ this._broadcast({
956
+ type: 'chat_title_update',
957
+ conversationId,
958
+ title: titleText,
959
+ timestamp: new Date().toISOString(),
960
+ });
961
+ }
962
+ });
963
+ }
964
+ catch {
965
+ // auto-title is fire-and-forget; failure is silent
966
+ }
967
+ }
968
+ }
969
+ exports.ChatManager = ChatManager;
970
+ const FENCE_OPEN = '```spec-draft';
971
+ const FENCE_CLOSE = '```';
972
+ // Hold back up to this many trailing chars in the pre-block state so we never
973
+ // emit a partial open fence. -1 because we know the user-visible prefix is at
974
+ // least 1 char shorter than the full marker on every step.
975
+ const PRE_BLOCK_TAIL = FENCE_OPEN.length - 1;
976
+ /**
977
+ * Stateful, side-effect-free filter that consumes `newText` and returns the
978
+ * substring that is safe to broadcast to the chat stream. Holds back partial
979
+ * fence markers in `state.pendingTail` so the next call can resolve them.
980
+ *
981
+ * Behaviour:
982
+ * - While outside a block: emit text up to (but not including) the start of
983
+ * a `\`\`\`spec-draft` marker. If no marker is present, hold back the
984
+ * trailing few chars so a marker starting on a chunk boundary is not
985
+ * leaked.
986
+ * - While inside a block: emit nothing. Look for the closing `\`\`\``.
987
+ * When found, consume it (plus an optional trailing newline) and resume
988
+ * emitting from the bytes that follow.
989
+ *
990
+ * The filter intentionally does NOT validate the JSON payload — that is
991
+ * server-side concern of `parseSpecDraftBlocks`. It only strips the fenced
992
+ * span.
993
+ */
994
+ function filterDraftBlocksLive(state, newText) {
995
+ let buf = state.pendingTail + newText;
996
+ let out = '';
997
+ state.pendingTail = '';
998
+ // Iterate in case a single delta contains multiple transitions
999
+ // (e.g. close + open + close again — pathological but cheap to support).
1000
+ while (buf.length > 0) {
1001
+ if (state.inBlock) {
1002
+ const closeIdx = buf.indexOf(FENCE_CLOSE);
1003
+ if (closeIdx === -1) {
1004
+ // No close yet — but the close could span the chunk boundary.
1005
+ // Hold back up to 2 trailing chars (closing fence is 3 chars; we keep
1006
+ // any trailing run of `\`` so the next call resolves it).
1007
+ const tailLen = trailingBacktickRun(buf, 2);
1008
+ state.pendingTail = buf.slice(buf.length - tailLen);
1009
+ return out;
1010
+ }
1011
+ // Consume the close fence + an optional trailing newline.
1012
+ let after = closeIdx + FENCE_CLOSE.length;
1013
+ if (buf[after] === '\n')
1014
+ after += 1;
1015
+ buf = buf.slice(after);
1016
+ state.inBlock = false;
1017
+ continue;
1018
+ }
1019
+ // Not in block: look for the open marker.
1020
+ const openIdx = buf.indexOf(FENCE_OPEN);
1021
+ if (openIdx !== -1) {
1022
+ out += buf.slice(0, openIdx);
1023
+ buf = buf.slice(openIdx + FENCE_OPEN.length);
1024
+ // Drop an optional newline immediately after the open marker so the
1025
+ // user never sees `\n` belonging to the fence.
1026
+ if (buf[0] === '\n')
1027
+ buf = buf.slice(1);
1028
+ state.inBlock = true;
1029
+ continue;
1030
+ }
1031
+ // No open marker — hold back only the trailing run that could become a
1032
+ // prefix of FENCE_OPEN (i.e. the longest suffix of `buf` that is also a
1033
+ // prefix of FENCE_OPEN). Anything past that is safe to emit.
1034
+ const holdBack = longestSuffixThatIsPrefixOf(buf, FENCE_OPEN);
1035
+ const safeEnd = buf.length - holdBack;
1036
+ out += buf.slice(0, safeEnd);
1037
+ state.pendingTail = buf.slice(safeEnd);
1038
+ return out;
1039
+ }
1040
+ return out;
1041
+ }
1042
+ /** Length of the longest suffix of `s` that is a prefix of `target`. */
1043
+ function longestSuffixThatIsPrefixOf(s, target) {
1044
+ const max = Math.min(s.length, target.length - 1);
1045
+ for (let len = max; len > 0; len--) {
1046
+ if (target.startsWith(s.slice(s.length - len)))
1047
+ return len;
1048
+ }
1049
+ return 0;
1050
+ }
1051
+ function trailingBacktickRun(s, max) {
1052
+ let n = 0;
1053
+ for (let i = s.length - 1; i >= 0 && n < max; i--) {
1054
+ if (s[i] === '`')
1055
+ n++;
1056
+ else
1057
+ break;
1058
+ }
1059
+ return n;
1060
+ }