oh-my-agent-web 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +385 -0
  3. package/README.md +322 -81
  4. package/dist/server/cli-info.js +4 -1
  5. package/dist/server/cli.js +54 -11
  6. package/dist/server/engine/builtin.js +28 -3
  7. package/dist/server/engine/codex-sessions.js +3 -3
  8. package/dist/server/engine/dsh.js +3 -1
  9. package/dist/server/engine/jsonrpc-stdio.js +33 -3
  10. package/dist/server/engine/opencode-sessions.js +8 -2
  11. package/dist/server/engine/opencode.js +3 -1
  12. package/dist/server/engine/pi-sdk.js +157 -15
  13. package/dist/server/engine/pi-subagent-tool.js +35 -13
  14. package/dist/server/engine/pi-subagents.js +38 -1
  15. package/dist/server/engine/registry.js +2 -1
  16. package/dist/server/engine/wiring.js +1 -0
  17. package/dist/server/files/dir-routes.js +7 -5
  18. package/dist/server/files/file-search.js +4 -3
  19. package/dist/server/files/file-service.js +45 -9
  20. package/dist/server/files/files-routes.js +8 -1
  21. package/dist/server/files/git-info.js +34 -7
  22. package/dist/server/http/auth.js +27 -1
  23. package/dist/server/http/aux-routes.js +6 -1
  24. package/dist/server/http/file-headers.js +75 -0
  25. package/dist/server/index.js +54 -77
  26. package/dist/server/mcp/mcp-auth.js +9 -3
  27. package/dist/server/mcp/mcp-command.js +50 -5
  28. package/dist/server/mcp/mcp-probe.js +43 -7
  29. package/dist/server/mcp/pi-mcp-config.js +30 -2
  30. package/dist/server/protocol-parse.js +11 -1
  31. package/dist/server/providers/catalog-store.js +7 -3
  32. package/dist/server/providers/catalog-writes.js +5 -3
  33. package/dist/server/providers/enabled-models.js +5 -3
  34. package/dist/server/providers/import-routes.js +15 -5
  35. package/dist/server/providers/model-test.js +72 -11
  36. package/dist/server/providers/probe.js +20 -0
  37. package/dist/server/providers/provider-handlers.js +27 -10
  38. package/dist/server/providers/provider-pool.js +63 -0
  39. package/dist/server/providers/provider-store.js +139 -20
  40. package/dist/server/providers/validate.js +13 -4
  41. package/dist/server/push/push-routes.js +3 -3
  42. package/dist/server/push/push.js +74 -12
  43. package/dist/server/session/approval-bridge.js +31 -19
  44. package/dist/server/session/conversation-create.js +1 -1
  45. package/dist/server/session/engine-switch.js +4 -1
  46. package/dist/server/session/model-controller.js +20 -2
  47. package/dist/server/session/notices.js +4 -2
  48. package/dist/server/session/pi-opener.js +44 -4
  49. package/dist/server/session/pi-sessions.js +79 -9
  50. package/dist/server/session/refresh-actions.js +3 -2
  51. package/dist/server/session/runtime-services.js +1 -0
  52. package/dist/server/session/runtime.js +50 -15
  53. package/dist/server/session/settings.js +4 -4
  54. package/dist/server/session/stats-refresher.js +7 -2
  55. package/dist/server/session/switch-conversation.js +1 -2
  56. package/dist/server/session/turn-events.js +1 -1
  57. package/dist/server/session/turn-finish.js +26 -8
  58. package/dist/server/session/turn-markers.js +6 -5
  59. package/dist/server/session/turn-queue.js +79 -14
  60. package/dist/server/session/turn-send.js +34 -10
  61. package/dist/server/skills/skill-install.js +2 -2
  62. package/dist/server/snapshot/fan-out.js +0 -5
  63. package/dist/server/snapshot/sender.js +0 -9
  64. package/dist/server/snapshot/state.js +60 -6
  65. package/dist/server/subagents/profile-store.js +2 -1
  66. package/dist/server/terminal/terminal-routes.js +50 -23
  67. package/dist/server/terminal/terminal-session.js +63 -6
  68. package/dist/server/uploads/upload-routes.js +3 -0
  69. package/dist/server/util/atomic-write.js +18 -0
  70. package/dist/server/util/env.js +58 -14
  71. package/dist/server/util/file-mode.js +44 -0
  72. package/dist/server/util/origin.js +61 -0
  73. package/dist/server/util/paths.js +79 -1
  74. package/dist/server/util/spawn-cli.js +147 -0
  75. package/dist/server/ws/handle-message.js +7 -1
  76. package/docs/screenshots/01-chat.png +0 -0
  77. package/docs/screenshots/02-answer.png +0 -0
  78. package/docs/screenshots/03-files-diff.png +0 -0
  79. package/docs/screenshots/04-terminal.png +0 -0
  80. package/docs/screenshots/05-models.png +0 -0
  81. package/docs/screenshots/06-skills.png +0 -0
  82. package/docs/screenshots/07-skills-hub.png +0 -0
  83. package/docs/screenshots/08-mcp.png +0 -0
  84. package/docs/screenshots/09-subagents.png +0 -0
  85. package/docs/screenshots/10-settings.png +0 -0
  86. package/docs/screenshots/11-plugins.png +0 -0
  87. package/docs/screenshots/12-voice.png +0 -0
  88. package/docs/screenshots/13-push.png +0 -0
  89. package/docs/screenshots/README.md +45 -0
  90. package/package.json +43 -19
  91. package/scripts/prune-prebuilds.mjs +42 -0
  92. package/web/dist/assets/_omaw-mermaid-pruned_chunk_abnfDiagram-WEYOIA3C-CvNbnJ75.js +1 -0
  93. package/web/dist/assets/_omaw-mermaid-pruned_chunk_architectureDiagram-ZHVYKT53-T7NZZ4ao.js +1 -0
  94. package/web/dist/assets/_omaw-mermaid-pruned_chunk_c4Diagram-XDPPPE54-ByuAPg39.js +1 -0
  95. package/web/dist/assets/_omaw-mermaid-pruned_chunk_cynefinDiagram-WF24UDRP-BY5DHttT.js +1 -0
  96. package/web/dist/assets/_omaw-mermaid-pruned_chunk_diagram-CBK3MWTC-D7ZYyrF7.js +1 -0
  97. package/web/dist/assets/_omaw-mermaid-pruned_chunk_diagram-ODIL2UCW-C6kcOZFc.js +1 -0
  98. package/web/dist/assets/_omaw-mermaid-pruned_chunk_diagram-PFBEJUXE-CzFPFq0D.js +1 -0
  99. package/web/dist/assets/_omaw-mermaid-pruned_chunk_diagram-TYUNFSNR-B4G4T8SL.js +1 -0
  100. package/web/dist/assets/_omaw-mermaid-pruned_chunk_ebnfDiagram-V4P3TTPT-BkQ4xwD0.js +1 -0
  101. package/web/dist/assets/_omaw-mermaid-pruned_chunk_ishikawaDiagram-HPEHV2GE-52xqckJv.js +1 -0
  102. package/web/dist/assets/_omaw-mermaid-pruned_chunk_mindmap-definition-6OY57UG3-7cQksbFn.js +1 -0
  103. package/web/dist/assets/_omaw-mermaid-pruned_chunk_pegDiagram-ECIUJNBX-udgtBpwv.js +1 -0
  104. package/web/dist/assets/_omaw-mermaid-pruned_chunk_railroadDiagram-Y72NRBL7-Cd2dcQWz.js +1 -0
  105. package/web/dist/assets/_omaw-mermaid-pruned_chunk_swimlanes-B3ENRKFQ-p34pL1Yk.js +1 -0
  106. package/web/dist/assets/_omaw-mermaid-pruned_chunk_swimlanesDiagram-AYQRV4AK-BslfPs4r.js +1 -0
  107. package/web/dist/assets/_omaw-mermaid-pruned_chunk_usecaseDiagram-WPOLBNNX-DJZoN9Xk.js +1 -0
  108. package/web/dist/assets/_omaw-mermaid-pruned_chunk_vennDiagram-Q3Y3IDCW-DPXo67cZ.js +1 -0
  109. package/web/dist/assets/_omaw-mermaid-pruned_chunk_wardleyDiagram-IVAIDKWA-BE6ukwkg.js +1 -0
  110. package/web/dist/assets/_omaw-mermaid-pruned_katex_katex-Zojedrce.js +1 -0
  111. package/web/dist/assets/arc-BTAvbR16.js +1 -0
  112. package/web/dist/assets/architecture-WOLXFQ4H-fewLD5_7.js +1 -0
  113. package/web/dist/assets/{blockDiagram-RJAE7QA3-4SqX2RCZ.js → blockDiagram-RJAE7QA3-ClmgyxZl.js} +5 -5
  114. package/web/dist/assets/channel-DfaYIFFE.js +1 -0
  115. package/web/dist/assets/{chunk-55XCU76K-BAQkUXw9.js → chunk-55XCU76K-DdC5EhZG.js} +1 -1
  116. package/web/dist/assets/chunk-7S6XSTFO-CgToWHK6.js +2 -0
  117. package/web/dist/assets/chunk-AQDTSKZB-D-rQMnp7.js +72 -0
  118. package/web/dist/assets/{chunk-DSNLSHJZ-CQfLJsEG.js → chunk-DSNLSHJZ-cuZoTGMH.js} +9 -9
  119. package/web/dist/assets/chunk-HNE4Q36P-CiKr9iSN.js +1 -0
  120. package/web/dist/assets/{chunk-NGNAAXSQ-C2Wy9Xmz.js → chunk-NGNAAXSQ-DuHRe0y2.js} +1 -1
  121. package/web/dist/assets/chunk-VQ5W62QI-DbTfXAAT.js +1 -0
  122. package/web/dist/assets/chunk-WQQL4CDK-DHanHfB5.js +10 -0
  123. package/web/dist/assets/chunk-ZAUX2LTX-SrjFXpy2.js +62 -0
  124. package/web/dist/assets/chunk-ZCHMQSOR-7Wy8xXQ5.js +2 -0
  125. package/web/dist/assets/{classDiagram-v2-4MYWR3UA-ByZcqRlk.js → classDiagram-v2-4MYWR3UA-CBe5ZDvZ.js} +4 -4
  126. package/web/dist/assets/cose-bilkent-JH36ORCC-Dljdyvhw.js +1 -0
  127. package/web/dist/assets/cynefin-EF2NZ3EQ-CcZU7GFP.js +1 -0
  128. package/web/dist/assets/dagre-NK4GIZVX-Bq_6y8qp.js +4 -0
  129. package/web/dist/assets/diagram-I5XIIV2D-C2lnnYVG.js +24 -0
  130. package/web/dist/assets/{diagram-RJA3I4VV-D-1dWVjH.js → diagram-RJA3I4VV-Bqf8Z6wJ.js} +1 -1
  131. package/web/dist/assets/elk-QONTBCTQ-Cdzq_cJ5.js +27 -0
  132. package/web/dist/assets/{erDiagram-KYQIGQAA-BkQvVdNn.js → erDiagram-KYQIGQAA-ZJLCjPFC.js} +2 -2
  133. package/web/dist/assets/eventmodeling-K75KTNOO-DHqeIpes.js +1 -0
  134. package/web/dist/assets/flowDiagram-IUGLLWBU-AVgYvXKN.js +213 -0
  135. package/web/dist/assets/ganttDiagram-R7GPNTJY-CR1SG1M7.js +292 -0
  136. package/web/dist/assets/gitGraph-VSP46ZUC-CIZQPK0R.js +1 -0
  137. package/web/dist/assets/gitGraphDiagram-PDYL7XHY-BhOCWEh6.js +106 -0
  138. package/web/dist/assets/index-CZQjJbRg.js +104 -0
  139. package/web/dist/assets/index-ChDUWKZX.css +1 -0
  140. package/web/dist/assets/info-OHQRW6UA-C2wuRxeQ.js +1 -0
  141. package/web/dist/assets/{infoDiagram-MVDRKAX5-Ct4k2rOv.js → infoDiagram-MVDRKAX5-BP5doAB5.js} +1 -1
  142. package/web/dist/assets/{journeyDiagram-BOSHW7O7-B0ommDDF.js → journeyDiagram-BOSHW7O7-BcIDRTPS.js} +4 -4
  143. package/web/dist/assets/{kanban-definition-FNSA4HC5-C5IHOEO9.js → kanban-definition-FNSA4HC5-CRH_bcoT.js} +5 -5
  144. package/web/dist/assets/{line-XIlcDZrk.js → line-iA4GPA_S.js} +1 -1
  145. package/web/dist/assets/linear-BGUKwkTH.js +1 -0
  146. package/web/dist/assets/math-DgNW_bVP.js +1 -0
  147. package/web/dist/assets/{mermaid-parser.core-9OmqIOQj.js → mermaid-parser.core-CsqmgpEv.js} +5 -5
  148. package/web/dist/assets/mermaid.core-C1yCGQK6.js +44 -0
  149. package/web/dist/assets/packet-JDAUHWVQ-2ggaoz2g.js +1 -0
  150. package/web/dist/assets/pie-XZMESJXO-h_yslQQs.js +1 -0
  151. package/web/dist/assets/{pieDiagram-GNMPY74T-BUKhe6YN.js → pieDiagram-GNMPY74T-6-JZMFyk.js} +2 -2
  152. package/web/dist/assets/{quadrantDiagram-D57K4MC3-COMDA2G7.js → quadrantDiagram-D57K4MC3-K5r1HOgL.js} +3 -3
  153. package/web/dist/assets/radar-ABXABTNO-6fOJGGgq.js +1 -0
  154. package/web/dist/assets/railroad-I3PHUGI6-B_gUgQ3d.js +1 -0
  155. package/web/dist/assets/railroad-abnf-LNEFI6M7-CJ_eqiXZ.js +1 -0
  156. package/web/dist/assets/railroad-ebnf-DWYD2UWJ-BTsgjLTU.js +1 -0
  157. package/web/dist/assets/railroad-peg-JR7BVNR7-D91uiLln.js +1 -0
  158. package/web/dist/assets/{requirementDiagram-EQYSW5RA-DilR-z_d.js → requirementDiagram-EQYSW5RA-uapnV_OO.js} +3 -3
  159. package/web/dist/assets/{sankeyDiagram-SY24JHO5-mnLwPz1t.js → sankeyDiagram-SY24JHO5-BQsbFgUb.js} +3 -3
  160. package/web/dist/assets/{sequenceDiagram-BLEHNHQU-BdH-2WXg.js → sequenceDiagram-BLEHNHQU-iRoSYX_e.js} +6 -6
  161. package/web/dist/assets/{src-DrBGN2Yi.js → src-BBrKcs9r.js} +1 -1
  162. package/web/dist/assets/{stateDiagram-v2-A6IESKWZ-Byl9Wc56.js → stateDiagram-v2-A6IESKWZ-Bzj9z5KH.js} +2 -2
  163. package/web/dist/assets/{timeline-definition-JXYKGLGB-D2DwVY9P.js → timeline-definition-JXYKGLGB-DLUUuJX0.js} +2 -2
  164. package/web/dist/assets/treeView-D4JQ5SDB-CphIQ3n5.js +1 -0
  165. package/web/dist/assets/treemap-SAJKECNS-DqU3insw.js +1 -0
  166. package/web/dist/assets/wardley-7MLQ67FV-Ch7d8cmC.js +1 -0
  167. package/web/dist/assets/{xychartDiagram-4URNHDU4-CUJ-AhMv.js → xychartDiagram-4URNHDU4-DdxpWBeM.js} +3 -3
  168. package/web/dist/index.html +2 -2
  169. package/web/dist/assets/abnfDiagram-WEYOIA3C-C0scvgfK.js +0 -1
  170. package/web/dist/assets/arc-DQWQXZ0D.js +0 -1
  171. package/web/dist/assets/architecture-WOLXFQ4H-DRYAlsMA.js +0 -1
  172. package/web/dist/assets/architectureDiagram-ZHVYKT53-DURI6MT_.js +0 -36
  173. package/web/dist/assets/c4Diagram-XDPPPE54-CyzS6It3.js +0 -38
  174. package/web/dist/assets/channel-sSfIOMg7.js +0 -1
  175. package/web/dist/assets/chunk-2E7WHVMD-B-50G064.js +0 -88
  176. package/web/dist/assets/chunk-4EMNKR2T-DsXkPvfB.js +0 -1
  177. package/web/dist/assets/chunk-7S6XSTFO-MNTmDz5n.js +0 -2
  178. package/web/dist/assets/chunk-AQDTSKZB-DxchdDrD.js +0 -72
  179. package/web/dist/assets/chunk-HAGFOTED-BaVCqyAB.js +0 -62
  180. package/web/dist/assets/chunk-HNE4Q36P-BachKm72.js +0 -1
  181. package/web/dist/assets/chunk-NQOKRVIF-D38mfZCv.js +0 -213
  182. package/web/dist/assets/chunk-UHW7PFBP-DzvEYnNC.js +0 -1
  183. package/web/dist/assets/chunk-VQ5W62QI-D7Rb-hYZ.js +0 -1
  184. package/web/dist/assets/chunk-WQQL4CDK-Dt2FUoIk.js +0 -10
  185. package/web/dist/assets/chunk-XXDRQBXY-DasKl82Z.js +0 -1
  186. package/web/dist/assets/chunk-YZO67HFG-3YefBeUz.js +0 -1
  187. package/web/dist/assets/chunk-ZAUX2LTX-CUoEknd1.js +0 -1
  188. package/web/dist/assets/chunk-ZCHMQSOR-Da3GBE09.js +0 -2
  189. package/web/dist/assets/cose-bilkent-JH36ORCC-PGAAEchc.js +0 -1
  190. package/web/dist/assets/cynefin-EF2NZ3EQ-BBkeqM17.js +0 -1
  191. package/web/dist/assets/cynefinDiagram-WF24UDRP-DOEytVty.js +0 -62
  192. package/web/dist/assets/cytoscape.esm-BegBNzOV.js +0 -321
  193. package/web/dist/assets/dagre-NK4GIZVX-Bei2BCmp.js +0 -4
  194. package/web/dist/assets/defaultLocale-BFoDCU3G.js +0 -1
  195. package/web/dist/assets/diagram-CBK3MWTC-BdeyWllS.js +0 -24
  196. package/web/dist/assets/diagram-I5XIIV2D-BmNMrWWj.js +0 -24
  197. package/web/dist/assets/diagram-ODIL2UCW-D8Kz7g8s.js +0 -30
  198. package/web/dist/assets/diagram-PFBEJUXE-BNSD58Rk.js +0 -3
  199. package/web/dist/assets/diagram-TYUNFSNR-8YHZJhel.js +0 -200
  200. package/web/dist/assets/dist-BBxVlYHc.js +0 -1
  201. package/web/dist/assets/ebnfDiagram-V4P3TTPT-CtgdOqHX.js +0 -1
  202. package/web/dist/assets/elk-QONTBCTQ-DM5bxyWI.js +0 -27
  203. package/web/dist/assets/eventmodeling-K75KTNOO-DWnHI7Q0.js +0 -1
  204. package/web/dist/assets/flowDiagram-IUGLLWBU-DYv3kVGg.js +0 -1
  205. package/web/dist/assets/ganttDiagram-R7GPNTJY-hF6RKzBy.js +0 -292
  206. package/web/dist/assets/gitGraph-VSP46ZUC-Dg0BaeiY.js +0 -1
  207. package/web/dist/assets/gitGraphDiagram-PDYL7XHY-CEJB8ISX.js +0 -106
  208. package/web/dist/assets/index-BLsWuXKg.js +0 -104
  209. package/web/dist/assets/index-Bph00xIt.css +0 -1
  210. package/web/dist/assets/info-OHQRW6UA-X0qorfja.js +0 -1
  211. package/web/dist/assets/ishikawaDiagram-HPEHV2GE-JsH1yFOe.js +0 -70
  212. package/web/dist/assets/katex-B_JcIwqj.js +0 -257
  213. package/web/dist/assets/linear-BXwFphKE.js +0 -1
  214. package/web/dist/assets/mermaid.core-B9yDV3Hs.js +0 -44
  215. package/web/dist/assets/mindmap-definition-6OY57UG3-Bc8uZ5T9.js +0 -98
  216. package/web/dist/assets/packet-JDAUHWVQ-9EbXSBeV.js +0 -1
  217. package/web/dist/assets/pegDiagram-ECIUJNBX-nYdgRfAO.js +0 -1
  218. package/web/dist/assets/pie-XZMESJXO-DNvb9i1G.js +0 -1
  219. package/web/dist/assets/radar-ABXABTNO-DAG-ygGV.js +0 -1
  220. package/web/dist/assets/railroad-I3PHUGI6-CjnKFfHL.js +0 -1
  221. package/web/dist/assets/railroad-abnf-LNEFI6M7-DOpu4hdH.js +0 -1
  222. package/web/dist/assets/railroad-ebnf-DWYD2UWJ-B7kV2hyB.js +0 -1
  223. package/web/dist/assets/railroad-peg-JR7BVNR7-Cy4mKxpf.js +0 -1
  224. package/web/dist/assets/railroadDiagram-Y72NRBL7-CJek641i.js +0 -1
  225. package/web/dist/assets/rough.esm-Dy-Kn_BL.js +0 -1
  226. package/web/dist/assets/swimlanes-B3ENRKFQ-D_Mnemnr.js +0 -1
  227. package/web/dist/assets/swimlanesDiagram-AYQRV4AK-D6uQfcsx.js +0 -8
  228. package/web/dist/assets/treeView-D4JQ5SDB-BPseZx68.js +0 -1
  229. package/web/dist/assets/treemap-SAJKECNS-DbkR3mjV.js +0 -1
  230. package/web/dist/assets/usecaseDiagram-WPOLBNNX-ClThWCPc.js +0 -328
  231. package/web/dist/assets/vennDiagram-Q3Y3IDCW-B50-oQjz.js +0 -34
  232. package/web/dist/assets/wardley-7MLQ67FV-Ct_6HXw3.js +0 -1
  233. package/web/dist/assets/wardleyDiagram-IVAIDKWA-B7Z8k44J.js +0 -78
@@ -18,6 +18,7 @@ export function createBuiltinEngineRegistry(options) {
18
18
  options.onLog?.(text);
19
19
  options.onNotice(text, level);
20
20
  },
21
+ ...(options.skillPaths ? { skillPaths: options.skillPaths } : {}),
21
22
  }),
22
23
  },
23
24
  ...(options.codex
@@ -29,7 +30,15 @@ export function createBuiltinEngineRegistry(options) {
29
30
  // 能力如实写:审批是命令/文件级的(比 pi 粗一档),没有 provider 面板
30
31
  description: options.codex.description ?? "codex 官方 agent:独立 loop 与沙箱,会话与 Codex Desktop / CLI 共用",
31
32
  providerConfig: false,
32
- capabilities: { approvals: true, tools: true, modelSwitch: true, thinkingLevel: true, resume: true },
33
+ // steer: false —— 底层(JSON-RPC / ACP)没有「插进正在跑的这一轮」的语义,宿主会降级成排队
34
+ capabilities: {
35
+ approvals: true,
36
+ tools: true,
37
+ modelSwitch: true,
38
+ thinkingLevel: true,
39
+ resume: true,
40
+ steer: false,
41
+ },
33
42
  create: () => createCodexEngine({ client: options.codex?.client, log: options.onLog }),
34
43
  },
35
44
  ]
@@ -43,7 +52,15 @@ export function createBuiltinEngineRegistry(options) {
43
52
  description: options.dsh.description ??
44
53
  "DeepSeek 官方 agent(ACP):模型与思考档可切、权限逐次确认,会话与 dsh web/tui 共用",
45
54
  providerConfig: false,
46
- capabilities: { approvals: true, tools: true, modelSwitch: true, thinkingLevel: true, resume: true },
55
+ // steer: false —— 底层(JSON-RPC / ACP)没有「插进正在跑的这一轮」的语义,宿主会降级成排队
56
+ capabilities: {
57
+ approvals: true,
58
+ tools: true,
59
+ modelSwitch: true,
60
+ thinkingLevel: true,
61
+ resume: true,
62
+ steer: false,
63
+ },
47
64
  create: () => createDshEngine({ client: options.dsh?.client, model: options.dsh?.model, log: options.onLog }),
48
65
  },
49
66
  ]
@@ -59,7 +76,15 @@ export function createBuiltinEngineRegistry(options) {
59
76
  "opencode 官方 agent(ACP):自带工具与 MCP/插件生态,模型与思考档可切,会话与 opencode CLI / TUI 共用;审批取决于 opencode 自身 permission 配置",
60
77
  providerConfig: false,
61
78
  // 没有子代理面板(ACP 不暴露 task 子代理的运行视图)→ subagents 不写,界面就不出现那块
62
- capabilities: { approvals: true, tools: true, modelSwitch: true, thinkingLevel: true, resume: true },
79
+ // steer: false —— 底层(JSON-RPC / ACP)没有「插进正在跑的这一轮」的语义,宿主会降级成排队
80
+ capabilities: {
81
+ approvals: true,
82
+ tools: true,
83
+ modelSwitch: true,
84
+ thinkingLevel: true,
85
+ resume: true,
86
+ steer: false,
87
+ },
63
88
  create: () => createOpencodeEngine({
64
89
  client: options.opencode?.client,
65
90
  bin: options.opencode?.bin,
@@ -13,7 +13,7 @@
13
13
  * 这条兜底很重要:本机实测 codex 的 threads.project_id 大量为 NULL(Desktop 只在某些路径下写入),
14
14
  * 只按 projectId 归类会让侧栏空掉。
15
15
  */
16
- import { basename } from "node:path";
16
+ import { basename, sep } from "node:path";
17
17
  import { isScratchCwd } from "./scratch.js";
18
18
  /**
19
19
  * 拉会话时必须显式声明这两项,否则会「看不见大部分历史」(实测 0.162/0.163):
@@ -126,12 +126,12 @@ export async function ensureCodexProject(client, workspace) {
126
126
  const list = await client.request("project/list", {});
127
127
  const match = (list?.data ?? [])
128
128
  .flatMap((project) => (project.roots ?? []).map((root) => ({ id: project.id, path: root.path ?? "" })))
129
- .filter((root) => root.path !== "" && (workspace === root.path || workspace.startsWith(`${root.path}/`)))
129
+ .filter((root) => root.path !== "" && (workspace === root.path || workspace.startsWith(`${root.path}${sep}`)))
130
130
  .sort((left, right) => right.path.length - left.path.length)[0];
131
131
  if (match)
132
132
  return match.id;
133
133
  const created = await client.request("project/create", {
134
- name: workspace.split("/").filter(Boolean).pop() ?? workspace,
134
+ name: basename(workspace) || workspace,
135
135
  roots: [{ path: workspace }],
136
136
  idempotencyKey: `agent-web:${workspace}`,
137
137
  });
@@ -119,7 +119,9 @@ export function createDshEngine(options = {}) {
119
119
  };
120
120
  const handlePermission = async (id, params) => {
121
121
  const facts = params.toolCall?.toolCallId ? toolFacts.get(params.toolCall.toolCallId) : undefined;
122
- const request = permissionApprovalRequest(params, facts);
122
+ // 带上会话归属:宿主收尾时只收这个会话的审批(不然会误伤并行会话,见 approval-bridge)
123
+ const owner = params.sessionId ? bySession.get(params.sessionId) : undefined;
124
+ const request = { ...permissionApprovalRequest(params, facts), ...(owner ? { conversationId: owner } : {}) };
123
125
  let decision = "deny";
124
126
  if (approvalHandler) {
125
127
  try {
@@ -20,6 +20,7 @@
20
20
  * 服务端请求 → `{"id":9,"method":"…","params":{…}}`(有 id + method,**必须回包**否则对端一直等)
21
21
  */
22
22
  import { spawn } from "node:child_process";
23
+ import { killTree, resolveCli } from "../util/spawn-cli.js";
23
24
  export function createJsonRpcStdioClient(options) {
24
25
  const baseTimeout = options.timeoutMs ?? 30_000;
25
26
  const prefix = options.name;
@@ -43,6 +44,10 @@ export function createJsonRpcStdioClient(options) {
43
44
  const send = (payload) => {
44
45
  if (!child)
45
46
  throw new Error(`${prefix} 未连接`);
47
+ // 子进程刚死、或已经被 close() 掉的时候这里还在写 → 会抛 EPIPE。
48
+ // 先自己拦一道,给调用方一个能看懂的错,而不是趟进 EPIPE 的未捕获异常(见 wire 里的 stdin 监听)
49
+ if (child.stdin.destroyed || !child.stdin.writable)
50
+ throw new Error(`${prefix} 管道已关闭`);
46
51
  child.stdin.write(`${JSON.stringify(options.jsonrpc ? { jsonrpc: "2.0", ...payload } : payload)}\n`);
47
52
  };
48
53
  const respond = (id, result) => {
@@ -103,6 +108,10 @@ export function createJsonRpcStdioClient(options) {
103
108
  const wire = (process) => {
104
109
  process.stdout.setEncoding("utf8");
105
110
  process.stdout.on("data", (chunk) => {
111
+ // close() 之后旧进程可能还会吐完缓冲区里的字节:不许它写进“新进程”的 buffer,
112
+ // 否则重启后第一帧会被旧半行拼坏 → JSON.parse 失败 → initialize 超时(看着就是「切回来连不上」)
113
+ if (child !== process)
114
+ return;
106
115
  buffer += chunk;
107
116
  let index;
108
117
  while ((index = buffer.indexOf("\n")) >= 0) {
@@ -133,6 +142,15 @@ export function createJsonRpcStdioClient(options) {
133
142
  };
134
143
  process.on("exit", (code) => onGone(`${prefix} 已退出(code ${code ?? "?"})`));
135
144
  process.on("error", (error) => onGone(`${prefix} 启动失败:${error.message}`));
145
+ /**
146
+ * stdin 的 error **必须**接住。
147
+ *
148
+ * 子进程已经没了但我们还在 write(审批回包、turn/interrupt、通知都可能撞上),
149
+ * Node 会把 EPIPE 以 'error' 事件形式丢到 Writable 上;没监听就是**未捕获异常**,
150
+ * 而本仓只注册了 unhandledRejection(index.ts),于是整个工作台跟着退出。
151
+ * 处理方式与进程退出一致:拒掉在飞请求、通知 onExit(上层会标记引擎不可用)。
152
+ */
153
+ process.stdin.on("error", (error) => onGone(`${prefix} 写入失败:${error.message}`));
136
154
  };
137
155
  const requestRaw = (method, params, timeoutMs = baseTimeout) => {
138
156
  const id = ++seq;
@@ -166,7 +184,14 @@ export function createJsonRpcStdioClient(options) {
166
184
  else
167
185
  env[key] = value;
168
186
  }
169
- const next = spawn(options.bin, options.args, { env, cwd: options.cwd, stdio: ["pipe", "pipe", "pipe"] });
187
+ // Windows 上 codex/dsh 是 .cmd shim:resolveCli 负责改成 cmd.exe /c
188
+ const command = resolveCli(options.bin, options.args, env);
189
+ const next = spawn(command.file, command.args, {
190
+ ...command.options,
191
+ env,
192
+ cwd: options.cwd,
193
+ stdio: ["pipe", "pipe", "pipe"],
194
+ });
170
195
  child = next;
171
196
  wire(next);
172
197
  }
@@ -182,7 +207,8 @@ export function createJsonRpcStdioClient(options) {
182
207
  child = null;
183
208
  initialized = false;
184
209
  version = null;
185
- process?.kill("SIGTERM");
210
+ if (process)
211
+ killTree(process);
186
212
  throw error instanceof Error ? error : new Error(String(error));
187
213
  }
188
214
  finally {
@@ -234,8 +260,12 @@ export function createJsonRpcStdioClient(options) {
234
260
  child = null;
235
261
  initialized = false;
236
262
  version = null;
263
+ // 必须清:onGone 里那句 `if (child !== process) return` 在 close 之后会提前返回,
264
+ // 所以这条路径上没人会帮我们清 buffer(旧半行会污染下次 spawn 的第一帧)
265
+ buffer = "";
237
266
  rejectAll(new Error(`${prefix} 已关闭`));
238
- process?.kill("SIGTERM");
267
+ if (process)
268
+ killTree(process);
239
269
  },
240
270
  };
241
271
  }
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { spawn } from "node:child_process";
21
21
  import { basename } from "node:path";
22
+ import { killTree, resolveCli } from "../util/spawn-cli.js";
22
23
  import { isScratchCwd } from "./scratch.js";
23
24
  /** 单个项目的 `session/list` 超时:侧栏刷新不该被一个卡住的项目拖死 */
24
25
  const PROJECT_LIST_TIMEOUT_MS = 10_000;
@@ -146,11 +147,16 @@ export function createOpencodeSessionsProvider(options) {
146
147
  */
147
148
  function runOpencode(bin, args, options = {}) {
148
149
  return new Promise((resolve, reject) => {
149
- const child = spawn(bin, args, { cwd: options.cwd, stdio: ["ignore", "pipe", "pipe"] });
150
+ const command = resolveCli(bin, args, process.env);
151
+ const child = spawn(command.file, command.args, {
152
+ ...command.options,
153
+ cwd: options.cwd,
154
+ stdio: ["ignore", "pipe", "pipe"],
155
+ });
150
156
  let stdout = "";
151
157
  let stderr = "";
152
158
  const timer = setTimeout(() => {
153
- child.kill("SIGTERM");
159
+ killTree(child);
154
160
  reject(new Error(`opencode ${args[0]} 超时`));
155
161
  }, options.timeoutMs ?? 30_000);
156
162
  child.stdout?.setEncoding("utf8");
@@ -159,7 +159,9 @@ export function createOpencodeEngine(options = {}) {
159
159
  };
160
160
  const handlePermission = async (id, params) => {
161
161
  const facts = params.toolCall?.toolCallId ? toolFacts.get(params.toolCall.toolCallId) : undefined;
162
- const request = permissionApprovalRequest(params, facts);
162
+ // 带上会话归属:宿主收尾时只收这个会话的审批(不然会误伤并行会话,见 approval-bridge)
163
+ const owner = params.sessionId ? byEngineSession.get(params.sessionId) : undefined;
164
+ const request = { ...permissionApprovalRequest(params, facts), ...(owner ? { conversationId: owner } : {}) };
163
165
  let decision = "deny";
164
166
  if (approvalHandler) {
165
167
  try {
@@ -7,6 +7,7 @@
7
7
  * 与 SDK 的对接方式(M3 侦察结论):
8
8
  */
9
9
  import { DefaultResourceLoader, SessionManager, createAgentSession, getAgentDir, } from "@earendil-works/pi-coding-agent";
10
+ import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all";
10
11
  import { classifyScope } from "../approval/scope.js";
11
12
  import { mapSessionEvent } from "./pi-events.js";
12
13
  import { createPiSubagents } from "./pi-subagents.js";
@@ -17,6 +18,29 @@ import { entriesToUiMessages } from "./pi-transcript.js";
17
18
  function asRecord(value) {
18
19
  return typeof value === "object" && value !== null ? value : {};
19
20
  }
21
+ /**
22
+ * pi 的「已经在跑了」那句拒绝(`Agent is already processing. Specify streamingBehavior…`)。
23
+ * 宿主拿它判断「这条其实该插队」—— 见 sendMessage 里的兜底。
24
+ */
25
+ function isAlreadyProcessing(error) {
26
+ const message = error instanceof Error ? error.message : String(error);
27
+ return message.includes("Agent is already processing");
28
+ }
29
+ /**
30
+ * 插队那条要等「这一轮真的 settle」才算收尾;这个上限只是兜底,
31
+ * 免得 pi 万一不吐 agent_settled(abort 失败之类)时回合永远不收尾(宿主看门狗本来也会 interrupt)。
32
+ */
33
+ const STEER_SETTLE_FALLBACK_MS = 20 * 60 * 1000;
34
+ /**
35
+ * 普通(非插队)回合的「等 settle」宽限。
36
+ *
37
+ * `prompt()` 返回时收尾事件(agent_settled / 最后的 message_end)可能还在路上:
38
+ * 本来只给 50ms,一步慢机器上就会错过 —— 后果不是报错而是**静默错报**:
39
+ * `interrupted` 只在 agent_settled 里置位,错过它的话「用户点停的回合」会被报成 `turn_end: done`,
40
+ * 而且 finally 里的 unsubscribe 会把尾帧(含 usage)一起丢掉(token/费用少记)。
41
+ * 800ms 是个只影响「settle 干脆不来」那种病态情况的兵底,正常回合 settles 一到就返回。
42
+ */
43
+ const SETTLE_GRACE_MS = 800;
20
44
  /**
21
45
  * provider 的显示名:只覆盖 SDK 名字太长/不直观的两个(和 pi-web 的选择一致),
22
46
  * 其余直接用引擎给的 OAuth 名称(`Anthropic (Claude Pro/Max)`、`xAI (Grok/X subscription)` 等)。
@@ -25,6 +49,18 @@ const OAUTH_LABELS = {
25
49
  "openai-codex": "ChatGPT Plus/Pro",
26
50
  "github-copilot": "GitHub Copilot",
27
51
  };
52
+ /**
53
+ * 「只填 key 还不够」的 provider:还得有端点 / 云凭证。
54
+ * 写死名单而不是去猜 provider 定义:这些的 baseUrl 要么挂在模型上、要么要 account id,
55
+ * 而 pi 的 provider 对象没有「需不需要额外配置」这个字段。
56
+ */
57
+ const NEEDS_EXTRA_CONFIG = new Set([
58
+ "amazon-bedrock",
59
+ "google-vertex",
60
+ "azure",
61
+ "cloudflare-ai-gateway",
62
+ "cloudflare-workers-ai",
63
+ ]);
28
64
  /** SDK 的 AuthPrompt → 端口的 ProviderAuthPrompt */
29
65
  function toAuthPrompt(prompt) {
30
66
  const record = asRecord(prompt);
@@ -94,8 +130,13 @@ export function createPiSdkEngine(options = {}) {
94
130
  // 子代理沿用父会话的模型运行时 / 当前模型(不然扩展提供的 provider 在子会话里不存在)
95
131
  getParentSession: (conversationId) => sessions.get(conversationId)?.session ?? null,
96
132
  });
97
- /** 审批用的 inline 扩展:拦在工具真正执行之前(SDK 的公开拦截点) */
98
- function makeApprovalExtension(workspace) {
133
+ /**
134
+ * 审批用的 inline 扩展:拦在工具真正执行之前(SDK 的公开拦截点)。
135
+ *
136
+ * 这个扩展是**按会话**建的(见 startSession),所以能把会话 id 一起带上 ——
137
+ * 回合收尾时宿主据此只收掉本会话悬着的审批,不会误伤并行跑着的其它会话。
138
+ */
139
+ function makeApprovalExtension(workspace, conversationId) {
99
140
  return {
100
141
  name: "agent-web-approval",
101
142
  hidden: true,
@@ -111,6 +152,7 @@ export function createPiSdkEngine(options = {}) {
111
152
  return undefined; // 只读工具不问
112
153
  const decision = approvalHandler
113
154
  ? await approvalHandler({
155
+ conversationId,
114
156
  toolName: scope.toolName,
115
157
  argsSummary: scope.argsSummary,
116
158
  scopeKey: scope.scopeKey,
@@ -131,11 +173,9 @@ export function createPiSdkEngine(options = {}) {
131
173
  const agentDir = options.agentDir ?? getAgentDir();
132
174
  // 子会话要按父会话归档:会话文件名在 createAgentSession 之后才确定,所以用一个懒引用
133
175
  const parentSessionPath = () => sessionManager.getSessionFile() ?? sessionPath;
134
- const extensionFactories = [makeApprovalExtension(workspace)];
176
+ const extensionFactories = [makeApprovalExtension(workspace, sessionId)];
135
177
  // 子代理开着、且有可用模板时,才把「什么时候派活」这段拼进系统提示
136
- const subagentReady = sessionId !== PREWARM_ID
137
- && readSubagentSettings(agentDir).enabled
138
- && enabledSubagentSpecs(agentDir).length > 0;
178
+ const subagentReady = sessionId !== PREWARM_ID && readSubagentSettings(agentDir).enabled && enabledSubagentSpecs(agentDir).length > 0;
139
179
  // 预热会话不暴露 Agent 工具:它不下发提示词,给它一个委派入口只会误伤
140
180
  if (sessionId !== PREWARM_ID) {
141
181
  extensionFactories.push(makeSubagentToolExtension({
@@ -149,14 +189,13 @@ export function createPiSdkEngine(options = {}) {
149
189
  const resourceLoader = new DefaultResourceLoader({
150
190
  cwd: workspace,
151
191
  agentDir,
192
+ // 本工作台的技能目录(默认 `~/.agents/skills`):pi 自己不扫那里,
193
+ // 不显式加上的话 `/skill:xxx` 不会被展开成技能正文(见 PiSdkEngineOptions.skillPaths)
194
+ ...(options.skillPaths && options.skillPaths.length > 0 ? { additionalSkillPaths: options.skillPaths } : {}),
152
195
  extensionFactories,
153
196
  // 教模型「下一步建议」的写法(ADR-0022):纯文本标记,不需要调用任何工具
154
197
  // 「动手前先说话」:过程默认折叠,不写正文用户就不知道它在干什么
155
- appendSystemPrompt: [
156
- SUGGESTION_GUIDANCE,
157
- NARRATE_GUIDANCE,
158
- ...(subagentReady ? [SUBAGENT_GUIDANCE] : []),
159
- ],
198
+ appendSystemPrompt: [SUGGESTION_GUIDANCE, NARRATE_GUIDANCE, ...(subagentReady ? [SUBAGENT_GUIDANCE] : [])],
160
199
  });
161
200
  await resourceLoader.reload();
162
201
  const { session } = await createAgentSession({
@@ -237,6 +276,66 @@ export function createPiSdkEngine(options = {}) {
237
276
  }))
238
277
  .sort((left, right) => left.id.localeCompare(right.id));
239
278
  }
279
+ /**
280
+ * 引擎目录里的全部 provider(ADR-0004 快照 / 「官方内置服务商」分组)。
281
+ *
282
+ * 与 `listModels()` 的区别:那个只回**已鉴权**的,这里连「没配 key 的」也列 ——
283
+ * 界面才有一个地方让用户粘 key。两个标记决定能不能归到「填 key 就能用」:
284
+ * - `oauthOnly`:pi 只给了订阅登录(openai-codex),填 key 没用;
285
+ * - `needsExtraConfig`:除了 key 还要端点 / 云凭证(bedrock、vertex、azure、cloudflare)。
286
+ * 名单按 id 写死:pi 的 provider 定义没有「需不需要额外配置」这个字段,而靠猜更不可靠。
287
+ */
288
+ async function listProviderCatalog() {
289
+ const runtime = await authRuntime();
290
+ if (!runtime)
291
+ return [];
292
+ return runtime
293
+ .getProviders()
294
+ .map((provider) => {
295
+ const apiKey = Boolean(provider.auth.apiKey);
296
+ const oauth = Boolean(provider.auth.oauth);
297
+ const models = runtime.getModels(provider.id);
298
+ // SDK 的 Provider 类型里没写 baseUrl(运行时是有的,pi-ai 的 createProvider 会给):
299
+ // 这里只读一个可选的显示信息,不值得为它去引 pi-ai 的内部类型。
300
+ const declaredBaseUrl = provider.baseUrl;
301
+ /**
302
+ * 端点 / 协议可能**挂在模型上**而不是 provider 上(opencode / opencode-go 就是这种,
303
+ * 而且同一家还混着 anthropic / openai / gemini 三种协议)。
304
+ * 运行时拿到的模型对象不一定带这些字段,所以再拿 pi 的静态目录按 id 补一份。
305
+ */
306
+ const builtin = getBuiltinModels(provider.id);
307
+ const specById = new Map();
308
+ for (const model of builtin) {
309
+ specById.set(model.id, {
310
+ ...(typeof model.api === "string" && model.api !== "" ? { api: model.api } : {}),
311
+ ...(typeof model.baseUrl === "string" && model.baseUrl !== "" ? { baseUrl: model.baseUrl } : {}),
312
+ });
313
+ }
314
+ const modelSpecs = models.map((model) => ({
315
+ id: model.id,
316
+ ...((model.api ?? specById.get(model.id)?.api) ? { api: model.api ?? specById.get(model.id)?.api } : {}),
317
+ ...((model.baseUrl ?? specById.get(model.id)?.baseUrl)
318
+ ? { baseUrl: model.baseUrl ?? specById.get(model.id)?.baseUrl }
319
+ : {}),
320
+ }));
321
+ const baseUrl = declaredBaseUrl ??
322
+ modelSpecs.find((model) => model.baseUrl !== undefined)?.baseUrl ??
323
+ models.find((model) => typeof model.baseUrl === "string" && model.baseUrl !== "")?.baseUrl;
324
+ const api = modelSpecs.find((model) => model.api !== undefined)?.api ??
325
+ models.find((model) => typeof model.api === "string" && model.api !== "")?.api;
326
+ return {
327
+ provider: provider.id,
328
+ name: provider.name,
329
+ ...(baseUrl ? { baseUrl } : {}),
330
+ ...(api ? { api } : {}),
331
+ ...(modelSpecs.length > 0 ? { models: modelSpecs } : {}),
332
+ modelCount: models.length,
333
+ ...(apiKey ? {} : oauth ? { oauthOnly: true } : { needsExtraConfig: true }),
334
+ ...(NEEDS_EXTRA_CONFIG.has(provider.id) ? { needsExtraConfig: true } : {}),
335
+ };
336
+ })
337
+ .sort((left, right) => left.provider.localeCompare(right.provider));
338
+ }
240
339
  return {
241
340
  /** 预热:pi 的 modelRuntime 挂在会话上,没会话就列不出模型(内存会话,不落盘) */
242
341
  async prewarm() {
@@ -335,6 +434,8 @@ export function createPiSdkEngine(options = {}) {
335
434
  }
336
435
  });
337
436
  handle.subscriptions.add(off);
437
+ /** 这条消息是被「插进正在跑的那一轮」的:它的输出要等那一轮结束,不能 50ms 就收尾 */
438
+ let steered = false;
338
439
  try {
339
440
  // 图片走 SDK 的 images 参数(视觉注入);没有图片时不传,避免空数组影响提示词缓存
340
441
  const images = messageOptions?.images?.map((image) => ({
@@ -348,9 +449,31 @@ export function createPiSdkEngine(options = {}) {
348
449
  promptOptions.images = images;
349
450
  if (messageOptions?.streamingBehavior)
350
451
  promptOptions.streamingBehavior = messageOptions.streamingBehavior;
351
- await handle.session.prompt(text, promptOptions);
352
- // 等 settle:prompt() 返回后仍可能有收尾事件
353
- await Promise.race([settled, new Promise((resolve) => setTimeout(resolve, 50))]);
452
+ const session = handle.session;
453
+ try {
454
+ await session.prompt(text, promptOptions);
455
+ }
456
+ catch (error) {
457
+ /**
458
+ * pi 说「已经在跑了」= 宿主的忙碌判断和 pi 的 `isStreaming` 错开了一步(排队排空恰好撞上
459
+ * 上一轮还没真正 settle、看门狗强制收尾、插队与 settle 抢跑……)。
460
+ * 这**不是**「回合失败」而是「该插队」:补一次 steer 交给 pi 排队(与 turn-queue 的
461
+ * flushSteer 同一语义),别把用户那条消息变成界面上的一条红条、然后人也没了。
462
+ * 已经就是 steer 还报这句 = 真异常,照旧报错。
463
+ */
464
+ if (promptOptions.streamingBehavior === "steer" || !isAlreadyProcessing(error))
465
+ throw error;
466
+ await session.prompt(text, { ...promptOptions, streamingBehavior: "steer" });
467
+ steered = true;
468
+ }
469
+ // 等 settle:prompt() 返回后仍可能有收尾事件(插队那条更要等整轮真结束,否则会提前收尾)
470
+ if (steered)
471
+ await Promise.race([
472
+ settled,
473
+ new Promise((done) => setTimeout(done, STEER_SETTLE_FALLBACK_MS).unref?.()),
474
+ ]);
475
+ else
476
+ await Promise.race([settled, new Promise((resolve) => setTimeout(resolve, SETTLE_GRACE_MS).unref?.())]);
354
477
  }
355
478
  catch (error) {
356
479
  failed = {
@@ -367,7 +490,10 @@ export function createPiSdkEngine(options = {}) {
367
490
  onEvent({ type: "turn_end", reason: "error" });
368
491
  return;
369
492
  }
370
- onEvent({ type: "turn_end", reason: interrupted ? "interrupted" : providerError ? "error" : "done" });
493
+ // `handle.aborting` 是兵底:中断时它已经被 interrupt() 置 true,
494
+ // 即使 agent_settled 没来得及到,也不要把「点停」报成「完成」
495
+ const aborted = interrupted || handle.aborting;
496
+ onEvent({ type: "turn_end", reason: aborted ? "interrupted" : providerError ? "error" : "done" });
371
497
  },
372
498
  async interrupt(sessionId) {
373
499
  const handle = sessions.get(sessionId);
@@ -405,6 +531,7 @@ export function createPiSdkEngine(options = {}) {
405
531
  setApprovalHandler(handler) {
406
532
  approvalHandler = handler;
407
533
  },
534
+ listProviderCatalog,
408
535
  async listModels(sessionId) {
409
536
  // 模型注册表挂在会话的 modelRuntime 上:没有会话时给不出(界面在打开会话后才显示列表)
410
537
  const handle = sessionId ? sessions.get(sessionId) : [...sessions.values()].at(-1);
@@ -484,6 +611,21 @@ export function createPiSdkEngine(options = {}) {
484
611
  * `sessions.get(undefined)` 什么也没关,每切一次引擎就漏一批 SDK 会话与资源加载器。
485
612
  */
486
613
  async dispose(sessionId) {
614
+ /**
615
+ * 引擎级 dispose(切引擎/退出)**必须连子代理一起收掉**。
616
+ *
617
+ * 子代理会话是 `pi-subagents` 自己 `createAgentSession` 起的,**不在下面这个 sessions map 里** ——
618
+ * 只关 sessions 就会漏掉它们:切走引擎后旧子代理还在跑(还在调工具、还在烧 token),
619
+ * 而且再也没人能把它们停下(宿主已经换了引擎,`interrupt_subagent` 会发给新引擎)。
620
+ */
621
+ if (!sessionId && subagents.dispose) {
622
+ try {
623
+ await subagents.dispose();
624
+ }
625
+ catch {
626
+ // 收不掉也不阻断切换(旧引擎的子代理最多多跑一会儿,但不会再阻塞切引擎)
627
+ }
628
+ }
487
629
  const ids = sessionId ? [sessionId] : [...sessions.keys()];
488
630
  for (const id of ids) {
489
631
  const handle = sessions.get(id);
@@ -40,28 +40,52 @@ function resultText(run) {
40
40
  return `${head}(已停止)${body ? `\n\n${body}` : ""}`;
41
41
  return `${head}:${run.error ?? "未知错误"}${body ? `\n\n${body}` : ""}`;
42
42
  }
43
- /** 等这个运行进入终态;返回最后一次看到的记录 */
44
- function waitForRun(port, id, timeoutMs) {
43
+ /**
44
+ * 等这个运行进入终态;返回最后一次看到的记录。
45
+ *
46
+ * 导出是为了单测(`tests/unit/subagent-wait.test.ts`):这里有一个「先查再订阅」的竞态,
47
+ * 只有把函数单独拿出来才能构造出那个窗口。
48
+ */
49
+ export function waitForRun(port, id, timeoutMs) {
45
50
  // 极快的任务可能在 start() 返回前就结束了:先看一眼,再订阅
46
51
  const current = port.get(id);
47
52
  if (current && TERMINAL.has(current.status))
48
53
  return Promise.resolve(current);
49
54
  return new Promise((resolve) => {
50
- let last = null;
51
- const timer = setTimeout(() => {
55
+ let last = current;
56
+ let settled = false;
57
+ let timer = null;
58
+ let off = () => { };
59
+ const finish = (run) => {
60
+ if (settled)
61
+ return;
62
+ settled = true;
63
+ if (timer)
64
+ clearTimeout(timer);
52
65
  off();
53
- resolve(last);
54
- }, timeoutMs);
55
- const off = port.subscribe((run) => {
66
+ resolve(run);
67
+ };
68
+ timer = setTimeout(() => finish(last), timeoutMs);
69
+ off = port.subscribe((run) => {
56
70
  if (run.id !== id)
57
71
  return;
58
72
  last = run;
59
73
  if (!TERMINAL.has(run.status))
60
74
  return;
61
- clearTimeout(timer);
62
- off();
63
- resolve(run);
75
+ finish(run);
64
76
  });
77
+ /**
78
+ * 订阅之后再补查一次。
79
+ *
80
+ * `port.subscribe` **只推后续事件、不回放当前状态**,所以上面那次 `get()` 与订阅之间
81
+ * 如果刚好跃迁到终态,那一推就永远收不到 —— 工具会一直等到 timeoutMs(默认 40 分钟)
82
+ * 才返回「一直没有结束」,父回合也就一直挂在「进行中」。补这一次查询把窗口关掉。
83
+ */
84
+ const after = port.get(id);
85
+ if (after)
86
+ last = after;
87
+ if (after && TERMINAL.has(after.status))
88
+ finish(after);
65
89
  });
66
90
  }
67
91
  export function makeSubagentToolExtension(options) {
@@ -152,9 +176,7 @@ export function makeSubagentToolExtension(options) {
152
176
  }
153
177
  catch (error) {
154
178
  return {
155
- content: [
156
- { type: "text", text: error instanceof Error ? error.message : String(error) },
157
- ],
179
+ content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
158
180
  details: undefined,
159
181
  isError: true,
160
182
  };
@@ -21,6 +21,15 @@ const RESULT_CHARS = 4000;
21
21
  const MAX_RUNS = 60;
22
22
  /** 状态推进的节流:正文增量很频繁,面板只需要「大概每秒一次」的刷新 */
23
23
  const EMIT_INTERVAL_MS = 300;
24
+ /**
25
+ * 单个子代理的**墙钟**上限。
26
+ *
27
+ * `session.prompt()` 没有任何超时参数,模板里的 `maxTurns` 也只在 `message_end` 时才检查 ——
28
+ * 一个卡在长工具调用 / provider 静默断流(不报错也不结束)的子会话可以永远挂着:
29
+ * 它占着一个并发槽(超了上限就再也不会起新的)、持续烧 token,
30
+ * 而且宿主侧只能靠用户手动点「停止」。
31
+ */
32
+ const DEFAULT_TIMEOUT_MS = 20 * 60 * 1000;
24
33
  /**
25
34
  * 子会话不加载扩展(noExtensions):由扩展注册的 provider(api 是扩展自己的实现)
26
35
  * 在子会话里就找不到 —— SDK 只会甩一句英文「No API provider registered for api: xxx」。
@@ -223,7 +232,35 @@ export function createPiSubagents(options) {
223
232
  settled();
224
233
  });
225
234
  try {
226
- await session.prompt(request.task);
235
+ /**
236
+ * 墙钟超时:`prompt()` 没有超时参数,卡住的子会话会一直占并发槽 + 烧 token。
237
+ * 到点先 abort(尽量让它正常收尾),并且**不再等 prompt 的 Promise** ——
238
+ * 真正卡死的会话那个 Promise 是永远不会 resolve 的。
239
+ */
240
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
241
+ const minutes = Math.max(1, Math.round(timeoutMs / 60_000));
242
+ let timer = null;
243
+ const deadline = new Promise((resolve) => {
244
+ timer = setTimeout(() => {
245
+ errorText = errorText ?? `子代理超时(超过 ${minutes} 分钟没有结束)`;
246
+ void session.abort().catch(() => undefined);
247
+ resolve();
248
+ }, timeoutMs);
249
+ timer.unref?.();
250
+ });
251
+ let promptError = null;
252
+ const prompt = session.prompt(request.task).catch((error) => {
253
+ promptError = error;
254
+ });
255
+ try {
256
+ await Promise.race([prompt, deadline]);
257
+ }
258
+ finally {
259
+ if (timer)
260
+ clearTimeout(timer);
261
+ }
262
+ if (promptError !== null)
263
+ throw promptError instanceof Error ? promptError : new Error(String(promptError));
227
264
  // prompt() 返回后仍可能有收尾事件:给它 50ms(与宿主侧同一套做法)
228
265
  await Promise.race([done, new Promise((resolve) => setTimeout(resolve, 50))]);
229
266
  }
@@ -44,4 +44,5 @@ export const FAKE_CAPABILITIES = {
44
44
  thinkingLevel: false,
45
45
  resume: true,
46
46
  };
47
- export const PI_CAPABILITIES = { ...ALL_ON, subagents: true };
47
+ /** pi:能力全开,而且**只有它真能插队**(`streamingBehavior: "steer"`,见 ADR-0043) */
48
+ export const PI_CAPABILITIES = { ...ALL_ON, subagents: true, steer: true };
@@ -119,6 +119,7 @@ export function createEngineWiring(options) {
119
119
  };
120
120
  const registry = createBuiltinEngineRegistry({
121
121
  onNotice: options.onNotice,
122
+ ...(options.skillPaths ? { skillPaths: options.skillPaths } : {}),
122
123
  onLog: (text) => options.onLog(text),
123
124
  codex: { client: codexClient },
124
125
  dsh: { client: dshClient, model: defaultModel },