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.
- package/.claude/commands/specrails/batch-implement.md +287 -0
- package/.claude/commands/specrails/compat-check.md +271 -0
- package/.claude/commands/specrails/doctor.md +62 -0
- package/.claude/commands/specrails/enrich.md +1635 -0
- package/.claude/commands/specrails/explore-spec.md +173 -0
- package/.claude/commands/specrails/health-check.md +527 -0
- package/.claude/commands/specrails/implement.md +1457 -0
- package/.claude/commands/specrails/memory-inspect.md +259 -0
- package/.claude/commands/specrails/opsx-diff.md +419 -0
- package/.claude/commands/specrails/propose-spec.md +102 -0
- package/.claude/commands/specrails/reconfig.md +89 -0
- package/.claude/commands/specrails/refactor-recommender.md +212 -0
- package/.claude/commands/specrails/retry.md +363 -0
- package/.claude/commands/specrails/telemetry.md +552 -0
- package/.claude/commands/specrails/why.md +96 -0
- package/LICENSE +21 -0
- package/README.md +290 -0
- package/cli/dist/specrails-desktop.js +1098 -0
- package/client/dist/assets/ActivityFeedPage-Gy4x8dBt.js +1 -0
- package/client/dist/assets/AgentsPage-CPgu--Fb.js +86 -0
- package/client/dist/assets/AnalyticsPage-B5sJEee2.js +1 -0
- package/client/dist/assets/BarChart-7IMQ8HY1.js +33 -0
- package/client/dist/assets/CodePage-CBdFvbwe.js +2 -0
- package/client/dist/assets/DesktopAnalyticsPage-w0rdTq4w.js +1 -0
- package/client/dist/assets/DocsDialog-BZUYM7wm.js +11 -0
- package/client/dist/assets/DocsPage-9QglWl46.js +11 -0
- package/client/dist/assets/ExportDropdown-BLZFXtNi.js +1 -0
- package/client/dist/assets/IntegrationsPage-BxBE4y99.js +3 -0
- package/client/dist/assets/JobDetailPage-DydWx_5S.js +16 -0
- package/client/dist/assets/JobsPage-20ibw0IO.js +1 -0
- package/client/dist/assets/abap-Bw6f2wDG.js +1 -0
- package/client/dist/assets/activity-BEIp_Y1A.js +1 -0
- package/client/dist/assets/activity-BdrPln96.js +1 -0
- package/client/dist/assets/activity-CpkRS8Sx.js +1 -0
- package/client/dist/assets/activity-DKCpESPt.js +1 -0
- package/client/dist/assets/activity-DOUVEjJi.js +1 -0
- package/client/dist/assets/activity-DRwkql_y.js +1 -0
- package/client/dist/assets/activity-DcDQ7tjw.js +1 -0
- package/client/dist/assets/activity-Dv6H7wEr.js +1 -0
- package/client/dist/assets/addon-image-3WCl5Vhd.js +1 -0
- package/client/dist/assets/addon-ligatures-C5OdliKs.js +2 -0
- package/client/dist/assets/addon-webgl-BbX6pSjl.js +44 -0
- package/client/dist/assets/addspec-B5yl4Loj.js +1 -0
- package/client/dist/assets/addspec-BEeF5-zc.js +1 -0
- package/client/dist/assets/addspec-D33ocMxf.js +1 -0
- package/client/dist/assets/addspec-DFswZ0jK.js +1 -0
- package/client/dist/assets/addspec-DRE-jZv7.js +1 -0
- package/client/dist/assets/addspec-DVZ15Jp8.js +1 -0
- package/client/dist/assets/addspec-Fkv91Opc.js +1 -0
- package/client/dist/assets/addspec-GWm4ffKl.js +1 -0
- package/client/dist/assets/agents-1nCDWRmP.js +1 -0
- package/client/dist/assets/agents-Bm9rPqnt.js +1 -0
- package/client/dist/assets/agents-CMxtJMLD.js +1 -0
- package/client/dist/assets/agents-DK-Dlc0i.js +1 -0
- package/client/dist/assets/agents-Q6Ldfpxx.js +1 -0
- package/client/dist/assets/agents-TeOSy-ax.js +1 -0
- package/client/dist/assets/agents-iTqjRajS.js +1 -0
- package/client/dist/assets/agents-s87sMGzL.js +1 -0
- package/client/dist/assets/agentstudio-B6Wb59E7.js +1 -0
- package/client/dist/assets/agentstudio-BADhZ41e.js +1 -0
- package/client/dist/assets/agentstudio-BSnWLR63.js +1 -0
- package/client/dist/assets/agentstudio-BdidyBzZ.js +1 -0
- package/client/dist/assets/agentstudio-CxlUllqI.js +1 -0
- package/client/dist/assets/agentstudio-D3I62TLJ.js +1 -0
- package/client/dist/assets/agentstudio-DuH9TogZ.js +1 -0
- package/client/dist/assets/agentstudio-Kw88_dUF.js +1 -0
- package/client/dist/assets/aiedit-BWxHGsYA.js +1 -0
- package/client/dist/assets/aiedit-D2ji6Qy0.js +1 -0
- package/client/dist/assets/aiedit-DAhZTvtk.js +1 -0
- package/client/dist/assets/aiedit-DJMny-D5.js +1 -0
- package/client/dist/assets/aiedit-DOcxERkU.js +1 -0
- package/client/dist/assets/aiedit-DvrcbwGv.js +1 -0
- package/client/dist/assets/aiedit-TTwzL1TS.js +1 -0
- package/client/dist/assets/aiedit-WBSjT_C1.js +1 -0
- package/client/dist/assets/analytics-BIdr0YfL.js +1 -0
- package/client/dist/assets/analytics-C6EzgtdE.js +1 -0
- package/client/dist/assets/analytics-C9Zc-rkM.js +1 -0
- package/client/dist/assets/analytics-CVx3YOc0.js +1 -0
- package/client/dist/assets/analytics-CYj0tfj7.js +1 -0
- package/client/dist/assets/analytics-CnY4kNG3.js +1 -0
- package/client/dist/assets/analytics-CrPCZRJ-.js +1 -0
- package/client/dist/assets/analytics-DMCto-TF.js +1 -0
- package/client/dist/assets/apex-Cw8_REBo.js +1 -0
- package/client/dist/assets/atom-one-dark-B-oHczHB.css +1 -0
- package/client/dist/assets/attachments-BIsSSnHJ.js +1 -0
- package/client/dist/assets/attachments-BW4L3l2L.js +1 -0
- package/client/dist/assets/attachments-Bcf6BG6V.js +1 -0
- package/client/dist/assets/attachments-Bke8sCU4.js +1 -0
- package/client/dist/assets/attachments-COcrGRFz.js +1 -0
- package/client/dist/assets/attachments-DYHGA2Dj.js +1 -0
- package/client/dist/assets/attachments-Dd92KpUH.js +1 -0
- package/client/dist/assets/attachments-DzdU6DV6.js +1 -0
- package/client/dist/assets/azcli-Cz6HAoOw.js +1 -0
- package/client/dist/assets/bat-CcJ-xyqL.js +1 -0
- package/client/dist/assets/bicep-z1WDCKYz.js +2 -0
- package/client/dist/assets/browser-5ErDlJoR.js +1 -0
- package/client/dist/assets/browser-Bc-YdlVg.js +1 -0
- package/client/dist/assets/browser-BlYF4OOq.js +1 -0
- package/client/dist/assets/browser-CT-ReZGt.js +1 -0
- package/client/dist/assets/browser-DGITz3fC.js +1 -0
- package/client/dist/assets/browser-JsAIGCEW.js +1 -0
- package/client/dist/assets/browser-M5-rbPlw.js +1 -0
- package/client/dist/assets/browser-Qya9cARy.js +1 -0
- package/client/dist/assets/cameligo-BRewOpfa.js +1 -0
- package/client/dist/assets/chat-BEGuC03z.js +1 -0
- package/client/dist/assets/chat-BEW60P_u.js +1 -0
- package/client/dist/assets/chat-BQNMD0PL.js +1 -0
- package/client/dist/assets/chat-BsbNGPW9.js +1 -0
- package/client/dist/assets/chat-CboQguCi.js +1 -0
- package/client/dist/assets/chat-DRCa9pOt.js +1 -0
- package/client/dist/assets/chat-DwUm6W9z.js +1 -0
- package/client/dist/assets/chat-yoXwguQu.js +1 -0
- package/client/dist/assets/chunk-CilyBKbf.js +1 -0
- package/client/dist/assets/clojure-DBjRWN6g.js +1 -0
- package/client/dist/assets/clsx-DnqN-uhr.js +1 -0
- package/client/dist/assets/code-AL1rVIMb.js +1 -0
- package/client/dist/assets/code-C0BKpkht.js +1 -0
- package/client/dist/assets/code-C0FTS3ew.js +1 -0
- package/client/dist/assets/code-CPcHxzxw.js +1 -0
- package/client/dist/assets/code-D3ryDniw.js +1 -0
- package/client/dist/assets/code-D3zVVQTj.js +1 -0
- package/client/dist/assets/code-PCmfS3dn.js +1 -0
- package/client/dist/assets/code-exI0G5Wd.js +1 -0
- package/client/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/client/dist/assets/coffee-Cfk_XHGR.js +1 -0
- package/client/dist/assets/commands-B772IyDa.js +1 -0
- package/client/dist/assets/commands-BDDp6xFG.js +1 -0
- package/client/dist/assets/commands-CJxCry-o.js +1 -0
- package/client/dist/assets/commands-CfgY-_of.js +1 -0
- package/client/dist/assets/commands-DLrvnPNg.js +1 -0
- package/client/dist/assets/commands-IXMOKBYt.js +1 -0
- package/client/dist/assets/commands-UD1NzmwX.js +1 -0
- package/client/dist/assets/commands-sqrqsxyE.js +1 -0
- package/client/dist/assets/common-DCr6VzJ7.js +1 -0
- package/client/dist/assets/common-Dard9UNH.js +1 -0
- package/client/dist/assets/common-DeDELLZJ.js +1 -0
- package/client/dist/assets/common-DltqHaAe.js +1 -0
- package/client/dist/assets/common-Dmm1GhdD.js +1 -0
- package/client/dist/assets/common-DnjcgkPH.js +1 -0
- package/client/dist/assets/common-GbpxfPG8.js +1 -0
- package/client/dist/assets/common-wA36jmj1.js +1 -0
- package/client/dist/assets/cpp-BVob6BaP.js +1 -0
- package/client/dist/assets/csharp-C4fbRuOu.js +1 -0
- package/client/dist/assets/csp-DthFP_vT.js +1 -0
- package/client/dist/assets/css-CGMH0hcW.js +3 -0
- package/client/dist/assets/css.worker-Wv5dxAWO.js +89 -0
- package/client/dist/assets/cssMode-Cc6ozl-J.js +1 -0
- package/client/dist/assets/cypher-Pnf68BRV.js +1 -0
- package/client/dist/assets/dart-PMMOtxZX.js +1 -0
- package/client/dist/assets/dashboard-B4ixDVk8.js +1 -0
- package/client/dist/assets/dashboard-BZBADHSj.js +1 -0
- package/client/dist/assets/dashboard-C1MfeUHs.js +1 -0
- package/client/dist/assets/dashboard-C7SK6xu5.js +1 -0
- package/client/dist/assets/dashboard-CB6Le1yN.js +1 -0
- package/client/dist/assets/dashboard-CoTpMOBM.js +1 -0
- package/client/dist/assets/dashboard-Duo4DDCW.js +1 -0
- package/client/dist/assets/dashboard-I19DXBxw.js +1 -0
- package/client/dist/assets/dist-js-BY-Fv_fg.js +1 -0
- package/client/dist/assets/dist-js-Bakc4uxT.js +1 -0
- package/client/dist/assets/dockerfile-di1nsJCc.js +1 -0
- package/client/dist/assets/ecl-D_WVcB5M.js +1 -0
- package/client/dist/assets/editor-Br_kD0ds.css +1 -0
- package/client/dist/assets/editor.api2-XLGzZfbc.js +872 -0
- package/client/dist/assets/editor.main-CfXxHimg.js +6 -0
- package/client/dist/assets/editor.worker-Bd9IXS8d.js +26 -0
- package/client/dist/assets/elixir-OAdJEMOn.js +1 -0
- package/client/dist/assets/explore-4mFpnrKU.js +1 -0
- package/client/dist/assets/explore-A8Ltoblq.js +1 -0
- package/client/dist/assets/explore-B9A3iN2W.js +1 -0
- package/client/dist/assets/explore-BV5Xxlsn.js +1 -0
- package/client/dist/assets/explore-BrBJvfjP.js +1 -0
- package/client/dist/assets/explore-C3FSE42C.js +1 -0
- package/client/dist/assets/explore-D2EFgt8J.js +1 -0
- package/client/dist/assets/explore-hFc3HFcp.js +1 -0
- package/client/dist/assets/flow9-D3QEZjgn.js +1 -0
- package/client/dist/assets/format-command-CwGuwzGA.js +1 -0
- package/client/dist/assets/freemarker2-DP7J1gG3.js +3 -0
- package/client/dist/assets/fsharp-BF0k_8N8.js +1 -0
- package/client/dist/assets/go-BAQO5Jsz.js +1 -0
- package/client/dist/assets/graphql-hdFVFkiV.js +1 -0
- package/client/dist/assets/handlebars-BjRlucw6.js +1 -0
- package/client/dist/assets/hcl-DWnl1o-X.js +1 -0
- package/client/dist/assets/html-OumBQJ-U.js +1 -0
- package/client/dist/assets/html.worker-CQP8QQsS.js +502 -0
- package/client/dist/assets/htmlMode-CStc3zXM.js +1 -0
- package/client/dist/assets/index-CimDRRi7.css +2 -0
- package/client/dist/assets/index-XGZaKl_u.js +142 -0
- package/client/dist/assets/ini-CB-6OVu3.js +1 -0
- package/client/dist/assets/integrations-C3p12Ms6.js +1 -0
- package/client/dist/assets/integrations-Cr6hH7XR.js +1 -0
- package/client/dist/assets/integrations-Cublz3m6.js +1 -0
- package/client/dist/assets/integrations-D28q1kF6.js +1 -0
- package/client/dist/assets/integrations-DRdbki5W.js +1 -0
- package/client/dist/assets/integrations-DaC4SzzL.js +1 -0
- package/client/dist/assets/integrations-DmQYCUvN.js +1 -0
- package/client/dist/assets/integrations-HIlUxXVs.js +1 -0
- package/client/dist/assets/java-d1CmfiHX.js +1 -0
- package/client/dist/assets/javascript-CMk--e7g.js +1 -0
- package/client/dist/assets/jobs-BE1siB0M.js +1 -0
- package/client/dist/assets/jobs-BHcQ_Faf.js +1 -0
- package/client/dist/assets/jobs-CFfc2dNX.js +1 -0
- package/client/dist/assets/jobs-CSi5n8X_.js +1 -0
- package/client/dist/assets/jobs-Dc3X86PY.js +1 -0
- package/client/dist/assets/jobs-De5tASex.js +1 -0
- package/client/dist/assets/jobs-DsoXEdo7.js +1 -0
- package/client/dist/assets/jobs-Wl-ApPMb.js +1 -0
- package/client/dist/assets/json.worker-DzV-CpCQ.js +58 -0
- package/client/dist/assets/jsonMode-C2h3ZcjZ.js +7 -0
- package/client/dist/assets/julia-Bgv08lKa.js +1 -0
- package/client/dist/assets/kotlin-u98kaVTf.js +1 -0
- package/client/dist/assets/less-CjYwpgg5.js +2 -0
- package/client/dist/assets/lexon-YTjaAFBB.js +1 -0
- package/client/dist/assets/lib-CPxTMOAq.js +7 -0
- package/client/dist/assets/liquid-mI3KJrBE.js +1 -0
- package/client/dist/assets/lspLanguageFeatures-DU09ggWi.js +4 -0
- package/client/dist/assets/lua-BzmkWv27.js +1 -0
- package/client/dist/assets/m3-CFwk9fw0.js +1 -0
- package/client/dist/assets/markdown-CR5iMpSZ.js +1 -0
- package/client/dist/assets/mdx-C41VDTR_.js +1 -0
- package/client/dist/assets/mips-CcEalc17.js +1 -0
- package/client/dist/assets/monaco.contribution-CPObAXMC.js +2 -0
- package/client/dist/assets/msdax-BQbkawnr.js +1 -0
- package/client/dist/assets/mysql-GTlaaW_P.js +1 -0
- package/client/dist/assets/nav-0fwkrgHt.js +1 -0
- package/client/dist/assets/nav-BEL3MTwK.js +1 -0
- package/client/dist/assets/nav-B_G-TJDW.js +1 -0
- package/client/dist/assets/nav-C2YXcbZS.js +1 -0
- package/client/dist/assets/nav-ClzOE4mA.js +1 -0
- package/client/dist/assets/nav-CtYwmMgu.js +1 -0
- package/client/dist/assets/nav-D2bOGSEg.js +1 -0
- package/client/dist/assets/nav-iH1V5j6o.js +1 -0
- package/client/dist/assets/objective-c-Byu1T5if.js +1 -0
- package/client/dist/assets/pascal-BrfzBfRm.js +1 -0
- package/client/dist/assets/pascaligo-BXXKFUeo.js +1 -0
- package/client/dist/assets/perl-B3OikKq-.js +1 -0
- package/client/dist/assets/pgsql-CTsa0Acc.js +1 -0
- package/client/dist/assets/php-DiQh3FUW.js +1 -0
- package/client/dist/assets/pla-92uH8Fzm.js +1 -0
- package/client/dist/assets/postiats-BbeWkKUr.js +1 -0
- package/client/dist/assets/powerquery-DgDMzpsm.js +1 -0
- package/client/dist/assets/powershell-BfdUUzaG.js +1 -0
- package/client/dist/assets/preload-helper-DSXbuxSR.js +1 -0
- package/client/dist/assets/protobuf-BojW2ftW.js +2 -0
- package/client/dist/assets/pug-BxqTg3IU.js +1 -0
- package/client/dist/assets/python-Y27rKQtk.js +1 -0
- package/client/dist/assets/qsharp-BX_A-MW9.js +1 -0
- package/client/dist/assets/r-D9BMnxvJ.js +1 -0
- package/client/dist/assets/razor-Cd5-q9Bp.js +1 -0
- package/client/dist/assets/redis-5cJqEQJJ.js +1 -0
- package/client/dist/assets/redshift-d8BBqiwb.js +1 -0
- package/client/dist/assets/restructuredtext-C8a6yIcZ.js +1 -0
- package/client/dist/assets/ruby-egeh-6KX.js +1 -0
- package/client/dist/assets/rust-a3r9IInB.js +1 -0
- package/client/dist/assets/sb-y8iRIDei.js +1 -0
- package/client/dist/assets/scala-BPDK2AmK.js +1 -0
- package/client/dist/assets/scheme-BIWUEoOs.js +1 -0
- package/client/dist/assets/scss-CA-PSzwg.js +3 -0
- package/client/dist/assets/settings-55oDcbSh.js +1 -0
- package/client/dist/assets/settings-Bd4Tq1RB.js +1 -0
- package/client/dist/assets/settings-CCSM-Fhn.js +1 -0
- package/client/dist/assets/settings-D3e_bDoW.js +1 -0
- package/client/dist/assets/settings-DKbTkbn7.js +1 -0
- package/client/dist/assets/settings-Dxpo6_w7.js +1 -0
- package/client/dist/assets/settings-bt84e3Aa.js +1 -0
- package/client/dist/assets/settings-nu68QukM.js +1 -0
- package/client/dist/assets/setup-BMqwfbW9.js +1 -0
- package/client/dist/assets/setup-Bb5LcG28.js +1 -0
- package/client/dist/assets/setup-BeEx2_da.js +1 -0
- package/client/dist/assets/setup-CCCrB53Q.js +1 -0
- package/client/dist/assets/setup-CJA0ATmd.js +1 -0
- package/client/dist/assets/setup-CeiDbZcb.js +1 -0
- package/client/dist/assets/setup-Cus7TApA.js +1 -0
- package/client/dist/assets/setup-D9qOs2Xo.js +1 -0
- package/client/dist/assets/shell--LiT1Bja.js +1 -0
- package/client/dist/assets/solidity-DdqZccZg.js +1 -0
- package/client/dist/assets/sophia-S6-YxNG_.js +1 -0
- package/client/dist/assets/sparql-BSf5kMp2.js +1 -0
- package/client/dist/assets/specs-BFfu3u-a.js +1 -0
- package/client/dist/assets/specs-B__C8-8a.js +1 -0
- package/client/dist/assets/specs-CZ1PsXsC.js +1 -0
- package/client/dist/assets/specs-D2FzlLn9.js +1 -0
- package/client/dist/assets/specs-DaUTrNF9.js +1 -0
- package/client/dist/assets/specs-Dyc5hYeE.js +1 -0
- package/client/dist/assets/specs-cKEh2LXt.js +1 -0
- package/client/dist/assets/specs-k0PyLDVt.js +1 -0
- package/client/dist/assets/sql-D7KgjR8G.js +1 -0
- package/client/dist/assets/st-BnoDa-Ml.js +1 -0
- package/client/dist/assets/swift-DEUHTkUX.js +1 -0
- package/client/dist/assets/systemverilog-Tqb_KPnW.js +1 -0
- package/client/dist/assets/tcl-BmBFS2qq.js +1 -0
- package/client/dist/assets/terminal-80yDMgMF.js +1 -0
- package/client/dist/assets/terminal-Bje4ziIa.js +1 -0
- package/client/dist/assets/terminal-C2WYcFHF.js +1 -0
- package/client/dist/assets/terminal-CSONJOex.js +1 -0
- package/client/dist/assets/terminal-DEqzGtcr.js +1 -0
- package/client/dist/assets/terminal-DeWzh6ys.js +1 -0
- package/client/dist/assets/terminal-YOlsJCQj.js +1 -0
- package/client/dist/assets/terminal-lkZYR4wJ.js +1 -0
- package/client/dist/assets/tickets-CB7N30gm.js +1 -0
- package/client/dist/assets/tickets-CF2PYelu.js +1 -0
- package/client/dist/assets/tickets-DNOANUXr.js +1 -0
- package/client/dist/assets/tickets-DU1aqsbr.js +1 -0
- package/client/dist/assets/tickets-DYvafSaY.js +1 -0
- package/client/dist/assets/tickets-DlpC_iTg.js +1 -0
- package/client/dist/assets/tickets-DucYgtdl.js +1 -0
- package/client/dist/assets/tickets-clefmXLv.js +1 -0
- package/client/dist/assets/ts.worker-METxwbDZ.js +67719 -0
- package/client/dist/assets/tsMode-B0y_xEci.js +11 -0
- package/client/dist/assets/twig-BQV8igWC.js +1 -0
- package/client/dist/assets/typescript-BzK0OgwW.js +1 -0
- package/client/dist/assets/typespec-DlFroUGY.js +1 -0
- package/client/dist/assets/useProjectCache-DSaiGFjV.js +1 -0
- package/client/dist/assets/vb-BlrJpIMX.js +1 -0
- package/client/dist/assets/wgsl-BWgIc6FZ.js +298 -0
- package/client/dist/assets/workers-rt--R2Qy.js +1 -0
- package/client/dist/assets/xml-eX9QXAmI.js +1 -0
- package/client/dist/assets/yaml-fcsNkpOt.js +1 -0
- package/client/dist/index.html +246 -0
- package/docs/README.md +54 -0
- package/docs/cli.md +198 -0
- package/docs/codex.md +210 -0
- package/docs/creating-specs.md +197 -0
- package/docs/customizing.md +197 -0
- package/docs/getting-started.md +140 -0
- package/docs/internals/README.md +25 -0
- package/docs/internals/adding-a-provider.md +238 -0
- package/docs/internals/api-reference.md +634 -0
- package/docs/internals/architecture.md +332 -0
- package/docs/internals/configuration.md +172 -0
- package/docs/internals/openspec-workflow.md +282 -0
- package/docs/internals/operations-runbook.md +198 -0
- package/docs/internals/profiles.md +152 -0
- package/docs/platforms/macos.md +130 -0
- package/docs/platforms/windows.md +81 -0
- package/docs/running-pipelines.md +240 -0
- package/docs/terminal.md +138 -0
- package/docs/tracking-cost.md +155 -0
- package/package.json +82 -0
- package/server/dist/agent-generator.js +232 -0
- package/server/dist/agent-refine-db.js +124 -0
- package/server/dist/agent-refine-manager.js +526 -0
- package/server/dist/ai-invocations.js +111 -0
- package/server/dist/attachment-manager.js +299 -0
- package/server/dist/auth.js +207 -0
- package/server/dist/binary-probe.js +35 -0
- package/server/dist/browser-capture-manager.js +576 -0
- package/server/dist/browser-capture-types.js +28 -0
- package/server/dist/browser-network.js +149 -0
- package/server/dist/browser-playwright.js +888 -0
- package/server/dist/build-dirs.js +44 -0
- package/server/dist/changes-reader.js +120 -0
- package/server/dist/chat-manager.js +1060 -0
- package/server/dist/chromium-resolver.js +311 -0
- package/server/dist/code-explorer-router.js +788 -0
- package/server/dist/codex-otel-bridge.js +235 -0
- package/server/dist/command-resolver.js +102 -0
- package/server/dist/config.js +306 -0
- package/server/dist/context-budget.js +113 -0
- package/server/dist/context-scope.js +279 -0
- package/server/dist/contract-refine-runner.js +521 -0
- package/server/dist/core-compat.js +207 -0
- package/server/dist/core-package.js +14 -0
- package/server/dist/db.js +1034 -0
- package/server/dist/desktop-analytics.js +156 -0
- package/server/dist/desktop-db.js +456 -0
- package/server/dist/desktop-router.js +735 -0
- package/server/dist/docs-router.js +207 -0
- package/server/dist/explore-contract-refine.js +421 -0
- package/server/dist/explore-cwd-manager.js +242 -0
- package/server/dist/explore-draft-title.js +47 -0
- package/server/dist/explore-smash.js +450 -0
- package/server/dist/feature-flags.js +17 -0
- package/server/dist/file-provenance.js +382 -0
- package/server/dist/file-summary-generator.js +221 -0
- package/server/dist/file-summary-manager.js +689 -0
- package/server/dist/hooks.js +102 -0
- package/server/dist/ids.js +7 -0
- package/server/dist/index.js +586 -0
- package/server/dist/metrics.js +136 -0
- package/server/dist/mobile/index.js +16 -0
- package/server/dist/mobile/mobile-admin-router.js +84 -0
- package/server/dist/mobile/mobile-auth.js +67 -0
- package/server/dist/mobile/mobile-devices.js +80 -0
- package/server/dist/mobile/mobile-event-bus.js +39 -0
- package/server/dist/mobile/mobile-gateway.js +285 -0
- package/server/dist/mobile/mobile-mdns.js +81 -0
- package/server/dist/mobile/mobile-pairing.js +179 -0
- package/server/dist/mobile/mobile-redact.js +53 -0
- package/server/dist/mobile/mobile-router.js +411 -0
- package/server/dist/mobile/mobile-tls.js +86 -0
- package/server/dist/mobile/mobile-types.js +9 -0
- package/server/dist/mobile/mobile-ws.js +275 -0
- package/server/dist/path-resolver.js +298 -0
- package/server/dist/plugin-manager.js +617 -0
- package/server/dist/plugins/claude-approval.js +179 -0
- package/server/dist/plugins/claude-md-mutation.js +146 -0
- package/server/dist/plugins/codex-mcp.js +108 -0
- package/server/dist/plugins/contributors.js +72 -0
- package/server/dist/plugins/drift.js +58 -0
- package/server/dist/plugins/index.js +14 -0
- package/server/dist/plugins/json-mutation.js +120 -0
- package/server/dist/plugins/manager.js +32 -0
- package/server/dist/plugins/ownership.js +86 -0
- package/server/dist/plugins/paths.js +37 -0
- package/server/dist/plugins/prereq-installer.js +104 -0
- package/server/dist/plugins/rail-integration.js +79 -0
- package/server/dist/plugins/serena/index.js +13 -0
- package/server/dist/plugins/serena/install.js +91 -0
- package/server/dist/plugins/serena/instructions-content.js +21 -0
- package/server/dist/plugins/serena/manifest.js +111 -0
- package/server/dist/plugins/serena/verify.js +78 -0
- package/server/dist/plugins-router.js +215 -0
- package/server/dist/pricing.js +89 -0
- package/server/dist/profile-manager.js +310 -0
- package/server/dist/profiles-router.js +759 -0
- package/server/dist/project-registry.js +443 -0
- package/server/dist/project-router.js +4016 -0
- package/server/dist/proposal-manager.js +291 -0
- package/server/dist/provider-selection.js +69 -0
- package/server/dist/providers/claude-adapter.js +281 -0
- package/server/dist/providers/codex-adapter.js +264 -0
- package/server/dist/providers/index.js +23 -0
- package/server/dist/providers/registry.js +37 -0
- package/server/dist/providers/types.js +22 -0
- package/server/dist/queue-manager.js +1511 -0
- package/server/dist/rails-router.js +362 -0
- package/server/dist/rails-store.js +116 -0
- package/server/dist/result-event.js +106 -0
- package/server/dist/schemas/profile.v1.json +151 -0
- package/server/dist/setup-manager.js +1165 -0
- package/server/dist/setup-prerequisites.js +372 -0
- package/server/dist/smash-runner.js +663 -0
- package/server/dist/spec-draft-parser.js +133 -0
- package/server/dist/spec-launcher-manager.js +174 -0
- package/server/dist/spec-models.js +32 -0
- package/server/dist/specrails-tech-client.js +82 -0
- package/server/dist/spending.js +448 -0
- package/server/dist/telemetry-compactor.js +180 -0
- package/server/dist/telemetry-export.js +317 -0
- package/server/dist/telemetry-receiver.js +224 -0
- package/server/dist/terminal-manager.js +633 -0
- package/server/dist/terminal-marks-store.js +117 -0
- package/server/dist/terminal-osc-parser.js +159 -0
- package/server/dist/terminal-settings.js +282 -0
- package/server/dist/terminal-shell-integration.js +196 -0
- package/server/dist/ticket-broadcast.js +47 -0
- package/server/dist/ticket-store.js +397 -0
- package/server/dist/ticket-watcher.js +117 -0
- package/server/dist/types.js +10 -0
- package/server/dist/user-mcp-config.js +117 -0
- package/server/dist/util/cli-prompt.js +181 -0
- package/server/dist/util/secure-fs.js +50 -0
- package/server/dist/util/win-spawn.js +43 -0
- package/server/dist/webhook-manager.js +89 -0
- 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
|
+
}
|