@youweichen/pi-harness 0.0.0-stage → 1.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/LICENSE +21 -0
  2. package/README.md +74 -2
  3. package/bin/pi-harness.mjs +1819 -0
  4. package/deploy/com.youweichen.pi-harness.plist +48 -0
  5. package/deploy/nginx-subpath.conf +88 -0
  6. package/deploy/pi-harness-task.xml +71 -0
  7. package/deploy/pi-harness.service +31 -0
  8. package/dist/server/agent-service.js +3051 -0
  9. package/dist/server/app-version.js +19 -0
  10. package/dist/server/attachments.js +526 -0
  11. package/dist/server/auth-file.js +61 -0
  12. package/dist/server/bg-servers.js +154 -0
  13. package/dist/server/client-eviction.js +19 -0
  14. package/dist/server/client-id.js +9 -0
  15. package/dist/server/client-state.js +202 -0
  16. package/dist/server/codemode-image-routes.js +36 -0
  17. package/dist/server/codemode-presentation.js +19 -0
  18. package/dist/server/component-updates.js +135 -0
  19. package/dist/server/context-breakdown.js +56 -0
  20. package/dist/server/control-socket.js +177 -0
  21. package/dist/server/conversation.js +65 -0
  22. package/dist/server/dir-browser.js +77 -0
  23. package/dist/server/editor-snapshot.js +38 -0
  24. package/dist/server/ensure-bash.js +80 -0
  25. package/dist/server/extension-display.js +23 -0
  26. package/dist/server/extensions-catalog.js +64 -0
  27. package/dist/server/extensions-model.js +46 -0
  28. package/dist/server/extensions-routes.js +255 -0
  29. package/dist/server/extensions-worker.js +187 -0
  30. package/dist/server/files-service.js +795 -0
  31. package/dist/server/index.js +1029 -0
  32. package/dist/server/markdown-images.js +42 -0
  33. package/dist/server/model-admin.js +801 -0
  34. package/dist/server/model-config-merge.js +32 -0
  35. package/dist/server/native-mcp-config.js +103 -0
  36. package/dist/server/native-mcp-presentation.js +12 -0
  37. package/dist/server/native-prompt-template.js +12 -0
  38. package/dist/server/native-tools.js +32 -0
  39. package/dist/server/node-sources.js +142 -0
  40. package/dist/server/node-workbench.js +668 -0
  41. package/dist/server/origin-policy.js +50 -0
  42. package/dist/server/patch-node-pty.js +96 -0
  43. package/dist/server/pi-environment.js +203 -0
  44. package/dist/server/plan/extension.js +74 -0
  45. package/dist/server/plan/progress.js +90 -0
  46. package/dist/server/plan/settings.js +41 -0
  47. package/dist/server/plan/state.js +113 -0
  48. package/dist/server/plugin-facilities.js +304 -0
  49. package/dist/server/plugin-updater.js +245 -0
  50. package/dist/server/plugins.js +712 -0
  51. package/dist/server/private-file.js +28 -0
  52. package/dist/server/process-utils.js +112 -0
  53. package/dist/server/prompt-delivery.js +80 -0
  54. package/dist/server/protocol-version.js +11 -0
  55. package/dist/server/protocol.js +6 -0
  56. package/dist/server/provider-auth.js +99 -0
  57. package/dist/server/query-cache.js +45 -0
  58. package/dist/server/recovery-state.js +37 -0
  59. package/dist/server/scm.js +459 -0
  60. package/dist/server/security-headers.js +24 -0
  61. package/dist/server/serialize.js +254 -0
  62. package/dist/server/session-file-read.js +225 -0
  63. package/dist/server/session-record-worker.js +21 -0
  64. package/dist/server/session-reload.js +13 -0
  65. package/dist/server/session-tree-controller.js +250 -0
  66. package/dist/server/session-tree.js +191 -0
  67. package/dist/server/settings-service.js +53 -0
  68. package/dist/server/skills-service.js +87 -0
  69. package/dist/server/slash-commands.js +280 -0
  70. package/dist/server/sqlite-preview.js +58 -0
  71. package/dist/server/sqlite-query.js +67 -0
  72. package/dist/server/sqlite-worker.js +12 -0
  73. package/dist/server/system-prompt-files.js +158 -0
  74. package/dist/server/system-prompt-routes.js +40 -0
  75. package/dist/server/system-prompt-view.js +49 -0
  76. package/dist/server/task-progress.js +233 -0
  77. package/dist/server/terminals.js +833 -0
  78. package/dist/server/text-sniff.js +269 -0
  79. package/dist/server/thinking-timing.js +91 -0
  80. package/dist/server/token-auth.js +37 -0
  81. package/dist/server/tool-output.js +97 -0
  82. package/dist/server/tool-text-incidents.js +82 -0
  83. package/dist/server/tool-text.js +67 -0
  84. package/dist/server/uploads.js +112 -0
  85. package/dist/server/user-attachments.js +96 -0
  86. package/dist/server/webui-context.js +257 -0
  87. package/dist/server/wiki-links.js +136 -0
  88. package/dist/server/wiki-pdf-worker.js +23 -0
  89. package/dist/server/wiki-pdf.js +36 -0
  90. package/dist/server/wiki-routes.js +177 -0
  91. package/dist/server/wiki-service.js +511 -0
  92. package/electron/main.mjs +607 -0
  93. package/extensions/webui.ts +192 -0
  94. package/package.json +122 -4
  95. package/web/dist/apple-touch-icon.png +0 -0
  96. package/web/dist/assets/NodeWorkbench-C4U1PldE.js +3 -0
  97. package/web/dist/assets/TerminalPanel-CWh6vw_N.js +2 -0
  98. package/web/dist/assets/abnfDiagram-VCTEODGH-D5Ti0Kpa.js +1 -0
  99. package/web/dist/assets/arc-DKJilLd6.js +1 -0
  100. package/web/dist/assets/architectureDiagram-5GKGNRK7-Cf0w38g-.js +36 -0
  101. package/web/dist/assets/blockDiagram-I7D4REHJ-BOTLWLIL.js +129 -0
  102. package/web/dist/assets/c4Diagram-7LVT6UL2-CLCCoJs3.js +38 -0
  103. package/web/dist/assets/channel-D8Hd7T4P.js +1 -0
  104. package/web/dist/assets/chunk-2Q5K7J3B-BHjga2Ge.js +1 -0
  105. package/web/dist/assets/chunk-5VM5RSS4-CQJ5VfDZ.js +15 -0
  106. package/web/dist/assets/chunk-F27PBJKO-C67qoPJH.js +1 -0
  107. package/web/dist/assets/chunk-IMKFNOWR-CgXXGwI8.js +231 -0
  108. package/web/dist/assets/chunk-JWPE2WC7-D0RPyByP.js +1 -0
  109. package/web/dist/assets/chunk-POPQ4Y6H-BeYC9s9x.js +1 -0
  110. package/web/dist/assets/chunk-SVP7TREG-eQPl4UBQ.js +88 -0
  111. package/web/dist/assets/chunk-TICWLB2K-DT73arPU.js +206 -0
  112. package/web/dist/assets/chunk-XXDRQBXY-B4FwQcP-.js +1 -0
  113. package/web/dist/assets/classDiagram-ZZMXUADV-B_btgYRB.js +1 -0
  114. package/web/dist/assets/classDiagram-v2-VYDZK3BY-B_btgYRB.js +1 -0
  115. package/web/dist/assets/cose-bilkent-JH36ORCC-Bnn5nlQN.js +1 -0
  116. package/web/dist/assets/cynefin-OW5HDTMX-CStWX9kE.js +178 -0
  117. package/web/dist/assets/cynefinDiagram-5FMLGOSQ-BMVHC03f.js +62 -0
  118. package/web/dist/assets/cytoscape.esm-Bch-eiPH.js +331 -0
  119. package/web/dist/assets/dagre-GXQ25YYZ-ClSaO4ml.js +4 -0
  120. package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  121. package/web/dist/assets/diagram-S7CK7UJ4-C43LL08C.js +30 -0
  122. package/web/dist/assets/diagram-UQ7AKVKN-BPVn_ySW.js +41 -0
  123. package/web/dist/assets/diagram-VSXAHHWV-C34FFhRi.js +3 -0
  124. package/web/dist/assets/diagram-VX7I27RA-BRk4bncf.js +24 -0
  125. package/web/dist/assets/diagram-Z3DM3KII-O5hYPLEd.js +24 -0
  126. package/web/dist/assets/ebnfDiagram-PWID7BFC-DVGEBm99.js +1 -0
  127. package/web/dist/assets/erDiagram-RLTQ6QDP-M-BLpTEV.js +99 -0
  128. package/web/dist/assets/flowDiagram-HODETNUW-BwdLopEx.js +168 -0
  129. package/web/dist/assets/ganttDiagram-EL5Y4UJY-DYxx2ppA.js +292 -0
  130. package/web/dist/assets/gitGraphDiagram-WWUBYQGX-DUU0wMYB.js +106 -0
  131. package/web/dist/assets/index-CI2KfVSr.css +10 -0
  132. package/web/dist/assets/index-DQcpggFU.js +275 -0
  133. package/web/dist/assets/infoDiagram-27XIBGKW-CFaW5C0C.js +2 -0
  134. package/web/dist/assets/init-Gi6I4Gst.js +1 -0
  135. package/web/dist/assets/ishikawaDiagram-5VMMS53U--ENP1MTB.js +70 -0
  136. package/web/dist/assets/journeyDiagram-3NMN7TZE-DgnAzSTj.js +139 -0
  137. package/web/dist/assets/kanban-definition-UXKFOSKX-_STZN1vU.js +89 -0
  138. package/web/dist/assets/katex-C5jXJg4s.js +257 -0
  139. package/web/dist/assets/layout-CkUClkXt.js +1 -0
  140. package/web/dist/assets/linear-sYHiUOxa.js +1 -0
  141. package/web/dist/assets/markdown-vNTCZJ6d.js +51 -0
  142. package/web/dist/assets/mermaid.core-B8fKGWsc.js +312 -0
  143. package/web/dist/assets/mindmap-definition-YA3MSWOX-DP0BYOEx.js +96 -0
  144. package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  145. package/web/dist/assets/pegDiagram-XKGWAZYB-DkWTmZre.js +1 -0
  146. package/web/dist/assets/pieDiagram-E7YTZNPT-DfZgMV4u.js +39 -0
  147. package/web/dist/assets/quadrantDiagram-AXDQQJYC-Djl4TT7q.js +7 -0
  148. package/web/dist/assets/railroadDiagram-O6MQD6OU-DbKF6zXn.js +1 -0
  149. package/web/dist/assets/react-BNj3n7jX.js +24 -0
  150. package/web/dist/assets/requirementDiagram-BXWQKSXE-idola5qb.js +84 -0
  151. package/web/dist/assets/sankeyDiagram-P5KCCOFB-KA0hQOle.js +40 -0
  152. package/web/dist/assets/sequenceDiagram-WJ2MYXX4-BXSKvxzP.js +162 -0
  153. package/web/dist/assets/sizeCapture-INFHLROL-C_Ewv3S5.js +1 -0
  154. package/web/dist/assets/stateDiagram-D77RDMKH-DIf5X8oo.js +1 -0
  155. package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DYwe_2mB.js +1 -0
  156. package/web/dist/assets/swimlanes-42K2YHIH-Cx_Rc8M2.js +1 -0
  157. package/web/dist/assets/swimlanesDiagram-VR7AAH4N-52Y8tgRV.js +8 -0
  158. package/web/dist/assets/timeline-definition-24CTP7MA-BRrDhWVz.js +120 -0
  159. package/web/dist/assets/vennDiagram-4TSXK5OY-Ad6G0YwD.js +34 -0
  160. package/web/dist/assets/wardleyDiagram-VM6X3IG4-X6-ENpSi.js +78 -0
  161. package/web/dist/assets/xterm-6GBZ9nXN.css +32 -0
  162. package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
  163. package/web/dist/assets/xychartDiagram-S5SC5T6Z-DhegNS6d.js +7 -0
  164. package/web/dist/brand-mark.svg +5 -0
  165. package/web/dist/favicon-32.png +0 -0
  166. package/web/dist/favicon.svg +5 -0
  167. package/web/dist/fonts/WIKI-FONTS.md +5 -0
  168. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  169. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  170. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  171. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  172. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  173. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
  174. package/web/dist/fonts/ibm-plex-sans/LICENSE +93 -0
  175. package/web/dist/fonts/ibm-plex-sans/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  176. package/web/dist/fonts/ibm-plex-sans/ibm-plex-sans-latin-500-normal.woff2 +0 -0
  177. package/web/dist/fonts/ibm-plex-sans/ibm-plex-sans-latin-600-normal.woff2 +0 -0
  178. package/web/dist/fonts/ibm-plex-sans/ibm-plex-sans-latin-ext-400-normal.woff2 +0 -0
  179. package/web/dist/fonts/ibm-plex-sans/ibm-plex-sans-latin-ext-500-normal.woff2 +0 -0
  180. package/web/dist/fonts/ibm-plex-sans/ibm-plex-sans-latin-ext-600-normal.woff2 +0 -0
  181. package/web/dist/fonts/inter/inter-latin-400-normal.woff2 +0 -0
  182. package/web/dist/fonts/inter/inter-latin-500-normal.woff2 +0 -0
  183. package/web/dist/fonts/inter/inter-latin-600-normal.woff2 +0 -0
  184. package/web/dist/fonts/inter/inter-latin-700-normal.woff2 +0 -0
  185. package/web/dist/fonts/inter/inter-latin-800-normal.woff2 +0 -0
  186. package/web/dist/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
  187. package/web/dist/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
  188. package/web/dist/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
  189. package/web/dist/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
  190. package/web/dist/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
  191. package/web/dist/fonts/jetbrains-mono/LICENSE +93 -0
  192. package/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-400-normal.woff2 +0 -0
  193. package/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-500-normal.woff2 +0 -0
  194. package/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-600-normal.woff2 +0 -0
  195. package/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  196. package/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  197. package/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-600-normal.woff2 +0 -0
  198. package/web/dist/fonts/noto-serif-sc/LICENSE +94 -0
  199. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-0.woff2 +0 -0
  200. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-1.woff2 +0 -0
  201. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-10.woff2 +0 -0
  202. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-100.woff2 +0 -0
  203. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-11.woff2 +0 -0
  204. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-12.woff2 +0 -0
  205. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-13.woff2 +0 -0
  206. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-14.woff2 +0 -0
  207. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-15.woff2 +0 -0
  208. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-16.woff2 +0 -0
  209. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-17.woff2 +0 -0
  210. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-18.woff2 +0 -0
  211. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-19.woff2 +0 -0
  212. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-2.woff2 +0 -0
  213. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-20.woff2 +0 -0
  214. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-21.woff2 +0 -0
  215. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-22.woff2 +0 -0
  216. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-23.woff2 +0 -0
  217. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-24.woff2 +0 -0
  218. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-25.woff2 +0 -0
  219. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-26.woff2 +0 -0
  220. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-27.woff2 +0 -0
  221. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-28.woff2 +0 -0
  222. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-29.woff2 +0 -0
  223. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-3.woff2 +0 -0
  224. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-30.woff2 +0 -0
  225. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-31.woff2 +0 -0
  226. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-32.woff2 +0 -0
  227. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-33.woff2 +0 -0
  228. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-34.woff2 +0 -0
  229. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-35.woff2 +0 -0
  230. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-36.woff2 +0 -0
  231. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-37.woff2 +0 -0
  232. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-38.woff2 +0 -0
  233. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-39.woff2 +0 -0
  234. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-4.woff2 +0 -0
  235. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-40.woff2 +0 -0
  236. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-41.woff2 +0 -0
  237. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-42.woff2 +0 -0
  238. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-43.woff2 +0 -0
  239. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-44.woff2 +0 -0
  240. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-45.woff2 +0 -0
  241. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-46.woff2 +0 -0
  242. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-47.woff2 +0 -0
  243. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-48.woff2 +0 -0
  244. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-49.woff2 +0 -0
  245. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-5.woff2 +0 -0
  246. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-50.woff2 +0 -0
  247. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-51.woff2 +0 -0
  248. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-52.woff2 +0 -0
  249. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-53.woff2 +0 -0
  250. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-54.woff2 +0 -0
  251. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-55.woff2 +0 -0
  252. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-56.woff2 +0 -0
  253. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-57.woff2 +0 -0
  254. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-58.woff2 +0 -0
  255. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-59.woff2 +0 -0
  256. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-6.woff2 +0 -0
  257. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-60.woff2 +0 -0
  258. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-61.woff2 +0 -0
  259. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-62.woff2 +0 -0
  260. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-63.woff2 +0 -0
  261. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-64.woff2 +0 -0
  262. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-65.woff2 +0 -0
  263. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-66.woff2 +0 -0
  264. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-67.woff2 +0 -0
  265. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-68.woff2 +0 -0
  266. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-69.woff2 +0 -0
  267. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-7.woff2 +0 -0
  268. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-70.woff2 +0 -0
  269. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-71.woff2 +0 -0
  270. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-72.woff2 +0 -0
  271. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-73.woff2 +0 -0
  272. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-74.woff2 +0 -0
  273. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-75.woff2 +0 -0
  274. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-76.woff2 +0 -0
  275. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-77.woff2 +0 -0
  276. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-78.woff2 +0 -0
  277. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-79.woff2 +0 -0
  278. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-8.woff2 +0 -0
  279. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-80.woff2 +0 -0
  280. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-81.woff2 +0 -0
  281. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-82.woff2 +0 -0
  282. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-83.woff2 +0 -0
  283. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-84.woff2 +0 -0
  284. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-85.woff2 +0 -0
  285. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-86.woff2 +0 -0
  286. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-87.woff2 +0 -0
  287. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-88.woff2 +0 -0
  288. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-89.woff2 +0 -0
  289. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-9.woff2 +0 -0
  290. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-90.woff2 +0 -0
  291. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-91.woff2 +0 -0
  292. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-92.woff2 +0 -0
  293. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-93.woff2 +0 -0
  294. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-94.woff2 +0 -0
  295. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-95.woff2 +0 -0
  296. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-96.woff2 +0 -0
  297. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-97.woff2 +0 -0
  298. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-98.woff2 +0 -0
  299. package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-99.woff2 +0 -0
  300. package/web/dist/fonts/source-serif-4/LICENSE +93 -0
  301. package/web/dist/fonts/source-serif-4/source-serif-4-500-0.woff2 +0 -0
  302. package/web/dist/fonts/source-serif-4/source-serif-4-500-1.woff2 +0 -0
  303. package/web/dist/fonts/source-serif-4/source-serif-4-500-2.woff2 +0 -0
  304. package/web/dist/fonts/source-serif-4/source-serif-4-500-3.woff2 +0 -0
  305. package/web/dist/fonts/source-serif-4/source-serif-4-500-4.woff2 +0 -0
  306. package/web/dist/fonts/source-serif-4/source-serif-4-500-5.woff2 +0 -0
  307. package/web/dist/icon/1a-mark.svg +1 -0
  308. package/web/dist/icon.ico +0 -0
  309. package/web/dist/index.html +18 -0
  310. package/web/public/apple-touch-icon.png +0 -0
  311. package/web/public/brand-mark.svg +5 -0
  312. package/web/public/favicon-32.png +0 -0
  313. package/web/public/favicon.svg +5 -0
  314. package/web/public/fonts/WIKI-FONTS.md +5 -0
  315. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  316. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  317. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  318. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  319. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  320. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
  321. package/web/public/fonts/ibm-plex-sans/LICENSE +93 -0
  322. package/web/public/fonts/ibm-plex-sans/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  323. package/web/public/fonts/ibm-plex-sans/ibm-plex-sans-latin-500-normal.woff2 +0 -0
  324. package/web/public/fonts/ibm-plex-sans/ibm-plex-sans-latin-600-normal.woff2 +0 -0
  325. package/web/public/fonts/ibm-plex-sans/ibm-plex-sans-latin-ext-400-normal.woff2 +0 -0
  326. package/web/public/fonts/ibm-plex-sans/ibm-plex-sans-latin-ext-500-normal.woff2 +0 -0
  327. package/web/public/fonts/ibm-plex-sans/ibm-plex-sans-latin-ext-600-normal.woff2 +0 -0
  328. package/web/public/fonts/inter/inter-latin-400-normal.woff2 +0 -0
  329. package/web/public/fonts/inter/inter-latin-500-normal.woff2 +0 -0
  330. package/web/public/fonts/inter/inter-latin-600-normal.woff2 +0 -0
  331. package/web/public/fonts/inter/inter-latin-700-normal.woff2 +0 -0
  332. package/web/public/fonts/inter/inter-latin-800-normal.woff2 +0 -0
  333. package/web/public/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
  334. package/web/public/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
  335. package/web/public/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
  336. package/web/public/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
  337. package/web/public/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
  338. package/web/public/fonts/jetbrains-mono/LICENSE +93 -0
  339. package/web/public/fonts/jetbrains-mono/jetbrains-mono-latin-400-normal.woff2 +0 -0
  340. package/web/public/fonts/jetbrains-mono/jetbrains-mono-latin-500-normal.woff2 +0 -0
  341. package/web/public/fonts/jetbrains-mono/jetbrains-mono-latin-600-normal.woff2 +0 -0
  342. package/web/public/fonts/jetbrains-mono/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  343. package/web/public/fonts/jetbrains-mono/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  344. package/web/public/fonts/jetbrains-mono/jetbrains-mono-latin-ext-600-normal.woff2 +0 -0
  345. package/web/public/fonts/noto-serif-sc/LICENSE +94 -0
  346. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-0.woff2 +0 -0
  347. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-1.woff2 +0 -0
  348. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-10.woff2 +0 -0
  349. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-100.woff2 +0 -0
  350. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-11.woff2 +0 -0
  351. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-12.woff2 +0 -0
  352. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-13.woff2 +0 -0
  353. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-14.woff2 +0 -0
  354. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-15.woff2 +0 -0
  355. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-16.woff2 +0 -0
  356. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-17.woff2 +0 -0
  357. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-18.woff2 +0 -0
  358. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-19.woff2 +0 -0
  359. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-2.woff2 +0 -0
  360. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-20.woff2 +0 -0
  361. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-21.woff2 +0 -0
  362. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-22.woff2 +0 -0
  363. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-23.woff2 +0 -0
  364. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-24.woff2 +0 -0
  365. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-25.woff2 +0 -0
  366. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-26.woff2 +0 -0
  367. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-27.woff2 +0 -0
  368. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-28.woff2 +0 -0
  369. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-29.woff2 +0 -0
  370. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-3.woff2 +0 -0
  371. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-30.woff2 +0 -0
  372. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-31.woff2 +0 -0
  373. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-32.woff2 +0 -0
  374. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-33.woff2 +0 -0
  375. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-34.woff2 +0 -0
  376. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-35.woff2 +0 -0
  377. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-36.woff2 +0 -0
  378. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-37.woff2 +0 -0
  379. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-38.woff2 +0 -0
  380. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-39.woff2 +0 -0
  381. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-4.woff2 +0 -0
  382. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-40.woff2 +0 -0
  383. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-41.woff2 +0 -0
  384. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-42.woff2 +0 -0
  385. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-43.woff2 +0 -0
  386. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-44.woff2 +0 -0
  387. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-45.woff2 +0 -0
  388. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-46.woff2 +0 -0
  389. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-47.woff2 +0 -0
  390. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-48.woff2 +0 -0
  391. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-49.woff2 +0 -0
  392. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-5.woff2 +0 -0
  393. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-50.woff2 +0 -0
  394. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-51.woff2 +0 -0
  395. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-52.woff2 +0 -0
  396. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-53.woff2 +0 -0
  397. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-54.woff2 +0 -0
  398. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-55.woff2 +0 -0
  399. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-56.woff2 +0 -0
  400. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-57.woff2 +0 -0
  401. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-58.woff2 +0 -0
  402. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-59.woff2 +0 -0
  403. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-6.woff2 +0 -0
  404. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-60.woff2 +0 -0
  405. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-61.woff2 +0 -0
  406. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-62.woff2 +0 -0
  407. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-63.woff2 +0 -0
  408. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-64.woff2 +0 -0
  409. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-65.woff2 +0 -0
  410. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-66.woff2 +0 -0
  411. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-67.woff2 +0 -0
  412. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-68.woff2 +0 -0
  413. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-69.woff2 +0 -0
  414. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-7.woff2 +0 -0
  415. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-70.woff2 +0 -0
  416. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-71.woff2 +0 -0
  417. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-72.woff2 +0 -0
  418. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-73.woff2 +0 -0
  419. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-74.woff2 +0 -0
  420. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-75.woff2 +0 -0
  421. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-76.woff2 +0 -0
  422. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-77.woff2 +0 -0
  423. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-78.woff2 +0 -0
  424. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-79.woff2 +0 -0
  425. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-8.woff2 +0 -0
  426. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-80.woff2 +0 -0
  427. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-81.woff2 +0 -0
  428. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-82.woff2 +0 -0
  429. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-83.woff2 +0 -0
  430. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-84.woff2 +0 -0
  431. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-85.woff2 +0 -0
  432. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-86.woff2 +0 -0
  433. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-87.woff2 +0 -0
  434. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-88.woff2 +0 -0
  435. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-89.woff2 +0 -0
  436. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-9.woff2 +0 -0
  437. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-90.woff2 +0 -0
  438. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-91.woff2 +0 -0
  439. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-92.woff2 +0 -0
  440. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-93.woff2 +0 -0
  441. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-94.woff2 +0 -0
  442. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-95.woff2 +0 -0
  443. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-96.woff2 +0 -0
  444. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-97.woff2 +0 -0
  445. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-98.woff2 +0 -0
  446. package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-99.woff2 +0 -0
  447. package/web/public/fonts/source-serif-4/LICENSE +93 -0
  448. package/web/public/fonts/source-serif-4/source-serif-4-500-0.woff2 +0 -0
  449. package/web/public/fonts/source-serif-4/source-serif-4-500-1.woff2 +0 -0
  450. package/web/public/fonts/source-serif-4/source-serif-4-500-2.woff2 +0 -0
  451. package/web/public/fonts/source-serif-4/source-serif-4-500-3.woff2 +0 -0
  452. package/web/public/fonts/source-serif-4/source-serif-4-500-4.woff2 +0 -0
  453. package/web/public/fonts/source-serif-4/source-serif-4-500-5.woff2 +0 -0
  454. package/web/public/icon/1a-mark.svg +1 -0
  455. package/web/public/icon.ico +0 -0
@@ -0,0 +1,3051 @@
1
+ import { planSettings } from "./plan/settings.js";
2
+ import { reloadSession } from "./session-reload.js";
3
+ import { resolveNativeAttachments } from "./user-attachments.js";
4
+ import { estimateTokens } from "@earendil-works/pi-coding-agent";
5
+ import { SessionTreeController } from "./session-tree-controller.js";
6
+ import { SessionBranchCounts } from "./session-file-read.js";
7
+ import { createHash, randomUUID } from "node:crypto";
8
+ import { recoveryEvent, isRecovering, recoverySnapshot } from "./recovery-state.js";
9
+ import { Readable } from "node:stream";
10
+ import { openToolOutput, toolOutputManifest, toolOutputId } from "./tool-output.js";
11
+ import { nativeToolDetails, toolExitCode } from "./serialize.js";
12
+ import { flushPromptReload, getSystemPromptState, promptReloadStatus, promptUsesFile, queuePromptReload, writeSystemPromptFile } from "./system-prompt-files.js";
13
+ import { parseNativeMcpStatus } from "./native-mcp-presentation.js";
14
+ import { codemodeDetails } from "./codemode-presentation.js";
15
+ import { NativeMcpConfigService } from "./native-mcp-config.js";
16
+ import { conversationSettings, setConversationRunSettings, nativeToolExtensions } from "./native-tools.js";
17
+ import { ProviderAuthService } from "./provider-auth.js";
18
+ import { packageManagerFor, updateTargets, checkComponents, componentRestartRequired, updateComponentPackage } from "./component-updates.js";
19
+ import { toolOutputUpdate } from "./tool-output.js";
20
+ import { ThinkingTimings, ThinkingDurationStore } from "./thinking-timing.js";
21
+ import { deliverPrompt, recallPending } from "./prompt-delivery.js";
22
+ import { validateEditorSnapshots } from "./editor-snapshot.js";
23
+ import { QueryCache } from "./query-cache.js";
24
+ import { DEFAULT_CONV_TITLE, MAX_OPEN_CONVERSATIONS, QuiesceRejectedError, conversationTitle, skillAwareTitleText, } from "./conversation.js";
25
+ import { browseDirs } from "./dir-browser.js";
26
+ import { projectToolTextMessages } from "./tool-text-incidents.js";
27
+ import { unexecutedToolText, TOOL_TEXT_CONTINUE_PROMPT } from "./tool-text.js";
28
+ /** Upper bound of automatic requests per user prompt (each also needs real tool progress). */
29
+ export const TOOL_TEXT_MAX_CONTINUES = 1;
30
+ export { TOOL_TEXT_CONTINUE_PROMPT } from "./tool-text.js";
31
+ import { clientIdleMsFromEnv, isClientEvictable } from "./client-eviction.js";
32
+ import { PiConfigProbe, checkUpdate, installPiAgent, isPiCliInstalled } from "./pi-environment.js";
33
+ export { QuiesceRejectedError };
34
+ /**
35
+ * AgentService — wraps the pi SDK (@earendil-works/pi-coding-agent) for the web
36
+ * frontend. Each browser client (identified by a persistent clientId) gets its
37
+ * own AgentSessionRuntime, but sessions live in the SDK default per-project
38
+ * directory (<agentDir>/sessions/--<cwd>--/) — the same transcript files the
39
+ * pi CLI/TUI use — so every conversation of a folder shows up everywhere.
40
+ *
41
+ * Streaming model: the SDK emits AgentSessionEvents; we forward lightweight
42
+ * `tool_delta` messages for live tool output and schedule throttled full-state
43
+ * snapshots. The frontend is snapshot-driven (server is the source of truth),
44
+ * so reconnects just re-request a snapshot.
45
+ */
46
+ import { existsSync, rmSync, statSync, writeFileSync, watch, } from "node:fs";
47
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
48
+ import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, getAgentDir, ModelRuntime, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
49
+ // pi-coding-agent 自己压缩历史消息(agent-session.js 的 compact())走的也是这两个
50
+ // 调用,不是我们临时拼出来的私活。声明成显式依赖、锁成跟 pi-coding-agent 完全
51
+ // 一致的版本号,保证两边用的是同一份实现。
52
+ import { contentText } from "@earendil-works/pi-ai";
53
+ import { estimateContextParts } from "./context-breakdown.js";
54
+ import { BgServerTracker } from "./bg-servers.js";
55
+ import { SettingsService } from "./settings-service.js";
56
+ import { SlashCommandsService, parseSlash } from "./slash-commands.js";
57
+ import { ModelAdminService } from "./model-admin.js";
58
+ import { FilesService, existingConversationFiles, workspacePath } from "./files-service.js";
59
+ import { ClientStateStore, mergeProjectSummaries, } from "./client-state.js";
60
+ import { saveUpload } from "./uploads.js";
61
+ import { WebUIContext } from "./webui-context.js";
62
+ import { buildAttachmentMessages, } from "./attachments.js";
63
+ import { serializeMessage, contentFingerprint, serializeStreamingMessage, } from "./serialize.js";
64
+ import { deriveTaskProgress } from "./task-progress.js";
65
+ import { taskHistoryFromSession } from "./plan/progress.js";
66
+ import { loadCommands, saveCommandsFile, TerminalManager, } from "./terminals.js";
67
+ const SNAPSHOT_INTERVAL_MS = 60;
68
+ /** While assistant deltas are flowing, live rendering is carried by
69
+ * message_delta — full snapshots become pure reconciliation checkpoints, so
70
+ * send them on a slow event-driven cadence (see flushSnapshot call-sites:
71
+ * agent_settled / tool_execution_end always checkpoint immediately). */
72
+ const STREAMING_SNAPSHOT_INTERVAL_MS = 2000;
73
+ /** Deltas newer than this keep the streaming (low-frequency) snapshot cadence. */
74
+ const DELTA_ACTIVE_WINDOW_MS = 1500;
75
+ const WIDGET_REFRESH_MS = 2000;
76
+ /** Model-stall watchdog: warn (don't abort — deep thinking can be legitimately
77
+ * quiet for minutes) when a streaming run produced NO SDK events for this long.
78
+ * Covers the failure class the per-tool watchdog cannot see: half-open API
79
+ * connections / hung proxies where no tool is running and no error is thrown.
80
+ * Override: PI_WEB_STALL_NOTIFY_MS (milliseconds; 0 disables). */
81
+ const STALL_NOTIFY_MS = (() => {
82
+ const v = Number(process.env.PI_WEB_STALL_NOTIFY_MS);
83
+ return Number.isFinite(v) && v >= 0 ? v : 180_000;
84
+ })();
85
+ /** Serialization-cache headroom per conversation (see serializeCached): the
86
+ * cache holds the current transcript plus this many stale entries (old
87
+ * tool-result revisions, abandoned branches) before evicting the oldest. */
88
+ const UI_MESSAGE_CACHE_CAP = 4096;
89
+ /** Preview panel cap: only the first 512KB of a file is ever read/sent. */
90
+ // ---------------------------------------------------------------------------
91
+ // Preview kind classification. The preview panel only opens image / video /
92
+ // text-editable files; everything else (exe, jar, archives, …) is refused so
93
+ // it is never read or sent to the browser. Media files are served over the
94
+ // /api/file HTTP endpoint instead of the WebSocket, so they are classified
95
+ // here but never read into the snapshot path.
96
+ // ---------------------------------------------------------------------------
97
+ // ---------------------------------------------------------------------------
98
+ // Web UI context adapter — bridges extension UI calls (setWidget/notify) to the
99
+ // browser. Extensions like rpiv-todo render a TUI widget via
100
+ // `ui.setWidget(key, (tui, theme) => comp)`; we capture the component, render it
101
+ // with a mock theme to plain text lines, and push them to the client.
102
+ // ---------------------------------------------------------------------------
103
+ export { workspacePath };
104
+ /**
105
+ * 用当前会话的模型给对话起一个短标题。
106
+ *
107
+ * 注意:这里踩了两块 SDK 的非公开地面,都是有意为之、也都做了兜底——
108
+ * 1. `_getSummarizationRequestAuth` 是 AgentSession 的私有方法(下划线开头,
109
+ * .d.ts 里只 declare、不给类型)。SDK 自己的 compact() 就是靠它解析
110
+ * summarization 请求要用的 { model, apiKey, headers, env },我们复用同一条
111
+ * 路径,省得自己再拼一遍 auth / baseUrl 解析。
112
+ * 2. `@earendil-works/pi-ai/compat` 这个入口,它自己的 .d.ts 里就写着是临时
113
+ * 兼容层,将来会随 ModelManager 迁移一起删掉。
114
+ *
115
+ * 所以整个函数包在 try/catch 里,方法不存在时用 typeof 判掉直接返回 null。任何
116
+ * 一步失败都只是「拿不到 AI 标题」,调用方会保留截断的兜底标题,用户侧看不到任何
117
+ * 报错。将来 SDK 把这两个口子改了,表现就是标题退回截断版本,不会崩。
118
+ */
119
+ export class ClientSession {
120
+ clientId;
121
+ static liveClients = new Map();
122
+ /** Set by AgentService.attach: reflects the SERVICE-wide quiesce flag
123
+ * (server draining — new work rejected). Default false for direct use. */
124
+ isQuiesced = () => false;
125
+ cwd;
126
+ /** pi config dir (auth/models/skills). */
127
+ agentDir;
128
+ /** Persisted per-client UI state (last workspace + recent projects). */
129
+ stateStore;
130
+ thinkingDurationStore;
131
+ /** Open conversations — each owns its OWN runtime, so starting a new chat
132
+ * or switching chats never interrupts an in-flight run. `runtime` and
133
+ * `session` accessors below target the ACTIVE conversation. */
134
+ convs = new Map();
135
+ activeId = "";
136
+ convSeq = 0;
137
+ creatingConversation = false;
138
+ /** One ModelRuntime shared by all conversations — the model chosen in the
139
+ * top bar applies to every chat, not just the one that set it. Seeded by
140
+ * the first conversation and reused by later ones. */
141
+ sharedModelRuntime;
142
+ // -----------------------------------------------------------------------
143
+ // -----------------------------------------------------------------------
144
+ settingsSvc; // 构造函数里创建(需要 clientId/stateStore)
145
+ /** How long a hard abort waits for session.abort() to make the run idle
146
+ * before force-resetting the conversation (model streams that ignore the
147
+ * abort signal would otherwise leave the chat stuck forever). */
148
+ static HARD_ABORT_TIMEOUT_MS = 15_000;
149
+ /** Extra settle window after session.abort() returns: the run is only
150
+ * considered stopped once its agent_settled event arrives. If it doesn't
151
+ * (model stream stuck before the run even started), force-reset. */
152
+ static HARD_ABORT_SETTLE_MS = 8_000;
153
+ /** Live AbortControllers of THIS client's running bash tool calls — aborting
154
+ * them kills only the command (agent run and conversation continue). */
155
+ /** Background-server tracking (port snapshots + 后台任务 panel state) —
156
+ * 自包含模块,见 bg-servers.ts。列表按 CLIENT 存活,不随对话切换/结束消失。 */
157
+ /** 文件树 / 预览读写 / SCM 查询 / watcher —— 自包含模块,见 files-service.ts。 */
158
+ files = new FilesService({
159
+ emit: (msg) => this.emit(msg),
160
+ isDisposed: () => this.disposed,
161
+ getCwd: () => this.cwd,
162
+ getActiveCwd: () => this.conv?.cwd ?? this.cwd,
163
+ });
164
+ bg = new BgServerTracker({
165
+ emit: (msg) => this.emit(msg),
166
+ flushSnapshot: () => this.flushSnapshot(),
167
+ isDisposed: () => this.disposed,
168
+ // 插件注册的常驻任务(host.registerBackgroundTask)并入同一「后台任务」面板。
169
+ pluginTasks: () => this.pluginBgTasksProvider?.() ?? [],
170
+ });
171
+ /** index.ts 注入(经 AgentService 拷贝到每个新会话):把 SDK 工具执行事件转发给
172
+ * 插件(PluginManager.emitToolEvent)。未设置时不做任何事。 */
173
+ onToolEvent = undefined;
174
+ /** index.ts 注入:读取插件当前注册的 AI 工具(attach 时拷贝到每个新会话)。 */
175
+ /** index.ts 注入:读取插件当前注册的斜杠命令(目录展示 + prompt 拦截执行)。 */
176
+ /** index.ts 注入:读取插件注册的常驻后台任务(并入 bg_servers 面板)。 */
177
+ pluginBgTasksProvider = undefined;
178
+ /** index.ts 注入:停止插件任务(kill_background_server with taskId)。 */
179
+ pluginStopBgTask = undefined;
180
+ /** 上一轮注入会话的插件工具名集合(用于检测注销/移除)。 */
181
+ /** The active conversation (all session operations target it). */
182
+ get conv() {
183
+ const conv = this.convs.get(this.activeId);
184
+ if (!conv)
185
+ throw new Error("no active conversation");
186
+ return conv;
187
+ }
188
+ /** Runtime of the active conversation. */
189
+ get runtime() {
190
+ return this.conv.runtime;
191
+ }
192
+ /** Session of the active conversation. */
193
+ get conversationId() { return this.activeId; }
194
+ get session() {
195
+ return this.conv.session;
196
+ }
197
+ /** PTYs are owned by individual conversations; this getter targets the active one
198
+ * for compatibility with the existing terminal-panel dispatch path. */
199
+ get terminals() {
200
+ return this.conv.terminals;
201
+ }
202
+ getTerminalManager(conversationId) {
203
+ return (conversationId ? this.convs.get(conversationId) : this.conv)?.terminals;
204
+ }
205
+ getTerminalCwd(conversationId) {
206
+ return (conversationId ? this.convs.get(conversationId) : this.conv)?.cwd ?? this.cwd;
207
+ }
208
+ makeTerminalManager(conversationId, cwd) {
209
+ const mgr = new TerminalManager((msg) => this.emitTerminal(conversationId, msg), cwd);
210
+ return mgr;
211
+ }
212
+ emitTerminal(conversationId, msg) {
213
+ // Background conversations keep collecting output in their own PTY buffer.
214
+ // Do not stream it into the active xterm; push the retained window on switch.
215
+ if (msg.type === "terminal_output" && conversationId !== this.activeId)
216
+ return;
217
+ if (msg.type === "terminal_output" || msg.type === "terminal_exit" || msg.type === "terminal_list") {
218
+ this.emit({ ...msg, conversationId });
219
+ return;
220
+ }
221
+ this.emit(msg);
222
+ }
223
+ pushTerminals(conversation = this.conv) {
224
+ this.emit({
225
+ type: "terminal_list",
226
+ conversationId: conversation.id,
227
+ terminals: conversation.terminals.list(),
228
+ });
229
+ for (const output of conversation.terminals.replay()) {
230
+ this.emit({
231
+ type: "terminal_output",
232
+ conversationId: conversation.id,
233
+ terminalId: output.terminalId,
234
+ data: output.data,
235
+ });
236
+ }
237
+ }
238
+ async systemPromptState() {
239
+ const session = this.session, cwd = this.cwd;
240
+ await flushPromptReload(session);
241
+ return getSystemPromptState(session, cwd, this.agentDir);
242
+ }
243
+ async savePromptFile(id, version, content, restore) {
244
+ const path = writeSystemPromptFile(this.session, this.cwd, this.agentDir, id, version, content, restore);
245
+ for (const client of ClientSession.liveClients.values())
246
+ for (const conv of client.convs.values()) {
247
+ if (!promptUsesFile(conv.session, conv.cwd, client.agentDir, path))
248
+ continue;
249
+ await queuePromptReload(conv.session, () => { if (!client.disposed && client.activeId === conv.id) {
250
+ client.pushSettings();
251
+ void client.pushSlashCommands();
252
+ client.flushSnapshot();
253
+ } });
254
+ }
255
+ }
256
+ async retryPromptReload() { await flushPromptReload(this.session, true); }
257
+ /** The FULL system prompt actually in effect right now (AgentSession getter,
258
+ * includes native sections such as
259
+ * project context, skills and tool guidance). Read-only view source for
260
+ * the settings panel. */
261
+ effectiveSystemPrompt() {
262
+ try {
263
+ const sp = this.session.systemPrompt;
264
+ return typeof sp === "string" ? sp : "";
265
+ }
266
+ catch {
267
+ // Session not ready yet.
268
+ return "";
269
+ }
270
+ }
271
+ pendingMcpReload = new Set();
272
+ mcpStatus = new WeakMap();
273
+ async reloadMcpConversation(conv) {
274
+ const trust = new NativeMcpConfigService().isTrusted(conv.cwd);
275
+ conv.session.settingsManager.setProjectTrusted(trust);
276
+ await reloadSession(conv.session);
277
+ if (conv.id === this.activeId)
278
+ await this.pushSlashCommands();
279
+ }
280
+ async nativeMcpRequest(msg) {
281
+ const config = new NativeMcpConfigService();
282
+ const requestSession = this.session;
283
+ try {
284
+ if (msg.cwd !== this.cwd || this.switchingWorkspace)
285
+ throw new Error("Workspace unavailable");
286
+ if (this.isQuiesced() && msg.action !== "get")
287
+ throw new Error("Service draining");
288
+ if (msg.action === "codemode")
289
+ config.saveCodemode(this.cwd, msg.scope, msg.version ?? "", msg.codemode);
290
+ if (msg.action === "save")
291
+ config.save(this.cwd, msg.scope, msg.version ?? "", msg.document ?? {});
292
+ if (msg.action === "trust")
293
+ config.trust(this.cwd);
294
+ if (msg.action === "radius") {
295
+ if (!this.nativeModelRuntime.hasConfiguredAuth("radius"))
296
+ throw new Error("Sign in to Radius first");
297
+ const state = config.get(this.cwd, "global");
298
+ const servers = (state.document.mcpServers ?? {});
299
+ const existing = Object.entries(servers).find(([, entry]) => typeof entry.url === "string" && entry.url.replace(/\/+$/, "") === "https://radius.pi.dev/mcp");
300
+ let name = existing?.[0] ?? "radius", index = 1;
301
+ if (!existing)
302
+ while (Object.hasOwn(servers, name))
303
+ name = `radius-${index++}`;
304
+ const entry = { ...(existing?.[1] ?? {}), url: "https://radius.pi.dev/mcp", auth: { provider: "radius" } };
305
+ delete entry.oauth;
306
+ config.save(this.cwd, "global", state.version, { ...state.document, mcpServers: { ...servers, [name]: entry } });
307
+ this.emit({ type: "notice", level: "info", text: `Radius MCP 已配置:${name}` });
308
+ }
309
+ if (msg.action === "command") {
310
+ const runner = this.session.extensionRunner;
311
+ const command = runner?.getCommand("mcp");
312
+ if (!command || !/^(builtin:mcp|<inline:mcp>)$/.test(command.sourceInfo.path))
313
+ throw new Error("Official MCP command unavailable or replaced");
314
+ if (this.session.isStreaming)
315
+ throw new Error("Wait for the current task before managing connections");
316
+ if (msg.name && !/^[\w.-]+$/.test(msg.name))
317
+ throw new Error("Invalid server name");
318
+ await command.handler(msg.command === "status" ? "" : `${msg.command ?? ""} ${msg.name ?? ""}`, runner.createCommandContext());
319
+ }
320
+ if (["save", "trust", "radius", "codemode"].includes(msg.action)) {
321
+ for (const cs of ClientSession.liveClients.values()) {
322
+ for (const conv of cs.convs.values()) {
323
+ if (msg.scope === "project" && msg.action !== "radius" && conv.cwd !== msg.cwd)
324
+ continue;
325
+ if (conv.session.isStreaming)
326
+ cs.pendingMcpReload.add(conv.id);
327
+ else
328
+ await cs.reloadMcpConversation(conv);
329
+ }
330
+ }
331
+ }
332
+ if (this.cwd !== msg.cwd || this.session !== requestSession || this.switchingWorkspace)
333
+ throw new Error("Workspace or conversation changed");
334
+ const session = requestSession;
335
+ const runner = session.extensionRunner;
336
+ const command = runner?.getCommand("mcp");
337
+ let cached = this.mcpStatus.get(session);
338
+ if (!cached) {
339
+ cached = { text: "", pending: false };
340
+ this.mcpStatus.set(session, cached);
341
+ }
342
+ if (command && /^(builtin:mcp|<inline:mcp>)$/.test(command.sourceInfo.path) && !cached.pending) {
343
+ // Official /mcp waits for startup connections. Never block the settings reply on it.
344
+ const context = runner.createCommandContext(), target = cached;
345
+ target.pending = true;
346
+ void Promise.resolve(command.handler("", { ...context, mode: "rpc", ui: { ...context.ui, notify: (text) => { target.text = text; } } }))
347
+ .catch(error => { target.text = String(error); }).finally(() => { target.pending = false; });
348
+ }
349
+ const statusText = cached.text;
350
+ const tools = session.getAllTools().filter(tool => tool.name.startsWith("mcp__"));
351
+ this.emit({ type: "native_mcp_result", requestId: msg.requestId, cwd: msg.cwd, state: config.get(msg.cwd, msg.scope),
352
+ pending: [...ClientSession.liveClients.values()].some(cs => cs.pendingMcpReload.size > 0), tools: tools.map(tool => tool.name),
353
+ toolInfo: tools.map(tool => ({ name: tool.name, exposure: tool.exposure, description: tool.description.slice(0, 2000), readOnly: tool.annotations?.readOnlyHint, destructive: tool.annotations?.destructiveHint })),
354
+ servers: parseNativeMcpStatus(statusText), statusText,
355
+ codemode: config.codemode(msg.cwd, msg.scope, session.settingsManager.getSettings().codemode),
356
+ ...(msg.action === "log" ? { log: config.log() } : {}),
357
+ });
358
+ }
359
+ catch (error) {
360
+ this.emit({ type: "native_mcp_result", requestId: msg.requestId, cwd: msg.cwd, error: error.message });
361
+ }
362
+ }
363
+ coordinatePlan(conv) {
364
+ const tree = conv.tree?.refresh();
365
+ planSettings().coordinate(conv.session, !tree?.externallyModified && !tree?.verifying && !tree?.busy);
366
+ }
367
+ async setPlanEnabled(enabled, conversationId) {
368
+ if (typeof enabled !== "boolean" || this.activeId !== conversationId || this.switchingWorkspace)
369
+ throw new Error("Conversation changed");
370
+ if (this.quiesceBlocked())
371
+ return;
372
+ planSettings().set(enabled);
373
+ for (const client of ClientSession.liveClients.values()) {
374
+ for (const conv of client.convs.values())
375
+ client.coordinatePlan(conv);
376
+ client.flushSnapshot();
377
+ }
378
+ }
379
+ get nativeModelRuntime() { return this.runtime.services.modelRuntime; }
380
+ providerAuth = new ProviderAuthService(() => this.runtime.services.modelRuntime, message => this.emit(message), async () => { this.piConfig.invalidate(); await this.modelAdmin.listProviders(); await this.listModels(); this.flushSnapshot(); }, () => this.session.settingsManager.getOrCreateDeviceId());
381
+ get webUi() { return this.conv.webUi; }
382
+ widgetsTimer = null;
383
+ /** Model-stall watchdog interval (see startStallTimer). */
384
+ stallTimer = null;
385
+ /** Connected sockets for this client (multiple tabs share the session). */
386
+ sinks = new Set();
387
+ pendingNotices = [];
388
+ snapshotTimer = null;
389
+ /** Timestamp of the most recent message_delta push — while fresh, snapshots
390
+ * use the slower STREAMING_SNAPSHOT_INTERVAL_MS cadence. */
391
+ lastDeltaAt = 0;
392
+ sessionsTimer = null;
393
+ version = 0;
394
+ /** Snapshot revision counter (see emitSnapshotNow / protocol snapshot_delta). */
395
+ snapRev = 0;
396
+ /** Messages array as of the last emitted snapshot/delta — identity-walked
397
+ * against the current array to detect append-only growth. */
398
+ emittedMessages = null;
399
+ /** Conversation whose messages emittedMessages belongs to. A conversation
400
+ * switch (set_cwd / new_chat / switch_*) must fall back to a FULL snapshot:
401
+ * two empty conversations have identical (empty) arrays, so the identity
402
+ * walk alone would misread the switch as "nothing changed" → delta. */
403
+ emittedConvId = null;
404
+ /** snapRev value at which emittedMessages was captured. */
405
+ emittedRev = 0;
406
+ /**
407
+ * Per-conversation serialization caches (stable message ids, UiMessage
408
+ * object cache, message-array signature, queue counts) live inside each
409
+ * Conversation — see Conversation above.
410
+ */
411
+ disposed = false;
412
+ /** pi-config readiness check, cached briefly so 60ms snapshots don't hit disk. */
413
+ piConfig; // 构造函数里创建(需要 agentDir)
414
+ /** fs.watch on the currently-listed directory — file changes push an instant
415
+ * refresh (`file_changed`) so the tree updates without waiting for the 10s
416
+ * poll. Only the listed directory is watched (one level); navigating
417
+ * re-watches the new target. fs.watch isn't available on every platform /
418
+ * filesystem — failures silently fall back to the poll. */
419
+ fsWatcher = null;
420
+ watchPath = null;
421
+ /** fs.watch on the active repo's git dir — external changes (CLI commit,
422
+ * IDE branch switch) push `scm_changed` so the panel refreshes itself.
423
+ * One watcher per client session, re-targeted when the queried cwd
424
+ * changes; failures (bare repo, unsupported fs) silently disable it. */
425
+ gitWatcher = null;
426
+ gitWatchCwd = null;
427
+ gitDirtyTimer = null;
428
+ watchTimer = null;
429
+ constructor(clientId, cwd, agentDir, stateStore, thinkingDurationStore) {
430
+ this.clientId = clientId;
431
+ ClientSession.liveClients.set(clientId, this);
432
+ this.cwd = cwd;
433
+ this.agentDir = agentDir;
434
+ this.piConfig = new PiConfigProbe(agentDir);
435
+ this.stateStore = stateStore;
436
+ this.thinkingDurationStore = thinkingDurationStore;
437
+ this.settingsSvc = new SettingsService({
438
+ clientId,
439
+ stateStore,
440
+ emit: (msg) => this.emit(msg),
441
+ flushSnapshot: () => this.flushSnapshot(),
442
+ isDisposed: () => this.disposed,
443
+ getSession: () => this.session,
444
+ cwd: () => this.cwd,
445
+ agentDir: () => this.agentDir,
446
+ isStreaming: () => this.session.isStreaming,
447
+ reloadSession: async () => {
448
+ const session = this.session;
449
+ await reloadSession(session);
450
+ // reload() 会把 custom 工具重新加回活跃集——重放终端开关。
451
+ if (this.session === session)
452
+ await this.pushSlashCommands();
453
+ },
454
+ effectiveSystemPrompt: () => this.effectiveSystemPrompt(),
455
+ });
456
+ this.modelAdmin = new ModelAdminService({
457
+ agentDir,
458
+ emit: (msg) => this.emit(msg),
459
+ flushSnapshot: () => this.flushSnapshot(),
460
+ isDisposed: () => this.disposed,
461
+ modelRuntime: () => this.runtime.services.modelRuntime,
462
+ invalidatePiConfig: () => {
463
+ this.piConfig.invalidate();
464
+ },
465
+ pushModels: async () => this.listModels(),
466
+ });
467
+ // Prune dead background tasks every 30s (only spawns netstat/lsof while
468
+ // the list is non-empty). unref: must not keep the process alive.
469
+ this.bg.start();
470
+ }
471
+ static async create(clientId, cwd, stateStore, thinkingDurationStore) {
472
+ const agentDir = process.env.PI_CODING_AGENT_DIR ?? getAgentDir();
473
+ const cs = new ClientSession(clientId, cwd, agentDir, stateStore, thinkingDurationStore);
474
+ const conversationId = cs.nextConversationId();
475
+ const terminals = cs.makeTerminalManager(conversationId, cwd);
476
+ const runtime = await createAgentSessionRuntime(cs.makeRuntimeFactory(), {
477
+ cwd,
478
+ agentDir,
479
+ // Resume the most recent session for this project — the SDK default
480
+ // per-project dir (<agentDir>/sessions/--<cwd>--/, shared with the
481
+ // pi CLI/TUI) — or start a fresh one on first visit.
482
+ sessionManager: SessionManager.continueRecent(cwd),
483
+ });
484
+ // First conversation = the resumed session; it also seeds the shared
485
+ // ModelRuntime that every later conversation reuses.
486
+ cs.sharedModelRuntime = runtime.services.modelRuntime;
487
+ const conv = cs.makeConversation(runtime, conversationId, terminals);
488
+ cs.convs.set(conv.id, conv);
489
+ cs.activeId = conv.id;
490
+ for (const d of runtime.diagnostics) {
491
+ if (d.type !== "info") {
492
+ cs.pendingNotices.push({
493
+ type: "notice",
494
+ level: d.type,
495
+ text: d.message,
496
+ });
497
+ }
498
+ }
499
+ await cs.bindSession();
500
+ return cs;
501
+ }
502
+ /**
503
+ * Factory for cwd-bound runtimes. All conversations share ONE ModelRuntime
504
+ * (the model choice is client-wide), so later conversations reuse the
505
+ * instance created with the first one.
506
+ */
507
+ makeRuntimeFactory() {
508
+ return async ({ cwd, sessionManager }) => {
509
+ const services = await createAgentSessionServices({
510
+ cwd,
511
+ agentDir: this.agentDir,
512
+ modelRuntime: this.sharedModelRuntime,
513
+ settingsManager: conversationSettings(cwd, this.agentDir),
514
+ resourceLoaderOptions: { extensionFactories: nativeToolExtensions() },
515
+ });
516
+ const created = await createAgentSessionFromServices({ services, sessionManager });
517
+ return { ...created, services, diagnostics: services.diagnostics };
518
+ };
519
+ }
520
+ /** Allocate a stable conversation id before constructing its runtime/tools. */
521
+ nextConversationId() {
522
+ return `c${++this.convSeq}`;
523
+ }
524
+ /** Wrap a fresh runtime as a new conversation record. */
525
+ makeConversation(runtime, id, terminals) {
526
+ return {
527
+ id,
528
+ recovery: {},
529
+ webUi: new WebUIContext(msg => this.emit(msg), id, () => `${runtime.cwd} · ${this.convs.get(id)?.title ?? conversationTitle(runtime.session)}`),
530
+ title: runtime.session.sessionManager.getSessionName() || conversationTitle(runtime.session),
531
+ runtime,
532
+ session: runtime.session,
533
+ cwd: runtime.cwd,
534
+ createdAt: Date.now(),
535
+ // A brand-new conversation is not yet in the running list — it enters
536
+ // only when it is displaced to the background while still streaming.
537
+ listed: false,
538
+ promptedSinceActive: false,
539
+ lastActiveAt: Date.now(),
540
+ lastSdkEventAt: Date.now(),
541
+ lastTaskEndedAt: undefined,
542
+ stallNoticed: false,
543
+ runningToolNames: new Map(),
544
+ toolsExecutedSincePrompt: false,
545
+ toolTextContinues: 0,
546
+ toolRanSinceContinue: false,
547
+ deltaSeq: 0,
548
+ thinkingTimings: new ThinkingTimings(),
549
+ terminals,
550
+ msgIds: new Map(),
551
+ nextMsgId: 1,
552
+ userSeqByTs: new Map(),
553
+ userSeqByKey: new Map(),
554
+ uiMessageCache: new Map(),
555
+ lastMessagesSig: "",
556
+ lastMessagesArray: [],
557
+ queueSteering: [],
558
+ queueFollowUp: [],
559
+ toolStartTimes: new Map(),
560
+ };
561
+ }
562
+ /** Summaries of conversations currently streaming — captured at shutdown
563
+ * so the next attach can tell the user their run was interrupted. */
564
+ streamingSummaries() {
565
+ const out = [];
566
+ for (const conv of this.convs.values()) {
567
+ if (conv.session.isStreaming)
568
+ out.push({ title: conv.title, cwd: conv.cwd });
569
+ }
570
+ return out;
571
+ }
572
+ /** Tell the user about runs lost to the last server restart (once). */
573
+ notifyInterrupted(list) {
574
+ if (!list || list.length === 0)
575
+ return;
576
+ const names = list
577
+ .map((r) => `「${r.title}」(${r.cwd})`)
578
+ .join("、");
579
+ this.pendingNotices.push({
580
+ type: "notice",
581
+ level: "warning",
582
+ text: `上次服务重启时有 ${list.length} 个进行中的对话被中断:${names}。可在历史对话中恢复继续。`,
583
+ });
584
+ }
585
+ /** Add a socket to this client's broadcast set; flushes buffered startup notices. */
586
+ attachSink(send) {
587
+ this.sinks.add(send);
588
+ this.idleSince = 0;
589
+ for (const result of this.recalledQueues.values())
590
+ send(result);
591
+ this.providerAuth.replay();
592
+ for (const conv of this.convs.values()) {
593
+ conv.webUi.replayDialogs(send);
594
+ conv.webUi.replayTitle(send);
595
+ send({ type: "widgets", conversationId: conv.id, widgets: conv.webUi.snapshot() });
596
+ send({ type: "statuses", conversationId: conv.id, statuses: conv.webUi.statusSnapshot() });
597
+ }
598
+ for (const conv of this.convs.values())
599
+ if (conv.stallNoticed && conv.session.isStreaming)
600
+ send({ type: "agent_silence", conversationId: conv.id, phase: "silent", since: conv.lastSdkEventAt, activity: conv.runningToolNames.size ? "tool" : "model" });
601
+ for (const msg of this.pendingNotices)
602
+ send(msg);
603
+ this.pendingNotices = [];
604
+ // Replay current extension widgets (setWidget may have fired during
605
+ // session creation, before any socket was attached).
606
+ // Reconnect: push the current project's running-conversation list so the
607
+ // left panel shows every background chat (a fresh socket never got the
608
+ // newChat/switch pushes).
609
+ this.emitConversations();
610
+ // Reconnect: same for the slash-command catalog (the picker needs it even
611
+ // before the client asks).
612
+ void this.pushSlashCommands();
613
+ // Reconnect: push the remembered goal prefs (model choice, rounds cap,
614
+ // locked) so the goal bar restores them on reload — "全局记忆".
615
+ // Reconnect: push the settings panel state (prompt text/mode, skill &
616
+ // extension toggles, saved presets).
617
+ this.pushSettings();
618
+ // Reconnect: push the background-task list — it must survive reconnects
619
+ // and outlive the conversation that started the tasks.
620
+ this.bg.push();
621
+ // PTYs are conversation-owned and survive a socket reconnect.
622
+ this.pushTerminals();
623
+ }
624
+ detachSink(send) {
625
+ this.sinks.delete(send);
626
+ // PTYs intentionally survive a socket drop: they are owned by the
627
+ // conversation and can be inspected after reconnecting. Only conversation
628
+ // disposal or server shutdown kills them.
629
+ if (this.sinks.size === 0) {
630
+ this.idleSince = Date.now();
631
+ this.files.unwatchDir();
632
+ }
633
+ }
634
+ /** ms epoch since the last socket detached (0 while attached). */
635
+ idleSince = Date.now();
636
+ /** True when nothing is attached or running, so the session can be rebuilt on demand. */
637
+ isEvictable(now, idleMs) {
638
+ return isClientEvictable({
639
+ socketCount: this.sinks.size,
640
+ idleSince: this.idleSince,
641
+ streamingConversations: this.activeConversations(),
642
+ queuedMessages: this.pendingMessages(),
643
+ liveTerminals: [...this.convs.values()].reduce((n, conv) => n + conv.terminals.list().length, 0),
644
+ backgroundTasks: this.bg.hasServers() ? 1 : 0,
645
+ switchingWorkspace: this.cwdSwitchRunning,
646
+ disposed: this.disposed,
647
+ }, now, idleMs);
648
+ }
649
+ /** Broadcast to every connected socket of this client. */
650
+ emit(msg) {
651
+ if (this.disposed)
652
+ return;
653
+ for (const sink of [...this.sinks])
654
+ sink(msg);
655
+ }
656
+ /** (Re)attach event plumbing to the ACTIVE conversation's session. */
657
+ boundUiSessions = new WeakSet();
658
+ async bindSession(conv = this.conv) {
659
+ if (this.disposed || this.convs.get(conv.id) !== conv)
660
+ return;
661
+ conv.unsubscribe?.();
662
+ if (conv.session !== conv.runtime.session) {
663
+ conv.webUi.dispose();
664
+ conv.webUi = new WebUIContext(msg => this.emit(msg), conv.id, () => `${conv.cwd} · ${conv.title}`);
665
+ }
666
+ conv.session = conv.runtime.session;
667
+ conv.tree ??= new SessionTreeController({
668
+ conversationId: conv.id, runtime: () => conv.runtime,
669
+ active: () => this.activeId === conv.id && this.convs.get(conv.id) === conv,
670
+ admitted: () => !this.quiesceBlocked(),
671
+ emit: message => this.emit(message),
672
+ changed: () => { this.coordinatePlan(conv); this.scheduleSnapshot(); this.scheduleSessionsRefresh(); },
673
+ replaced: async () => { conv.treeProjectionRevision = undefined; conv.uiMessageCache.clear(); conv.title = conv.runtime.session.sessionManager.getSessionName() || conversationTitle(conv.runtime.session); await this.bindSession(conv); this.invalidateLists(); this.flushSnapshot(true); },
674
+ summary: operation => { conv.recovery = { ...conv.recovery, branch: operation, summary: !operation && conv.recovery.summary?.source === "branchSummary" ? undefined : conv.recovery.summary }; this.flushSnapshot(); },
675
+ });
676
+ conv.tree.bindFile();
677
+ if (!this.boundUiSessions.has(conv.session)) {
678
+ this.boundUiSessions.add(conv.session);
679
+ const session = conv.session;
680
+ const reload = session.reload.bind(session);
681
+ session.reload = async (options) => {
682
+ conv.webUi.dispose();
683
+ conv.webUi = new WebUIContext(msg => this.emit(msg), conv.id, () => `${conv.cwd} · ${conv.title}`);
684
+ await reload({ ...options, beforeSessionStart: async () => {
685
+ session.extensionRunner.setUIContext(conv.webUi, "rpc");
686
+ await options?.beforeSessionStart?.();
687
+ } });
688
+ this.coordinatePlan(conv);
689
+ };
690
+ }
691
+ const runtime = conv.runtime;
692
+ const session = conv.session;
693
+ await session.bindExtensions({
694
+ mode: "rpc",
695
+ uiContext: conv.webUi,
696
+ onError: (err) => {
697
+ this.emit({ type: "notice", conversationId: conv.id, level: "error", text: err.error });
698
+ },
699
+ });
700
+ if (this.disposed || this.convs.get(conv.id) !== conv || conv.session !== session) {
701
+ // An async session_start may finish after the first shutdown.
702
+ // Dispose the captured runtime again to release those late resources.
703
+ await runtime.dispose();
704
+ return;
705
+ }
706
+ this.coordinatePlan(conv);
707
+ const unsubscribe = conv.session.subscribe((event) => this.onEvent(conv, event));
708
+ conv.unsubscribe = unsubscribe;
709
+ this.scheduleSnapshot();
710
+ conv.webUi.refresh();
711
+ this.startWidgetsTimer();
712
+ this.startStallTimer();
713
+ }
714
+ /** Poll extension widgets so TUI-only overlays (e.g. rpiv-todo) stay live. */
715
+ startWidgetsTimer() {
716
+ if (this.disposed || this.widgetsTimer)
717
+ return;
718
+ this.widgetsTimer = setInterval(() => {
719
+ if (!this.disposed)
720
+ for (const conv of this.convs.values())
721
+ conv.webUi.refresh();
722
+ }, WIDGET_REFRESH_MS);
723
+ }
724
+ /** Report a quiet model or tool as conversation-scoped state. The WebSocket
725
+ * heartbeat is separate: a live socket does not imply a live model request. */
726
+ startStallTimer() {
727
+ if (this.disposed || this.stallTimer || STALL_NOTIFY_MS === 0)
728
+ return;
729
+ this.stallTimer = setInterval(() => {
730
+ if (this.disposed)
731
+ return;
732
+ const now = Date.now();
733
+ for (const conv of this.convs.values()) {
734
+ if (!isRecovering(conv.recovery) &&
735
+ !conv.stallNoticed &&
736
+ conv.session.isStreaming &&
737
+ now - conv.lastSdkEventAt > STALL_NOTIFY_MS) {
738
+ conv.stallNoticed = true;
739
+ this.emit({ type: "agent_silence", conversationId: conv.id, phase: "silent", since: conv.lastSdkEventAt, activity: conv.runningToolNames.size ? "tool" : "model" });
740
+ }
741
+ }
742
+ }, 30_000);
743
+ }
744
+ /** Cancel a tool's watchdog — called when the tool finishes normally. */
745
+ /** Cancel every watchdog of a conversation (removeConversation / dispose). */
746
+ onEvent(conv, event) {
747
+ const contextTokens = event.type === "compaction_start" ? conv.session.getContextUsage()?.tokens ?? undefined : event.type === "compaction_end" && event.result ? conv.session.messages.reduce((total, message) => total + estimateTokens(message), 0) : undefined;
748
+ conv.recovery = recoveryEvent(conv.recovery, event, Date.now(), contextTokens);
749
+ if (event.type === "agent_settled" || event.type === "message_end")
750
+ conv.tree?.refresh();
751
+ // Any SDK event proves the run is alive — feeds the stall watchdog below.
752
+ if (conv.stallNoticed)
753
+ this.emit({ type: "agent_silence", conversationId: conv.id, phase: "active", since: Date.now(), activity: conv.runningToolNames.size ? "tool" : "model" });
754
+ conv.lastSdkEventAt = Date.now();
755
+ conv.stallNoticed = false;
756
+ switch (event.type) {
757
+ case "bash_execution_update": {
758
+ if (event.id) {
759
+ this.emit({
760
+ type: "tool_delta",
761
+ conversationId: conv.id,
762
+ seq: ++conv.deltaSeq,
763
+ toolCallId: event.id,
764
+ toolName: "bash",
765
+ delta: event.delta,
766
+ });
767
+ }
768
+ break;
769
+ }
770
+ case "tool_execution_start": {
771
+ if (event.parentToolCallId)
772
+ this.emit({
773
+ type: "tool_status", conversationId: conv.id, parentToolCallId: event.parentToolCallId,
774
+ toolCallId: event.toolCallId, toolName: event.toolName, isError: false, running: true,
775
+ argumentsText: JSON.stringify(event.args)?.slice(0, 20_000),
776
+ });
777
+ conv.runningToolNames.set(event.toolCallId, event.toolName);
778
+ conv.toolsExecutedSincePrompt = true;
779
+ conv.toolRanSinceContinue = true;
780
+ // Record the moment the tool actually starts so tool_status can
781
+ // report real execution time (vs. time spent waiting on the model).
782
+ conv.toolStartTimes.set(event.toolCallId, Date.now());
783
+ // Snapshot listeners before a bash run — the post-run diff catches
784
+ // servers the agent started in the background.
785
+ if (event.toolName === "bash") {
786
+ this.bg.snapshotBefore();
787
+ }
788
+ // 插件扩展点:工具开始执行(异常由 emitToolEvent 隔离)。
789
+ this.onToolEvent?.({ phase: "start", toolName: event.toolName, conversationId: conv.id });
790
+ break;
791
+ }
792
+ case "tool_execution_end": {
793
+ conv.runningToolNames.delete(event.toolCallId);
794
+ const startedAt = conv.toolStartTimes.get(event.toolCallId);
795
+ conv.toolStartTimes.delete(event.toolCallId);
796
+ // Bash finished — wait briefly for background servers to bind their
797
+ // ports, then diff against the pre-run snapshot and record them.
798
+ if (event.toolName === "bash")
799
+ void this.bg.trackAfterBash();
800
+ const durationMs = startedAt !== undefined ? Date.now() - startedAt : undefined;
801
+ // 插件扩展点:工具结束执行(带耗时与错误标志)。
802
+ this.onToolEvent?.({
803
+ phase: "end",
804
+ toolName: event.toolName,
805
+ conversationId: conv.id,
806
+ ...(durationMs !== undefined ? { durationMs } : {}),
807
+ isError: event.isError,
808
+ });
809
+ const exitCode = toolExitCode(event.result, event.isError);
810
+ this.emit({
811
+ type: "tool_status",
812
+ conversationId: conv.id,
813
+ running: false,
814
+ parentToolCallId: event.parentToolCallId,
815
+ toolCallId: event.toolCallId,
816
+ toolName: event.toolName,
817
+ isError: event.isError,
818
+ exitCode,
819
+ durationMs,
820
+ });
821
+ break;
822
+ }
823
+ case "tool_execution_update": {
824
+ const update = toolOutputUpdate(event.partialResult);
825
+ const codemode = event.toolName === "codemode" ? codemodeDetails(event.partialResult?.details) : undefined;
826
+ if (update || codemode) {
827
+ this.emit({
828
+ type: "tool_delta",
829
+ parentToolCallId: event.parentToolCallId,
830
+ conversationId: conv.id,
831
+ seq: ++conv.deltaSeq,
832
+ toolCallId: event.toolCallId,
833
+ toolName: event.toolName,
834
+ delta: "",
835
+ ...update,
836
+ codemode,
837
+ });
838
+ }
839
+ break;
840
+ }
841
+ case "queue_update":
842
+ conv.queueSteering = event.steering.map(skillAwareTitleText).map(text => text.length > 2000 ? text.slice(0, 2000) + "…" : text);
843
+ conv.queueFollowUp = event.followUp.map(skillAwareTitleText).map(text => text.length > 2000 ? text.slice(0, 2000) + "…" : text);
844
+ break;
845
+ // A run finished or a new entry was persisted — keep the session list fresh
846
+ // (new chat + first message, completed turns, compaction, etc.).
847
+ case "agent_settled": {
848
+ this.coordinatePlan(conv);
849
+ if (this.pendingMcpReload.delete(conv.id))
850
+ void this.reloadMcpConversation(conv).catch(error => this.emit({ type: "notice", level: "error", text: `MCP reload failed: ${error.message}` }));
851
+ conv.lastTaskEndedAt = Date.now();
852
+ this.scheduleSessionsRefresh();
853
+ void this.continueAfterToolText(conv);
854
+ // Deferred settings reload: settings (system prompt / skills /
855
+ // extensions) changed while the run was streaming — applying now
856
+ // would have torn down the in-flight run.
857
+ if (this.settingsSvc.hasPendingReload() && !this.disposed) {
858
+ this.settingsSvc.consumePendingReload();
859
+ void this.applySettingsReload();
860
+ }
861
+ break;
862
+ }
863
+ case "entry_appended":
864
+ this.scheduleSessionsRefresh();
865
+ break;
866
+ case "message_end": {
867
+ if (event.message.role === "assistant") {
868
+ conv.thinkingTimings.finish(event.message.timestamp);
869
+ this.thinkingDurationStore.save(conv.session.sessionFile, event.message.timestamp, conv.thinkingTimings.finishedDurations(event.message.timestamp));
870
+ }
871
+ break;
872
+ }
873
+ case "message_update": {
874
+ conv.thinkingTimings.observe(event.message.timestamp, event.assistantMessageEvent);
875
+ // Live assistant-message increment, deliberately OUTSIDE the snapshot
876
+ // channel: send() drops snapshots under backpressure (big sessions),
877
+ // but this small message must always get through or the UI freezes on
878
+ // stale state. Only the ACTIVE conversation streams to the browser —
879
+ // background conversations would clobber the streaming view; their
880
+ // state arrives via snapshot when switched to.
881
+ if (conv.id !== this.conv.id)
882
+ break;
883
+ const ame = event.assistantMessageEvent;
884
+ const m = event.message;
885
+ this.lastDeltaAt = Date.now();
886
+ this.emit({
887
+ type: "message_delta",
888
+ conversationId: conv.id,
889
+ seq: ++conv.deltaSeq,
890
+ // Must match serializeStreamingMessage()'s stable id so deltas
891
+ // patch onto the snapshot's streamingMessage and reconcile.
892
+ messageId: `stream-${m?.timestamp ?? 0}`,
893
+ usage: (() => {
894
+ try {
895
+ const t = this.session.getSessionStats().tokens;
896
+ return t ? { input: t.input, output: t.output, total: t.total } : null;
897
+ }
898
+ catch {
899
+ return null;
900
+ }
901
+ })(),
902
+ // Strip `partial` (the cumulative message): re-serializing it per
903
+ // token is exactly what we're trying to avoid. The next snapshot
904
+ // carries the authoritative full message anyway.
905
+ assistantMessageEvent: {
906
+ type: ame.type,
907
+ contentIndex: "contentIndex" in ame ? ame.contentIndex : undefined,
908
+ delta: "delta" in ame ? ame.delta : undefined,
909
+ },
910
+ });
911
+ break;
912
+ }
913
+ default:
914
+ break;
915
+ }
916
+ // Snapshot checkpoint policy: deltas carry live rendering during streaming;
917
+ // full snapshots are reconciliation checkpoints taken immediately at
918
+ // run/tool boundaries and on a slow timer otherwise.
919
+ if (event.type === "agent_settled" || event.type === "tool_execution_end") {
920
+ this.flushSnapshot();
921
+ }
922
+ else {
923
+ this.scheduleSnapshot();
924
+ }
925
+ }
926
+ /** Debounced push of the persisted session list + open conversations. */
927
+ scheduleSessionsRefresh() {
928
+ if (this.sessionsTimer)
929
+ return;
930
+ this.sessionsTimer = setTimeout(() => {
931
+ this.sessionsTimer = null;
932
+ if (this.disposed)
933
+ return;
934
+ for (const conv of [...this.convs.values()]) {
935
+ if (conv.wiki && conv.id !== this.activeId && !conv.session.isStreaming && conv.terminals.list().length === 0)
936
+ this.removeConversation(conv.id);
937
+ }
938
+ this.invalidateLists();
939
+ this.emitConversations();
940
+ void this.pushSessions();
941
+ }, 800);
942
+ // pushSessions no-ops unless the client opted in via list_sessions.
943
+ }
944
+ toolContentIds = new WeakMap();
945
+ toolContentSeq = 0;
946
+ /** Serialize a persisted message with a STABLE id + cached object reference. */
947
+ serializeCached(m, entryId, occurrence = 0) {
948
+ const conv = this.conv;
949
+ // toolResult messages are keyed by toolCallId; everything else by
950
+ // role+timestamp. A single prompt can emit several same-role messages
951
+ // within the SAME millisecond (multiple attachment asides), so the
952
+ // timestamp alone collides in the cache and only the first one renders
953
+ // — append a full-content fingerprint to keep them distinct while
954
+ // staying stable across snapshots (content never changes once persisted).
955
+ const contentKey = m.role === "toolResult"
956
+ ? `t:${m.toolCallId}`
957
+ : `${m.role}:${m.timestamp}:${contentFingerprint(m)}`;
958
+ const key = entryId ? `entry:${entryId}:${contentKey}` : occurrence ? `${contentKey}:occurrence:${occurrence}` : contentKey;
959
+ let n = conv.msgIds.get(key);
960
+ if (n === undefined) {
961
+ n = conv.nextMsgId++;
962
+ conv.msgIds.set(key, n);
963
+ }
964
+ // Persisted content is immutable; inspect mutable projected metadata without
965
+ // rebuilding or hashing the potentially very large output on every snapshot.
966
+ let toolRevision = "";
967
+ if (m.role === "toolResult") {
968
+ let contentId = this.toolContentIds.get(m.content);
969
+ if (contentId === undefined) {
970
+ contentId = ++this.toolContentSeq;
971
+ this.toolContentIds.set(m.content, contentId);
972
+ }
973
+ const metadata = serializeMessage({ ...m, content: [] }, n);
974
+ toolRevision = `:${contentId}:${createHash("sha256").update(JSON.stringify(metadata)).digest("hex")}`;
975
+ }
976
+ const cacheKey = `${key}#${n}${toolRevision}`;
977
+ const cached = conv.uiMessageCache.get(cacheKey);
978
+ if (cached)
979
+ return cached;
980
+ // User-message id suffix is a 1-based count of user messages sharing
981
+ // this timestamp (that's what resolveUserMessageEntryId() expects). n is
982
+ // a global per-conversation counter across ALL roles, so it can't be
983
+ // reused as the seq — otherwise editing anything but the first question
984
+ // fails to resolve ("找不到要编辑的消息").
985
+ let seq = n;
986
+ if (m.role === "user") {
987
+ // Assigned once per message: re-serializing after cache eviction must
988
+ // keep the same id, or edits resolve the wrong message.
989
+ const known = conv.userSeqByKey.get(key);
990
+ if (known !== undefined)
991
+ seq = known;
992
+ else {
993
+ const ts = m.timestamp ?? 0;
994
+ seq = (conv.userSeqByTs.get(ts) ?? 0) + 1;
995
+ conv.userSeqByTs.set(ts, seq);
996
+ conv.userSeqByKey.set(key, seq);
997
+ }
998
+ }
999
+ const measured = conv.thinkingTimings.annotate(serializeMessage(m, seq), m.timestamp ?? 0);
1000
+ const msg = this.thinkingDurationStore.annotate(measured, conv.session.sessionFile, m.timestamp ?? 0);
1001
+ if (msg) {
1002
+ if (m.role === "toolResult")
1003
+ msg.toolOutputUrl = `/api/tool-output?${new URLSearchParams({ clientId: this.clientId, conversationId: conv.id, toolCallId: m.toolCallId })}`;
1004
+ conv.uiMessageCache.set(cacheKey, msg);
1005
+ // Bound the cache (marathon sessions otherwise grow without limit;
1006
+ // single messages can reach TEXT_CAP = 200K chars). Map iteration is
1007
+ // insertion order, so dropping from the front evicts the oldest —
1008
+ // recent messages (the ones every snapshot touches) always survive.
1009
+ // Safe: a miss just recomputes an identical object on next access.
1010
+ // Every snapshot walks the whole transcript, so the cap must cover it:
1011
+ // a cap below the message count evicts entries just before they are
1012
+ // needed again and every snapshot re-serializes everything.
1013
+ let excess = conv.uiMessageCache.size - (conv.session.agent.state.messages.length + UI_MESSAGE_CACHE_CAP);
1014
+ while (excess-- > 0) {
1015
+ const oldest = conv.uiMessageCache.keys().next().value;
1016
+ if (oldest === undefined)
1017
+ break;
1018
+ conv.uiMessageCache.delete(oldest);
1019
+ }
1020
+ }
1021
+ return msg;
1022
+ }
1023
+ /** Current messages array (with the existing sig-reuse optimization).
1024
+ * Element objects are reference-stable (serializeCached cache), which is
1025
+ * what lets emitSnapshotNow detect append-only growth via identity walk. */
1026
+ currentMessages() {
1027
+ const conv = this.conv;
1028
+ const tree = conv.tree?.refresh();
1029
+ const messageKey = (m) => m.role === "toolResult" ? `t:${m.toolCallId}` : `${m.role}:${m.timestamp}:${contentFingerprint(m)}`;
1030
+ if (tree && conv.treeProjectionRevision !== tree.revision) {
1031
+ conv.treeEntryIds = new Map();
1032
+ for (const entry of conv.session.sessionManager.buildSessionProjection().entries)
1033
+ for (const m of entry.messages) {
1034
+ const key = messageKey(m), ids = conv.treeEntryIds.get(key) ?? [];
1035
+ ids.push(entry.sourceEntry.id);
1036
+ conv.treeEntryIds.set(key, ids);
1037
+ }
1038
+ conv.treeProjectionRevision = tree.revision;
1039
+ }
1040
+ const occurrences = new Map();
1041
+ const rawMessages = projectToolTextMessages(conv.session.agent.state.messages
1042
+ .map((m) => {
1043
+ const key = messageKey(m), index = occurrences.get(key) ?? 0;
1044
+ occurrences.set(key, index + 1);
1045
+ const entryId = conv.treeEntryIds?.get(key)?.[index];
1046
+ const value = this.serializeCached(m, entryId, index);
1047
+ return value ? conv.tree?.decorate(value, entryId) ?? value : null;
1048
+ })
1049
+ .filter((m) => m !== null));
1050
+ // Reuse the previous array when nothing changed: the element objects are
1051
+ // cached (reference-stable) anyway, and a stable array reference lets the
1052
+ // frontend memoize derived maps instead of rebuilding them every 60ms.
1053
+ const sig = rawMessages.map((m) => m.id).join("\u0001");
1054
+ const messages = conv.lastMessagesArray.length === rawMessages.length && rawMessages.every((m, i) => m === conv.lastMessagesArray[i]) ? conv.lastMessagesArray : rawMessages;
1055
+ conv.lastMessagesSig = sig;
1056
+ conv.lastMessagesArray = messages;
1057
+ return messages;
1058
+ }
1059
+ /** Build every UiState field EXCEPT messages (the expensive part). */
1060
+ buildLightState(rev, messages) {
1061
+ const conv = this.conv;
1062
+ const state = conv.session.agent.state;
1063
+ const model = state.model;
1064
+ const streamingMessage = state.streamingMessage
1065
+ ? conv.thinkingTimings.annotate(serializeStreamingMessage(state.streamingMessage), state.streamingMessage.timestamp ?? 0)
1066
+ : null;
1067
+ let stats = {
1068
+ totalMessages: 0,
1069
+ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1070
+ cost: 0,
1071
+ contextUsage: { tokens: null, contextWindow: 0, percent: null },
1072
+ contextParts: null,
1073
+ };
1074
+ try {
1075
+ const s = this.session.getSessionStats();
1076
+ stats = {
1077
+ totalMessages: s.totalMessages,
1078
+ tokens: s.tokens,
1079
+ cost: s.cost,
1080
+ contextUsage: s.contextUsage
1081
+ ? {
1082
+ tokens: s.contextUsage.tokens,
1083
+ contextWindow: s.contextUsage.contextWindow,
1084
+ percent: s.contextUsage.percent,
1085
+ }
1086
+ : stats.contextUsage,
1087
+ contextParts: estimateContextParts(this.session.messages, this.session.systemPrompt, s.contextUsage?.tokens ?? null),
1088
+ };
1089
+ }
1090
+ catch {
1091
+ // stats are best-effort
1092
+ }
1093
+ return {
1094
+ planSettings: planSettings().state(conv.session),
1095
+ tree: conv.tree?.refresh(),
1096
+ clientId: this.clientId,
1097
+ cwd: this.cwd,
1098
+ sessionId: this.session.sessionId,
1099
+ sessionFile: this.session.sessionFile,
1100
+ conversationId: this.activeId,
1101
+ cwdEvents: this.session.sessionManager.getBranch()
1102
+ .filter((entry) => entry.type === "custom" && entry.customType === "pi-harness:cwd-switch" && typeof entry.data?.cwd === "string")
1103
+ .slice(-40)
1104
+ .map((entry) => ({ cwd: entry.data.cwd, timestamp: Date.parse(entry.timestamp) })),
1105
+ rev,
1106
+ // The in-progress assistant message lives in state.streamingMessage
1107
+ // (the SDK only pushes it into state.messages at message_end). Surfacing
1108
+ // it here is what makes thinking + text stream into the browser at
1109
+ // ~60ms granularity instead of appearing only when the turn finishes.
1110
+ streamingMessage,
1111
+ taskProgress: deriveTaskProgress(conv.id, taskHistoryFromSession(conv.session.sessionManager, (message, entryId) => this.serializeCached(message, entryId)) ?? messages, streamingMessage, conv.session.isStreaming, conv.lastTaskEndedAt),
1112
+ isStreaming: this.session.isStreaming,
1113
+ recovery: recoverySnapshot(conv.recovery),
1114
+ runSettings: { autoCompaction: conv.session.autoCompactionEnabled, autoRetry: conv.session.autoRetryEnabled },
1115
+ model: model
1116
+ ? {
1117
+ id: model.id,
1118
+ name: model.name,
1119
+ provider: model.provider,
1120
+ vision: model.input?.includes("image") ?? false,
1121
+ }
1122
+ : null,
1123
+ ...(this.session.routedModel ? { routedModel: {
1124
+ id: this.session.routedModel.model.id, name: this.session.routedModel.model.name,
1125
+ provider: this.session.routedModel.model.provider,
1126
+ vision: this.session.routedModel.model.input.includes("image"),
1127
+ thinkingLevel: this.session.routedModel.thinkingLevel,
1128
+ } } : {}),
1129
+ thinkingLevel: state.thinkingLevel,
1130
+ // Only the levels the current model actually supports — the SDK clamps
1131
+ // anything else, so the UI must not offer (or must disable) the rest.
1132
+ availableThinkingLevels: this.session.getAvailableThinkingLevels(),
1133
+ queue: { steering: conv.queueSteering, followUp: conv.queueFollowUp },
1134
+ errorMessage: state.errorMessage,
1135
+ tools: state.tools.map((t) => t.name),
1136
+ version: ++this.version,
1137
+ piConfigured: this.isPiConfigured(),
1138
+ piAgentInstalled: isPiCliInstalled(),
1139
+ stats,
1140
+ };
1141
+ }
1142
+ /** Emit one snapshot update — incremental when possible, full otherwise.
1143
+ *
1144
+ * Persisted messages are content-immutable with reference-stable objects
1145
+ * (serializeCached), so an IDENTITY WALK over the previous array detects
1146
+ * append-only growth in O(n) pointer compares. Appends travel as
1147
+ * snapshot_delta carrying only the new tail + light fields; any mid-array
1148
+ * change/truncation (switch session, edit fork, compaction) or a forced
1149
+ * resync falls back to a full snapshot. The 10MB-stringify-per-checkpoint
1150
+ * cost of big sessions collapses to a few hundred bytes for the common
1151
+ * "nothing but stats/version changed" checkpoint. */
1152
+ emitSnapshotNow(forceFull = false) {
1153
+ if (this.disposed)
1154
+ return;
1155
+ const cur = this.currentMessages();
1156
+ const prev = this.emittedMessages;
1157
+ let incremental = !forceFull &&
1158
+ prev !== null &&
1159
+ this.emittedConvId === this.activeId &&
1160
+ prev.length <= cur.length;
1161
+ if (incremental && prev) {
1162
+ for (let i = 0; i < prev.length; i++) {
1163
+ if (prev[i] !== cur[i]) {
1164
+ incremental = false;
1165
+ break;
1166
+ }
1167
+ }
1168
+ }
1169
+ const rev = ++this.snapRev;
1170
+ if (incremental && prev) {
1171
+ const baseRev = this.emittedRev;
1172
+ this.emittedMessages = cur;
1173
+ this.emittedConvId = this.activeId;
1174
+ this.emittedRev = rev;
1175
+ this.emit({
1176
+ type: "snapshot_delta",
1177
+ conversationId: this.activeId,
1178
+ rev,
1179
+ baseRev,
1180
+ appended: cur.slice(prev.length),
1181
+ state: this.buildLightState(rev, cur),
1182
+ });
1183
+ }
1184
+ else {
1185
+ this.emittedMessages = cur;
1186
+ this.emittedConvId = this.activeId;
1187
+ this.emittedRev = rev;
1188
+ this.emit({
1189
+ type: "snapshot",
1190
+ state: { ...this.buildLightState(rev, cur), messages: cur },
1191
+ });
1192
+ }
1193
+ }
1194
+ /** Resolve a browser-bridged dialog (select/confirm/input) for this session. */
1195
+ cancelRecovery(conversationId, operationId) {
1196
+ const conv = this.convs.get(conversationId);
1197
+ if (!conv || conversationId !== this.activeId)
1198
+ return;
1199
+ const { compaction, retry, summary, branch } = conv.recovery;
1200
+ if (branch?.id === operationId)
1201
+ conv.session.abortBranchSummary();
1202
+ else if (summary?.id === operationId) {
1203
+ if (summary.source === "branchSummary")
1204
+ conv.session.abortBranchSummary();
1205
+ else
1206
+ conv.session.abortCompaction();
1207
+ }
1208
+ else if (compaction?.id === operationId)
1209
+ conv.session.abortCompaction();
1210
+ else if (retry?.id === operationId)
1211
+ conv.session.abortRetry();
1212
+ }
1213
+ async treeRequest(message) {
1214
+ const conv = this.convs.get(message.conversationId);
1215
+ if (!conv?.tree || conv.id !== this.activeId) {
1216
+ this.emit({ type: "tree_navigate_result", conversationId: message.conversationId, reqId: message.reqId, status: "error", error: "只能操作当前活动对话。" });
1217
+ return;
1218
+ }
1219
+ await conv.tree.request(message);
1220
+ this.coordinatePlan(conv);
1221
+ this.flushSnapshot(true);
1222
+ }
1223
+ setRunSettings(message) {
1224
+ const conv = this.convs.get(message.conversationId);
1225
+ if (!conv || conv.id !== this.activeId)
1226
+ return;
1227
+ setConversationRunSettings(conv.session.settingsManager, message);
1228
+ this.flushSnapshot();
1229
+ }
1230
+ recalledQueues = new Map();
1231
+ acknowledgeQueueRecall(conversationId, requestId) {
1232
+ if (this.recalledQueues.get(requestId)?.conversationId === conversationId)
1233
+ this.recalledQueues.delete(requestId);
1234
+ }
1235
+ recallQueue(conversationId, requestId) {
1236
+ const conv = this.convs.get(conversationId);
1237
+ if (!conv || conversationId !== this.activeId)
1238
+ return;
1239
+ const previous = this.recalledQueues.get(requestId);
1240
+ if (previous) {
1241
+ this.emit(previous);
1242
+ return;
1243
+ }
1244
+ if (this.recalledQueues.size >= 8) {
1245
+ this.emitNotice("warning", "请先接收已撤回的消息,再继续撤回。");
1246
+ return;
1247
+ }
1248
+ const restored = recallPending(conv.session);
1249
+ const result = { type: "queue_recalled", conversationId, requestId, text: [...restored.steering, ...restored.followUp].join("\n\n"), images: restored.images };
1250
+ this.recalledQueues.set(requestId, result);
1251
+ this.emit(result);
1252
+ this.flushSnapshot();
1253
+ }
1254
+ toolResultForDownload(conversationId, toolCallId) {
1255
+ const conv = this.convs.get(conversationId);
1256
+ if (!conv)
1257
+ throw Object.assign(new Error("Output access denied"), { code: "EACCES" });
1258
+ // Context projection omits pre-compaction records. Native entries retain them.
1259
+ const result = conv.session.sessionManager.getEntries().flatMap(entry => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolCallId === toolCallId ? [entry.message] : []).at(-1)
1260
+ ?? conv.session.messages.find(m => m.role === "toolResult" && m.toolCallId === toolCallId);
1261
+ if (!result || result.role !== "toolResult")
1262
+ throw Object.assign(new Error("Output access denied"), { code: "EACCES" });
1263
+ return { conv, result };
1264
+ }
1265
+ async listToolOutputs(conversationId, toolCallId) {
1266
+ const { conv, result } = this.toolResultForDownload(conversationId, toolCallId);
1267
+ const path = nativeToolDetails(result.toolName, result)?.fullOutputPath;
1268
+ const text = result.content.filter(part => part.type === "text").map(part => part.text).join("\n");
1269
+ return toolOutputManifest(conv.cwd, result.toolName, text, typeof path === "string" ? path : undefined);
1270
+ }
1271
+ async downloadToolOutput(conversationId, toolCallId, outputId) {
1272
+ const { conv, result } = this.toolResultForDownload(conversationId, toolCallId);
1273
+ const path = nativeToolDetails(result.toolName, result)?.fullOutputPath;
1274
+ if (outputId && !(typeof path === "string" && outputId === toolOutputId(path))) {
1275
+ const output = (await this.listToolOutputs(conversationId, toolCallId)).find(output => output.id === outputId);
1276
+ if (!output)
1277
+ throw Object.assign(new Error("Output access denied"), { code: "EACCES" });
1278
+ return { name: output.name, handle: await openToolOutput(conv.cwd, output.path) };
1279
+ }
1280
+ if (typeof path === "string")
1281
+ return { name: path.split(/[\\/]/).pop(), handle: await openToolOutput(conv.cwd, path) };
1282
+ return { name: "tool-output.txt", handle: { createReadStream: () => Readable.from(result.content.filter(part => part.type === "text").map(part => part.text + "\n")) } };
1283
+ }
1284
+ resolveDialog(conversationId, id, value) {
1285
+ if (conversationId !== this.activeId)
1286
+ return;
1287
+ this.convs.get(conversationId)?.webUi.resolveDialog(id, value);
1288
+ }
1289
+ /** Whether the pi agent config looks ready (cached for 2s). */
1290
+ isPiConfigured() {
1291
+ return this.piConfig.isConfigured();
1292
+ }
1293
+ /** Set by index.ts: called when /pi-harness:quit is invoked. */
1294
+ onQuit = undefined;
1295
+ /** 本客户端成功切换工作区(set_cwd)后触发,参数为新绝对路径。
1296
+ * attach 时由 AgentService 接到全局 onClientCwdChanged —— 编辑器等
1297
+ * 工作区跟随型插件借此把根目录切到用户当前项目。 */
1298
+ onCwdChanged = undefined;
1299
+ /** Ask the npm registry for the latest pi-harness version and report it. */
1300
+ async checkUpdate() {
1301
+ return checkUpdate((msg) => this.emit(msg));
1302
+ }
1303
+ async checkComponentUpdates(requestId) {
1304
+ const session = this.session;
1305
+ const cwd = this.cwd;
1306
+ this.emit({ type: "component_updates", requestId, cwd, phase: "checking", items: [] });
1307
+ try {
1308
+ const manager = packageManagerFor(session, cwd, this.agentDir);
1309
+ const items = await checkComponents(updateTargets(session, manager));
1310
+ this.emit({ type: "component_updates", requestId, cwd, phase: "ready", items, restartRequired: componentRestartRequired() });
1311
+ }
1312
+ catch (error) {
1313
+ this.emit({ type: "component_updates", requestId, cwd, phase: "error", items: [], error: String(error) });
1314
+ }
1315
+ }
1316
+ async updateComponent(requestId, id) {
1317
+ const cwd = this.cwd;
1318
+ const session = this.session;
1319
+ this.emit({ type: "component_updates", requestId, cwd, phase: "updating", items: [] });
1320
+ try {
1321
+ const manager = packageManagerFor(session, cwd, this.agentDir);
1322
+ const targets = updateTargets(session, manager);
1323
+ await updateComponentPackage(targets, id, (source) => manager.update(source));
1324
+ // SDK modules may be shared by other conversations. Restart, rather than
1325
+ // hot-swapping one session and claiming every session now runs new code.
1326
+ const items = await checkComponents(updateTargets(session, manager));
1327
+ this.emit({ type: "component_updates", requestId, cwd, phase: "updated", items, restartRequired: true });
1328
+ }
1329
+ catch (error) {
1330
+ this.emit({ type: "component_updates", requestId, cwd, phase: "error", items: [], error: error instanceof Error ? error.message : String(error) });
1331
+ }
1332
+ }
1333
+ async installPiAgent() {
1334
+ await installPiAgent(this.agentDir, (msg) => this.emit(msg));
1335
+ this.flushSnapshot();
1336
+ }
1337
+ /** Send a snapshot immediately (cancels any pending throttled one).
1338
+ * forceFull skips the incremental path — used by get_state so a (re)
1339
+ * connecting or desynced client always receives an authoritative full
1340
+ * state it can rebuild from. */
1341
+ flushSnapshot(forceFull = false) {
1342
+ if (this.snapshotTimer) {
1343
+ clearTimeout(this.snapshotTimer);
1344
+ this.snapshotTimer = null;
1345
+ }
1346
+ this.emitSnapshotNow(forceFull);
1347
+ }
1348
+ scheduleSnapshot() {
1349
+ if (this.snapshotTimer || this.disposed)
1350
+ return;
1351
+ // During active streaming the deltas carry live rendering — full snapshots
1352
+ // are just a periodic reconciliation checkpoint, so send them far less
1353
+ // often (they serialize the whole session; big sessions made this path OOM).
1354
+ const interval = Date.now() - this.lastDeltaAt < DELTA_ACTIVE_WINDOW_MS
1355
+ ? STREAMING_SNAPSHOT_INTERVAL_MS
1356
+ : SNAPSHOT_INTERVAL_MS;
1357
+ this.snapshotTimer = setTimeout(() => {
1358
+ this.snapshotTimer = null;
1359
+ this.emitSnapshotNow();
1360
+ }, interval);
1361
+ }
1362
+ /** Slash-command catalog + native command execution — 自包含模块,见
1363
+ * slash-commands.ts(内置命令拦截 + 扩展/模板/技能目录推送)。 */
1364
+ slash = new SlashCommandsService({
1365
+ emit: (msg) => this.emit(msg),
1366
+ cwd: () => this.cwd,
1367
+ getSession: () => this.session,
1368
+ startNewSession: () => this.startNewSession(),
1369
+ treeCommand: async (name, args, context) => {
1370
+ const conv = this.conv;
1371
+ if (conv.id !== context.conversationId)
1372
+ return;
1373
+ if (name === "tree" || name === "fork")
1374
+ this.emit({ type: "tree_open", conversationId: conv.id, mode: name === "fork" ? "fork" : "tree" });
1375
+ else if (name === "name") {
1376
+ conv.tree?.assertWritable();
1377
+ if (!args.trim()) {
1378
+ this.emitNotice("info", "用法:/name <名称>");
1379
+ return;
1380
+ }
1381
+ conv.session.setSessionName(args.trim().slice(0, 200));
1382
+ conv.title = args.trim().slice(0, 200);
1383
+ this.invalidateLists();
1384
+ this.scheduleSessionsRefresh();
1385
+ this.flushSnapshot();
1386
+ }
1387
+ else
1388
+ await this.treeRequest({ type: "session_clone", conversationId: conv.id, reqId: `command-${context.requestId}` });
1389
+ },
1390
+ setModel: (id) => this.setModel(id),
1391
+ setCwd: (path) => this.setCwd(path),
1392
+ setThinking: (level) => this.setThinking(level),
1393
+ refreshSessions: () => this.refreshSessions(),
1394
+ onQuit: () => this.onQuit?.() ?? false,
1395
+ });
1396
+ /** Catalog push — index.ts get_commands / attach / cwd 切换等都会调用。 */
1397
+ pushSlashCommands() {
1398
+ return this.slash.push();
1399
+ }
1400
+ /** 模型/服务商配置管理 —— 自包含模块,见 model-admin.ts。 */
1401
+ modelAdmin;
1402
+ /** Persist an api-key credential for a provider (auth.json). */
1403
+ setProviderApiKey(provider, apiKey) {
1404
+ return this.modelAdmin.setProviderApiKey(provider, apiKey);
1405
+ }
1406
+ clearProviderApiKey(provider) {
1407
+ return this.modelAdmin.clearProviderApiKey(provider);
1408
+ }
1409
+ listProviders() {
1410
+ return this.modelAdmin.listProviders();
1411
+ }
1412
+ listModelsConfig() {
1413
+ return this.modelAdmin.listModelsConfig();
1414
+ }
1415
+ fetchModelsList(reqId, baseUrl, apiKey, authHeader, api) {
1416
+ return this.modelAdmin.fetchModelsList(reqId, baseUrl, apiKey, authHeader, api);
1417
+ }
1418
+ refreshProviderModels(providerId, reqId) {
1419
+ return this.modelAdmin.refreshProviderModels(providerId, reqId);
1420
+ }
1421
+ /** Copy a built-in provider into an editable custom-provider draft
1422
+ * (clone_provider_result) — lets the user run a second API key without
1423
+ * overwriting the built-in one. */
1424
+ cloneProvider(providerId, reqId) {
1425
+ return this.modelAdmin.cloneProvider(providerId, reqId);
1426
+ }
1427
+ saveModelConfig(providerId, config) {
1428
+ return this.modelAdmin.saveModelConfig(providerId, config);
1429
+ }
1430
+ deleteModelConfig(providerId) {
1431
+ return this.modelAdmin.deleteModelConfig(providerId);
1432
+ }
1433
+ // ---------------------------------------------------------------------------
1434
+ // Settings (system prompt / skills / extensions / presets)
1435
+ // ---------------------------------------------------------------------------
1436
+ /** Push the full settings state (current settings + loaded skills/extensions
1437
+ * with enabled flags + saved presets). Pushed on attach and after every
1438
+ * settings change. */
1439
+ pushSettings() {
1440
+ this.settingsSvc.push();
1441
+ }
1442
+ /** Extensions/skills changed externally (e.g. `pi remove` finished in the
1443
+ * terminal): re-run session.reload() and re-push state. Streaming-safe —
1444
+ * deferred to agent_settled, same as settings reloads. */
1445
+ async reloadExtensions() {
1446
+ return this.settingsSvc.applyRuntime();
1447
+ }
1448
+ /** Persist + apply a partial settings update (prompt text/mode, toggles). */
1449
+ async setSettings(partial) { await this.settingsSvc.set(partial); }
1450
+ /** Replace the current settings with the named preset and apply it. */
1451
+ /** Remove a named preset. */
1452
+ /** Make settings effective in the running runtime(流式中则延迟到 agent_settled)。 */
1453
+ async applyRuntimeSettings() {
1454
+ return this.settingsSvc.applyRuntime();
1455
+ }
1456
+ /** 把终端工具开关应用到 session 的活跃工具集:关闭时从活跃集中剔除
1457
+ * terminal_*(工具仍留在注册表,重开时可直接加回)。session.reload() 与新
1458
+ * 会话创建都会把 custom 工具加回活跃集,所以这两条路径之后都要重放本方法。 */
1459
+ /** 把插件 AI 工具同步进一个已存在的会话(新增/更新/移除)。
1460
+ * 实际 diff 逻辑在 plugins.ts 的 syncPluginToolsIntoSession(可单测)。 */
1461
+ /** index.ts 经 pluginMgr.onAgentToolsChanged 触发:把插件 AI 工具推入全部会话。 */
1462
+ async applySettingsReload() {
1463
+ // 兼容旧入口:reload + 刷目录在宿主回调里完成
1464
+ return this.settingsSvc.applyRuntime();
1465
+ }
1466
+ // ---------------------------------------------------------------------------
1467
+ // Commands
1468
+ // ---------------------------------------------------------------------------
1469
+ /** True when the service is draining (quiesced): emits a rejection notice
1470
+ * and returns true. Guards every NEW-work entry point (prompt / new chat /
1471
+ * edit-resend / session resume / goal wizard) — existing runs keep going.
1472
+ * Called BEFORE any LLM/token work starts so quiesce is a hard admission
1473
+ * gate, not a best-effort hint. */
1474
+ quiesceBlocked() {
1475
+ if (!this.isQuiesced())
1476
+ return false;
1477
+ this.emit({
1478
+ type: "notice",
1479
+ level: "error",
1480
+ text: "服务器正在排空存量工作(quiesce),已拒绝新的对话/消息/编辑。存量运行会继续跑完;用 pi-harness server unquiesce 可恢复。",
1481
+ });
1482
+ this.flushSnapshot();
1483
+ return true;
1484
+ }
1485
+ /** Conversations with an in-flight run — active work for quiesce status. */
1486
+ activeConversations() {
1487
+ let n = 0;
1488
+ for (const c of this.convs.values()) {
1489
+ try {
1490
+ if (c.session.isStreaming)
1491
+ n += 1;
1492
+ }
1493
+ catch {
1494
+ // session being replaced — not running
1495
+ }
1496
+ }
1497
+ return n;
1498
+ }
1499
+ /** Messages queued in the SDK (steer + follow-up) — pending work for
1500
+ * quiesce status. Quiesce refuses to add more, so this only drains. */
1501
+ pendingMessages() {
1502
+ let n = 0;
1503
+ for (const c of this.convs.values())
1504
+ n += c.queueFollowUp.length + c.queueSteering.length;
1505
+ return n;
1506
+ }
1507
+ async prompt(text, attachments,
1508
+ /**
1509
+ * true = followUp: while streaming, queue the prompt and deliver it only
1510
+ * after the WHOLE run finishes (补充 button — "AI 生成结束才发送").
1511
+ * false/undefined = steer: the pi CLI Enter semantic — injected right
1512
+ * after the current turn settles, skipping remaining planned tool calls.
1513
+ */
1514
+ queue = false, requestId, onAccepted) {
1515
+ const conv = this.conv;
1516
+ let acknowledged = false;
1517
+ const acknowledge = (ok, commandExecuted) => {
1518
+ if (acknowledged)
1519
+ return;
1520
+ acknowledged = true;
1521
+ if (requestId)
1522
+ this.emit({ type: "prompt_result", requestId, conversationId: conv.id, ok, commandExecuted, attachmentsConsumed: ok && !commandExecuted });
1523
+ onAccepted?.(ok);
1524
+ };
1525
+ try {
1526
+ if (!["tree", "fork"].includes(parseSlash(text)?.name ?? ""))
1527
+ await conv.tree?.waitForVerification();
1528
+ const s = conv.session;
1529
+ await flushPromptReload(s);
1530
+ const promptReloadError = promptReloadStatus(s).reloadError;
1531
+ if (promptReloadError)
1532
+ throw new Error(`Native prompt reload failed: ${promptReloadError}`);
1533
+ if (this.conv !== conv || conv.session !== s)
1534
+ throw new Error("Conversation changed");
1535
+ const extensionCommand = text.startsWith("/") && s.extensionRunner.getCommand(text.slice(1).split(" ", 1)[0]);
1536
+ if (!extensionCommand) {
1537
+ attachments = resolveNativeAttachments(s, attachments);
1538
+ validateEditorSnapshots(this.cwd, attachments);
1539
+ }
1540
+ // Native slash commands (see NATIVE_COMMANDS) are executed here and
1541
+ // never reach the SDK. Extension / skill / template commands fall
1542
+ // through — AgentSession.prompt() handles those itself.
1543
+ const slash = parseSlash(text);
1544
+ if (slash?.name === "reload")
1545
+ acknowledge(true);
1546
+ if (slash && (await this.slash.exec(slash.name, slash.args, { conversationId: conv.id, requestId: requestId ?? `reload-${Date.now()}` }))) {
1547
+ acknowledge(true);
1548
+ this.flushSnapshot();
1549
+ return;
1550
+ }
1551
+ // Native commands above are pure config tweaks (no tokens) — allow them
1552
+ // even while quiesced. Everything that reaches the SDK is NEW work and
1553
+ // is refused until admission reopens.
1554
+ if (this.quiesceBlocked())
1555
+ throw new Error("服务暂停接收消息");
1556
+ if (conv.title === DEFAULT_CONV_TITLE && text.trim()) {
1557
+ const temporary = skillAwareTitleText(text).trim().replace(/\s+/g, " ");
1558
+ conv.title = temporary.length > 30 ? `${temporary.slice(0, 30)}…` : temporary;
1559
+ this.emitConversations();
1560
+ }
1561
+ // Bundle file context with the question as one native queue item.
1562
+ const asides = await buildAttachmentMessages({
1563
+ cwd: this.cwd,
1564
+ clientId: this.clientId,
1565
+ emit: (msg) => this.emit(msg),
1566
+ session: this.session,
1567
+ }, extensionCommand ? undefined : attachments);
1568
+ await conv.tree?.waitForVerification();
1569
+ if (this.conv !== conv || conv.session !== s)
1570
+ throw new Error("Conversation changed");
1571
+ if (!s.isStreaming) {
1572
+ conv.toolsExecutedSincePrompt = false;
1573
+ conv.lastTaskEndedAt = undefined;
1574
+ }
1575
+ conv.toolTextContinues = 0;
1576
+ this.coordinatePlan(conv);
1577
+ await deliverPrompt(s, text, asides, queue, acknowledge);
1578
+ }
1579
+ catch (err) {
1580
+ acknowledge(false);
1581
+ this.emit({
1582
+ type: "notice",
1583
+ level: "error",
1584
+ conversationId: conv.id,
1585
+ text: `提示发送失败:${err.message}`,
1586
+ });
1587
+ }
1588
+ // The active conversation has been continued since it was opened — it
1589
+ // must not be dismissed when the user switches away. (Also bumps the
1590
+ // per-project "most recently active" order used by set_cwd.)
1591
+ conv.promptedSinceActive = true;
1592
+ conv.lastActiveAt = Date.now();
1593
+ // Fresh run — restart the stall watchdog window.
1594
+ conv.lastSdkEventAt = Date.now();
1595
+ if (conv.stallNoticed)
1596
+ this.emit({ type: "agent_silence", conversationId: conv.id, phase: "active", since: conv.lastSdkEventAt, activity: "model" });
1597
+ conv.stallNoticed = false;
1598
+ this.flushSnapshot();
1599
+ }
1600
+ /**
1601
+ * Turn attached files into custom-message payloads.
1602
+ *
1603
+ * Text files are size-aware: small files are inlined into the message so the
1604
+ * model sees them immediately; large files are passed as a <file path="...">
1605
+ * reference and the model reads them on demand with its read tool (which has
1606
+ * built-in truncation). Images are always passed as image content. Mode
1607
+ * "lines" inlines only a 1-based inclusive line range of the file. Raw
1608
+ * pasted/dropped/uploaded images (attachment.imageData) skip the workspace
1609
+ * path entirely and go straight to the model as image content. Raw uploaded
1610
+ * files (attachment.fileData) are persisted under <dataDir>/uploads/ and
1611
+ * attached as absolute-path references (small text ones are inlined).
1612
+ */
1613
+ /**
1614
+ * Hard-abort the running agent (Stop button / global 中断). Tries
1615
+ * session.abort() first; if the run is not idle within
1616
+ * HARD_ABORT_TIMEOUT_MS (model stream ignoring the abort signal), the
1617
+ * conversation's runtime is force-disposed and recreated from the last
1618
+ * persisted session so the chat ALWAYS comes back usable — never stuck
1619
+ * overnight. The notice fires only on the forced-reset path.
1620
+ */
1621
+ async abort() {
1622
+ // 只停止智能体运行本身;AI 在后台启动的服务由「后台任务」面板单独
1623
+ // 管理(可逐个停止或全部关闭),不会在停止对话时被连带杀掉。
1624
+ const conv = this.conv;
1625
+ await this.interruptRun(conv, "已停止");
1626
+ conv.runningToolNames.clear();
1627
+ if (conv.stallNoticed)
1628
+ this.emit({ type: "agent_silence", conversationId: conv.id, phase: "active", since: Date.now(), activity: "model" });
1629
+ conv.stallNoticed = false;
1630
+ this.flushSnapshot();
1631
+ }
1632
+ /** Retry only a silent model request with no tool side effects in this turn. */
1633
+ async retrySilentPrompt(conversationId, text) {
1634
+ const conv = this.conv;
1635
+ if (conv.id !== conversationId || isRecovering(conv.recovery) || !conv.stallNoticed || conv.toolsExecutedSincePrompt || conv.runningToolNames.size || !text.trim()) {
1636
+ this.emit({ type: "notice", level: "warning", text: "当前运行状态已变化,无法手动重试;请检查对话后手动发送。" });
1637
+ return;
1638
+ }
1639
+ const user = conv.session.messages.findLast(m => m.role === "user");
1640
+ if (!user || user.role !== "user")
1641
+ return;
1642
+ const original = typeof user.content === "string" ? user.content : user.content.every(b => b.type === "text") ? user.content.map(b => b.type === "text" ? b.text : "").join("\n") : undefined;
1643
+ if (original !== text)
1644
+ return;
1645
+ await this.abort();
1646
+ if (this.conv !== conv || isRecovering(conv.recovery) || conv.toolsExecutedSincePrompt || conv.session.messages.findLast(m => m.role === "user")?.timestamp !== user.timestamp)
1647
+ return;
1648
+ await this.prompt(text);
1649
+ }
1650
+ /**
1651
+ * A model sometimes writes a tool call as text (`<invoke …>` / DSML) instead of
1652
+ * calling the tool. The SDK sees a plain answer and the run ends, leaving the task
1653
+ * half done. Ask the model to re-issue it through the tool channel. Loop guard: at most TOOL_TEXT_MAX_CONTINUES reminder per user prompt,
1654
+ * even when a real tool ran between leaks.
1655
+ * The request is a visible user message and the run can be stopped as usual; when
1656
+ * the guard stops it, the recovery card offers a manual retry.
1657
+ */
1658
+ async continueAfterToolText(conv) {
1659
+ const session = conv.session;
1660
+ if (this.disposed || conv.wiki || session.isStreaming || isRecovering(conv.recovery))
1661
+ return;
1662
+ if (conv.queueSteering.length || conv.queueFollowUp.length || this.quiesceBlocked())
1663
+ return;
1664
+ if (conv.toolTextContinues >= TOOL_TEXT_MAX_CONTINUES || (conv.toolTextContinues > 0 && !conv.toolRanSinceContinue))
1665
+ return;
1666
+ const messages = session.messages;
1667
+ const userIndex = messages.findLastIndex(message => message.role === "user");
1668
+ const last = messages[messages.length - 1];
1669
+ if (userIndex < 0 || !last || last.role !== "assistant" || messages.indexOf(last) <= userIndex)
1670
+ return;
1671
+ if (last.stopReason === "aborted" || last.stopReason === "error")
1672
+ return;
1673
+ if (last.content.some(block => block.type === "toolCall"))
1674
+ return;
1675
+ const text = last.content.map(block => block.type === "text" ? block.text : "").join("\n");
1676
+ if (!unexecutedToolText(text))
1677
+ return;
1678
+ conv.toolTextContinues += 1;
1679
+ conv.toolRanSinceContinue = false;
1680
+ try {
1681
+ if (conv.session !== session || session.isStreaming)
1682
+ return;
1683
+ await deliverPrompt(session, TOOL_TEXT_CONTINUE_PROMPT, [], false, () => { });
1684
+ }
1685
+ catch (error) {
1686
+ this.emit({ type: "notice", level: "error", conversationId: conv.id, text: `自动继续失败:${error.message}` });
1687
+ }
1688
+ }
1689
+ /** Re-push the current list on request (panel opened); prunes dead entries first. */
1690
+ async listBgServers() {
1691
+ await this.bg.listAndPush();
1692
+ }
1693
+ /** 插件任务集合变化时由宿主调用:重推一次 bg_servers(含插件任务)。 */
1694
+ refreshBgTasks() {
1695
+ this.bg.push();
1696
+ }
1697
+ /** 插件设置保存结果等需要从 index.ts 发 notice 时用(emit 是私有的)。 */
1698
+ emitNotice(level, text) {
1699
+ this.emit({ type: "notice", level, text });
1700
+ }
1701
+ /** Kill ONE background server (by port); returns whether anything was killed. */
1702
+ /** Kill ONE background server (by port) OR a plugin task (by taskId). */
1703
+ async killBackgroundServer(port, taskId) {
1704
+ if (taskId) {
1705
+ // 插件任务:交给插件管理器 stop 回调(不杀进程树——任务在宿主进程内)。
1706
+ const ok = this.pluginStopBgTask?.(taskId) ?? false;
1707
+ if (!ok) {
1708
+ this.emit({
1709
+ type: "notice",
1710
+ level: "info",
1711
+ text: `后台任务「${taskId}」不存在或已结束`,
1712
+ });
1713
+ }
1714
+ this.bg.push();
1715
+ this.flushSnapshot();
1716
+ return ok;
1717
+ }
1718
+ if (typeof port !== "number")
1719
+ return false;
1720
+ return this.bg.killOne(port);
1721
+ }
1722
+ /** Kill every background server the agent started; returns the freed ports. */
1723
+ async killAllBackgroundServers() {
1724
+ return this.bg.killAll();
1725
+ }
1726
+ /** Kill only the running bash command(s) — the agent run itself continues
1727
+ * (the bash tool returns an aborted error and the model moves on). Uses
1728
+ * the per-client AbortController set registered by
1729
+ * makeKillableBashTool. */
1730
+ /** Interrupt a run: abort, with a force-reset fallback on timeout. */
1731
+ async interruptRun(conv, reason) {
1732
+ const runtime = conv.runtime;
1733
+ const current = () => !this.disposed && this.convs.get(conv.id) === conv && conv.runtime === runtime;
1734
+ if (!current())
1735
+ return;
1736
+ // The run is only truly stopped when its agent_settled event arrives:
1737
+ // session.abort() can return without stopping anything when the run is
1738
+ // stuck before the agent even started (e.g. a model stream that never
1739
+ // begins), so we watch for agent_settled and force-reset when it never
1740
+ // comes — abort 卡住(超时)或空转(结算窗口)两条路都覆盖。
1741
+ let ended = false;
1742
+ let forced;
1743
+ const off = conv.session.subscribe((e) => {
1744
+ if (e.type === "agent_settled") {
1745
+ ended = true;
1746
+ }
1747
+ });
1748
+ const force = () => {
1749
+ if (forced)
1750
+ return forced;
1751
+ if (!current())
1752
+ return Promise.resolve();
1753
+ return forced = this.forceResetConversation(conv, `${reason}:运行未终止,已强制重置当前对话`);
1754
+ };
1755
+ // 1) abort itself hangs (model stream ignores the signal) → hard kill.
1756
+ const abortTimer = setTimeout(() => {
1757
+ if (!ended)
1758
+ force();
1759
+ }, ClientSession.HARD_ABORT_TIMEOUT_MS);
1760
+ abortTimer.unref?.();
1761
+ // 2) abort itself (Stop semantics: kills the process tree, emits
1762
+ // agent_end with stopReason "aborted" on the normal path).
1763
+ try {
1764
+ await conv.runtime.session.abort();
1765
+ }
1766
+ catch (err) {
1767
+ this.emit({
1768
+ type: "notice",
1769
+ level: "error",
1770
+ text: `中止失败:${err.message}`,
1771
+ });
1772
+ }
1773
+ // 3) abort returned but no agent_settled within the settle window → the
1774
+ // run was stuck before it started; force-reset to recover.
1775
+ if (!ended) {
1776
+ await new Promise((r) => setTimeout(r, ClientSession.HARD_ABORT_SETTLE_MS));
1777
+ }
1778
+ clearTimeout(abortTimer);
1779
+ off();
1780
+ if (!ended)
1781
+ await force();
1782
+ }
1783
+ /** Force-reset a conversation: dispose the stuck runtime (kills the hung
1784
+ * model stream / child processes) and rebuild it from the most recent
1785
+ * persisted session. The conversation record itself is kept (same id,
1786
+ * same cwd, same serialization caches), so the UI stays attached. */
1787
+ forcedResets = new WeakMap();
1788
+ forceResetConversation(conv, reason) {
1789
+ const pending = this.forcedResets.get(conv);
1790
+ if (pending)
1791
+ return pending;
1792
+ const operation = this.rebuildConversation(conv, reason).finally(() => this.forcedResets.delete(conv));
1793
+ this.forcedResets.set(conv, operation);
1794
+ return operation;
1795
+ }
1796
+ async rebuildConversation(conv, reason) {
1797
+ const previous = conv.runtime;
1798
+ const current = () => !this.disposed && this.convs.get(conv.id) === conv && conv.runtime === previous;
1799
+ if (!current())
1800
+ return;
1801
+ try {
1802
+ conv.unsubscribe?.();
1803
+ conv.unsubscribe = undefined;
1804
+ const manager = conv.session.sessionManager;
1805
+ const header = manager.getHeader();
1806
+ const wikiManager = conv.wiki ? SessionManager.inMemory(conv.cwd, undefined, [...(header ? [header] : []), ...manager.getEntries()]) : null;
1807
+ // Reopen THIS conversation's file. continueRecent() would pick the most
1808
+ // recently written session of the project, which may be another open chat.
1809
+ const sessionFile = conv.session.sessionFile;
1810
+ conv.toolStartTimes.clear();
1811
+ await previous.dispose();
1812
+ if (!current())
1813
+ return;
1814
+ const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(), {
1815
+ cwd: conv.cwd,
1816
+ agentDir: this.agentDir,
1817
+ sessionManager: wikiManager ?? (sessionFile ? SessionManager.open(sessionFile) : SessionManager.create(conv.cwd)),
1818
+ });
1819
+ if (!current()) {
1820
+ await runtime.dispose();
1821
+ return;
1822
+ }
1823
+ conv.runtime = runtime;
1824
+ conv.webUi.dispose();
1825
+ conv.webUi = new WebUIContext(msg => this.emit(msg), conv.id, () => `${conv.cwd} · ${conv.title}`);
1826
+ conv.recovery = {};
1827
+ conv.session = runtime.session;
1828
+ this.emit({ type: "notice", level: "warning", text: reason });
1829
+ // Rebind the recovered conversation itself, which may be in the background.
1830
+ await this.bindSession(conv);
1831
+ if (this.disposed || this.convs.get(conv.id) !== conv || conv.runtime !== runtime)
1832
+ return;
1833
+ this.emitConversations();
1834
+ void this.pushSlashCommands();
1835
+ }
1836
+ catch (err) {
1837
+ this.emit({
1838
+ type: "notice",
1839
+ level: "error",
1840
+ text: `强制中断失败:${err.message}`,
1841
+ });
1842
+ }
1843
+ }
1844
+ /** /new delegates session creation and history persistence to the SDK; the Web slot is reused. */
1845
+ async startNewSession() {
1846
+ if (this.conv.tree?.busy) {
1847
+ this.emitNotice("warning", "等待当前切换完成。");
1848
+ return;
1849
+ }
1850
+ if (this.quiesceBlocked())
1851
+ return;
1852
+ const previous = this.conv;
1853
+ const model = previous.session.model;
1854
+ const thinking = previous.session.thinkingLevel;
1855
+ const result = await previous.runtime.newSession();
1856
+ if (result.cancelled)
1857
+ return;
1858
+ previous.unsubscribe?.();
1859
+ previous.tree?.dispose();
1860
+ const conv = this.makeConversation(previous.runtime, previous.id, previous.terminals);
1861
+ // IDs and delta sequence remain monotonic within this conversation.
1862
+ conv.wiki = previous.wiki;
1863
+ conv.deltaSeq = previous.deltaSeq;
1864
+ conv.nextMsgId = previous.nextMsgId;
1865
+ conv.createdAt = previous.createdAt;
1866
+ this.convs.set(conv.id, conv);
1867
+ await this.bindSession(conv);
1868
+ if (model)
1869
+ await conv.session.setModel(model);
1870
+ conv.session.setThinkingLevel(thinking);
1871
+ this.invalidateLists();
1872
+ this.emitConversations();
1873
+ if (previous.stallNoticed)
1874
+ this.emit({ type: "agent_silence", conversationId: conv.id, phase: "active", since: Date.now(), activity: "model" });
1875
+ this.flushSnapshot(true);
1876
+ void this.pushSlashCommands();
1877
+ }
1878
+ async newChat(fresh = false, wiki = false) {
1879
+ if (this.conv.tree?.busy) {
1880
+ this.emitNotice("warning", "等待当前切换完成。");
1881
+ return false;
1882
+ }
1883
+ if (this.creatingConversation)
1884
+ return false;
1885
+ this.creatingConversation = true;
1886
+ const previous = this.activeId;
1887
+ try {
1888
+ await this.createChat(fresh, wiki);
1889
+ return this.activeId !== previous;
1890
+ }
1891
+ finally {
1892
+ this.creatingConversation = false;
1893
+ }
1894
+ }
1895
+ async createChat(fresh, wiki = false) {
1896
+ this.invalidateLists();
1897
+ if (this.quiesceBlocked())
1898
+ return;
1899
+ // Reuse an already-open blank conversation instead of piling up new ones
1900
+ // on every click: if the active chat has no messages it IS the new chat
1901
+ // (focus already on it); otherwise switch to the first blank one (under
1902
+ // the per-project running-list model displaced blanks are disposed, so
1903
+ // this branch normally can't exist — kept as a safety net).
1904
+ const isBlank = (c) => {
1905
+ try {
1906
+ return c.session.getSessionStats().totalMessages === 0 && c.terminals.list().length === 0;
1907
+ }
1908
+ catch {
1909
+ // session being replaced — treat as used so we don't switch onto it
1910
+ return false;
1911
+ }
1912
+ };
1913
+ const active = this.conv;
1914
+ if (!fresh && active && !active.wiki && isBlank(active)) {
1915
+ this.flushSnapshot();
1916
+ return;
1917
+ }
1918
+ for (const conv of this.convs.values()) {
1919
+ if (conv.id === this.activeId)
1920
+ continue;
1921
+ if (!fresh && !conv.wiki && isBlank(conv)) {
1922
+ await this.switchConversation(conv.id);
1923
+ this.flushSnapshot();
1924
+ return;
1925
+ }
1926
+ }
1927
+ // Wiki opens a fresh session for each document. Retire the outgoing idle
1928
+ // runtime after success; temporary Wiki history is discarded with it.
1929
+ const replaceActive = fresh && active && !active.session.isStreaming && active.terminals.list().length === 0 ? active : null;
1930
+ // Cap is per project — conversations of other projects keep their own
1931
+ // lists and don't consume this project's slots.
1932
+ const openInProject = [...this.convs.values()].filter((c) => c.cwd === this.cwd).length;
1933
+ if (openInProject >= MAX_OPEN_CONVERSATIONS && !replaceActive) {
1934
+ this.emit({
1935
+ type: "notice",
1936
+ level: "warning",
1937
+ text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先打开某个对话并离开(不继续对话)以移出列表`,
1938
+ });
1939
+ return;
1940
+ }
1941
+ // The outgoing conversation is left behind — apply the running-list
1942
+ // lifecycle. Removal is deferred until the new chat exists so the active
1943
+ // conversation stays valid during the (async) runtime creation.
1944
+ const displaced = replaceActive ?? this.displaceActive();
1945
+ // Carry the model and thinking level chosen in the active chat over to the
1946
+ // new chat so they don't silently revert to the defaults. Some models reject
1947
+ // the default "off" level (e.g. always-thinking GLM), so this matters for Wiki.
1948
+ const prevModel = this.conv.session.agent.state.model ?? null;
1949
+ const prevThinking = this.conv.session.thinkingLevel;
1950
+ try {
1951
+ const conversationId = this.nextConversationId();
1952
+ const terminals = this.makeTerminalManager(conversationId, this.cwd);
1953
+ const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(), {
1954
+ cwd: this.cwd,
1955
+ agentDir: this.agentDir,
1956
+ sessionManager: wiki ? SessionManager.inMemory(this.cwd) : SessionManager.create(this.cwd),
1957
+ });
1958
+ const conv = this.makeConversation(runtime, conversationId, terminals);
1959
+ conv.wiki = wiki;
1960
+ this.convs.set(conv.id, conv);
1961
+ this.activeId = conv.id;
1962
+ if (displaced)
1963
+ this.removeConversation(displaced.id);
1964
+ await this.bindSession();
1965
+ // New session seeds with the ModelRuntime default model — restore the
1966
+ // model the user had selected in the previous chat.
1967
+ if (prevModel && this.sharedModelRuntime) {
1968
+ try {
1969
+ await this.session.setModel(prevModel);
1970
+ }
1971
+ catch {
1972
+ // model no longer resolvable — keep the default
1973
+ }
1974
+ }
1975
+ // setModel applied the default level for the model; restore the chosen one (clamped by the SDK).
1976
+ if (prevModel)
1977
+ this.session.setThinkingLevel(prevThinking);
1978
+ this.emitConversations();
1979
+ this.pushTerminals();
1980
+ // The new runtime re-discovered skills/templates — refresh the catalog
1981
+ // so the picker stops showing the previous runtime's list.
1982
+ void this.pushSlashCommands();
1983
+ }
1984
+ catch (err) {
1985
+ this.emit({
1986
+ type: "notice",
1987
+ level: "error",
1988
+ text: `新建对话失败:${err.message}`,
1989
+ });
1990
+ }
1991
+ this.flushSnapshot();
1992
+ }
1993
+ /**
1994
+ * The active conversation is being left (new_chat / switch_conversation /
1995
+ * set_cwd). Runs the running-list lifecycle:
1996
+ *
1997
+ * - still streaming → it becomes a background run: ensure it is listed;
1998
+ * - idle + listed + continued → keep it (the user did continue it);
1999
+ * - any retained terminal state → keep it listed until the terminals are closed;
2000
+ * - idle + just-viewed (never prompted this visit) → ALSO kept listed, as
2001
+ * long as the owning project is under its MAX_OPEN_CONVERSATIONS cap.
2002
+ * Disposing on every glance meant switching back to a project you'd only
2003
+ * looked at (not typed into) paid a full cold restart — a fresh
2004
+ * AgentSessionRuntime + SessionManager.continueRecent() disk resume +
2005
+ * TerminalManager — every single time. That cost is real on any
2006
+ * platform but lands hardest on Windows (slower fs I/O, AV scanning
2007
+ * every spawned process/file, ConPTY init), which is what made "project
2008
+ * switching" itself feel slow rather than just the first visit. Idle
2009
+ * conversations are cheap to hold (no streaming, no PTYs) — bounding by
2010
+ * the existing per-project cap keeps memory use where it already was
2011
+ * allowed to go, just makes it more likely to actually get there.
2012
+ * - only over-cap does the caller now actually drop it (returns it so
2013
+ * removal happens after the active conversation has been switched away).
2014
+ */
2015
+ displaceActive() {
2016
+ const conv = this.conv;
2017
+ // An isolated reviewer can keep working while the main session is idle;
2018
+ // retain that conversation so its review is not disposed when the user
2019
+ // switches away without sending another prompt.
2020
+ if (conv.session.isStreaming) {
2021
+ conv.listed = true;
2022
+ return null;
2023
+ }
2024
+ // Terminal state is a reason to keep an otherwise idle conversation alive:
2025
+ // switching chats must not kill a PTY the user or agent may still need.
2026
+ if (conv.terminals.list().length > 0) {
2027
+ conv.listed = true;
2028
+ return null;
2029
+ }
2030
+ if (conv.wiki)
2031
+ return conv;
2032
+ if (conv.listed && conv.promptedSinceActive)
2033
+ return null;
2034
+ // Idle and never continued this visit — still worth keeping warm
2035
+ // (see above) unless doing so would push this project over its cap,
2036
+ // in which case free the slot the old way.
2037
+ const openInProject = [...this.convs.values()].filter((c) => c.cwd === conv.cwd).length;
2038
+ if (openInProject < MAX_OPEN_CONVERSATIONS) {
2039
+ conv.listed = true;
2040
+ return null;
2041
+ }
2042
+ return conv;
2043
+ }
2044
+ /** Remove a conversation from the running list and free its runtime. The
2045
+ * session stays persisted on disk, so it remains recoverable from the
2046
+ * history list. Never removes the active conversation. */
2047
+ removeConversation(id) {
2048
+ const conv = this.convs.get(id);
2049
+ if (!conv || id === this.activeId)
2050
+ return Promise.resolve();
2051
+ conv.tree?.dispose();
2052
+ conv.webUi.dispose();
2053
+ this.convs.delete(id);
2054
+ conv.terminals.killAll();
2055
+ conv.unsubscribe?.();
2056
+ conv.unsubscribe = undefined;
2057
+ return conv.runtime.dispose().catch(() => { });
2058
+ }
2059
+ /** Switch the ACTIVE conversation without interrupting any other chat. */
2060
+ async switchConversation(id) {
2061
+ if (this.conv.tree?.busy) {
2062
+ this.emitNotice("warning", "等待当前切换完成。");
2063
+ return;
2064
+ }
2065
+ if (!this.convs.has(id) || id === this.activeId)
2066
+ return;
2067
+ const displaced = this.displaceActive();
2068
+ this.activeId = id;
2069
+ this.cwd = this.conv.cwd;
2070
+ // All listed conversations share the current project's cwd, so this is
2071
+ // normally a no-op — kept defensive for stale clients.
2072
+ if (displaced)
2073
+ this.removeConversation(displaced.id);
2074
+ this.conv.promptedSinceActive = false;
2075
+ this.conv.lastActiveAt = Date.now();
2076
+ this.webUi.refresh();
2077
+ this.emitConversations();
2078
+ this.pushTerminals();
2079
+ // The switched-to conversation has its own runtime (own resource cache).
2080
+ void this.pushSlashCommands();
2081
+ this.flushSnapshot();
2082
+ }
2083
+ /** Push the current project's running-conversation list to the client. */
2084
+ emitConversations() {
2085
+ const conversations = [];
2086
+ for (const conv of this.convs.values()) {
2087
+ // Wiki history stays out of the project list. Keep only running Wiki
2088
+ // tasks reachable so users can stop a background run.
2089
+ if (conv.wiki && !conv.session.isStreaming)
2090
+ continue;
2091
+ // Always include the active conversation. A fresh session has no
2092
+ // transcript yet, so history cannot provide its sidebar row.
2093
+ if (conv.cwd !== this.cwd || (!conv.listed && conv.id !== this.activeId))
2094
+ continue;
2095
+ let messageCount = 0;
2096
+ let isStreaming = false;
2097
+ try {
2098
+ messageCount = conv.session.getSessionStats().totalMessages;
2099
+ isStreaming = conv.session.isStreaming;
2100
+ }
2101
+ catch {
2102
+ // session being replaced — report defaults
2103
+ }
2104
+ conversations.push({
2105
+ branchPoints: conv.tree?.refresh().branchPoints,
2106
+ parentSessionPath: conv.session.sessionManager.getHeader()?.parentSession,
2107
+ id: conv.id,
2108
+ createdAt: conv.createdAt,
2109
+ title: conv.title,
2110
+ cwd: conv.cwd,
2111
+ messageCount,
2112
+ isStreaming,
2113
+ // resolve() so it is byte-comparable with the paths
2114
+ // SessionManager.list() reports to the client — the two are
2115
+ // compared as plain strings there, and an unnormalized form
2116
+ // here would make the dedup silently miss.
2117
+ sessionFile: conv.session.sessionFile
2118
+ ? resolve(conv.session.sessionFile)
2119
+ : undefined,
2120
+ });
2121
+ }
2122
+ this.emit({
2123
+ type: "conversations",
2124
+ conversations,
2125
+ activeId: this.activeId,
2126
+ });
2127
+ }
2128
+ /** List persisted sessions for this client, newest first. */
2129
+ /** The client asked for the session list at least once (lazy loading) —
2130
+ * background refreshes only re-push when this is true, so a mobile
2131
+ * client that never opened the panel never pays the disk scan. */
2132
+ sessionsRequested = false;
2133
+ sessionQueries = new QueryCache(5_000);
2134
+ projectQueries = new QueryCache(30_000, 1);
2135
+ invalidateLists() {
2136
+ this.sessionQueries.clear();
2137
+ this.projectQueries.clear();
2138
+ }
2139
+ /** Push the persisted session list to the client (client-requested). */
2140
+ async refreshSessions() {
2141
+ this.sessionsRequested = true;
2142
+ await this.pushSessions();
2143
+ }
2144
+ branchCounts = new SessionBranchCounts();
2145
+ async pushSessions() {
2146
+ if (!this.sessionsRequested)
2147
+ return;
2148
+ const cwd = this.cwd;
2149
+ try {
2150
+ // Sessions live in the SDK default per-project dir
2151
+ // (<agentDir>/sessions/--<cwd>--/), the same files the pi CLI/TUI
2152
+ // use — one listing covers every conversation of the current folder.
2153
+ const sorted = await this.sessionQueries.get(cwd, async () => {
2154
+ const infos = await SessionManager.list(cwd);
2155
+ // SDK SessionInfo includes allMessagesText. Retain only UI summaries.
2156
+ const sessions = new Map();
2157
+ const selected = infos.sort((a, b) => b.created.getTime() - a.created.getTime()).slice(0, 200);
2158
+ const counts = await this.branchCounts.getMany(selected.map(info => resolve(info.path)));
2159
+ for (const [index, info] of selected.entries()) {
2160
+ const path = resolve(info.path);
2161
+ sessions.set(path, {
2162
+ path, name: info.name, firstMessage: info.firstMessage,
2163
+ parentSessionPath: info.parentSessionPath,
2164
+ branchPoints: counts[index],
2165
+ messageCount: info.messageCount, modified: info.modified.getTime(),
2166
+ created: info.created.getTime(), source: "web",
2167
+ });
2168
+ }
2169
+ // 创建时间新→旧;条目位置此后固定(UI 不再按选中/活动重排)。
2170
+ return [...sessions.values()].sort((a, b) => b.created - a.created).slice(0, 200);
2171
+ }, () => { if (this.cwd === cwd)
2172
+ void this.pushSessions(); });
2173
+ this.emit({ type: "sessions", cwd, sessions: sorted });
2174
+ }
2175
+ catch {
2176
+ this.emit({ type: "sessions", cwd, sessions: [] });
2177
+ }
2178
+ }
2179
+ /** Remove an entry from the client's recent-project list (UI state only). */
2180
+ async removeProject(path) {
2181
+ this.invalidateLists();
2182
+ this.stateStore.removeProject(this.clientId, path);
2183
+ await this.pushProjects();
2184
+ }
2185
+ /** Permanently delete a persisted session transcript file (history list ✕). */
2186
+ async deleteSession(path) {
2187
+ this.invalidateLists();
2188
+ try {
2189
+ const abs = resolve(path);
2190
+ // Guardrail: only transcripts under the shared sessions root
2191
+ // (<agentDir>/sessions/) may be deleted — never arbitrary files.
2192
+ const sessionsRoot = resolve(this.agentDir, "sessions");
2193
+ if (!abs.startsWith(sessionsRoot + sep)) {
2194
+ this.emit({
2195
+ type: "notice",
2196
+ level: "error",
2197
+ text: "只能删除会话目录中的对话记录",
2198
+ });
2199
+ return;
2200
+ }
2201
+ // Warm, idle runtimes are caches, not active use. Protect actual work
2202
+ // before releasing any matching runtimes and deleting the transcript.
2203
+ const owners = [...this.convs.values()].filter((conv) => conv.session.sessionFile && resolve(conv.session.sessionFile) === abs);
2204
+ for (const conv of owners) {
2205
+ if (conv.id === this.activeId || conv.session.isStreaming ||
2206
+ conv.queueSteering.length > 0 || conv.queueFollowUp.length > 0 ||
2207
+ conv.terminals.list().length > 0) {
2208
+ this.emit({
2209
+ type: "notice",
2210
+ level: "warning",
2211
+ text: conv.id === this.activeId
2212
+ ? "该对话正在使用中,请先切换到其他对话再删除"
2213
+ : "该对话仍有后台任务或终端,请先停止任务并关闭终端再删除",
2214
+ });
2215
+ return;
2216
+ }
2217
+ }
2218
+ await Promise.all(owners.map((conv) => this.removeConversation(conv.id)));
2219
+ rmSync(abs, { force: true });
2220
+ this.emitConversations();
2221
+ await this.refreshSessions();
2222
+ }
2223
+ catch (err) {
2224
+ this.emit({
2225
+ type: "notice",
2226
+ level: "error",
2227
+ text: `删除会话失败:${err.message}`,
2228
+ });
2229
+ }
2230
+ }
2231
+ async browseDirs(path) {
2232
+ return browseDirs(path, (msg) => this.emit(msg));
2233
+ }
2234
+ /** Rename a persisted session (history list ✎).
2235
+ *
2236
+ * The display name lives in the transcript itself as a `session_info`
2237
+ * entry, so renaming means appending one via the SDK rather than touching
2238
+ * any sidecar state — `SessionManager.list()` already surfaces the latest
2239
+ * one as `SessionInfo.name`, which is what the panel renders. An empty
2240
+ * name clears it and the list falls back to the first user message.
2241
+ */
2242
+ async renameSession(path, name) {
2243
+ this.invalidateLists();
2244
+ try {
2245
+ const abs = resolve(path);
2246
+ // Same guardrail as deleteSession: only transcripts under the
2247
+ // shared sessions root may be written, never arbitrary files.
2248
+ const sessionsRoot = resolve(this.agentDir, "sessions");
2249
+ if (!abs.startsWith(sessionsRoot + sep)) {
2250
+ this.emit({
2251
+ type: "notice",
2252
+ level: "error",
2253
+ text: "只能重命名会话目录中的对话记录",
2254
+ });
2255
+ return;
2256
+ }
2257
+ const trimmed = name.trim().slice(0, 120);
2258
+ // A live conversation holds its own SessionManager on this file;
2259
+ // append through that one so the two don't fight over the tail.
2260
+ const liveConv = [...this.convs.values()].find((conv) => conv.session.sessionFile === abs);
2261
+ if (liveConv) {
2262
+ liveConv.tree?.assertWritable();
2263
+ liveConv.session.sessionManager.appendSessionInfo(trimmed);
2264
+ liveConv.title = trimmed || conversationTitle(liveConv.session);
2265
+ this.emitConversations();
2266
+ }
2267
+ else {
2268
+ SessionManager.open(abs).appendSessionInfo(trimmed);
2269
+ }
2270
+ await this.refreshSessions();
2271
+ }
2272
+ catch (err) {
2273
+ this.emit({
2274
+ type: "notice",
2275
+ level: "error",
2276
+ text: `重命名会话失败:${err.message}`,
2277
+ });
2278
+ }
2279
+ }
2280
+ /** Open a persisted session as the active conversation (from listSessions).
2281
+ *
2282
+ * A persisted-session click must follow the same ownership rule as
2283
+ * new_chat/switch_conversation: every open conversation keeps its own
2284
+ * runtime. AgentSessionRuntime.switchSession() tears down (and aborts) the
2285
+ * current runtime, which would otherwise stop a response merely because the
2286
+ * user opened history while it was streaming.
2287
+ */
2288
+ async switchSession(path) {
2289
+ if (this.conv.tree?.busy) {
2290
+ this.emitNotice("warning", "等待当前切换完成。");
2291
+ return;
2292
+ }
2293
+ if (this.quiesceBlocked())
2294
+ return;
2295
+ let openedRuntime = null;
2296
+ let openedTerminals = null;
2297
+ try {
2298
+ const targetPath = resolve(path);
2299
+ // A session may already be open in the running-conversation map. Reuse it
2300
+ // instead of creating a second writer for the same JSONL transcript.
2301
+ for (const conv of this.convs.values()) {
2302
+ const sessionFile = conv.session.sessionFile;
2303
+ if (sessionFile && resolve(sessionFile) === targetPath) {
2304
+ await this.switchConversation(conv.id);
2305
+ return;
2306
+ }
2307
+ }
2308
+ const sessionManager = SessionManager.open(targetPath);
2309
+ const targetCwd = sessionManager.getCwd();
2310
+ const conversationId = this.nextConversationId();
2311
+ openedTerminals = this.makeTerminalManager(conversationId, targetCwd);
2312
+ openedRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(), {
2313
+ cwd: targetCwd,
2314
+ agentDir: this.agentDir,
2315
+ sessionManager,
2316
+ });
2317
+ // Only displace the old active conversation after the replacement runtime
2318
+ // is known-good. This keeps a failed history open entirely non-destructive.
2319
+ const oldListed = this.conv.listed;
2320
+ const displaced = this.displaceActive();
2321
+ const openInProject = [...this.convs.values()].filter((c) => c.cwd === targetCwd).length +
2322
+ 1 -
2323
+ (displaced?.cwd === targetCwd ? 1 : 0);
2324
+ if (openInProject > MAX_OPEN_CONVERSATIONS) {
2325
+ // displaceActive() may have promoted a streaming conversation into the
2326
+ // running list. Roll that presentation-only mutation back because no
2327
+ // switch will take place.
2328
+ this.conv.listed = oldListed;
2329
+ openedTerminals.killAll();
2330
+ await openedRuntime.dispose();
2331
+ openedRuntime = null;
2332
+ openedTerminals = null;
2333
+ this.emit({
2334
+ type: "notice",
2335
+ level: "warning",
2336
+ text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先打开某个对话并离开(不继续对话)以移出列表`,
2337
+ });
2338
+ return;
2339
+ }
2340
+ const conv = this.makeConversation(openedRuntime, conversationId, openedTerminals);
2341
+ // Deliberately resumed — must not be dismissed when the user later
2342
+ // switches away without sending a new message.
2343
+ conv.promptedSinceActive = true;
2344
+ this.convs.set(conv.id, conv);
2345
+ this.activeId = conv.id;
2346
+ openedRuntime = null;
2347
+ openedTerminals = null;
2348
+ if (displaced)
2349
+ this.removeConversation(displaced.id);
2350
+ await this.bindSession();
2351
+ this.cwd = targetCwd;
2352
+ this.conv.lastActiveAt = Date.now();
2353
+ this.webUi.refresh();
2354
+ this.emitConversations();
2355
+ this.pushTerminals();
2356
+ // The restored conversation has a fresh project-bound resource cache.
2357
+ void this.pushSlashCommands();
2358
+ }
2359
+ catch (err) {
2360
+ openedTerminals?.killAll();
2361
+ if (openedRuntime)
2362
+ await openedRuntime.dispose().catch(() => { });
2363
+ this.emit({
2364
+ type: "notice",
2365
+ level: "error",
2366
+ text: `切换会话失败:${err.message}`,
2367
+ });
2368
+ }
2369
+ this.flushSnapshot();
2370
+ }
2371
+ /**
2372
+ * Map a rendered user-message id (`u-<timestamp>-<seq>`, assigned in
2373
+ * serialize.ts) back to its append-only session entry id. The seq handles
2374
+ * two user messages sharing the same millisecond timestamp.
2375
+ */
2376
+ resolveUserMessageEntryId(messageId) {
2377
+ const m = /^u-(\d+)(?:-(\d+))?$/.exec(messageId);
2378
+ if (!m)
2379
+ return null;
2380
+ const ts = Number(m[1]);
2381
+ const seq = m[2] ? Number(m[2]) : 1;
2382
+ let count = 0;
2383
+ // Resolve against the compaction-aware current leaf path — the same list
2384
+ // the UI renders (state.messages). Scanning the whole file (getEntries)
2385
+ // could match a summarized entry or one on a different branch.
2386
+ for (const entry of this.session.sessionManager.buildContextEntries()) {
2387
+ if (entry.type !== "message")
2388
+ continue;
2389
+ const msg = entry.message;
2390
+ if (!msg || msg.role !== "user" || msg.timestamp !== ts)
2391
+ continue;
2392
+ count += 1;
2393
+ if (count === seq)
2394
+ return entry.id;
2395
+ }
2396
+ return null;
2397
+ }
2398
+ /** Re-ask on a native in-file branch by default; an explicit choice or UI
2399
+ * preference preserves the separate-file fork behavior. Attachments travel
2400
+ * through prompt() again because their original asides remain on the old path. */
2401
+ async editMessage(messageId, text, attachments, options = {}) {
2402
+ if (this.quiesceBlocked())
2403
+ return;
2404
+ const trimmed = text.trim();
2405
+ if (!trimmed) {
2406
+ this.emit({
2407
+ type: "notice",
2408
+ level: "warning",
2409
+ text: "编辑内容为空,已取消",
2410
+ });
2411
+ this.flushSnapshot();
2412
+ return;
2413
+ }
2414
+ const conv = this.conv;
2415
+ if (options.conversationId && options.conversationId !== conv.id)
2416
+ return;
2417
+ const entryId = options.entryId ?? this.resolveUserMessageEntryId(messageId);
2418
+ if (!entryId) {
2419
+ this.emit({
2420
+ type: "notice",
2421
+ level: "error",
2422
+ text: "找不到要编辑的消息(可能已被压缩或不在当前分支)",
2423
+ });
2424
+ this.flushSnapshot();
2425
+ return;
2426
+ }
2427
+ try {
2428
+ attachments = resolveNativeAttachments(conv.session, attachments);
2429
+ validateEditorSnapshots(this.cwd, attachments);
2430
+ // Preserve the model the user had selected — fork() seeds a new
2431
+ // branch with the ModelRuntime default model otherwise.
2432
+ const prevModel = this.session.agent.state.model ?? null;
2433
+ conv.tree?.assertWritable();
2434
+ const entry = conv.session.sessionManager.getEntry(entryId);
2435
+ if (entry?.type !== "message" || entry.message.role !== "user")
2436
+ throw new Error("只能编辑用户消息。");
2437
+ const newSession = options.newSession ?? this.settingsSvc.current.editResendNewSession ?? false;
2438
+ let status = "error";
2439
+ const context = { conversationId: conv.id, reqId: `edit-${randomUUID()}` };
2440
+ await conv.tree?.request(newSession
2441
+ ? { type: "session_fork", ...context, entryId, position: "before" }
2442
+ : { type: "tree_navigate", ...context, targetId: entryId, summary: "none" }, result => { status = result; });
2443
+ if (status !== "ok" || this.conv !== conv)
2444
+ return;
2445
+ await conv.tree?.waitForVerification();
2446
+ if (newSession && prevModel && this.sharedModelRuntime) {
2447
+ try {
2448
+ await conv.session.setModel(prevModel);
2449
+ }
2450
+ catch { /* Keep the runtime default if unavailable. */ }
2451
+ }
2452
+ await conv.tree?.waitForVerification();
2453
+ if (this.conv !== conv)
2454
+ return;
2455
+ await this.prompt(trimmed, attachments);
2456
+ if (newSession)
2457
+ this.emit({
2458
+ type: "notice",
2459
+ level: "info",
2460
+ text: "已从该问题重新提问(原对话保留在会话列表中)",
2461
+ });
2462
+ }
2463
+ catch (err) {
2464
+ this.emit({
2465
+ type: "notice",
2466
+ level: "error",
2467
+ text: `编辑重问失败:${err.message}`,
2468
+ });
2469
+ }
2470
+ this.flushSnapshot();
2471
+ }
2472
+ /**
2473
+ * Push the recent-project list (persisted per client, merged with every cwd
2474
+ * that has persisted sessions in this client's session store — so workspaces
2475
+ * opened before the recent-list feature existed still show up).
2476
+ */
2477
+ async pushProjects() {
2478
+ try {
2479
+ const saved = this.stateStore.get(this.clientId);
2480
+ const removedProjects = new Set(this.stateStore.getRemovedProjects(this.clientId));
2481
+ const all = await this.projectQueries.get("all", async () => {
2482
+ const newest = new Map();
2483
+ for (const info of await SessionManager.listAll()) {
2484
+ if (!info.cwd)
2485
+ continue;
2486
+ const previous = newest.get(info.cwd);
2487
+ newest.set(info.cwd, {
2488
+ lastUsed: Math.max(previous?.lastUsed ?? 0, info.modified.getTime()),
2489
+ conversationCount: (previous?.conversationCount ?? 0) + 1,
2490
+ // 首次添加锚点:该 cwd 下最早的会话创建时间。
2491
+ firstAdded: Math.min(previous?.firstAdded ?? Number.MAX_SAFE_INTEGER, info.created.getTime()),
2492
+ });
2493
+ }
2494
+ return [...newest].map(([path, summary]) => ({ path, ...summary }));
2495
+ }, () => { void this.pushProjects(); });
2496
+ // Only keep directories that still exist — a deleted/unmounted workspace
2497
+ // is useless in the picker. Tombstoned entries (explicitly removed by
2498
+ // the user) stay hidden even though session files still mention them.
2499
+ // Order is most recently used/activity first (mergeProjectSummaries).
2500
+ const projects = mergeProjectSummaries(saved.projects.filter((p) => existsSync(p.path)), all.filter((p) => existsSync(p.path)), removedProjects);
2501
+ this.stateStore.rememberDisplayedProjects(this.clientId, projects);
2502
+ this.emit({ type: "projects", projects });
2503
+ }
2504
+ catch {
2505
+ this.emit({ type: "projects", projects: [] });
2506
+ }
2507
+ }
2508
+ /** List a workspace directory (relative to the configured cwd). */
2509
+ async listFiles(relPath) {
2510
+ return this.files.listFiles(relPath);
2511
+ }
2512
+ checkConversationFiles(cwd, reqId, paths) {
2513
+ const current = this.cwd;
2514
+ this.emit({ type: "conversation_files_checked", cwd, reqId, paths: cwd === current && Array.isArray(paths) ? existingConversationFiles(current, paths) : [] });
2515
+ }
2516
+ /** 全局搜索:递归文件名匹配(结果经 search_files_result 回推,reqId 匹配)。 */
2517
+ async searchFiles(query, reqId) {
2518
+ return this.files.searchFiles(query, reqId);
2519
+ }
2520
+ /** SCM 只读查询(结构化 JSON,reqId 匹配)。 */
2521
+ async gitBranch() { return this.files.gitBranch(); }
2522
+ async scmQuery(kind, reqId, arg) {
2523
+ return this.files.scmQuery(kind, reqId, arg);
2524
+ }
2525
+ /** Read a workspace file for the preview panel (size-capped, binary-safe). */
2526
+ async readFile(relPath, options) {
2527
+ return this.files.readFile(relPath, options);
2528
+ }
2529
+ /** Save text from the file preview panel within the active workspace. */
2530
+ async writeFile(relPath, text, options) {
2531
+ return this.files.writeFile(relPath, text, options);
2532
+ }
2533
+ async cycleModel() {
2534
+ try {
2535
+ this.conv.tree?.assertWritable();
2536
+ await this.session.cycleModel();
2537
+ }
2538
+ catch (err) {
2539
+ this.emit({
2540
+ type: "notice",
2541
+ level: "error",
2542
+ text: `切换模型失败:${err.message}`,
2543
+ });
2544
+ }
2545
+ this.flushSnapshot();
2546
+ }
2547
+ /**
2548
+ * Path completion for the cwd input: expand ~/relative paths, list the parent
2549
+ * directory, and return prefix matches (dirs first, capped).
2550
+ */
2551
+ async completePath(input) {
2552
+ return this.files.completePath(input);
2553
+ }
2554
+ cwdQueue = null;
2555
+ cwdSwitchRunning = false;
2556
+ get switchingWorkspace() { return this.cwdSwitchRunning; }
2557
+ async setCwd(path, id, source) {
2558
+ if (this.conv.tree?.busy) {
2559
+ this.emitNotice("warning", "等待当前切换完成。");
2560
+ return;
2561
+ }
2562
+ return new Promise((done) => {
2563
+ if (this.cwdQueue) {
2564
+ if (this.cwdQueue.id)
2565
+ this.emit({ type: "cwd_result", requestId: this.cwdQueue.id, cwd: this.cwd, ok: false, error: "superseded" });
2566
+ this.cwdQueue.done();
2567
+ }
2568
+ this.cwdQueue = { path, id, source, done };
2569
+ void this.drainCwdQueue();
2570
+ });
2571
+ }
2572
+ async drainCwdQueue() {
2573
+ if (this.cwdSwitchRunning)
2574
+ return;
2575
+ this.cwdSwitchRunning = true;
2576
+ try {
2577
+ while (this.cwdQueue) {
2578
+ const next = this.cwdQueue;
2579
+ this.cwdQueue = null;
2580
+ await this.commitCwd(next.path, next.id, next.source);
2581
+ next.done();
2582
+ }
2583
+ }
2584
+ finally {
2585
+ this.cwdSwitchRunning = false;
2586
+ }
2587
+ }
2588
+ async commitCwd(newCwd, requestId, source) {
2589
+ const startedAt = Date.now();
2590
+ try {
2591
+ const { resolve } = await import("node:path");
2592
+ // Watchers change only after the target has been prepared.
2593
+ const fs = await import("node:fs/promises");
2594
+ const abs = resolve(newCwd);
2595
+ const st = await fs.stat(abs);
2596
+ if (!st.isDirectory()) {
2597
+ throw new Error("路径不是目录");
2598
+ }
2599
+ if (abs === this.cwd) {
2600
+ if (requestId)
2601
+ this.emit({ type: "cwd_result", requestId, cwd: abs, ok: true });
2602
+ this.flushSnapshot(true);
2603
+ return;
2604
+ }
2605
+ // The outgoing conversation is left behind — apply the running-list
2606
+ // lifecycle (removal is deferred until the active conversation is
2607
+ // safely switched away).
2608
+ // Prefer the target project's own most recently active conversation;
2609
+ // only create a fresh one (resuming its most recent session) when the
2610
+ // project has none open yet.
2611
+ let target;
2612
+ for (const c of this.convs.values()) {
2613
+ if (c.cwd === abs && !c.wiki &&
2614
+ (!target || c.lastActiveAt > target.lastActiveAt)) {
2615
+ target = c;
2616
+ }
2617
+ }
2618
+ if (target) {
2619
+ const displaced = this.displaceActive();
2620
+ this.activeId = target.id;
2621
+ if (displaced)
2622
+ this.removeConversation(displaced.id);
2623
+ }
2624
+ else {
2625
+ // First visit to this project: resume its most recent session.
2626
+ const conversationId = this.nextConversationId();
2627
+ const terminals = this.makeTerminalManager(conversationId, abs);
2628
+ const newRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(), {
2629
+ cwd: abs,
2630
+ agentDir: this.agentDir,
2631
+ sessionManager: SessionManager.continueRecent(abs),
2632
+ });
2633
+ const conv = this.makeConversation(newRuntime, conversationId, terminals);
2634
+ this.convs.set(conv.id, conv);
2635
+ try {
2636
+ await this.bindSession(conv);
2637
+ }
2638
+ catch (error) {
2639
+ this.removeConversation(conv.id);
2640
+ throw error;
2641
+ }
2642
+ const displaced = this.displaceActive();
2643
+ this.activeId = conv.id;
2644
+ if (displaced)
2645
+ this.removeConversation(displaced.id);
2646
+ for (const d of newRuntime.diagnostics) {
2647
+ if (d.type !== "info") {
2648
+ this.emit({ type: "notice", level: d.type, text: d.message });
2649
+ }
2650
+ }
2651
+ }
2652
+ this.pushTerminals();
2653
+ this.conv.promptedSinceActive = false;
2654
+ this.conv.lastActiveAt = Date.now();
2655
+ this.cwd = abs;
2656
+ this.files.unwatchGit();
2657
+ this.files.unwatchDir();
2658
+ if (source !== "ui") {
2659
+ try {
2660
+ this.session.sessionManager.appendCustomEntry("pi-harness:cwd-switch", { cwd: abs });
2661
+ }
2662
+ catch { /* A transcript write failure must not turn a completed switch into an error. */ }
2663
+ }
2664
+ const preparedAt = Date.now();
2665
+ this.flushSnapshot(true);
2666
+ if (requestId)
2667
+ this.emit({ type: "cwd_result", requestId, cwd: abs, ok: true,
2668
+ timing: { startedAt, preparedAt, snapshotAt: Date.now() },
2669
+ });
2670
+ // 工作区跟随型插件(编辑器文件树等)同步切根。
2671
+ try {
2672
+ this.onCwdChanged?.(abs);
2673
+ }
2674
+ catch {
2675
+ /* 钩子异常不影响主流程 */
2676
+ }
2677
+ // Remember the new workspace (restore target + recent-project entry).
2678
+ this.stateStore.remember(this.clientId, abs);
2679
+ void this.pushProjects();
2680
+ this.webUi.refresh();
2681
+ this.emitConversations();
2682
+ // Skills / prompt templates are project-bound — refresh the catalog.
2683
+ void this.pushSlashCommands();
2684
+ void this.refreshSessions();
2685
+ // Commands are per-project (.pi/commands.json in the current cwd).
2686
+ void this.listCommands();
2687
+ return;
2688
+ }
2689
+ catch (err) {
2690
+ if (requestId)
2691
+ this.emit({ type: "cwd_result", requestId, cwd: this.cwd, ok: false, error: err.message });
2692
+ this.emit({
2693
+ type: "notice",
2694
+ level: "error",
2695
+ text: `切换工作目录失败:${err.message}`,
2696
+ });
2697
+ }
2698
+ this.flushSnapshot(true);
2699
+ }
2700
+ /** List models that have valid authentication configured. */
2701
+ async listModels() {
2702
+ try {
2703
+ const mr = this.runtime.services.modelRuntime;
2704
+ const available = await mr.getAvailable();
2705
+ const models = available.map((m) => ({
2706
+ id: `${m.provider}/${m.id}`,
2707
+ name: m.name,
2708
+ provider: m.provider,
2709
+ reasoning: m.reasoning,
2710
+ vision: m.input?.includes("image") ?? false,
2711
+ }));
2712
+ this.emit({ type: "models", models });
2713
+ }
2714
+ catch (err) {
2715
+ this.emit({
2716
+ type: "notice",
2717
+ level: "error",
2718
+ text: `获取模型列表失败:${err.message}`,
2719
+ });
2720
+ }
2721
+ }
2722
+ // ---------------------------------------------------------------------------
2723
+ // Goal / review
2724
+ // ---------------------------------------------------------------------------
2725
+ /** Run a git diff (unstaged + staged) in a conversation's workspace, or
2726
+ * "" when not a repo. */
2727
+ /** Switch to a specific model by "provider/id" (e.g. "anthropic/claude-sonnet-5"). */
2728
+ async setModel(modelId) {
2729
+ try {
2730
+ this.conv.tree?.assertWritable();
2731
+ const mr = this.runtime.services.modelRuntime;
2732
+ const slash = modelId.indexOf("/");
2733
+ if (slash <= 0 || slash === modelId.length - 1) {
2734
+ throw new Error(`无效的模型 ID:${modelId}`);
2735
+ }
2736
+ const provider = modelId.slice(0, slash);
2737
+ const id = modelId.slice(slash + 1);
2738
+ const model = mr.getModel(provider, id);
2739
+ if (!model)
2740
+ throw new Error(`模型不存在:${modelId}`);
2741
+ await this.session.setModel(model);
2742
+ }
2743
+ catch (err) {
2744
+ this.emit({
2745
+ type: "notice",
2746
+ level: "error",
2747
+ text: `切换模型失败:${err.message}`,
2748
+ });
2749
+ }
2750
+ this.flushSnapshot();
2751
+ }
2752
+ /** Set the thinking level for future turns. */
2753
+ setThinking(level) {
2754
+ try {
2755
+ this.conv.tree?.assertWritable();
2756
+ this.session.setThinkingLevel(level);
2757
+ }
2758
+ catch (err) {
2759
+ this.emit({
2760
+ type: "notice",
2761
+ level: "error",
2762
+ text: `切换思考强度失败:${err.message}`,
2763
+ });
2764
+ }
2765
+ this.flushSnapshot();
2766
+ }
2767
+ cycleThinking() {
2768
+ try {
2769
+ this.conv.tree?.assertWritable();
2770
+ this.session.cycleThinkingLevel();
2771
+ }
2772
+ catch (err) {
2773
+ this.emit({
2774
+ type: "notice",
2775
+ level: "error",
2776
+ text: `切换思考强度失败:${err.message}`,
2777
+ });
2778
+ }
2779
+ this.flushSnapshot();
2780
+ }
2781
+ /** Push the user command list (.pi/commands.json) to the client. */
2782
+ async listCommands() {
2783
+ const cwd = this.cwd;
2784
+ const { commands, path, warning } = await loadCommands(cwd);
2785
+ if (warning) {
2786
+ this.emit({ type: "notice", level: "warning", text: warning });
2787
+ }
2788
+ this.emit({ type: "commands", commands, path, cwd });
2789
+ }
2790
+ /** Persist the user command list (.pi/commands.json). */
2791
+ async saveCommands(commands) {
2792
+ const cwd = this.cwd;
2793
+ const { path, error } = await saveCommandsFile(cwd, commands);
2794
+ if (error) {
2795
+ this.emit({ type: "notice", level: "error", text: error });
2796
+ return;
2797
+ }
2798
+ this.emit({ type: "commands", commands, path, cwd });
2799
+ this.emit({ type: "notice", level: "info", text: `命令已保存:${path}` });
2800
+ }
2801
+ async dispose() {
2802
+ ClientSession.liveClients.delete(this.clientId);
2803
+ this.disposed = true;
2804
+ for (const conv of this.convs.values()) {
2805
+ conv.terminals.killAll();
2806
+ }
2807
+ if (this.snapshotTimer) {
2808
+ clearTimeout(this.snapshotTimer);
2809
+ this.snapshotTimer = null;
2810
+ }
2811
+ if (this.sessionsTimer) {
2812
+ clearTimeout(this.sessionsTimer);
2813
+ this.sessionsTimer = null;
2814
+ }
2815
+ if (this.widgetsTimer) {
2816
+ clearInterval(this.widgetsTimer);
2817
+ this.widgetsTimer = null;
2818
+ }
2819
+ if (this.stallTimer) {
2820
+ clearInterval(this.stallTimer);
2821
+ this.stallTimer = null;
2822
+ }
2823
+ this.files.unwatchDir();
2824
+ this.files.unwatchGit();
2825
+ this.providerAuth.cancel();
2826
+ for (const conv of this.convs.values()) {
2827
+ conv.tree?.dispose();
2828
+ conv.webUi.dispose();
2829
+ }
2830
+ this.bg.stop();
2831
+ for (const conv of this.convs.values()) {
2832
+ conv.unsubscribe?.();
2833
+ conv.unsubscribe = undefined;
2834
+ try {
2835
+ await conv.runtime.dispose();
2836
+ }
2837
+ catch {
2838
+ // best effort
2839
+ }
2840
+ }
2841
+ }
2842
+ }
2843
+ export class AgentService {
2844
+ cwd;
2845
+ /** index.ts 注入:SDK 工具执行事件的插件转发钩子,attach 时拷贝到每个新会话。 */
2846
+ onToolEvent = undefined;
2847
+ /** index.ts 注入:读取插件当前注册的 AI 工具(attach 时拷贝到每个新会话)。 */
2848
+ /** index.ts 注入:读取插件当前注册的斜杠命令(attach 时拷贝到每个新会话)。 */
2849
+ /** index.ts 注入:读取插件注册的常驻后台任务(并入 bg_servers 面板)。 */
2850
+ pluginBgTasksProvider = undefined;
2851
+ /** index.ts 注入:停止插件任务(kill_background_server with taskId)。 */
2852
+ pluginStopBgTask = undefined;
2853
+ clients = new Map();
2854
+ /** Quiesce (draining) state — the service refuses NEW work (prompts, forks,
2855
+ * session resumes, new clients) so a deploy/upgrade/backup can stop cleanly
2856
+ * once existing runs finish. Controlled via the local control socket:
2857
+ * `pi-harness server quiesce|unquiesce`. */
2858
+ quiesced = false;
2859
+ quiescedAt = 0;
2860
+ /** Attached browser sockets (reported by index.ts on open/close) — the
2861
+ * control socket reports real sockets, not cached client-session objects. */
2862
+ socketCount = 0;
2863
+ pending = new Map();
2864
+ stateStore;
2865
+ thinkingDurationStore;
2866
+ /** Set by index.ts: called when /pi-harness:quit is invoked. */
2867
+ onQuit = undefined;
2868
+ /** 任意客户端成功切换工作区后触发(新绝对路径)。index.ts 接到
2869
+ * PluginManager.notifyCwd,让插件宿主的 host.cwd 实时跟随当前项目。 */
2870
+ onClientCwdChanged = undefined;
2871
+ constructor(cwd, stateFile) {
2872
+ this.cwd = cwd;
2873
+ this.stateStore = new ClientStateStore(stateFile);
2874
+ this.thinkingDurationStore = new ThinkingDurationStore(join(dirname(stateFile), "thinking-durations.json"));
2875
+ const idleMs = clientIdleMsFromEnv(process.env.PI_WEB_CLIENT_IDLE_MINUTES);
2876
+ if (idleMs > 0) {
2877
+ this.evictTimer = setInterval(() => void this.evictIdleClients(idleMs), Math.min(5 * 60_000, idleMs));
2878
+ this.evictTimer.unref();
2879
+ }
2880
+ }
2881
+ evictTimer = null;
2882
+ /** Dispose sessions of clients that left (closed tabs). Persisted state survives; a
2883
+ * returning clientId simply gets a fresh session. Skipped while draining. */
2884
+ async evictIdleClients(idleMs, now = Date.now()) {
2885
+ if (this.quiesced)
2886
+ return 0;
2887
+ const evicted = [];
2888
+ for (const [id, cs] of [...this.clients]) {
2889
+ if (!cs.isEvictable(now, idleMs))
2890
+ continue;
2891
+ this.clients.delete(id);
2892
+ evicted.push(cs);
2893
+ }
2894
+ await Promise.all(evicted.map((cs) => cs.dispose().catch(() => { })));
2895
+ return evicted.length;
2896
+ }
2897
+ /** Get or create the session for a client, racing attach calls safely. */
2898
+ /** True while the service is draining — new work is refused. */
2899
+ isQuiesced() {
2900
+ return this.quiesced;
2901
+ }
2902
+ /** Enter quiesce: stop admitting new work. Existing runs keep going. */
2903
+ quiesce() {
2904
+ this.quiesced = true;
2905
+ this.quiescedAt = Date.now();
2906
+ }
2907
+ /** Leave quiesce: admit new work again. */
2908
+ unquiesce() {
2909
+ this.quiesced = false;
2910
+ this.quiescedAt = 0;
2911
+ }
2912
+ /** Snapshot for the control socket / status command. */
2913
+ quiesceInfo() {
2914
+ return this.quiesced
2915
+ ? { quiesced: true, quiescedSince: this.quiescedAt }
2916
+ : { quiesced: false };
2917
+ }
2918
+ /** Aggregate across every client session: conversations with in-flight runs. */
2919
+ activeConversations() {
2920
+ let n = 0;
2921
+ for (const cs of this.clients.values())
2922
+ n += cs.activeConversations();
2923
+ return n;
2924
+ }
2925
+ /** Aggregate across every client session: messages queued in the SDK. */
2926
+ pendingMessages() {
2927
+ let n = 0;
2928
+ for (const cs of this.clients.values())
2929
+ n += cs.pendingMessages();
2930
+ return n;
2931
+ }
2932
+ /** index.ts calls this when a browser socket opens/closes. */
2933
+ noteSocketOpen() {
2934
+ this.socketCount += 1;
2935
+ }
2936
+ noteSocketClose() {
2937
+ this.socketCount = Math.max(0, this.socketCount - 1);
2938
+ }
2939
+ /** Full status for the control socket / `server status` command. */
2940
+ serviceStatus() {
2941
+ return {
2942
+ pid: process.pid,
2943
+ version: VERSION,
2944
+ cwd: this.cwd,
2945
+ ...this.quiesceInfo(),
2946
+ connectedClients: this.socketCount,
2947
+ clientSessions: this.clients.size,
2948
+ activeConversations: this.activeConversations(),
2949
+ pendingMessages: this.pendingMessages(),
2950
+ };
2951
+ }
2952
+ /** Get or create the session for a client, racing attach calls safely. */
2953
+ async attach(clientId, send) {
2954
+ let cs = this.clients.get(clientId);
2955
+ if (!cs) {
2956
+ const inflight = this.pending.get(clientId);
2957
+ if (inflight) {
2958
+ cs = await inflight;
2959
+ }
2960
+ else {
2961
+ // Restore this client's last-used workspace when it still exists;
2962
+ // Admission gate: while quiesced, only clients with an EXISTING
2963
+ // session may attach (they can watch their runs drain); brand-new
2964
+ // clients are refused — index.ts closes their socket (4403) and the
2965
+ // browser reconnect loop retries after admission reopens.
2966
+ if (this.quiesced) {
2967
+ throw new QuiesceRejectedError("新连接被拒绝,请等服务器恢复后重试");
2968
+ }
2969
+ // otherwise fall back to the server's configured default cwd.
2970
+ let cwd = this.cwd;
2971
+ const saved = this.stateStore.get(clientId);
2972
+ if (saved.lastCwd && saved.lastCwd !== this.cwd) {
2973
+ try {
2974
+ if (statSync(saved.lastCwd).isDirectory())
2975
+ cwd = saved.lastCwd;
2976
+ }
2977
+ catch {
2978
+ // gone (unmounted drive / deleted) — fall back to the default
2979
+ }
2980
+ }
2981
+ // Sessions use the SDK default per-project dir — no per-client dir.
2982
+ const creating = ClientSession.create(clientId, cwd, this.stateStore, this.thinkingDurationStore).finally(() => {
2983
+ this.pending.delete(clientId);
2984
+ });
2985
+ this.pending.set(clientId, creating);
2986
+ cs = await creating;
2987
+ this.clients.set(clientId, cs);
2988
+ // Make sure the restored/default workspace appears in the project list.
2989
+ this.stateStore.remember(clientId, cwd);
2990
+ if (cwd !== this.cwd) {
2991
+ send({
2992
+ type: "notice",
2993
+ level: "info",
2994
+ text: `已恢复上次的工作目录:${cwd}`,
2995
+ });
2996
+ }
2997
+ }
2998
+ }
2999
+ // First attach after a restart: report runs that were interrupted when
3000
+ // the previous process shut down (consumed once, then cleared). Queue
3001
+ // BEFORE attachSink so the notice rides the initial pending-notice flush.
3002
+ cs.notifyInterrupted(this.stateStore.takeInterrupted(clientId));
3003
+ cs.attachSink(send);
3004
+ // Forward hooks (set once by index.ts) to every session.
3005
+ cs.onQuit = this.onQuit;
3006
+ cs.onToolEvent = this.onToolEvent;
3007
+ cs.pluginBgTasksProvider = this.pluginBgTasksProvider;
3008
+ cs.pluginStopBgTask = this.pluginStopBgTask;
3009
+ cs.isQuiesced = () => this.quiesced;
3010
+ // 插件宿主工作区跟随:初次接入也同步一次(恢复的 lastCwd 可能≠服务启动目录),
3011
+ // notifyCwd 幂等去重;此后 set_cwd 成功时由 cs.onCwdChanged 继续驱动。
3012
+ cs.onCwdChanged = (abs) => this.onClientCwdChanged?.(abs);
3013
+ this.onClientCwdChanged?.(cs.cwd);
3014
+ return cs;
3015
+ }
3016
+ /** 插件 AI 工具集合变化(注册/注销)时由 index.ts 触发:推送到所有客户端的全部会话。 */
3017
+ /** 插件斜杠命令集合变化时由 index.ts 触发:重推各客户端的命令目录。 */
3018
+ /** 插件常驻后台任务变化时由 index.ts 触发:重推各客户端的 bg_servers。 */
3019
+ refreshBackgroundServers() {
3020
+ for (const cs of this.clients.values())
3021
+ cs.refreshBgTasks();
3022
+ }
3023
+ /** Remove a socket from a client's broadcast set (called on socket close). */
3024
+ detach(clientId, send) {
3025
+ this.clients.get(clientId)?.detachSink(send);
3026
+ }
3027
+ get(clientId) {
3028
+ return this.clients.get(clientId);
3029
+ }
3030
+ async disposeAll() {
3031
+ if (this.evictTimer)
3032
+ clearInterval(this.evictTimer);
3033
+ this.evictTimer = null;
3034
+ // Record still-streaming conversations BEFORE tearing anything down, so
3035
+ // the next attach can tell the user what was lost (SIGTERM / update).
3036
+ for (const [clientId, cs] of [...this.clients]) {
3037
+ try {
3038
+ const running = cs.streamingSummaries();
3039
+ if (running.length > 0) {
3040
+ this.stateStore.saveInterrupted(clientId, running.map((r) => ({ ...r, at: Date.now() })));
3041
+ }
3042
+ }
3043
+ catch {
3044
+ // best effort — never block shutdown on bookkeeping
3045
+ }
3046
+ }
3047
+ const all = [...this.clients.values()];
3048
+ this.clients.clear();
3049
+ await Promise.all(all.map((cs) => cs.dispose()));
3050
+ }
3051
+ }