@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.
- package/LICENSE +21 -0
- package/README.md +74 -2
- package/bin/pi-harness.mjs +1819 -0
- package/deploy/com.youweichen.pi-harness.plist +48 -0
- package/deploy/nginx-subpath.conf +88 -0
- package/deploy/pi-harness-task.xml +71 -0
- package/deploy/pi-harness.service +31 -0
- package/dist/server/agent-service.js +3051 -0
- package/dist/server/app-version.js +19 -0
- package/dist/server/attachments.js +526 -0
- package/dist/server/auth-file.js +61 -0
- package/dist/server/bg-servers.js +154 -0
- package/dist/server/client-eviction.js +19 -0
- package/dist/server/client-id.js +9 -0
- package/dist/server/client-state.js +202 -0
- package/dist/server/codemode-image-routes.js +36 -0
- package/dist/server/codemode-presentation.js +19 -0
- package/dist/server/component-updates.js +135 -0
- package/dist/server/context-breakdown.js +56 -0
- package/dist/server/control-socket.js +177 -0
- package/dist/server/conversation.js +65 -0
- package/dist/server/dir-browser.js +77 -0
- package/dist/server/editor-snapshot.js +38 -0
- package/dist/server/ensure-bash.js +80 -0
- package/dist/server/extension-display.js +23 -0
- package/dist/server/extensions-catalog.js +64 -0
- package/dist/server/extensions-model.js +46 -0
- package/dist/server/extensions-routes.js +255 -0
- package/dist/server/extensions-worker.js +187 -0
- package/dist/server/files-service.js +795 -0
- package/dist/server/index.js +1029 -0
- package/dist/server/markdown-images.js +42 -0
- package/dist/server/model-admin.js +801 -0
- package/dist/server/model-config-merge.js +32 -0
- package/dist/server/native-mcp-config.js +103 -0
- package/dist/server/native-mcp-presentation.js +12 -0
- package/dist/server/native-prompt-template.js +12 -0
- package/dist/server/native-tools.js +32 -0
- package/dist/server/node-sources.js +142 -0
- package/dist/server/node-workbench.js +668 -0
- package/dist/server/origin-policy.js +50 -0
- package/dist/server/patch-node-pty.js +96 -0
- package/dist/server/pi-environment.js +203 -0
- package/dist/server/plan/extension.js +74 -0
- package/dist/server/plan/progress.js +90 -0
- package/dist/server/plan/settings.js +41 -0
- package/dist/server/plan/state.js +113 -0
- package/dist/server/plugin-facilities.js +304 -0
- package/dist/server/plugin-updater.js +245 -0
- package/dist/server/plugins.js +712 -0
- package/dist/server/private-file.js +28 -0
- package/dist/server/process-utils.js +112 -0
- package/dist/server/prompt-delivery.js +80 -0
- package/dist/server/protocol-version.js +11 -0
- package/dist/server/protocol.js +6 -0
- package/dist/server/provider-auth.js +99 -0
- package/dist/server/query-cache.js +45 -0
- package/dist/server/recovery-state.js +37 -0
- package/dist/server/scm.js +459 -0
- package/dist/server/security-headers.js +24 -0
- package/dist/server/serialize.js +254 -0
- package/dist/server/session-file-read.js +225 -0
- package/dist/server/session-record-worker.js +21 -0
- package/dist/server/session-reload.js +13 -0
- package/dist/server/session-tree-controller.js +250 -0
- package/dist/server/session-tree.js +191 -0
- package/dist/server/settings-service.js +53 -0
- package/dist/server/skills-service.js +87 -0
- package/dist/server/slash-commands.js +280 -0
- package/dist/server/sqlite-preview.js +58 -0
- package/dist/server/sqlite-query.js +67 -0
- package/dist/server/sqlite-worker.js +12 -0
- package/dist/server/system-prompt-files.js +158 -0
- package/dist/server/system-prompt-routes.js +40 -0
- package/dist/server/system-prompt-view.js +49 -0
- package/dist/server/task-progress.js +233 -0
- package/dist/server/terminals.js +833 -0
- package/dist/server/text-sniff.js +269 -0
- package/dist/server/thinking-timing.js +91 -0
- package/dist/server/token-auth.js +37 -0
- package/dist/server/tool-output.js +97 -0
- package/dist/server/tool-text-incidents.js +82 -0
- package/dist/server/tool-text.js +67 -0
- package/dist/server/uploads.js +112 -0
- package/dist/server/user-attachments.js +96 -0
- package/dist/server/webui-context.js +257 -0
- package/dist/server/wiki-links.js +136 -0
- package/dist/server/wiki-pdf-worker.js +23 -0
- package/dist/server/wiki-pdf.js +36 -0
- package/dist/server/wiki-routes.js +177 -0
- package/dist/server/wiki-service.js +511 -0
- package/electron/main.mjs +607 -0
- package/extensions/webui.ts +192 -0
- package/package.json +122 -4
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/NodeWorkbench-C4U1PldE.js +3 -0
- package/web/dist/assets/TerminalPanel-CWh6vw_N.js +2 -0
- package/web/dist/assets/abnfDiagram-VCTEODGH-D5Ti0Kpa.js +1 -0
- package/web/dist/assets/arc-DKJilLd6.js +1 -0
- package/web/dist/assets/architectureDiagram-5GKGNRK7-Cf0w38g-.js +36 -0
- package/web/dist/assets/blockDiagram-I7D4REHJ-BOTLWLIL.js +129 -0
- package/web/dist/assets/c4Diagram-7LVT6UL2-CLCCoJs3.js +38 -0
- package/web/dist/assets/channel-D8Hd7T4P.js +1 -0
- package/web/dist/assets/chunk-2Q5K7J3B-BHjga2Ge.js +1 -0
- package/web/dist/assets/chunk-5VM5RSS4-CQJ5VfDZ.js +15 -0
- package/web/dist/assets/chunk-F27PBJKO-C67qoPJH.js +1 -0
- package/web/dist/assets/chunk-IMKFNOWR-CgXXGwI8.js +231 -0
- package/web/dist/assets/chunk-JWPE2WC7-D0RPyByP.js +1 -0
- package/web/dist/assets/chunk-POPQ4Y6H-BeYC9s9x.js +1 -0
- package/web/dist/assets/chunk-SVP7TREG-eQPl4UBQ.js +88 -0
- package/web/dist/assets/chunk-TICWLB2K-DT73arPU.js +206 -0
- package/web/dist/assets/chunk-XXDRQBXY-B4FwQcP-.js +1 -0
- package/web/dist/assets/classDiagram-ZZMXUADV-B_btgYRB.js +1 -0
- package/web/dist/assets/classDiagram-v2-VYDZK3BY-B_btgYRB.js +1 -0
- package/web/dist/assets/cose-bilkent-JH36ORCC-Bnn5nlQN.js +1 -0
- package/web/dist/assets/cynefin-OW5HDTMX-CStWX9kE.js +178 -0
- package/web/dist/assets/cynefinDiagram-5FMLGOSQ-BMVHC03f.js +62 -0
- package/web/dist/assets/cytoscape.esm-Bch-eiPH.js +331 -0
- package/web/dist/assets/dagre-GXQ25YYZ-ClSaO4ml.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-S7CK7UJ4-C43LL08C.js +30 -0
- package/web/dist/assets/diagram-UQ7AKVKN-BPVn_ySW.js +41 -0
- package/web/dist/assets/diagram-VSXAHHWV-C34FFhRi.js +3 -0
- package/web/dist/assets/diagram-VX7I27RA-BRk4bncf.js +24 -0
- package/web/dist/assets/diagram-Z3DM3KII-O5hYPLEd.js +24 -0
- package/web/dist/assets/ebnfDiagram-PWID7BFC-DVGEBm99.js +1 -0
- package/web/dist/assets/erDiagram-RLTQ6QDP-M-BLpTEV.js +99 -0
- package/web/dist/assets/flowDiagram-HODETNUW-BwdLopEx.js +168 -0
- package/web/dist/assets/ganttDiagram-EL5Y4UJY-DYxx2ppA.js +292 -0
- package/web/dist/assets/gitGraphDiagram-WWUBYQGX-DUU0wMYB.js +106 -0
- package/web/dist/assets/index-CI2KfVSr.css +10 -0
- package/web/dist/assets/index-DQcpggFU.js +275 -0
- package/web/dist/assets/infoDiagram-27XIBGKW-CFaW5C0C.js +2 -0
- package/web/dist/assets/init-Gi6I4Gst.js +1 -0
- package/web/dist/assets/ishikawaDiagram-5VMMS53U--ENP1MTB.js +70 -0
- package/web/dist/assets/journeyDiagram-3NMN7TZE-DgnAzSTj.js +139 -0
- package/web/dist/assets/kanban-definition-UXKFOSKX-_STZN1vU.js +89 -0
- package/web/dist/assets/katex-C5jXJg4s.js +257 -0
- package/web/dist/assets/layout-CkUClkXt.js +1 -0
- package/web/dist/assets/linear-sYHiUOxa.js +1 -0
- package/web/dist/assets/markdown-vNTCZJ6d.js +51 -0
- package/web/dist/assets/mermaid.core-B8fKGWsc.js +312 -0
- package/web/dist/assets/mindmap-definition-YA3MSWOX-DP0BYOEx.js +96 -0
- package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/web/dist/assets/pegDiagram-XKGWAZYB-DkWTmZre.js +1 -0
- package/web/dist/assets/pieDiagram-E7YTZNPT-DfZgMV4u.js +39 -0
- package/web/dist/assets/quadrantDiagram-AXDQQJYC-Djl4TT7q.js +7 -0
- package/web/dist/assets/railroadDiagram-O6MQD6OU-DbKF6zXn.js +1 -0
- package/web/dist/assets/react-BNj3n7jX.js +24 -0
- package/web/dist/assets/requirementDiagram-BXWQKSXE-idola5qb.js +84 -0
- package/web/dist/assets/sankeyDiagram-P5KCCOFB-KA0hQOle.js +40 -0
- package/web/dist/assets/sequenceDiagram-WJ2MYXX4-BXSKvxzP.js +162 -0
- package/web/dist/assets/sizeCapture-INFHLROL-C_Ewv3S5.js +1 -0
- package/web/dist/assets/stateDiagram-D77RDMKH-DIf5X8oo.js +1 -0
- package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DYwe_2mB.js +1 -0
- package/web/dist/assets/swimlanes-42K2YHIH-Cx_Rc8M2.js +1 -0
- package/web/dist/assets/swimlanesDiagram-VR7AAH4N-52Y8tgRV.js +8 -0
- package/web/dist/assets/timeline-definition-24CTP7MA-BRrDhWVz.js +120 -0
- package/web/dist/assets/vennDiagram-4TSXK5OY-Ad6G0YwD.js +34 -0
- package/web/dist/assets/wardleyDiagram-VM6X3IG4-X6-ENpSi.js +78 -0
- package/web/dist/assets/xterm-6GBZ9nXN.css +32 -0
- package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
- package/web/dist/assets/xychartDiagram-S5SC5T6Z-DhegNS6d.js +7 -0
- package/web/dist/brand-mark.svg +5 -0
- package/web/dist/favicon-32.png +0 -0
- package/web/dist/favicon.svg +5 -0
- package/web/dist/fonts/WIKI-FONTS.md +5 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-sans/LICENSE +93 -0
- package/web/dist/fonts/ibm-plex-sans/ibm-plex-sans-latin-400-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-sans/ibm-plex-sans-latin-500-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-sans/ibm-plex-sans-latin-600-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-sans/ibm-plex-sans-latin-ext-400-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-sans/ibm-plex-sans-latin-ext-500-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-sans/ibm-plex-sans-latin-ext-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-400-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-500-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-700-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-800-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
- package/web/dist/fonts/jetbrains-mono/LICENSE +93 -0
- package/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-600-normal.woff2 +0 -0
- package/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- package/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
- package/web/dist/fonts/jetbrains-mono/jetbrains-mono-latin-ext-600-normal.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/LICENSE +94 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-0.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-1.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-10.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-100.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-11.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-12.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-13.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-14.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-15.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-16.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-17.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-18.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-19.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-2.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-20.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-21.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-22.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-23.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-24.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-25.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-26.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-27.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-28.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-29.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-3.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-30.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-31.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-32.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-33.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-34.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-35.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-36.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-37.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-38.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-39.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-4.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-40.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-41.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-42.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-43.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-44.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-45.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-46.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-47.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-48.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-49.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-5.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-50.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-51.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-52.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-53.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-54.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-55.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-56.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-57.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-58.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-59.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-6.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-60.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-61.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-62.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-63.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-64.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-65.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-66.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-67.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-68.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-69.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-7.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-70.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-71.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-72.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-73.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-74.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-75.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-76.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-77.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-78.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-79.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-8.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-80.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-81.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-82.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-83.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-84.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-85.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-86.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-87.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-88.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-89.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-9.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-90.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-91.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-92.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-93.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-94.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-95.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-96.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-97.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-98.woff2 +0 -0
- package/web/dist/fonts/noto-serif-sc/noto-serif-sc-500-99.woff2 +0 -0
- package/web/dist/fonts/source-serif-4/LICENSE +93 -0
- package/web/dist/fonts/source-serif-4/source-serif-4-500-0.woff2 +0 -0
- package/web/dist/fonts/source-serif-4/source-serif-4-500-1.woff2 +0 -0
- package/web/dist/fonts/source-serif-4/source-serif-4-500-2.woff2 +0 -0
- package/web/dist/fonts/source-serif-4/source-serif-4-500-3.woff2 +0 -0
- package/web/dist/fonts/source-serif-4/source-serif-4-500-4.woff2 +0 -0
- package/web/dist/fonts/source-serif-4/source-serif-4-500-5.woff2 +0 -0
- package/web/dist/icon/1a-mark.svg +1 -0
- package/web/dist/icon.ico +0 -0
- package/web/dist/index.html +18 -0
- package/web/public/apple-touch-icon.png +0 -0
- package/web/public/brand-mark.svg +5 -0
- package/web/public/favicon-32.png +0 -0
- package/web/public/favicon.svg +5 -0
- package/web/public/fonts/WIKI-FONTS.md +5 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-sans/LICENSE +93 -0
- package/web/public/fonts/ibm-plex-sans/ibm-plex-sans-latin-400-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-sans/ibm-plex-sans-latin-500-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-sans/ibm-plex-sans-latin-600-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-sans/ibm-plex-sans-latin-ext-400-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-sans/ibm-plex-sans-latin-ext-500-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-sans/ibm-plex-sans-latin-ext-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-400-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-500-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-700-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-800-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
- package/web/public/fonts/jetbrains-mono/LICENSE +93 -0
- package/web/public/fonts/jetbrains-mono/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/web/public/fonts/jetbrains-mono/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/web/public/fonts/jetbrains-mono/jetbrains-mono-latin-600-normal.woff2 +0 -0
- package/web/public/fonts/jetbrains-mono/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- package/web/public/fonts/jetbrains-mono/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
- package/web/public/fonts/jetbrains-mono/jetbrains-mono-latin-ext-600-normal.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/LICENSE +94 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-0.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-1.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-10.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-100.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-11.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-12.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-13.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-14.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-15.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-16.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-17.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-18.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-19.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-2.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-20.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-21.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-22.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-23.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-24.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-25.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-26.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-27.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-28.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-29.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-3.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-30.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-31.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-32.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-33.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-34.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-35.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-36.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-37.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-38.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-39.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-4.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-40.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-41.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-42.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-43.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-44.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-45.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-46.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-47.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-48.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-49.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-5.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-50.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-51.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-52.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-53.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-54.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-55.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-56.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-57.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-58.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-59.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-6.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-60.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-61.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-62.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-63.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-64.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-65.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-66.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-67.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-68.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-69.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-7.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-70.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-71.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-72.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-73.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-74.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-75.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-76.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-77.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-78.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-79.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-8.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-80.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-81.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-82.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-83.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-84.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-85.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-86.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-87.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-88.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-89.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-9.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-90.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-91.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-92.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-93.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-94.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-95.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-96.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-97.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-98.woff2 +0 -0
- package/web/public/fonts/noto-serif-sc/noto-serif-sc-500-99.woff2 +0 -0
- package/web/public/fonts/source-serif-4/LICENSE +93 -0
- package/web/public/fonts/source-serif-4/source-serif-4-500-0.woff2 +0 -0
- package/web/public/fonts/source-serif-4/source-serif-4-500-1.woff2 +0 -0
- package/web/public/fonts/source-serif-4/source-serif-4-500-2.woff2 +0 -0
- package/web/public/fonts/source-serif-4/source-serif-4-500-3.woff2 +0 -0
- package/web/public/fonts/source-serif-4/source-serif-4-500-4.woff2 +0 -0
- package/web/public/fonts/source-serif-4/source-serif-4-500-5.woff2 +0 -0
- package/web/public/icon/1a-mark.svg +1 -0
- 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
|
+
}
|