@rexvane/piweb 0.3.24 → 0.3.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (285) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +11 -11
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/prerender-manifest.json +5 -5
  5. package/.next/react-loadable-manifest.json +13 -20
  6. package/.next/required-server-files.js +2 -1
  7. package/.next/required-server-files.json +2 -1
  8. package/.next/routes-manifest.json +6 -8
  9. package/.next/server/app/_global-error/page.js +2 -2
  10. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  11. package/.next/server/app/_global-error.html +1 -1
  12. package/.next/server/app/_global-error.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  15. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/server/app/_not-found/page.js +1 -1
  17. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/server/app/_not-found.html +1 -1
  19. package/.next/server/app/_not-found.rsc +2 -2
  20. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  21. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  22. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/server/app/api/agent/[id]/events/route.js +2 -2
  24. package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
  25. package/.next/server/app/api/agent/[id]/route.js +1 -1
  26. package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
  27. package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
  28. package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
  29. package/.next/server/app/api/agent/new/route.js +1 -1
  30. package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
  31. package/.next/server/app/api/agent/resources/route.js +1 -0
  32. package/.next/server/app/api/agent/resources/route.js.nft.json +1 -0
  33. package/.next/server/app/api/agent/resources/route_client-reference-manifest.js +1 -0
  34. package/.next/server/app/api/config/route.js +1 -1
  35. package/.next/server/app/api/config/route.js.nft.json +1 -1
  36. package/.next/server/app/api/files/route.js +1 -1
  37. package/.next/server/app/api/files/route.js.nft.json +1 -1
  38. package/.next/server/app/api/fs/route.js +1 -1
  39. package/.next/server/app/api/fs/route.js.nft.json +1 -1
  40. package/.next/server/app/api/git/route.js +4 -4
  41. package/.next/server/app/api/git/route.js.nft.json +1 -1
  42. package/.next/server/app/api/github/route.js +4 -4
  43. package/.next/server/app/api/github/route.js.nft.json +1 -1
  44. package/.next/server/app/api/growth/route.js +1 -1
  45. package/.next/server/app/api/growth/route.js.nft.json +1 -1
  46. package/.next/server/app/api/health/route.js +1 -1
  47. package/.next/server/app/api/models/defaults/route.js +1 -1
  48. package/.next/server/app/api/models/defaults/route.js.nft.json +1 -1
  49. package/.next/server/app/api/models/route.js +1 -1
  50. package/.next/server/app/api/models/route.js.nft.json +1 -1
  51. package/.next/server/app/api/pi-settings/route.js +1 -1
  52. package/.next/server/app/api/pi-settings/route.js.nft.json +1 -1
  53. package/.next/server/app/api/plugins/route.js +1 -1
  54. package/.next/server/app/api/plugins/route.js.nft.json +1 -1
  55. package/.next/server/app/api/prompts/route.js +1 -1
  56. package/.next/server/app/api/prompts/route.js.nft.json +1 -1
  57. package/.next/server/app/api/remote/route.js +4 -3
  58. package/.next/server/app/api/security/route.js +1 -1
  59. package/.next/server/app/api/security/route.js.nft.json +1 -1
  60. package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
  61. package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
  62. package/.next/server/app/api/sessions/[id]/route.js +1 -1
  63. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  64. package/.next/server/app/api/sessions/import/route.js +2 -2
  65. package/.next/server/app/api/sessions/import/route.js.nft.json +1 -1
  66. package/.next/server/app/api/sessions/route.js +1 -1
  67. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  68. package/.next/server/app/api/skills/route.js +2 -2
  69. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  70. package/.next/server/app/api/update/route.js +2 -2
  71. package/.next/server/app/api/update/route.js.nft.json +1 -1
  72. package/.next/server/app/api/version/route.js +1 -1
  73. package/.next/server/app/api/version/route.js.nft.json +1 -1
  74. package/.next/server/app/api/web-auth/route.js +2 -2
  75. package/.next/server/app/api/workspaces/route.js +1 -1
  76. package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  77. package/.next/server/app/icon.svg/route.js +1 -1
  78. package/.next/server/app/index.html +1 -1
  79. package/.next/server/app/index.rsc +6 -7
  80. package/.next/server/app/index.segments/__PAGE__.segment.rsc +6 -7
  81. package/.next/server/app/index.segments/_full.segment.rsc +6 -7
  82. package/.next/server/app/index.segments/_tree.segment.rsc +2 -3
  83. package/.next/server/app/login/page.js +1 -1
  84. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  85. package/.next/server/app/login.html +1 -1
  86. package/.next/server/app/login.rsc +3 -3
  87. package/.next/server/app/login.segments/_full.segment.rsc +3 -3
  88. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  89. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +3 -3
  90. package/.next/server/app/page.js +1 -1
  91. package/.next/server/app/page.js.nft.json +1 -1
  92. package/.next/server/app/page_client-reference-manifest.js +1 -1
  93. package/.next/server/app-paths-manifest.json +11 -11
  94. package/.next/server/chunks/3924.js +3 -3
  95. package/.next/server/chunks/538.js +1 -1
  96. package/.next/server/chunks/620.js +1 -0
  97. package/.next/server/chunks/6229.js +1 -1
  98. package/.next/server/chunks/7496.js +1 -1
  99. package/.next/server/chunks/7662.js +2 -2
  100. package/.next/server/chunks/8686.js +1 -1
  101. package/.next/server/functions-config-manifest.json +1 -2
  102. package/.next/server/instrumentation.js.nft.json +1 -1
  103. package/.next/server/middleware-build-manifest.js +1 -1
  104. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  105. package/.next/server/pages/404.html +1 -1
  106. package/.next/server/pages/500.html +1 -1
  107. package/.next/server/preview-props.json +3 -3
  108. package/.next/server/server-reference-manifest.json +1 -1
  109. package/.next/static/QJCtqgDKzGS2BdKABd9ch/_buildManifest.js +1 -0
  110. package/.next/static/chunks/2577.86c8538fbf05a8b2.js +1 -0
  111. package/.next/static/chunks/2716.fa561519675a3ff1.js +1 -0
  112. package/.next/static/chunks/2906.8a0e09bb47605cf1.js +1 -0
  113. package/.next/static/chunks/4673.ea36e55005c0f82f.js +1 -0
  114. package/.next/static/chunks/4686-4dc565817a160351.js +1 -0
  115. package/.next/static/chunks/4778.0d2c5d75b791d352.js +1 -0
  116. package/.next/static/chunks/{618.949981da99be7182.js → 618.9a22656b863f242b.js} +2 -2
  117. package/.next/static/chunks/app/_global-error/page-f2d93969c0bcf406.js +1 -0
  118. package/.next/static/chunks/app/api/agent/[id]/events/route-f2d93969c0bcf406.js +1 -0
  119. package/.next/static/chunks/app/api/agent/[id]/route-f2d93969c0bcf406.js +1 -0
  120. package/.next/static/chunks/app/api/agent/[id]/tree/route-f2d93969c0bcf406.js +1 -0
  121. package/.next/static/chunks/app/api/agent/new/route-f2d93969c0bcf406.js +1 -0
  122. package/.next/static/chunks/app/api/agent/resources/route-f2d93969c0bcf406.js +1 -0
  123. package/.next/static/chunks/app/api/config/route-f2d93969c0bcf406.js +1 -0
  124. package/.next/static/chunks/app/api/files/route-f2d93969c0bcf406.js +1 -0
  125. package/.next/static/chunks/app/api/fs/route-f2d93969c0bcf406.js +1 -0
  126. package/.next/static/chunks/app/api/git/route-f2d93969c0bcf406.js +1 -0
  127. package/.next/static/chunks/app/api/github/route-f2d93969c0bcf406.js +1 -0
  128. package/.next/static/chunks/app/api/growth/route-f2d93969c0bcf406.js +1 -0
  129. package/.next/static/chunks/app/api/health/route-f2d93969c0bcf406.js +1 -0
  130. package/.next/static/chunks/app/api/models/defaults/route-f2d93969c0bcf406.js +1 -0
  131. package/.next/static/chunks/app/api/models/route-f2d93969c0bcf406.js +1 -0
  132. package/.next/static/chunks/app/api/pi-settings/route-f2d93969c0bcf406.js +1 -0
  133. package/.next/static/chunks/app/api/plugins/route-f2d93969c0bcf406.js +1 -0
  134. package/.next/static/chunks/app/api/prompts/route-f2d93969c0bcf406.js +1 -0
  135. package/.next/static/chunks/app/api/remote/route-f2d93969c0bcf406.js +1 -0
  136. package/.next/static/chunks/app/api/security/route-f2d93969c0bcf406.js +1 -0
  137. package/.next/static/chunks/app/api/sessions/[id]/export/route-f2d93969c0bcf406.js +1 -0
  138. package/.next/static/chunks/app/api/sessions/[id]/route-f2d93969c0bcf406.js +1 -0
  139. package/.next/static/chunks/app/api/sessions/import/route-f2d93969c0bcf406.js +1 -0
  140. package/.next/static/chunks/app/api/sessions/route-f2d93969c0bcf406.js +1 -0
  141. package/.next/static/chunks/app/api/skills/route-f2d93969c0bcf406.js +1 -0
  142. package/.next/static/chunks/app/api/update/route-f2d93969c0bcf406.js +1 -0
  143. package/.next/static/chunks/app/api/version/route-f2d93969c0bcf406.js +1 -0
  144. package/.next/static/chunks/app/api/web-auth/route-f2d93969c0bcf406.js +1 -0
  145. package/.next/static/chunks/app/api/workspaces/route-f2d93969c0bcf406.js +1 -0
  146. package/.next/static/chunks/app/page-71f2d3b3ed864b3d.js +1 -0
  147. package/.next/static/chunks/next/dist/client/components/builtin/app-error-f2d93969c0bcf406.js +1 -0
  148. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-f2d93969c0bcf406.js +1 -0
  149. package/.next/static/chunks/next/dist/client/components/builtin/not-found-f2d93969c0bcf406.js +1 -0
  150. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-f2d93969c0bcf406.js +1 -0
  151. package/.next/static/chunks/webpack-ffc09b5fcc8aaa6d.js +1 -0
  152. package/.next/static/css/014eb1f21414e615.css +1 -0
  153. package/.next/static/css/56b129a13637fcfd.css +1 -0
  154. package/.next/static/css/7e3caf797e4cb19a.css +1 -0
  155. package/.next/types/app/api/agent/{[id]/side-chat → resources}/route.ts +3 -3
  156. package/.next/types/routes.d.ts +2 -2
  157. package/.next/types/validator.ts +9 -9
  158. package/README.md +1 -1
  159. package/next.config.ts +1 -1
  160. package/package.json +9 -9
  161. package/scripts/apply-global.mjs +87 -0
  162. package/scripts/launcher.mjs +21 -0
  163. package/scripts/testing/prepare-sdk-tools.mjs +19 -0
  164. package/src/app/api/agent/new/route.ts +2 -1
  165. package/src/app/api/agent/resources/route.ts +18 -0
  166. package/src/app/api/models/route.ts +22 -1
  167. package/src/app/api/plugins/route.ts +3 -2
  168. package/src/app/api/prompts/route.ts +3 -2
  169. package/src/app/api/remote/route.ts +22 -4
  170. package/src/app/api/sessions/[id]/export/route.ts +3 -1
  171. package/src/app/api/skills/route.ts +3 -2
  172. package/src/app/globals.css +1002 -1765
  173. package/src/components/AppShell.tsx +128 -93
  174. package/src/components/ChatInput.tsx +177 -98
  175. package/src/components/ChatWindow.tsx +189 -56
  176. package/src/components/ContextMeter.tsx +33 -9
  177. package/src/components/CustomSelect.module.css +142 -0
  178. package/src/components/CustomSelect.tsx +157 -0
  179. package/src/components/DrawerPanel.tsx +1 -1
  180. package/src/components/ExtensionUI.tsx +9 -3
  181. package/src/components/FileViewer.tsx +24 -21
  182. package/src/components/GitPanel.tsx +35 -13
  183. package/src/components/GrowthTree.tsx +6 -2
  184. package/src/components/ImageViewer.tsx +72 -0
  185. package/src/components/MarkdownBlock.tsx +67 -96
  186. package/src/components/ModelSelector.tsx +36 -39
  187. package/src/components/PiMark.tsx +76 -41
  188. package/src/components/PreviewImage.tsx +33 -0
  189. package/src/components/ProjectPanel.tsx +8 -5
  190. package/src/components/PromptPanel.tsx +85 -20
  191. package/src/components/ProviderSetupModal.module.css +12 -11
  192. package/src/components/ProviderSetupModal.tsx +107 -36
  193. package/src/components/RemoteSection.tsx +53 -2
  194. package/src/components/SessionImportSection.tsx +30 -15
  195. package/src/components/SessionSidebar.tsx +72 -62
  196. package/src/components/SettingsPanel.tsx +49 -151
  197. package/src/components/TrajectoryAnalyzer.module.css +26 -11
  198. package/src/components/TrajectoryView.tsx +11 -3
  199. package/src/components/chat/markdown-actions.tsx +23 -0
  200. package/src/components/composer/ComposerFiles.tsx +224 -0
  201. package/src/components/composer/ComposerImages.tsx +59 -127
  202. package/src/hooks/usePiWeb.ts +47 -2
  203. package/src/i18n.tsx +62 -80
  204. package/src/lib/agent-manager.ts +365 -33
  205. package/src/lib/chat/file-path.ts +54 -0
  206. package/src/lib/chat/skill-expansion.ts +51 -0
  207. package/src/lib/extension-ui.ts +25 -1
  208. package/src/lib/font.ts +37 -39
  209. package/src/lib/git-service.ts +4 -2
  210. package/src/lib/growth-turns.ts +3 -1
  211. package/src/lib/model-draft.ts +69 -2
  212. package/src/lib/models-service.ts +53 -3
  213. package/src/lib/pi.ts +2 -2
  214. package/src/lib/remote/assets.json +1 -1
  215. package/src/lib/remote/supervisor.ts +48 -5
  216. package/src/lib/security-service.ts +2 -1
  217. package/src/lib/session-reader.ts +25 -13
  218. package/src/lib/session-retention.ts +11 -8
  219. package/src/lib/skills-service.ts +2 -1
  220. package/src/lib/slash-command-token.ts +14 -0
  221. package/src/lib/trajectory.ts +111 -15
  222. package/src/lib/types.ts +28 -3
  223. package/src/lib/update-service.ts +29 -9
  224. package/src/lib/workspace-store.ts +36 -1
  225. package/.next/server/app/api/agent/[id]/side-chat/route.js +0 -5
  226. package/.next/server/app/api/agent/[id]/side-chat/route.js.nft.json +0 -1
  227. package/.next/server/app/api/agent/[id]/side-chat/route_client-reference-manifest.js +0 -1
  228. package/.next/server/chunks/3421.js +0 -1
  229. package/.next/static/chunks/2716.44fdf1b21454b961.js +0 -1
  230. package/.next/static/chunks/4673.e261707dce391be7.js +0 -1
  231. package/.next/static/chunks/4686-0cd87513f9ea3538.js +0 -1
  232. package/.next/static/chunks/4778.eddd3a5813f85824.js +0 -1
  233. package/.next/static/chunks/7411.5f79671208209b13.js +0 -1
  234. package/.next/static/chunks/8552.25c39ef4c995eba0.js +0 -1
  235. package/.next/static/chunks/app/_global-error/page-cbaaee0d7aacb85a.js +0 -1
  236. package/.next/static/chunks/app/api/agent/[id]/events/route-cbaaee0d7aacb85a.js +0 -1
  237. package/.next/static/chunks/app/api/agent/[id]/route-cbaaee0d7aacb85a.js +0 -1
  238. package/.next/static/chunks/app/api/agent/[id]/side-chat/route-cbaaee0d7aacb85a.js +0 -1
  239. package/.next/static/chunks/app/api/agent/[id]/tree/route-cbaaee0d7aacb85a.js +0 -1
  240. package/.next/static/chunks/app/api/agent/new/route-cbaaee0d7aacb85a.js +0 -1
  241. package/.next/static/chunks/app/api/config/route-cbaaee0d7aacb85a.js +0 -1
  242. package/.next/static/chunks/app/api/files/route-cbaaee0d7aacb85a.js +0 -1
  243. package/.next/static/chunks/app/api/fs/route-cbaaee0d7aacb85a.js +0 -1
  244. package/.next/static/chunks/app/api/git/route-cbaaee0d7aacb85a.js +0 -1
  245. package/.next/static/chunks/app/api/github/route-cbaaee0d7aacb85a.js +0 -1
  246. package/.next/static/chunks/app/api/growth/route-cbaaee0d7aacb85a.js +0 -1
  247. package/.next/static/chunks/app/api/health/route-cbaaee0d7aacb85a.js +0 -1
  248. package/.next/static/chunks/app/api/models/defaults/route-cbaaee0d7aacb85a.js +0 -1
  249. package/.next/static/chunks/app/api/models/route-cbaaee0d7aacb85a.js +0 -1
  250. package/.next/static/chunks/app/api/pi-settings/route-cbaaee0d7aacb85a.js +0 -1
  251. package/.next/static/chunks/app/api/plugins/route-cbaaee0d7aacb85a.js +0 -1
  252. package/.next/static/chunks/app/api/prompts/route-cbaaee0d7aacb85a.js +0 -1
  253. package/.next/static/chunks/app/api/remote/route-cbaaee0d7aacb85a.js +0 -1
  254. package/.next/static/chunks/app/api/security/route-cbaaee0d7aacb85a.js +0 -1
  255. package/.next/static/chunks/app/api/sessions/[id]/export/route-cbaaee0d7aacb85a.js +0 -1
  256. package/.next/static/chunks/app/api/sessions/[id]/route-cbaaee0d7aacb85a.js +0 -1
  257. package/.next/static/chunks/app/api/sessions/import/route-cbaaee0d7aacb85a.js +0 -1
  258. package/.next/static/chunks/app/api/sessions/route-cbaaee0d7aacb85a.js +0 -1
  259. package/.next/static/chunks/app/api/skills/route-cbaaee0d7aacb85a.js +0 -1
  260. package/.next/static/chunks/app/api/update/route-cbaaee0d7aacb85a.js +0 -1
  261. package/.next/static/chunks/app/api/version/route-cbaaee0d7aacb85a.js +0 -1
  262. package/.next/static/chunks/app/api/web-auth/route-cbaaee0d7aacb85a.js +0 -1
  263. package/.next/static/chunks/app/api/workspaces/route-cbaaee0d7aacb85a.js +0 -1
  264. package/.next/static/chunks/app/page-dc3cb41d91c7432e.js +0 -1
  265. package/.next/static/chunks/next/dist/client/components/builtin/app-error-cbaaee0d7aacb85a.js +0 -1
  266. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-cbaaee0d7aacb85a.js +0 -1
  267. package/.next/static/chunks/next/dist/client/components/builtin/not-found-cbaaee0d7aacb85a.js +0 -1
  268. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-cbaaee0d7aacb85a.js +0 -1
  269. package/.next/static/chunks/webpack-2ac3d76b81895f5b.js +0 -1
  270. package/.next/static/css/08815507594e8b8d.css +0 -1
  271. package/.next/static/css/9fc559c2c4ad1d0f.css +0 -1
  272. package/.next/static/css/c6e2227c36ec2fd6.css +0 -1
  273. package/.next/static/css/f15a853af8b5127f.css +0 -1
  274. package/.next/static/lvKCJmjFj8LuqSojRVLRF/_buildManifest.js +0 -1
  275. package/src/app/api/agent/[id]/side-chat/route.ts +0 -77
  276. package/src/components/side-chat/SideChatPanel.module.css +0 -131
  277. package/src/components/side-chat/SideChatPanel.tsx +0 -189
  278. package/src/components/side-chat/icons.tsx +0 -8
  279. package/src/components/side-chat/request-history.ts +0 -29
  280. package/src/components/side-chat/store.ts +0 -248
  281. package/src/lib/chat/stream-settings.ts +0 -192
  282. package/src/lib/side-chat/context.ts +0 -123
  283. package/src/lib/side-chat/service.ts +0 -191
  284. package/src/lib/side-chat/types.ts +0 -61
  285. /package/.next/static/{lvKCJmjFj8LuqSojRVLRF → QJCtqgDKzGS2BdKABd9ch}/_ssgManifest.js +0 -0
@@ -2,11 +2,11 @@
2
2
  * agent-manager:AgentSession 池 + pi 事件 → Web 事件翻译 + SSE 订阅管理。
3
3
  * 打开会话即惰性冷启动(createAgentSession),空闲回收,事件先快照后增量。
4
4
  */
5
- import { existsSync, readFileSync, statSync } from "node:fs";
5
+ import { closeSync, existsSync, openSync, readFileSync, readSync, statSync } from "node:fs";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import path from "node:path";
8
8
  import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
9
- import { formatSkillsForPrompt, generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
9
+ import { ExtensionRunner, ModelRegistry, formatSkillsForPrompt, generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
10
10
  import {
11
11
  getResourceLoader,
12
12
  createSessionResourceLoader,
@@ -32,7 +32,7 @@ import { createExtensionUiBridge, type ExtensionUiBridge } from "./extension-ui"
32
32
  import { createGrowthTracker, GROWTH_TRACKER_VERSION, type GrowthTracker } from "./growth-tracker";
33
33
  import { userTurnsFromEntries } from "./growth-turns";
34
34
  import { projectedContextUsage } from "./session/metrics";
35
- import { TrajLedger, buildTrajectoryFromEntries, toTrajTokens } from "./trajectory";
35
+ import { TrajLedger, buildTrajectoryFromEntries, toolStepPreview, toTrajTokens } from "./trajectory";
36
36
  import { startArchivedSessionRetention } from "./session-retention";
37
37
  import { sanitizeToolOutput } from "./text-sanitize";
38
38
  import { BoundaryError } from "./path-security";
@@ -56,8 +56,11 @@ const IDLE_MS = 10 * 60 * 1000;
56
56
  const MAX_ACTIVE = 6;
57
57
  const BUFFER_CAP = 400;
58
58
  const SHUTDOWN_TIMEOUT_MS = 5_000;
59
+ /** 外来写入重载后,这么久之内连上的快照都带着提示 */
60
+ const EXTERNAL_RELOAD_NOTICE_MS = 60_000;
59
61
 
60
62
  interface Managed {
63
+ viewerAvailable?: () => boolean;
61
64
  sessionPath: string;
62
65
  cwd: string;
63
66
  sm: SessionManager;
@@ -105,6 +108,12 @@ interface Managed {
105
108
  ui: ExtensionUiBridge | null;
106
109
  /** 项目生长:影子仓库快照的触发器(首个会话事件时创建,dispose 时释放) */
107
110
  growth: GrowthTracker | null;
111
+ /** JSONL 已核对到的字节位置(总在整行末尾);之后多出来的行要看是不是别的进程写的(见 externalEntryCount) */
112
+ diskSize: number;
113
+ /** 已发现、还没重载进来的外来条目数(终端 pi 同时在写这个会话) */
114
+ externalEntries: number;
115
+ /** 这一份是因外来写入从磁盘重载出来的:快照带上,前端提示一次 */
116
+ externalReload?: { entries: number; at: number };
108
117
  }
109
118
 
110
119
  const globalForAgentManager = globalThis as typeof globalThis & {
@@ -164,10 +173,95 @@ export function toWebMessage(m: any, entryId?: string): WebMessage {
164
173
  encodingLoss: result.encodingLoss || undefined,
165
174
  patch: patchOf(m.details),
166
175
  }];
176
+ } else if (m.role === "compactionSummary" || m.role === "branchSummary") {
177
+ out.role = "summary";
178
+ out.summaryKind = m.role === "branchSummary" ? "branch" : "compaction";
179
+ out.summary = typeof m.summary === "string" ? m.summary : "";
180
+ if (typeof m.tokensBefore === "number" && Number.isFinite(m.tokensBefore)) out.tokensBefore = m.tokensBefore;
167
181
  }
168
182
  return out;
169
183
  }
170
184
 
185
+ /** 会话条目 → 显示消息:消息照常投影,压缩 / 分支摘要投影成摘要卡,其余条目(切模型、自定义状态等)不显示 */
186
+ function entryToWebMessage(entry: any): WebMessage | null {
187
+ if (entry?.type === "message" && entry.message) return toWebMessage(entry.message, entry.id);
188
+ if (entry?.type !== "compaction" && !(entry?.type === "branch_summary" && entry.summary)) return null;
189
+ const timestamp = Date.parse(entry.timestamp);
190
+ return toWebMessage({
191
+ role: entry.type === "compaction" ? "compactionSummary" : "branchSummary",
192
+ summary: entry.summary,
193
+ tokensBefore: entry.tokensBefore,
194
+ timestamp: Number.isFinite(timestamp) ? timestamp : undefined,
195
+ }, entry.id);
196
+ }
197
+
198
+ /**
199
+ * 被压缩掉的历史:当前分支上已不在模型上下文里的条目,按原顺序照常显示(可编辑、可分叉)。
200
+ * 每张压缩卡挪到它保留的第一条(firstKeptEntryId)前面,和上下文部分「摘要在前、保留消息在后」一致;
201
+ * 这样卡片以上就是模型只看得到摘要的内容。
202
+ */
203
+ function compactedHistory(sm: Pick<SessionManager, "getBranch">, context: any[]): WebMessage[] {
204
+ if (context[0]?.type !== "compaction") return [];
205
+ const inContext = new Set(context.map((entry) => entry.id));
206
+ const entries = (sm.getBranch() as any[]).filter((entry) => !inContext.has(entry.id));
207
+ const position = new Map(entries.map((entry, index) => [entry.id, index]));
208
+ const markersBefore = new Map<string, WebMessage[]>();
209
+ const moved = new Set<string>();
210
+ entries.forEach((entry, index) => {
211
+ if (entry.type !== "compaction") return;
212
+ const at = position.get(entry.firstKeptEntryId);
213
+ if (at === undefined || at >= index) return;
214
+ const marker = entryToWebMessage(entry)!;
215
+ markersBefore.set(entry.firstKeptEntryId, [...(markersBefore.get(entry.firstKeptEntryId) ?? []), marker]);
216
+ moved.add(entry.id);
217
+ });
218
+ const out: WebMessage[] = [];
219
+ for (const entry of entries) {
220
+ out.push(...(markersBefore.get(entry.id) ?? []));
221
+ if (moved.has(entry.id)) continue;
222
+ const message = entryToWebMessage(entry);
223
+ if (message) out.push(message);
224
+ }
225
+ return out;
226
+ }
227
+
228
+ /** 冷会话 / 只读接口的消息列表:被压缩掉的历史 + 当前上下文(最新一次压缩投影成分隔卡) */
229
+ export function sessionDisplayMessages(sm: Pick<SessionManager, "getBranch" | "buildContextEntries">): WebMessage[] {
230
+ const context = sm.buildContextEntries() as any[];
231
+ const current = context.flatMap((entry, index) => {
232
+ // 保留区里更早的压缩条目不进模型上下文(SDK 投影只取下标 0 的那次),这里同样跳过
233
+ if (entry.type === "compaction" && index > 0) return [];
234
+ const message = entryToWebMessage(entry);
235
+ return message ? [message] : [];
236
+ });
237
+ return [...compactedHistory(sm, context), ...current];
238
+ }
239
+
240
+ /**
241
+ * 压缩刚完成(SDK 先写条目再发 compaction_end):分隔卡 + 它该插在哪条消息前。
242
+ * 取保留下来的第一条用户 / 助手消息,和快照里「卡片在保留消息之前」的位置一致;没有保留消息就追加。
243
+ */
244
+ function compactionMarker(m: Managed): { marker?: WebMessage; beforeId?: string; beforeStreamId?: string } {
245
+ try {
246
+ const context = m.sm.buildContextEntries() as any[];
247
+ if (context[0]?.type !== "compaction") return {};
248
+ const kept = context.find((entry) => entry.type === "message" && (entry.message?.role === "user" || entry.message?.role === "assistant"));
249
+ return {
250
+ marker: entryToWebMessage(context[0]) ?? undefined,
251
+ beforeId: kept?.id,
252
+ beforeStreamId: kept ? m.messageIds?.get(kept.message) : undefined,
253
+ };
254
+ } catch {
255
+ return {};
256
+ }
257
+ }
258
+
259
+ /** 热会话里摘要消息(SDK 每次投影新建的对象)对应的条目 ID:压缩取上下文首条,分支摘要按 fromId + 时间匹配 */
260
+ function summaryEntryId(context: any[], message: any): string | undefined {
261
+ if (message?.role === "compactionSummary") return context[0]?.type === "compaction" ? context[0].id : undefined;
262
+ return context.find((entry) => entry.type === "branch_summary" && entry.fromId === message?.fromId && Date.parse(entry.timestamp) === message?.timestamp)?.id;
263
+ }
264
+
171
265
  /** 消息对象 → 持久化条目 ID 的索引;条目数一变就重建(会话只追加,改写分支时条目数也会变) */
172
266
  const entryIdIndexes = new WeakMap<SessionManager, { count: number; ids: WeakMap<object, string> }>();
173
267
 
@@ -345,7 +439,11 @@ function publishModelState(m: Managed, session: AgentSession): void {
345
439
  });
346
440
  }
347
441
 
348
- /** 切模型后恢复用户显式选过的级别(按新模型钳制) */
442
+ /**
443
+ * 切模型后恢复用户显式选过的级别(按新模型钳制)。
444
+ * 只记进本会话(SDK 会写 thinking_level_change 条目),不写全局默认:
445
+ * ~/.pi/agent/settings.json 与终端 pi 共用,在 PiWeb 里切一下档位不该改掉终端 pi 的默认值。
446
+ */
349
447
  function reapplyDesiredLevel(m: Managed, session: AgentSession): void {
350
448
  if (!m.desiredThinkingLevel) return;
351
449
  const target = clampThinkingLevel(session.model as any, m.desiredThinkingLevel as any);
@@ -406,6 +504,14 @@ function timingStats(m: Managed): Pick<WebStats, "llmMs" | "toolMs" | "ttftMs" |
406
504
  return { llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens, llmTimedSteps, toolTimedCalls, decodeSteps };
407
505
  }
408
506
 
507
+ /**
508
+ * 会话级用量缓存。SDK 的 getSessionStats() 每次都全表遍历 getEntries()(浅拷贝整个会话)、
509
+ * getContextUsage() 再 buildSessionProjection()(又一次全表拷贝 + 逐消息估算 token)——
510
+ * 一轮 agent 里 publishUsage 会在每个工具结束/每条消息结束各触发一次,长会话下这是
511
+ * 服务端最大的重复开销。条目数与 leaf 指针没变时结果必然相同,直接复用。
512
+ */
513
+ const usageCache = new WeakMap<Managed, { entryCount: number; leafId: string | null; evt: Extract<WebEvent, { type: "usage" }> }>();
514
+
409
515
  function sessionStats(m: Managed): WebStats | null {
410
516
  if (!m.session) return null;
411
517
  try {
@@ -459,17 +565,54 @@ function coldSessionStats(m: Managed): WebStats {
459
565
 
460
566
  function usageEvent(m: Managed): WebEvent | null {
461
567
  if (!m.session) return null;
568
+ // 条目数与 leaf 没变(还停在同一轮事件循环的同一状态)→ 复用上次的完整 usage 事件
569
+ try {
570
+ const entryCount = (m.sm as unknown as { getEntryCount?: () => number }).getEntryCount?.() ?? m.sm.getEntries().length;
571
+ const leafId = m.sm.getLeafId();
572
+ const cached = usageCache.get(m);
573
+ if (cached && cached.entryCount === entryCount && cached.leafId === leafId) return { ...cached.evt, ts: Date.now() };
574
+ } catch {
575
+ /* 缓存键拿不到就走原路径 */
576
+ }
462
577
  const stats = sessionStats(m);
463
- const usage = m.session.getContextUsage() ?? null;
464
- return {
465
- type: "usage",
578
+ // 用量统计是旁路信息:SDK 偶发取不到时不能让调用方失败,
579
+ // 否则一次统计异常就会把「切模型 / 刷新目录」这类命令打回 ok:false。
580
+ let usage: ReturnType<AgentSession["getContextUsage"]> | null = null;
581
+ try {
582
+ usage = m.session.getContextUsage() ?? null;
583
+ } catch {
584
+ usage = null;
585
+ }
586
+ const evt = {
587
+ type: "usage" as const,
466
588
  stats,
467
589
  contextUsage: usage ? { tokens: usage.tokens, contextWindow: usage.contextWindow, percent: usage.percent } : null,
468
590
  ts: Date.now(),
469
591
  };
592
+ try {
593
+ const entryCount = (m.sm as unknown as { getEntryCount?: () => number }).getEntryCount?.() ?? m.sm.getEntries().length;
594
+ usageCache.set(m, { entryCount, leafId: m.sm.getLeafId(), evt });
595
+ } catch {
596
+ /* ignore */
597
+ }
598
+ return evt;
470
599
  }
471
600
 
472
- function publishUsage(m: Managed): void {
601
+ /**
602
+ * 忙时节流:一轮 agent 期间 publishUsage 的调用点密(每个工具结束 + 每条消息结束),
603
+ * 而 usage 在条目数没变时本来就不会动。500ms 内的重复请求直接跳过;
604
+ * settle/compaction 等关键节点用 flush 参数强制发一次,收尾数字不缺席。
605
+ */
606
+ const USAGE_MIN_INTERVAL_MS = 500;
607
+ const usageThrottle = new WeakMap<Managed, number>();
608
+
609
+ function publishUsage(m: Managed, flush = false): void {
610
+ const now = Date.now();
611
+ if (!flush) {
612
+ const last = usageThrottle.get(m) ?? 0;
613
+ if (now - last < USAGE_MIN_INTERVAL_MS) return;
614
+ }
615
+ usageThrottle.set(m, now);
473
616
  const evt = usageEvent(m);
474
617
  if (evt) publish(m, evt);
475
618
  }
@@ -591,7 +734,7 @@ function translate(m: Managed, evt: AgentSessionEvent): void {
591
734
  case "agent_settled":
592
735
  m.runActive = false;
593
736
  publish(m, { type: "status", isStreaming: false, state: "idle", ts: now });
594
- publishUsage(m);
737
+ publishUsage(m, true);
595
738
  if (m.resourceReloadPending) queueMicrotask(() => void reloadManagedResources(m));
596
739
  break;
597
740
  case "queue_update":
@@ -612,9 +755,10 @@ function translate(m: Managed, evt: AgentSessionEvent): void {
612
755
  phase: "end",
613
756
  reason: (evt as any).reason,
614
757
  errorMessage: (evt as any).errorMessage,
758
+ ...((evt as any).result && !(evt as any).errorMessage ? compactionMarker(m) : {}),
615
759
  ts: now,
616
760
  });
617
- publishUsage(m);
761
+ publishUsage(m, true);
618
762
  publishContextBreakdown(m);
619
763
  if ((evt as any).reason === "manual") {
620
764
  publish(m, { type: "status", isStreaming: isManagedBusy(m), state: isManagedBusy(m) ? "running" : "idle", ts: now });
@@ -714,7 +858,8 @@ export function warmSessionResources(cwds: string[]): void {
714
858
 
715
859
  export function getManaged(sessionPath: string): Managed {
716
860
  let m = sessions.get(sessionPath);
717
- if (!m) {
861
+ if (m) m = syncWithDisk(m);
862
+ else {
718
863
  if (!existsSync(sessionPath)) throw new BoundaryError("session not found");
719
864
  const sm = openSessionManager(sessionPath);
720
865
  m = registerManaged(sessionPath, sm.getCwd() || process.cwd(), sm);
@@ -723,6 +868,115 @@ export function getManaged(sessionPath: string): Managed {
723
868
  return m;
724
869
  }
725
870
 
871
+ /**
872
+ * 数出 JSONL 里自上次核对以来、不是本进程写的新条目。
873
+ * 自己的条目在 SessionManager._appendEntry 里先进内存索引再落盘,所以索引里找不到 id 的行就是外来的
874
+ * (终端 pi 同时打开了这个会话)。只读新增的字节;文件变短(被整体重写)就从头核对;没写完的最后一行留到下次。
875
+ */
876
+ function externalEntryCount(m: Managed): number {
877
+ let size: number;
878
+ try {
879
+ size = statSync(m.sessionPath).size;
880
+ } catch {
881
+ return 0; // 还没落盘
882
+ }
883
+ if (size === m.diskSize) return 0;
884
+ const from = size < m.diskSize ? 0 : m.diskSize;
885
+ let buf = Buffer.alloc(size - from);
886
+ let fd: number | undefined;
887
+ try {
888
+ fd = openSync(m.sessionPath, "r");
889
+ buf = buf.subarray(0, readSync(fd, buf, 0, buf.length, from));
890
+ } catch {
891
+ return 0;
892
+ } finally {
893
+ if (fd !== undefined) closeSync(fd);
894
+ }
895
+ const end = buf.lastIndexOf(0x0a);
896
+ m.diskSize = from + end + 1;
897
+ let count = 0;
898
+ for (const line of buf.subarray(0, Math.max(0, end)).toString("utf8").split("\n")) {
899
+ if (!line.trim()) continue;
900
+ try {
901
+ const entry = JSON.parse(line);
902
+ if (entry?.type !== "session" && typeof entry?.id === "string" && !m.sm.getEntry(entry.id)) count += 1;
903
+ } catch {
904
+ /* 从行中间读起的半行、写坏的行都不算 */
905
+ }
906
+ }
907
+ return count;
908
+ }
909
+
910
+ /**
911
+ * pi 终端和 PiWeb 共用同一份 JSONL,但各自在内存里记着会话树:一边写了,另一边不知道,
912
+ * 接着写就从自己的旧叶子分叉。打开会话、发消息都经过 getManaged,这里先核对磁盘:
913
+ * 有外来条目且本会话空闲,就丢掉内存里的旧副本从磁盘重开;正在跑就只提醒,空闲后的下一次核对再重载。
914
+ */
915
+ function syncWithDisk(m: Managed): Managed {
916
+ const found = externalEntryCount(m);
917
+ if (!found && !m.externalEntries) return m;
918
+ m.externalEntries += found;
919
+ if (m.creating || isManagedBusy(m)) {
920
+ if (found) publish(m, { type: "external_write", entries: m.externalEntries, ts: Date.now() });
921
+ return m;
922
+ }
923
+ return reopenManaged(m);
924
+ }
925
+
926
+ /** 等同空闲回收后再打开:会话级的选择(工具档位、思考级别)带过去,正在看的浏览器立刻重连拿新快照 */
927
+ function reopenManaged(m: Managed): Managed {
928
+ let sm: SessionManager;
929
+ try {
930
+ sm = openSessionManager(m.sessionPath);
931
+ } catch {
932
+ return m; // 读不出来(正被写坏?)就先沿用旧副本,下次再试
933
+ }
934
+ const at = Date.now();
935
+ const reload = { entries: m.externalEntries, at };
936
+ // 直接写给订阅者:publish 可能排在队列里,晚于 dispose 就送不到了
937
+ const frame = JSON.stringify({ type: "external_reload", ...reload, ts: at });
938
+ for (const send of m.subscribers) {
939
+ try {
940
+ send(m.seq, frame);
941
+ } catch {
942
+ /* ignore */
943
+ }
944
+ }
945
+ disposeSession(m);
946
+ const fresh = registerManaged(m.sessionPath, sm.getCwd() || m.cwd, sm);
947
+ // 核对位置沿用旧的(整行末尾):打开时恰好没写完的那行,写完后还会被认作外来的
948
+ fresh.diskSize = m.diskSize;
949
+ fresh.toolPreset = m.toolPreset;
950
+ fresh.customActiveTools = m.customActiveTools;
951
+ fresh.desiredThinkingLevel = m.desiredThinkingLevel;
952
+ fresh.viewerGraceUntil = m.viewerGraceUntil;
953
+ fresh.externalReload = reload;
954
+ return fresh;
955
+ }
956
+
957
+ /**
958
+ * 侧栏每 3 秒拉一次会话列表,顺带核对所有开着的会话:终端写入几秒内就能提示 / 重载,
959
+ * 回合中途发现的外来写入也在回合结束后的下一次拉取时重载(不在 agent_settled 里做:
960
+ * SDK 收尾时还可能有延后的提问要开跑,那一刻回收会把它丢掉)。每个会话一次 stat,没变就不读文件。
961
+ */
962
+ export function syncSessionsFromDisk(): void {
963
+ for (const m of [...sessions.values()]) {
964
+ try {
965
+ syncWithDisk(m);
966
+ } catch {
967
+ /* 核对失败只是晚一点发现,打开 / 发送时还会再查 */
968
+ }
969
+ }
970
+ }
971
+
972
+ function fileSize(file: string): number {
973
+ try {
974
+ return statSync(file).size;
975
+ } catch {
976
+ return 0;
977
+ }
978
+ }
979
+
726
980
  function registerManaged(sessionPath: string, cwd: string, sm: SessionManager): Managed {
727
981
  let initialTrajectory: TrajEntry[] = [];
728
982
  try {
@@ -754,6 +1008,8 @@ function registerManaged(sessionPath: string, cwd: string, sm: SessionManager):
754
1008
  runActive: false,
755
1009
  disposed: false,
756
1010
  messageIds: new WeakMap(),
1011
+ diskSize: fileSize(sessionPath),
1012
+ externalEntries: 0,
757
1013
  };
758
1014
  sessions.set(sessionPath, m);
759
1015
  return m;
@@ -872,6 +1128,10 @@ function publishContextBreakdown(m: Managed): void {
872
1128
  });
873
1129
  }
874
1130
 
1131
+ /** 资源加载器注入文件的展示上限:同一份全文已经随快照的 contextResources 下发过一次,
1132
+ * 轨迹条目的 detail 只留头部供列表预览与搜索——两处不再各带一份全文 */
1133
+ const TRAJ_CONTEXT_DETAIL_MAX_CHARS = 4_000;
1134
+
875
1135
  /** resources 已经算过(快照)就传进来,免得资源发现结果再读一遍 */
876
1136
  function environmentTrajectory(m: Managed, session: AgentSession | null, known?: ContextResource[]): TrajEntry[] {
877
1137
  const ts = m.ledger.entries[0]?.ts ?? Date.now();
@@ -893,7 +1153,10 @@ function environmentTrajectory(m: Managed, session: AgentSession | null, known?:
893
1153
  kind: "context",
894
1154
  ts,
895
1155
  title: resource.path,
896
- detail: resource.content,
1156
+ // 全文在快照 contextResources 里;这里只留头部,快照不再双份序列化
1157
+ detail: resource.content.length > TRAJ_CONTEXT_DETAIL_MAX_CHARS
1158
+ ? `${resource.content.slice(0, TRAJ_CONTEXT_DETAIL_MAX_CHARS)}…`
1159
+ : resource.content,
897
1160
  }));
898
1161
  } catch {
899
1162
  /* Resource discovery diagnostics are already surfaced by Pi. */
@@ -913,6 +1176,7 @@ export async function ensureSession(m: Managed): Promise<AgentSession> {
913
1176
  if (m.session) return m.session;
914
1177
  m.creating = (async () => {
915
1178
  await resourceLoaderReady(m.cwd);
1179
+ const settingsManager = getSettingsManager(m.cwd);
916
1180
  const loader = await createSessionResourceLoader(m.cwd);
917
1181
  let created: AgentSession | undefined;
918
1182
  try {
@@ -923,7 +1187,7 @@ export async function ensureSession(m: Managed): Promise<AgentSession> {
923
1187
  sessionManager: m.sm,
924
1188
  modelRuntime: await getModelRuntime(),
925
1189
  resourceLoader: loader,
926
- settingsManager: getSettingsManager(m.cwd),
1190
+ settingsManager,
927
1191
  });
928
1192
  created = session;
929
1193
  if (m.disposed) throw new Error("session is disposed");
@@ -1178,7 +1442,22 @@ startArchivedSessionRetention({ isActive: isSessionInUse, log: (message) => cons
1178
1442
 
1179
1443
  type ResourceBundle = Pick<WebSnapshot, "skills" | "promptTemplates" | "extensionCommands" | "projectTrust" | "resourceDiagnostics">;
1180
1444
 
1181
- function collectResources(m: Managed): ResourceBundle {
1445
+ /**
1446
+ * 会话还不存在时也能发现命令:不绑定运行时、不发 session_start、不启动 MCP。
1447
+ * 新会话页和冷会话的斜杠补全都靠它,等第一次提问才建会话。
1448
+ */
1449
+ export async function loadWorkspaceResources(cwd: string): Promise<ResourceBundle> {
1450
+ await refreshResourceLoaderIfStale(cwd);
1451
+ const modelRegistry = new ModelRegistry(await getModelRuntime());
1452
+ return withResourceLock(async () => {
1453
+ const loader = getResourceLoader(cwd);
1454
+ const { extensions, runtime } = loader.getExtensions();
1455
+ const runner = new ExtensionRunner(extensions, runtime, cwd, SessionManager.inMemory(cwd), modelRegistry);
1456
+ return collectResources({ cwd, session: null }, runner);
1457
+ });
1458
+ }
1459
+
1460
+ function collectResources(m: Pick<Managed, "cwd" | "session">, commandRunner?: ExtensionRunner): ResourceBundle {
1182
1461
  const loader = m.session?.resourceLoader ?? peekResourceLoader(m.cwd) ?? getResourceLoader(m.cwd);
1183
1462
  const diagnostics: WebSnapshot["resourceDiagnostics"] = [];
1184
1463
  let skills: WebSnapshot["skills"] = [];
@@ -1203,9 +1482,9 @@ function collectResources(m: Managed): ResourceBundle {
1203
1482
  /* ignore */
1204
1483
  }
1205
1484
  let extensionCommands: WebSnapshot["extensionCommands"] = [];
1206
- if (m.session) {
1485
+ if (m.session || commandRunner) {
1207
1486
  try {
1208
- const runner = (m.session as unknown as { extensionRunner?: { getRegisteredCommands(): Array<{ invocationName: string; description?: string; sourceInfo?: { path?: string } }>; getCommandDiagnostics(): Array<{ path?: string; message: string }> } }).extensionRunner;
1487
+ const runner = commandRunner ?? m.session?.extensionRunner;
1209
1488
  if (runner) {
1210
1489
  extensionCommands = runner.getRegisteredCommands().map((c) => ({
1211
1490
  name: c.invocationName,
@@ -1235,6 +1514,13 @@ async function reloadManagedResources(m: Managed): Promise<boolean> {
1235
1514
  m.resourceReloadPending = false;
1236
1515
  try {
1237
1516
  await m.session.reload();
1517
+ // pi 1.3.0 起 reload 会保留原来的活动工具集:被移除的扩展工具会继续留在活动集里。
1518
+ // 这里按当前注册表重算一次;策略本身出错不能把重载判成失败。
1519
+ try {
1520
+ applyToolPolicy(m, m.session);
1521
+ } catch {
1522
+ /* 工具策略是附带动作,失败下次重载/切档位会再算 */
1523
+ }
1238
1524
  swapCurrentModelObject(m.session);
1239
1525
  return true;
1240
1526
  } catch (err) {
@@ -1265,7 +1551,9 @@ export function refreshSessionModels(provider: string, id: string): number {
1265
1551
  const current = m.session?.model;
1266
1552
  if (!m.session || current?.provider !== provider || current.id !== id) continue;
1267
1553
  swapCurrentModelObject(m.session);
1268
- publishUsage(m);
1554
+ // 换了模型对象(contextWindow 可能变了):条目数没变也会命中旧缓存,先失效再发
1555
+ usageCache.delete(m);
1556
+ publishUsage(m, true);
1269
1557
  n += 1;
1270
1558
  }
1271
1559
  return n;
@@ -1300,8 +1588,10 @@ export async function buildSnapshot(m: Managed): Promise<WebSnapshot> {
1300
1588
  // 用户刚在 prompts/skills/extensions 目录新建了文件的话顺手重载一次(只 stat 几个目录)
1301
1589
  await resourceLoaderReady(m.cwd);
1302
1590
  try {
1303
- // 运行中只标记变更,不让 snapshot 请求重新执行扩展工厂/切换绑定。
1304
- if (m.session && isManagedBusy(m)) {
1591
+ // 创建还未完成时,资源版本尚未落定;快照不能把它误标成需要再次 session_start。
1592
+ if (m.creating) {
1593
+ // 当前创建流程会发布最终资源状态。
1594
+ } else if (m.session && isManagedBusy(m)) {
1305
1595
  if (resourceLoaderIsStale(m.cwd) || m.resourceVersion !== resourceLoaderVersion(m.cwd)) m.resourceReloadPending = true;
1306
1596
  } else {
1307
1597
  await refreshResourceLoaderIfStale(m.cwd);
@@ -1323,7 +1613,22 @@ export async function buildSnapshot(m: Managed): Promise<WebSnapshot> {
1323
1613
 
1324
1614
  if (session) {
1325
1615
  const hasInflightFinal = linkInflightFinal(m, session);
1326
- messages = session.messages.map((message) => projectMessage(m, message));
1616
+ // session.messages 只是压缩后的模型上下文;前面补上被压缩掉的历史,压缩摘要投影成分隔卡
1617
+ let context: any[] = [];
1618
+ let history: WebMessage[] = [];
1619
+ try {
1620
+ context = m.sm.buildContextEntries() as any[];
1621
+ history = compactedHistory(m.sm, context);
1622
+ } catch {
1623
+ /* 条目读不到时只显示上下文 */
1624
+ }
1625
+ messages = [
1626
+ ...history,
1627
+ ...session.messages.map((message: any) =>
1628
+ message?.role === "compactionSummary" || message?.role === "branchSummary"
1629
+ ? toWebMessage(message, summaryEntryId(context, message))
1630
+ : projectMessage(m, message)),
1631
+ ];
1327
1632
  // final 已被 SDK push 时只投影一次;延后的 end 使用相同 streamId 继续更新权威内容。
1328
1633
  if (!hasInflightFinal && m.inflight) {
1329
1634
  const partial = projectMessage(m, m.inflight);
@@ -1340,12 +1645,9 @@ export async function buildSnapshot(m: Managed): Promise<WebSnapshot> {
1340
1645
  }
1341
1646
  isStreaming = isManagedBusy(m);
1342
1647
  } else {
1343
- // 冷渲染:从 JSONL 还原上下文消息(不启动 agent)
1648
+ // 冷渲染:从 JSONL 还原消息(不启动 agent),压缩掉的历史也照常显示
1344
1649
  try {
1345
- const entries = m.sm.buildContextEntries();
1346
- messages = entries
1347
- .filter((e: any) => e.type === "message" && e.message)
1348
- .map((e: any) => toWebMessage(e.message, e.id));
1650
+ messages = sessionDisplayMessages(m.sm);
1349
1651
  } catch {
1350
1652
  messages = [];
1351
1653
  }
@@ -1416,7 +1718,8 @@ export async function buildSnapshot(m: Managed): Promise<WebSnapshot> {
1416
1718
  }
1417
1719
 
1418
1720
  // 斜杠命令数据源:技能 / 提示模板 / 扩展命令,加上项目信任与资源诊断
1419
- const resources = collectResources(m);
1721
+ // 冷会话还没有 extensionRunner,但命令目录已经能发现;不要在第一次提问前推空列表。
1722
+ const resources = session && !m.creating ? collectResources(m) : await loadWorkspaceResources(m.cwd);
1420
1723
 
1421
1724
  const baseTrajectory = m.ledger.entries.length ? m.ledger.entries : coldTrajectory(m);
1422
1725
  const trajectory = [...environmentTrajectory(m, session, contextResourcesLoaded ? contextResources : undefined), ...baseTrajectory];
@@ -1448,6 +1751,8 @@ export async function buildSnapshot(m: Managed): Promise<WebSnapshot> {
1448
1751
  queue,
1449
1752
  trajectory,
1450
1753
  isStreaming,
1754
+ // 重载时没人在看(打开会话那一刻才发现):靠快照把提示带给随后连上的浏览器,前端按 at 去重
1755
+ ...(m.externalReload && Date.now() - m.externalReload.at < EXTERNAL_RELOAD_NOTICE_MS ? { externalReload: m.externalReload } : {}),
1451
1756
  };
1452
1757
  }
1453
1758
 
@@ -1506,6 +1811,7 @@ export function holdViewer(m: Managed, ms: number): void {
1506
1811
  }
1507
1812
 
1508
1813
  function hasViewers(m: Managed): boolean {
1814
+ if (m.viewerAvailable) return m.viewerAvailable();
1509
1815
  return m.subscribers.size > 0 || (m.viewerGraceUntil ?? 0) > Date.now();
1510
1816
  }
1511
1817
 
@@ -1528,6 +1834,17 @@ async function prepareGrowthBriefly(m: Managed): Promise<void> {
1528
1834
  ]);
1529
1835
  }
1530
1836
 
1837
+ type PromptImages = NonNullable<NonNullable<Parameters<AgentSession["prompt"]>[1]>["images"]>;
1838
+
1839
+ /** 被编辑的那条用户消息里的图片:编辑只改文字,图片原样带进重发(编辑框里也照样显示它们) */
1840
+ function userMessageImages(session: AgentSession, entryId: string): PromptImages {
1841
+ const entry = session.sessionManager?.getEntry?.(entryId) as { type?: string; message?: { role?: string; content?: unknown } } | undefined;
1842
+ if (entry?.type !== "message" || entry.message?.role !== "user" || !Array.isArray(entry.message.content)) return [];
1843
+ return entry.message.content.filter(
1844
+ (c): c is PromptImages[number] => c?.type === "image" && typeof c.data === "string" && typeof c.mimeType === "string",
1845
+ );
1846
+ }
1847
+
1531
1848
  /** 交给 SDK,并在「受理」那一刻返回,而不是等整轮跑完;受理之后的错误经 SSE 可见 */
1532
1849
  function submitPrompt(
1533
1850
  m: Managed,
@@ -1549,7 +1866,10 @@ function submitPrompt(
1549
1866
  });
1550
1867
  // 同步挂好 rejection handler;接受后的错误仍经 SSE 可见,不能成为未处理拒绝。
1551
1868
  void run.then(() => {
1552
- if (!accepted) resolve({ ok: false, error: "prompt completed without acceptance" });
1869
+ // 上一轮的 agent_settled 还没发完时,SDK 不拒绝提问,而是把它挂进 deferredSettledActions,
1870
+ // 立即返回并在稍后用同一份 options 重发;这条路径不会调用 preflightResult。
1871
+ // 消息确实会发出去,不能报「未受理」——否则界面报错,消息随后照样出现。真正的拒绝只会是 rejection。
1872
+ if (!accepted) resolve({ ok: true, data: { accepted: true } });
1553
1873
  }, (error) => {
1554
1874
  const message = String(error?.message ?? error);
1555
1875
  if (accepted) publish(m, { type: "error", message, ts: Date.now() });
@@ -1622,7 +1942,9 @@ export async function execute(m: Managed, cmd: AgentCommand): Promise<CommandRes
1622
1942
  reapplyDesiredLevel(m, session);
1623
1943
  // 新会话页选的模型(fresh):思考档位按新会话的规则定;之后显式选的档位仍会覆盖
1624
1944
  if (cmd.fresh === true && !m.desiredThinkingLevel) applyNewSessionThinking(m, session);
1945
+ usageCache.delete(m); // 换模型 = contextWindow 变了,旧 usage 缓存作废
1625
1946
  publishModelState(m, session);
1947
+ publishUsage(m, true);
1626
1948
  return { ok: true, data: { thinkingLevel: session.thinkingLevel, thinkingLevels: session.getAvailableThinkingLevels() } };
1627
1949
  }
1628
1950
  case "setThinkingLevel": {
@@ -1631,6 +1953,7 @@ export async function execute(m: Managed, cmd: AgentCommand): Promise<CommandRes
1631
1953
  // 与 pi 终端一致:不支持的档位就近钳制而不是拒绝(非推理模型 → off),并把实际生效值回传
1632
1954
  const effective = clampThinkingLevel(session.model as any, requested as any);
1633
1955
  m.desiredThinkingLevel = requested;
1956
+ // 不传 persist:与终端 pi 一样,聊天里切档位只影响本会话;全局默认仍以 settings.json 为准(见 reapplyDesiredLevel)
1634
1957
  session.setThinkingLevel(effective as never);
1635
1958
  publishModelState(m, session);
1636
1959
  return { ok: true, data: { requested, thinkingLevel: session.thinkingLevel, clamped: session.thinkingLevel !== requested } };
@@ -1638,7 +1961,8 @@ export async function execute(m: Managed, cmd: AgentCommand): Promise<CommandRes
1638
1961
  case "setToolPreset": {
1639
1962
  const preset = cmd.preset as ToolPreset;
1640
1963
  if (!Object.hasOwn(TOOL_PRESETS, preset)) return { ok: false, error: "invalid tool preset" };
1641
- if (isManagedBusy(m)) return { ok: false, error: "cannot change tools while the agent is running" };
1964
+ // 运行中也能改:SDK 的 setActiveToolsByName 不做 busy 检查,改完重建系统提示词,
1965
+ // 在下一轮生效(本轮的请求已经发出去了,改不了)。这里不再自行拦一层。
1642
1966
  if (!m.session && !m.creating) {
1643
1967
  // 冷会话只记下选择,等真正启动时(ensureSession → applyToolPolicy)再生效。
1644
1968
  // 不能为了切个档位就冷启动 agent:打开会话时前端会恢复本地档位,
@@ -1649,7 +1973,7 @@ export async function execute(m: Managed, cmd: AgentCommand): Promise<CommandRes
1649
1973
  return { ok: true };
1650
1974
  }
1651
1975
  const session = await ensureSession(m);
1652
- if (isManagedBusy(m)) return { ok: false, error: "cannot change tools while the agent is running" };
1976
+ if (m.disposed) return { ok: false, error: "session is disposed" };
1653
1977
  m.toolPreset = preset;
1654
1978
  m.customActiveTools = undefined;
1655
1979
  applyToolPolicy(m, session);
@@ -1658,9 +1982,9 @@ export async function execute(m: Managed, cmd: AgentCommand): Promise<CommandRes
1658
1982
  return { ok: true };
1659
1983
  }
1660
1984
  case "setActiveTools": {
1661
- if (isManagedBusy(m)) return { ok: false, error: "cannot change tools while the agent is running" };
1985
+ // 同上:运行中允许改,下一轮生效。
1662
1986
  const session = await ensureSession(m);
1663
- if (isManagedBusy(m)) return { ok: false, error: "cannot change tools while the agent is running" };
1987
+ if (m.disposed) return { ok: false, error: "session is disposed" };
1664
1988
  const wanted = Array.isArray(cmd.names) ? cmd.names.map(String) : [];
1665
1989
  const all = listAllToolNames(session);
1666
1990
  const next = [...new Set(wanted)].filter((n) => all.includes(n));
@@ -1708,13 +2032,15 @@ export async function execute(m: Managed, cmd: AgentCommand): Promise<CommandRes
1708
2032
  }
1709
2033
  }
1710
2034
  if (m.disposed) throw new Error("session is disposed");
2035
+ // 回到消息之前先取它带的图片:客户端只发改过的文字,不取的话重发出去图就没了
2036
+ const images = userMessageImages(session, entryId);
1711
2037
  const moved = await session.navigateTree(entryId);
1712
2038
  if (moved?.cancelled) return { ok: false, error: "edit cancelled" };
1713
2039
  await prepareGrowthBriefly(m);
1714
2040
  if (m.disposed) throw new Error("session is disposed");
1715
2041
  // 与普通 prompt 一样在受理时就返回:等整轮跑完的话,这期间 promptSubmitting 一直为真,
1716
2042
  // 插话 / 排队 / 新消息全被拒,HTTP 请求也一直挂着
1717
- return await submitPrompt(m, session, text);
2043
+ return await submitPrompt(m, session, text, images.length ? { images } : {});
1718
2044
  } finally {
1719
2045
  m.promptSubmitting = false;
1720
2046
  if (!m.disposed) publish(m, { type: "status", isStreaming: isManagedBusy(m), state: isManagedBusy(m) ? "running" : "idle", ts: Date.now() });
@@ -1744,6 +2070,12 @@ export async function execute(m: Managed, cmd: AgentCommand): Promise<CommandRes
1744
2070
  }
1745
2071
  const session = await ensureSession(m);
1746
2072
  await session.reload();
2073
+ // 同 reloadManagedResources:pi 1.3.0 保留活动工具集,重载后按当前注册表重算
2074
+ try {
2075
+ applyToolPolicy(m, session);
2076
+ } catch {
2077
+ /* 同上:策略失败不影响 /reload 本身 */
2078
+ }
1747
2079
  swapCurrentModelObject(session);
1748
2080
  return { ok: true };
1749
2081
  }
@@ -1784,7 +2116,7 @@ export function activeStatus(): Record<string, { streaming: boolean; lastStep?:
1784
2116
  for (let i = m.ledger.entries.length - 1; i >= 0; i -= 1) {
1785
2117
  const e = m.ledger.entries[i];
1786
2118
  if (e.kind === "tool") {
1787
- lastStep = [e.toolName, (e.preview ?? e.title ?? "").split("\n")[0].slice(0, 80)].filter(Boolean).join(" · ");
2119
+ lastStep = [e.toolName, toolStepPreview(e)].filter(Boolean).join(" · ");
1788
2120
  break;
1789
2121
  }
1790
2122
  }