oh-my-agent-web 0.2.0 → 0.4.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 (250) hide show
  1. package/README.md +86 -447
  2. package/dist/server/approval/read-only.js +107 -0
  3. package/dist/server/approval/rules.js +85 -0
  4. package/dist/server/approval/scope.js +150 -0
  5. package/dist/server/brand.js +2 -0
  6. package/dist/server/cli-config.js +52 -0
  7. package/dist/server/cli-info.js +101 -0
  8. package/dist/server/cli.js +304 -0
  9. package/dist/server/engine/builtin.js +61 -0
  10. package/dist/server/engine/catalog.js +8 -0
  11. package/dist/server/engine/codex-approvals.js +45 -0
  12. package/dist/server/engine/codex-map.js +298 -0
  13. package/dist/server/engine/codex-sessions.js +159 -0
  14. package/dist/server/engine/codex-transport.js +49 -0
  15. package/dist/server/engine/codex.js +314 -0
  16. package/dist/server/engine/dsh-history.js +195 -0
  17. package/dist/server/engine/dsh-log.js +245 -0
  18. package/dist/server/engine/dsh-map.js +221 -0
  19. package/dist/server/engine/dsh-sessions.js +207 -0
  20. package/dist/server/engine/dsh-transport.js +37 -0
  21. package/dist/server/engine/dsh.js +284 -0
  22. package/dist/server/engine/fake.js +231 -0
  23. package/dist/server/engine/jsonrpc-stdio.js +241 -0
  24. package/dist/server/engine/model-registry-reload.js +115 -0
  25. package/dist/server/engine/pi-events.js +148 -0
  26. package/dist/server/engine/pi-rename.js +14 -0
  27. package/dist/server/engine/pi-sdk.js +343 -0
  28. package/dist/server/engine/pi-session-model.js +23 -0
  29. package/dist/server/engine/pi-thinking.js +34 -0
  30. package/dist/server/engine/pi-transcript.js +225 -0
  31. package/dist/server/engine/pi-version.js +11 -0
  32. package/dist/server/engine/port.js +1 -0
  33. package/dist/server/engine/registry.js +47 -0
  34. package/dist/server/engine/scratch.js +13 -0
  35. package/dist/server/engine/suggestion-guidance.js +9 -0
  36. package/dist/server/engine/switchable.js +22 -0
  37. package/dist/server/engine/wiring.js +107 -0
  38. package/dist/server/files/dir-routes.js +40 -0
  39. package/dist/server/files/file-search.js +63 -0
  40. package/dist/server/files/file-service.js +232 -0
  41. package/dist/server/files/files-routes.js +182 -0
  42. package/dist/server/files/git-info.js +186 -0
  43. package/dist/server/http/auth-routes.js +99 -0
  44. package/dist/server/http/auth.js +191 -0
  45. package/dist/server/http/aux-routes.js +24 -0
  46. package/dist/server/http/compress.js +75 -0
  47. package/dist/server/http/health-and-static.js +33 -0
  48. package/dist/server/http/live-routes.js +113 -0
  49. package/dist/server/http/login-page.js +200 -0
  50. package/dist/server/index.js +342 -520
  51. package/dist/server/markers/actions.js +262 -0
  52. package/dist/server/protocol-parse.js +201 -0
  53. package/dist/server/protocol-settings.js +1 -0
  54. package/dist/server/protocol.js +6 -0
  55. package/dist/server/providers/catalog-store.js +85 -0
  56. package/dist/server/providers/catalog-writes.js +197 -0
  57. package/dist/server/providers/catalog.js +77 -0
  58. package/dist/server/providers/enabled-models.js +118 -0
  59. package/dist/server/providers/import-models.js +69 -0
  60. package/dist/server/providers/import-routes.js +136 -0
  61. package/dist/server/providers/model-test.js +52 -0
  62. package/dist/server/providers/probe.js +64 -0
  63. package/dist/server/providers/provider-handlers.js +203 -0
  64. package/dist/server/providers/provider-store.js +327 -0
  65. package/dist/server/providers/request-headers.js +7 -0
  66. package/dist/server/providers/validate.js +241 -0
  67. package/dist/server/push/push-routes.js +114 -0
  68. package/dist/server/push/push.js +120 -0
  69. package/dist/server/session/approval-bridge.js +103 -0
  70. package/dist/server/session/attachment-resolve.js +21 -0
  71. package/dist/server/session/client-view.js +82 -0
  72. package/dist/server/session/conversation-create.js +65 -0
  73. package/dist/server/session/delete-session.js +84 -0
  74. package/dist/server/session/engine-switch.js +108 -0
  75. package/dist/server/session/lock.js +82 -0
  76. package/dist/server/session/model-controller.js +111 -0
  77. package/dist/server/session/notices.js +31 -0
  78. package/dist/server/session/pi-opener.js +123 -0
  79. package/dist/server/session/pi-refresh.js +51 -0
  80. package/dist/server/session/pi-sessions-provider.js +46 -0
  81. package/dist/server/session/pi-sessions.js +272 -0
  82. package/dist/server/session/refresh-actions.js +42 -0
  83. package/dist/server/session/rename-session.js +23 -0
  84. package/dist/server/session/runtime-services.js +28 -0
  85. package/dist/server/session/runtime-types.js +1 -0
  86. package/dist/server/session/runtime.js +358 -0
  87. package/dist/server/session/session-stats.js +88 -0
  88. package/dist/server/session/settings.js +367 -0
  89. package/dist/server/session/snapshot-for.js +7 -0
  90. package/dist/server/session/stats-refresher.js +50 -0
  91. package/dist/server/session/switch-conversation.js +86 -0
  92. package/dist/server/session/throttle.js +14 -0
  93. package/dist/server/session/turn-events.js +126 -0
  94. package/dist/server/session/turn-finish.js +79 -0
  95. package/dist/server/session/turn-markers.js +26 -0
  96. package/dist/server/session/turn-queue.js +111 -0
  97. package/dist/server/session/turn-send.js +88 -0
  98. package/dist/server/session/turn-watchdog.js +77 -0
  99. package/dist/server/sessions/session-routes.js +17 -0
  100. package/dist/server/skills/skill-routes.js +28 -0
  101. package/dist/server/skills/skill-store.js +134 -0
  102. package/dist/server/snapshot/fan-out.js +10 -0
  103. package/dist/server/snapshot/messages.js +75 -0
  104. package/dist/server/snapshot/sender.js +62 -0
  105. package/dist/server/snapshot/state.js +193 -0
  106. package/dist/server/terminal/terminal-routes.js +176 -0
  107. package/dist/server/terminal/terminal-session.js +140 -0
  108. package/dist/server/uploads/upload-routes.js +64 -0
  109. package/dist/server/uploads/upload-store.js +115 -0
  110. package/dist/server/util/data-dir.js +36 -0
  111. package/dist/server/util/env.js +140 -0
  112. package/dist/server/util/ids.js +28 -0
  113. package/dist/server/util/log-file.js +67 -0
  114. package/dist/server/util/log.js +52 -0
  115. package/dist/server/util/origin.js +75 -0
  116. package/dist/server/util/paths.js +60 -0
  117. package/dist/server/util/pricing-index.js +121 -0
  118. package/dist/server/util/pricing.js +214 -0
  119. package/dist/server/util/startup.js +34 -0
  120. package/dist/server/util/upstream-version.js +140 -0
  121. package/dist/server/util/usage.js +19 -0
  122. package/dist/server/util/version.js +50 -0
  123. package/dist/server/ws/handle-message.js +95 -0
  124. package/package.json +49 -63
  125. package/web/dist/apple-touch-icon-precomposed.png +0 -0
  126. package/web/dist/apple-touch-icon.png +0 -0
  127. package/web/dist/assets/abnfDiagram-WEYOIA3C-NLp09iEC.js +1 -0
  128. package/web/dist/assets/arc-DviL9o3c.js +1 -0
  129. package/web/dist/assets/architecture-WOLXFQ4H-fjsM_Sy3.js +1 -0
  130. package/web/dist/assets/architectureDiagram-ZHVYKT53-Cn3B4TE8.js +36 -0
  131. package/web/dist/assets/array-BifhSqXX.js +1 -0
  132. package/web/dist/assets/blockDiagram-RJAE7QA3-CIuvxWvi.js +140 -0
  133. package/web/dist/assets/c4Diagram-XDPPPE54-DvdKmEpF.js +38 -0
  134. package/web/dist/assets/channel-C3AQ6nAs.js +1 -0
  135. package/web/dist/assets/chunk-2E7WHVMD-HNNASGCS.js +88 -0
  136. package/web/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  137. package/web/dist/assets/chunk-4EMNKR2T-Cg-eRwBE.js +1 -0
  138. package/web/dist/assets/chunk-55XCU76K-BweiisZL.js +1 -0
  139. package/web/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  140. package/web/dist/assets/chunk-7S6XSTFO-BCzIiP-6.js +2 -0
  141. package/web/dist/assets/chunk-AQDTSKZB-Dl0NRFuU.js +72 -0
  142. package/web/dist/assets/chunk-DSNLSHJZ-zhHCjjN9.js +129 -0
  143. package/web/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  144. package/web/dist/assets/chunk-HAGFOTED-BDQqU2EH.js +62 -0
  145. package/web/dist/assets/chunk-HNE4Q36P-B-qRzMjX.js +1 -0
  146. package/web/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
  147. package/web/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  148. package/web/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
  149. package/web/dist/assets/chunk-NQOKRVIF-CNcvhMn_.js +213 -0
  150. package/web/dist/assets/chunk-UHW7PFBP-Dwr8LUzr.js +1 -0
  151. package/web/dist/assets/chunk-VQ5W62QI-O8sN6jJu.js +1 -0
  152. package/web/dist/assets/chunk-WQQL4CDK-B6-59Zg2.js +10 -0
  153. package/web/dist/assets/chunk-XXDRQBXY-DvSbeUlZ.js +1 -0
  154. package/web/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  155. package/web/dist/assets/chunk-YZO67HFG-BXpKYKsw.js +1 -0
  156. package/web/dist/assets/chunk-ZAUX2LTX-B1Z5VSkz.js +1 -0
  157. package/web/dist/assets/chunk-ZCHMQSOR-h_5mBLIA.js +2 -0
  158. package/web/dist/assets/classDiagram-v2-4MYWR3UA-Dr6JnaaV.js +217 -0
  159. package/web/dist/assets/cose-bilkent-JH36ORCC-36ShkR7R.js +1 -0
  160. package/web/dist/assets/cynefin-EF2NZ3EQ-Dg6bI7pC.js +1 -0
  161. package/web/dist/assets/cynefinDiagram-WF24UDRP-B0YHpBlR.js +62 -0
  162. package/web/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  163. package/web/dist/assets/dagre-NK4GIZVX-Cx3mn7YS.js +4 -0
  164. package/web/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  165. package/web/dist/assets/diagram-CBK3MWTC-l8aZG_Bm.js +24 -0
  166. package/web/dist/assets/diagram-I5XIIV2D-Bd5f14_h.js +24 -0
  167. package/web/dist/assets/diagram-ODIL2UCW-hBsxCYUj.js +30 -0
  168. package/web/dist/assets/diagram-PFBEJUXE-BgpIBUYl.js +3 -0
  169. package/web/dist/assets/diagram-RJA3I4VV-CDU1gUVD.js +41 -0
  170. package/web/dist/assets/diagram-TYUNFSNR-Iuxh7Tqf.js +200 -0
  171. package/web/dist/assets/dist-C86cKTuE.js +1 -0
  172. package/web/dist/assets/ebnfDiagram-V4P3TTPT-Cms0HFKh.js +1 -0
  173. package/web/dist/assets/elk-QONTBCTQ-B7yEy0PQ.js +27 -0
  174. package/web/dist/assets/erDiagram-KYQIGQAA-CL48j4aU.js +99 -0
  175. package/web/dist/assets/eventmodeling-K75KTNOO-CFvkaejh.js +1 -0
  176. package/web/dist/assets/flowDiagram-IUGLLWBU-6UyKXpqG.js +1 -0
  177. package/web/dist/assets/ganttDiagram-R7GPNTJY-vdMr-0ZV.js +292 -0
  178. package/web/dist/assets/gitGraph-VSP46ZUC-2z85Wuwo.js +1 -0
  179. package/web/dist/assets/gitGraphDiagram-PDYL7XHY-BY4GPiqC.js +106 -0
  180. package/web/dist/assets/graphlib-DS17s2tU.js +1 -0
  181. package/web/dist/assets/index-Bjn56a2i.js +76 -0
  182. package/web/dist/assets/index-Drp6e5iU.css +1 -0
  183. package/web/dist/assets/info-OHQRW6UA-ECzkYTx-.js +1 -0
  184. package/web/dist/assets/infoDiagram-MVDRKAX5-DDk5EAeb.js +2 -0
  185. package/web/dist/assets/init-C-OQMol4.js +1 -0
  186. package/web/dist/assets/ishikawaDiagram-HPEHV2GE-9yPH4J94.js +70 -0
  187. package/web/dist/assets/journeyDiagram-BOSHW7O7-BPTIap3l.js +139 -0
  188. package/web/dist/assets/kanban-definition-FNSA4HC5-BbADHrA8.js +89 -0
  189. package/web/dist/assets/katex-B_JcIwqj.js +257 -0
  190. package/web/dist/assets/line-CHV9_4lL.js +1 -0
  191. package/web/dist/assets/linear-C-eZsBTv.js +1 -0
  192. package/web/dist/assets/mermaid-parser.core-BiAhX1l1.js +7 -0
  193. package/web/dist/assets/mermaid.core-BxP_A_zj.js +44 -0
  194. package/web/dist/assets/mindmap-definition-6OY57UG3-BMWlit95.js +98 -0
  195. package/web/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  196. package/web/dist/assets/packet-JDAUHWVQ-FVApuKOp.js +1 -0
  197. package/web/dist/assets/path-fybaL0A-.js +1 -0
  198. package/web/dist/assets/pegDiagram-ECIUJNBX-WjWJvDHM.js +1 -0
  199. package/web/dist/assets/pie-XZMESJXO-BGCZnvhp.js +1 -0
  200. package/web/dist/assets/pieDiagram-GNMPY74T-DJpAC_JC.js +39 -0
  201. package/web/dist/assets/quadrantDiagram-D57K4MC3-hxx5c7oq.js +7 -0
  202. package/web/dist/assets/radar-ABXABTNO-BU9Shc-O.js +1 -0
  203. package/web/dist/assets/railroad-I3PHUGI6-pWj8d-G4.js +1 -0
  204. package/web/dist/assets/railroad-abnf-LNEFI6M7-D7HZBTAj.js +1 -0
  205. package/web/dist/assets/railroad-ebnf-DWYD2UWJ-CKiXJSuM.js +1 -0
  206. package/web/dist/assets/railroad-peg-JR7BVNR7-DB3VJHgR.js +1 -0
  207. package/web/dist/assets/railroadDiagram-Y72NRBL7-Cev7Y65k.js +1 -0
  208. package/web/dist/assets/requirementDiagram-EQYSW5RA-CqbwMLD8.js +84 -0
  209. package/web/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  210. package/web/dist/assets/sankeyDiagram-SY24JHO5-V2cyVi8q.js +40 -0
  211. package/web/dist/assets/sequenceDiagram-BLEHNHQU-By6NJOPM.js +169 -0
  212. package/web/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  213. package/web/dist/assets/src-Da91fG3g.js +1 -0
  214. package/web/dist/assets/stateDiagram-v2-A6IESKWZ-BnPGX5UI.js +291 -0
  215. package/web/dist/assets/swimlanes-B3ENRKFQ-C58BpIpm.js +1 -0
  216. package/web/dist/assets/swimlanesDiagram-AYQRV4AK-RaivxSsc.js +8 -0
  217. package/web/dist/assets/timeline-definition-JXYKGLGB-Hgo1DhnS.js +120 -0
  218. package/web/dist/assets/treeView-D4JQ5SDB-DF0dfjqB.js +1 -0
  219. package/web/dist/assets/treemap-SAJKECNS-CSSM5FqB.js +1 -0
  220. package/web/dist/assets/usecaseDiagram-WPOLBNNX-BkBBD1NY.js +328 -0
  221. package/web/dist/assets/vennDiagram-Q3Y3IDCW-Bm28keX_.js +34 -0
  222. package/web/dist/assets/wardley-7MLQ67FV-By7KgEQX.js +1 -0
  223. package/web/dist/assets/wardleyDiagram-IVAIDKWA-oVtIrnGa.js +78 -0
  224. package/web/dist/assets/xychartDiagram-4URNHDU4-aI8FH4g2.js +7 -0
  225. package/web/dist/favicon-32.png +0 -0
  226. package/web/dist/favicon.ico +0 -0
  227. package/web/dist/favicon.svg +20 -8
  228. package/web/dist/icons/apple-touch-icon-180.png +0 -0
  229. package/web/dist/icons/icon-192.png +0 -0
  230. package/web/dist/icons/icon-512.png +0 -0
  231. package/web/dist/icons/icon-maskable-512.png +0 -0
  232. package/web/dist/index.html +21 -27
  233. package/web/dist/manifest.webmanifest +17 -0
  234. package/web/dist/maskable.svg +25 -0
  235. package/web/dist/sw.js +40 -0
  236. package/bin/oh-my-agent-web-ps.mjs +0 -203
  237. package/bin/oh-my-agent-web-ws.mjs +0 -593
  238. package/bin/oh-my-agent-web.mjs +0 -321
  239. package/bin/update-check.mjs +0 -142
  240. package/dist/server/codex-client.js +0 -310
  241. package/dist/server/codex-supervisor.js +0 -446
  242. package/dist/server/fs-service.js +0 -144
  243. package/dist/server/request-inspector.js +0 -458
  244. package/dist/shared/protocol.js +0 -112
  245. package/scripts/manage-server.mjs +0 -155
  246. package/scripts/omaw.mjs +0 -74
  247. package/shared/protocol.ts +0 -228
  248. package/web/dist/assets/index-B8-VzDf2.css +0 -1
  249. package/web/dist/assets/index-DeC2eiiq.js +0 -42
  250. package/web/dist/assets/markdown-CYXe6JZx.js +0 -55
@@ -0,0 +1,79 @@
1
+ /**
2
+ * 回合收尾(从 runtime 抽出来:400 行护栏)。
3
+ *
4
+ * 要做的事按顺序:
5
+ * 1. 从 turns 里摘掉这个会话的回合(并行时别的会话不受影响)
6
+ * 2. state.running 只复位"当前会话"(ADR-0030 §2)
7
+ * 3. 排空该会话的排队消息(ADR-0030 的「排队」)
8
+ * 4. 审批状态收尾(ADR-0006:悬着的审批按拒绝处理)
9
+ * 5. 助手消息整条落盘 + 建议标记解析(ADR-0022)
10
+ * 6. 刷新统计与 pi 会话列表,推快照
11
+ */
12
+ import { emptyRunning } from "../snapshot/state.js";
13
+ import { settleTurnMarkers } from "./turn-markers.js";
14
+ export function createTurnFinisher(deps) {
15
+ return async (conversationId, reason) => {
16
+ const turn = deps.turns.get(conversationId);
17
+ deps.turns.delete(conversationId);
18
+ const { [conversationId]: _gone, ...rest } = deps.state.runningByConversation;
19
+ deps.state.runningByConversation = rest;
20
+ if (conversationId === deps.state.activeId) {
21
+ const keepError = reason === "error" || deps.state.running.status === "error";
22
+ deps.state.running = { ...emptyRunning(), status: keepError ? "error" : "idle" };
23
+ }
24
+ deps.refreshStats();
25
+ const next = deps.shiftQueued(conversationId);
26
+ if (next)
27
+ deps.sendNext(next, conversationId);
28
+ deps.state.pendingApproval = null;
29
+ deps.cancelPending();
30
+ if (turn)
31
+ deps.settle({ conversationId, messageId: turn.messageId, rawText: turn.rawText });
32
+ deps.touchConversation(conversationId);
33
+ deps.refreshProjects();
34
+ if (reason === "interrupted")
35
+ deps.notice("回合已中断", "info");
36
+ deps.pushSnapshot();
37
+ };
38
+ }
39
+ /**
40
+ * runtime 侧的整套收尾装配(落盘 + 队列排空 + 推送回调)。
41
+ * 抽出来是为了让 runtime 只留一行:它自己的业务(推送通知)用 onTurnFinished 注入。
42
+ */
43
+ export function createRuntimeTurnFinisher(options) {
44
+ return createTurnFinisher({
45
+ state: options.state,
46
+ turns: options.turns,
47
+ shiftQueued: options.shiftQueued,
48
+ sendNext: options.sendNext,
49
+ cancelPending: options.cancelPending,
50
+ settle: (info) => {
51
+ settleWith(options.state, options.nextId, options.now)(info);
52
+ const conversation = options.state.conversations.find((item) => item.id === info.conversationId);
53
+ options.onTurnFinished?.({
54
+ conversationId: info.conversationId,
55
+ title: conversation?.title ?? "会话",
56
+ workspace: conversation?.workspace ?? options.state.workspace,
57
+ failed: false,
58
+ });
59
+ },
60
+ touchConversation: options.touchConversation,
61
+ refreshStats: options.refreshStats,
62
+ refreshProjects: options.refreshProjects,
63
+ notice: options.notice,
64
+ pushSnapshot: options.pushSnapshot,
65
+ });
66
+ }
67
+ /** 把 settleTurnMarkers 的调用签名收成上面那个形状(避免 runtime 里再写一段) */
68
+ export function settleWith(state, nextId, now) {
69
+ return (input) => settleTurnMarkers({ state, ...input, nextId, at: now?.() });
70
+ }
71
+ /** 维护 runningByConversation(并行时侧栏可以同时多行转圈) */
72
+ export function setRunning(state, conversationId, value) {
73
+ if (value === null) {
74
+ const { [conversationId]: _gone, ...rest } = state.runningByConversation;
75
+ state.runningByConversation = rest;
76
+ return;
77
+ }
78
+ state.runningByConversation = { ...state.runningByConversation, [conversationId]: value };
79
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * 回合结束时的标记收尾(ADR-0022):从 runtime 拆出(400 行护栏第六次咬人)。
3
+ *
4
+ * 两件事:
5
+ * 1. 把流式过滤器里还扣着的尾巴放出来(此时它已经不可能是标记);
6
+ * 2. 对最终的助手文本做完整解析:剥离标记 → 建议项进快照。
7
+ */
8
+ import { applyMarkersToMessage, reduceActionMarkers } from "../markers/actions.js";
9
+ export function settleTurnMarkers(input) {
10
+ const { state, conversationId, messageId } = input;
11
+ // 1) 建议从**原始文本**里收(ADR-0022):流式过滤器为了不漏语法已经把标记丢掉了,
12
+ // 所以回合结束必须回看原始文本,否则建议永远收不到。
13
+ const reduced = reduceActionMarkers(input.rawText, state.actions, () => input.nextId("act"));
14
+ state.actions = reduced.actions;
15
+ // 2) 消息正文收尾:剥掉可能漏网的标记 + 收拾残留空行(applyMarkersToMessage 是幂等的)
16
+ const assistant = (state.messages[conversationId] ?? []).find((message) => message.id === messageId);
17
+ if (!assistant)
18
+ return;
19
+ const applied = applyMarkersToMessage(assistant, state.actions, () => input.nextId("act"));
20
+ state.actions = applied.actions;
21
+ const endedAt = input.at ?? Date.now();
22
+ const blocksChanged = applied.blocks !== assistant.blocks;
23
+ if (blocksChanged || assistant.endedAt !== endedAt) {
24
+ state.messages[conversationId] = (state.messages[conversationId] ?? []).map((message) => message.id === messageId ? { ...message, blocks: applied.blocks, endedAt } : message);
25
+ }
26
+ }
@@ -0,0 +1,111 @@
1
+ export function createTurnQueue() {
2
+ const entries = [];
3
+ return {
4
+ push(entry) {
5
+ entries.push(entry);
6
+ return toViews(entries);
7
+ },
8
+ /** 取走某会话的队首(没有就返回 null) */
9
+ shift(conversationId) {
10
+ const index = entries.findIndex((entry) => entry.conversationId === conversationId);
11
+ if (index < 0)
12
+ return null;
13
+ return entries.splice(index, 1)[0] ?? null;
14
+ },
15
+ /** 取消一条(按 id);返回是否真的删掉了 */
16
+ remove(id) {
17
+ const index = entries.findIndex((entry) => entry.id === id);
18
+ if (index < 0)
19
+ return false;
20
+ entries.splice(index, 1);
21
+ return true;
22
+ },
23
+ /** 删掉某个会话的全部排队项(会话被删时用) */
24
+ removeConversation(conversationId) {
25
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
26
+ if (entries[i]?.conversationId === conversationId)
27
+ entries.splice(i, 1);
28
+ }
29
+ },
30
+ views: () => toViews(entries),
31
+ };
32
+ }
33
+ function toViews(entries) {
34
+ return entries.map(({ id, conversationId, text }) => ({ id, conversationId, text }));
35
+ }
36
+ export function createBusyHandler(deps) {
37
+ return async (mode, text, promptText, _images, attachments) => {
38
+ const conversationId = deps.activeId();
39
+ if (!conversationId)
40
+ return { ok: false, error: { code: "session.none", message: "先打开一个会话" } };
41
+ if (mode === "queue") {
42
+ const depth = deps.queue(conversationId, text, attachments);
43
+ deps.notice(`已排队(第 ${depth} 条):这一轮结束后自动发送`, "info");
44
+ deps.pushSnapshot();
45
+ return { ok: true };
46
+ }
47
+ if (mode === "steer") {
48
+ await deps.steer(conversationId, promptText);
49
+ deps.appendUser(conversationId, text, true);
50
+ deps.notice("已引导:插进当前这一轮", "info");
51
+ deps.pushSnapshot();
52
+ return { ok: true };
53
+ }
54
+ deps.notice("另一个回合还在跑,先等它结束或点「停止」", "warn");
55
+ return { ok: false, error: { code: "session.busy", message: "同一会话已有回合在跑" } };
56
+ };
57
+ }
58
+ /** 队列 + 快照同步(runtime 里只留三个一行调用) */
59
+ export function createQueueController(options) {
60
+ const sync = () => {
61
+ options.state.queued = options.turnQueue.views();
62
+ options.onChanged();
63
+ };
64
+ return {
65
+ push(conversationId, text, attachments) {
66
+ const views = options.turnQueue.push({
67
+ id: options.nextId("q"),
68
+ conversationId,
69
+ text,
70
+ ...(attachments ? { attachments } : {}),
71
+ });
72
+ sync();
73
+ return views.filter((view) => view.conversationId === conversationId).length;
74
+ },
75
+ shift(conversationId) {
76
+ const next = options.turnQueue.shift(conversationId);
77
+ sync();
78
+ return next;
79
+ },
80
+ cancel(queueId) {
81
+ const removed = options.turnQueue.remove(queueId);
82
+ sync();
83
+ return removed;
84
+ },
85
+ };
86
+ }
87
+ /** runtime 的队列/忙碌处理装配(一行调用,runtime 守 400 行) */
88
+ export function createTurnPlumbing(options) {
89
+ const queue = createQueueController({
90
+ turnQueue: createTurnQueue(),
91
+ state: options.state,
92
+ nextId: options.nextId,
93
+ onChanged: options.pushSnapshot,
94
+ });
95
+ const busy = createBusyHandler({
96
+ activeId: () => options.state.activeId,
97
+ steer: (id, prompt) => options.engine.sendMessage(id, prompt, () => { }, { streamingBehavior: "steer" }),
98
+ queue: (id, text, attachments) => queue.push(id, text, attachments),
99
+ appendUser: options.appendUser,
100
+ notice: options.notice,
101
+ pushSnapshot: options.pushSnapshot,
102
+ });
103
+ return { queue, busy };
104
+ }
105
+ /** 取消一条排队消息的对外动作(runtime 里只留一行) */
106
+ export function cancelQueuedWith(queue, queueId, notice) {
107
+ if (!queue.cancel(queueId))
108
+ return { ok: false, error: { code: "queue.not_found", message: "这条排队消息已经不在了" } };
109
+ notice("已取消排队", "info");
110
+ return { ok: true };
111
+ }
@@ -0,0 +1,88 @@
1
+ import { createMarkerStreamFilter } from "../markers/actions.js";
2
+ import { appendText, createMessage } from "../snapshot/messages.js";
3
+ import { resolveAttachments } from "./attachment-resolve.js";
4
+ import { setRunning } from "./turn-finish.js";
5
+ import { applyEngineEvent } from "./turn-events.js";
6
+ export function createTurnSender(deps) {
7
+ const { state } = deps;
8
+ return async (text, attachments, mode = "steer", targetConversationId, clientId, options = {}) => {
9
+ const resolved = await resolveAttachments(text, attachments, deps.uploadStore);
10
+ for (const name of resolved.missing)
11
+ deps.notice(`附件已失效(可能过期被清理):${name}`, "warn");
12
+ if (resolved.images.length > 0)
13
+ deps.notice(`已附加 ${resolved.images.length} 张图片(视觉注入)`, "info");
14
+ if (state.activeId !== null && deps.turns.has(state.activeId)) {
15
+ const handled = await deps.busy(mode, text, resolved.promptText, resolved.images, attachments);
16
+ if (handled)
17
+ return handled;
18
+ }
19
+ let conversationId = null;
20
+ let failed = false;
21
+ try {
22
+ // 没有任何会话时懒建一个(空实例的第一次发消息);建了就把发起者的视图指过去
23
+ const existing = targetConversationId ?? state.activeId;
24
+ const created = existing ? null : await deps.createConversation(text);
25
+ if (created && clientId)
26
+ deps.clientView.setActive(clientId, created);
27
+ const conv = (existing ?? created); // 两者必有一个(ADR-0033)
28
+ conversationId = conv;
29
+ if (!deps.isOpen(conv)) {
30
+ const opened = await deps.openConversation(conv, (force) => deps.refreshProjects(force));
31
+ if (opened === null)
32
+ throw new Error(`会话打不开:${conv}`);
33
+ state.messages[conv] = opened;
34
+ await deps.refreshModels(conv);
35
+ await deps.refreshThinking(conv);
36
+ }
37
+ const ts = deps.now();
38
+ const userMessage = appendText(createMessage(deps.nextId("msg"), "user", ts), text);
39
+ const assistantMessage = createMessage(deps.nextId("msg"), "assistant", ts);
40
+ // 隐藏回合(「想一想」):不落任何消息,只靠 rawText 收标记
41
+ if (!options.hidden) {
42
+ const isFirstMessage = (state.messages[conv] ?? []).length === 0;
43
+ if (isFirstMessage)
44
+ deps.touchConversation(conv, { title: text.replace(/\s+/g, " ").trim().slice(0, deps.titleChars) });
45
+ state.messages[conv] = [...(state.messages[conv] ?? []), userMessage, assistantMessage];
46
+ }
47
+ if (conv === state.activeId) {
48
+ const running = {
49
+ status: "streaming",
50
+ conversationId: conv,
51
+ turnStartedAt: ts,
52
+ streamingText: "",
53
+ activeTool: null,
54
+ };
55
+ state.running = running;
56
+ }
57
+ state.actions = [];
58
+ deps.turns.set(conv, { messageId: assistantMessage.id, filter: createMarkerStreamFilter(), rawText: "" });
59
+ setRunning(state, conv, { status: "streaming", turnStartedAt: ts });
60
+ deps.watchdog.start(conv);
61
+ if (!options.hidden)
62
+ deps.touchConversation(conv);
63
+ deps.pushSnapshot();
64
+ await deps.engine.sendMessage(conv, resolved.promptText, (event) => {
65
+ applyEngineEvent(deps.turnContext, { conversationId: conv, messageId: assistantMessage.id, event });
66
+ }, resolved.images.length > 0 ? { images: resolved.images } : undefined);
67
+ return { ok: true };
68
+ }
69
+ catch (error) {
70
+ failed = true;
71
+ const message = error instanceof Error ? error.message : String(error);
72
+ deps.notice(`回合执行失败:${message}`, "error");
73
+ return { ok: false, error: { code: "session.turn_failed", message } };
74
+ }
75
+ finally {
76
+ if (conversationId !== null) {
77
+ deps.watchdog.stop(conversationId);
78
+ // 引擎的 sendMessage 已经返回(成功或抛错)= 这一轮在引擎侧结束了。它要是没吐 turn_end
79
+ // (provider 掉线 / 流被掐断 / 异常),这里必须补一次收尾:否则 turns 里留一个永不结束的
80
+ // 条目 → isBusy() 恒为真 → 切引擎报 engine.busy,界面上还一直是「工作中」。
81
+ // 异常分支以前先 `turns.delete` 了,这里就成了死代码 —— 那个会话的排队消息永久卡住、
82
+ // 助手消息也永远不打 endedAt。统一交给 finishTurn,语义靠 reason 区分。
83
+ if (deps.turns.has(conversationId))
84
+ await deps.finishTurn(conversationId, failed ? "error" : "done");
85
+ }
86
+ }
87
+ };
88
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * 回合看门狗(ADR-0023)。
3
+ *
4
+ * 为什么必须有:回合卡住时,界面看起来像死了(ADR-0005 时期是全局 1 路,影响整个应用)。于是「某个命令卡住」
5
+ * 不是「那一轮慢」,而是**整个应用不能再用** —— 新消息全被 `session.busy` 拒掉。
6
+ * (真事:模型自己去跑了一条会挂住的 bash,回合卡了 7 分钟,界面看起来像死了。)
7
+ *
8
+ * 规则:
9
+ * 1. 回合开始时计时;超时 → 中断该回合 + 给一条明确提示(不是静默杀掉);
10
+ * 2. **等待用户审批时不计时**(ADR-0006:那是人的时间,不该被机器打断)→ 重新计时;
11
+ * 3. 时间由外部注入(`setTimer`/`clearTimer`),单测可以确定性地触发超时。
12
+ */
13
+ /** 给人看的时长格式(提示语用:「20 分钟」/「10 秒」) */
14
+ export function formatDuration(ms) {
15
+ if (ms < 60_000)
16
+ return `${Math.max(1, Math.round(ms / 1000))} 秒`;
17
+ return `${Math.round(ms / 60_000)} 分钟`;
18
+ }
19
+ export const DEFAULT_TURN_TIMEOUT_MS = 20 * 60 * 1000;
20
+ /**
21
+ * 每个会话一个定时器(ADR-0030 ①):并行回合上线后,A 卡住不能连带把 B 的超时也清掉。
22
+ * 现在虽然还只允许一个回合并行,但结构先准备好 —— 行为完全不变。
23
+ */
24
+ export function createTurnWatchdog(options) {
25
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
26
+ const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
27
+ const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
28
+ const timers = new Map();
29
+ const clear = (conversationId) => {
30
+ const handle = timers.get(conversationId);
31
+ if (handle !== undefined)
32
+ clearTimer(handle);
33
+ timers.delete(conversationId);
34
+ };
35
+ const arm = (conversationId) => {
36
+ clear(conversationId);
37
+ const handle = setTimer(() => {
38
+ timers.delete(conversationId);
39
+ if (options.isWaitingForUser()) {
40
+ arm(conversationId); // 人在犹豫:继续等,不算卡住
41
+ return;
42
+ }
43
+ options.onTimeout(conversationId);
44
+ }, timeoutMs);
45
+ timers.set(conversationId, handle);
46
+ };
47
+ return {
48
+ start: (conversationId) => arm(conversationId),
49
+ stop(conversationId) {
50
+ if (conversationId === undefined) {
51
+ for (const id of timers.keys())
52
+ clear(id);
53
+ return;
54
+ }
55
+ clear(conversationId);
56
+ },
57
+ armed: (conversationId) => (conversationId === undefined ? timers.size > 0 : timers.has(conversationId)),
58
+ count: () => timers.size,
59
+ };
60
+ }
61
+ /**
62
+ * runtime 侧的便捷包装:把「超时 → 提示 + 中断」这套接线收在这里,
63
+ * 免得每个调用点都重复写一遍(也让 runtime 守住 400 行护栏)。
64
+ */
65
+ export function createRuntimeTurnWatchdog(options) {
66
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
67
+ return createTurnWatchdog({
68
+ timeoutMs,
69
+ isWaitingForUser: options.isWaitingForUser,
70
+ ...(options.setTimer ? { setTimer: options.setTimer } : {}),
71
+ ...(options.clearTimer ? { clearTimer: options.clearTimer } : {}),
72
+ onTimeout: (conversationId) => {
73
+ options.notice(`回合超过 ${formatDuration(timeoutMs)}没结束,已自动中断(常见原因:某条命令卡住)`, "warn");
74
+ void options.engine.interrupt(conversationId);
75
+ },
76
+ });
77
+ }
@@ -0,0 +1,17 @@
1
+ export function registerSessionRoutes(app, options) {
2
+ app.post("/api/sessions/delete", async (request, response) => {
3
+ const body = request.body;
4
+ const id = typeof body?.id === "string" ? body.id : "";
5
+ if (id === "") {
6
+ response.status(400).json({ error: { code: "session.no_id", message: "缺少会话 id" } });
7
+ return;
8
+ }
9
+ const result = await options.deleteSession(id);
10
+ if (!result.ok) {
11
+ const status = result.error.code === "session.not_found" ? 404 : 500;
12
+ response.status(status).json({ error: result.error });
13
+ return;
14
+ }
15
+ response.json({ ok: true, mode: result.mode, ...(result.movedTo ? { movedTo: result.movedTo } : {}) });
16
+ });
17
+ }
@@ -0,0 +1,28 @@
1
+ import { createSkillStore } from "./skill-store.js";
2
+ export function registerSkillRoutes(app, options = {}) {
3
+ const skills = options.skills ?? createSkillStore();
4
+ app.get("/api/skills", async (_request, response) => {
5
+ response.json({ skills: await skills.list() });
6
+ });
7
+ app.post("/api/skills/enabled", async (request, response) => {
8
+ const body = request.body;
9
+ const ids = Array.isArray(body?.ids) ? body.ids.filter((item) => typeof item === "string") : [];
10
+ if (ids.length === 0) {
11
+ response.status(400).json({ error: { code: "skills.no_ids", message: "没有指定要操作的技能" } });
12
+ return;
13
+ }
14
+ const enabled = body?.enabled === true;
15
+ const result = await skills.setEnabled(ids, enabled);
16
+ options.onChange?.();
17
+ response.json({ ...result, skills: await skills.list() });
18
+ });
19
+ app.get("/api/skills/:id/file", async (request, response) => {
20
+ const relPath = typeof request.query.path === "string" ? request.query.path : "";
21
+ const result = await skills.readFile(request.params.id, relPath);
22
+ if (!result.ok) {
23
+ response.status(400).json({ error: { code: "skills.read_failed", message: result.error } });
24
+ return;
25
+ }
26
+ response.json(result);
27
+ });
28
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * 技能列表与启停(P8)。
3
+ *
4
+ * 技能目录:`~/.agents/skills/<目录名>/SKILL.md`(frontmatter 里有 name / description),
5
+ * 目录里还可能有 `scripts/`、`references/` 等。
6
+ *
7
+ * **启停的实现方式(刻意选择)**:把 `SKILL.md` 改名成 `.SKILL.md.disabled`。
8
+ * - pi 扫描技能只认 `SKILL.md`,改名即"看不见",这是真正生效的禁用;
9
+ * - 不动其它文件、不动目录结构,一条 mv 就能复原,**不产生第二份状态文件**
10
+ * (两处状态必然不一致,这是 ADR-0021 的教训)。
11
+ */
12
+ import { readdir, readFile, rename, stat } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { readPreview } from "../files/file-service.js";
16
+ export const SKILL_ENTRY = "SKILL.md";
17
+ export const SKILL_ENTRY_DISABLED = ".SKILL.md.disabled";
18
+ export function defaultSkillsDir() {
19
+ return join(homedir(), ".agents", "skills");
20
+ }
21
+ /** 纯函数:从 SKILL.md 的 frontmatter 里抠 name / description(不引 yaml 依赖) */
22
+ export function parseFrontmatter(text) {
23
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
24
+ if (!match)
25
+ return { name: "", description: "" };
26
+ const body = match[1] ?? "";
27
+ const read = (key) => {
28
+ const line = new RegExp(`^${key}:\\s*(.+)$`, "m").exec(body);
29
+ if (!line)
30
+ return "";
31
+ return (line[1] ?? "").trim().replace(/^["']/, "").replace(/["']$/, "");
32
+ };
33
+ return { name: read("name"), description: read("description") };
34
+ }
35
+ /** 递归列目录里的文件(相对路径),有上限,避免某个技能塞几千个文件把界面拖死 */
36
+ export async function listFiles(root, prefix = "", out = []) {
37
+ if (out.length >= 200)
38
+ return out;
39
+ let entries;
40
+ try {
41
+ entries = await readdir(join(root, prefix), { withFileTypes: true });
42
+ }
43
+ catch {
44
+ return out;
45
+ }
46
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
47
+ if (out.length >= 200)
48
+ break;
49
+ if (entry.name === "cache" || entry.name.startsWith("."))
50
+ continue;
51
+ const rel = join(prefix, entry.name);
52
+ if (entry.isDirectory())
53
+ await listFiles(root, rel, out);
54
+ else
55
+ out.push(rel);
56
+ }
57
+ return out;
58
+ }
59
+ function safeId(id) {
60
+ // 目录名白名单:不允许穿越、不允许以点开头(那是内部文件)
61
+ return /^[A-Za-z0-9._-]{1,80}$/.test(id) && !id.startsWith(".");
62
+ }
63
+ export function createSkillStore(options = {}) {
64
+ const root = options.dir ?? defaultSkillsDir();
65
+ const resolve = (id) => (safeId(id) ? join(root, id) : null);
66
+ return {
67
+ async list() {
68
+ let names = [];
69
+ try {
70
+ names = (await readdir(root, { withFileTypes: true }))
71
+ .filter((entry) => entry.isDirectory())
72
+ .map((entry) => entry.name);
73
+ }
74
+ catch {
75
+ return [];
76
+ }
77
+ const skills = [];
78
+ for (const id of names.sort()) {
79
+ const dir = join(root, id);
80
+ const enabled = await stat(join(dir, SKILL_ENTRY))
81
+ .then(() => true)
82
+ .catch(() => false);
83
+ const entryPath = join(dir, enabled ? SKILL_ENTRY : SKILL_ENTRY_DISABLED);
84
+ const text = await readFile(entryPath, "utf8").catch(() => "");
85
+ if (text === "")
86
+ continue; // 既没有 SKILL.md 也没有 .disabled 的目录不是技能
87
+ const meta = parseFrontmatter(text);
88
+ skills.push({
89
+ id,
90
+ name: meta.name !== "" ? meta.name : id,
91
+ description: meta.description,
92
+ enabled,
93
+ files: await listFiles(dir),
94
+ });
95
+ }
96
+ return skills;
97
+ },
98
+ async setEnabled(ids, enabled) {
99
+ const changed = [];
100
+ const errors = [];
101
+ for (const id of ids) {
102
+ const dir = resolve(id);
103
+ if (!dir) {
104
+ errors.push(`非法技能 id:${id}`);
105
+ continue;
106
+ }
107
+ const from = join(dir, enabled ? SKILL_ENTRY_DISABLED : SKILL_ENTRY);
108
+ const to = join(dir, enabled ? SKILL_ENTRY : SKILL_ENTRY_DISABLED);
109
+ const exists = await stat(from)
110
+ .then(() => true)
111
+ .catch(() => false);
112
+ if (!exists)
113
+ continue; // 已经是目标状态
114
+ try {
115
+ await rename(from, to);
116
+ changed.push(id);
117
+ }
118
+ catch (error) {
119
+ errors.push(`${id}:${error instanceof Error ? error.message : String(error)}`);
120
+ }
121
+ }
122
+ return { changed, errors };
123
+ },
124
+ async readFile(id, relPath) {
125
+ const dir = resolve(id);
126
+ if (!dir)
127
+ return { ok: false, error: "非法技能 id" };
128
+ const result = await readPreview(dir, relPath);
129
+ if (!result.ok)
130
+ return { ok: false, error: result.error };
131
+ return { ok: true, text: result.text, binary: result.binary, bytes: result.bytes };
132
+ },
133
+ };
134
+ }
@@ -0,0 +1,10 @@
1
+ export function fanOutSnapshot(snapshot, targets) {
2
+ const shared = JSON.stringify({ type: "event", event: "snapshot", snapshot });
3
+ for (const socket of targets.sockets) {
4
+ if (socket.readyState === socket.OPEN)
5
+ socket.send(shared);
6
+ }
7
+ for (const [clientId, sink] of targets.sinks) {
8
+ sink(JSON.stringify({ type: "event", event: "snapshot", snapshot: targets.snapshotFor(clientId) }));
9
+ }
10
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * 消息块的纯函数工具(ADR-0012:消息由块构成)。
3
+ *
4
+ * 全部是纯函数(不改入参),便于单测覆盖「文本追加 / 工具卡状态流转 / 摘录截断」。
5
+ */
6
+ import { TOOL_EXCERPT_CHARS } from "../protocol.js";
7
+ export function createMessage(id, role, ts) {
8
+ return { id, role, ts, blocks: [] };
9
+ }
10
+ /** 追加文本:接到最后一个文本块后面(保持「说一句 → 调工具 → 再说一句」的交错顺序) */
11
+ export function appendText(message, text) {
12
+ const blocks = [...message.blocks];
13
+ const last = blocks[blocks.length - 1];
14
+ if (last && last.kind === "text")
15
+ blocks[blocks.length - 1] = { kind: "text", text: last.text + text };
16
+ else
17
+ blocks.push({ kind: "text", text });
18
+ return { ...message, blocks };
19
+ }
20
+ export function startTool(message, input) {
21
+ const block = {
22
+ kind: "tool",
23
+ toolCallId: input.toolCallId,
24
+ name: input.name,
25
+ argsSummary: input.argsSummary,
26
+ // 摘要已经够短就不重复带一份:界面靠「有没有 argsFull」判断要不要显示「完整命令」
27
+ ...(input.argsFull && input.argsFull !== input.argsSummary ? { argsFull: input.argsFull } : {}),
28
+ status: "running",
29
+ outputExcerpt: "",
30
+ truncated: false,
31
+ startedAt: input.startedAt,
32
+ };
33
+ return { ...message, blocks: [...message.blocks, block] };
34
+ }
35
+ /** 摘录预算(ADR-0004 §5):超限截断并打标记,而不是把 200KB 塞进快照 */
36
+ export function truncateExcerpt(output, limit = TOOL_EXCERPT_CHARS) {
37
+ if (output.length <= limit)
38
+ return { outputExcerpt: output, truncated: false };
39
+ return { outputExcerpt: output.slice(0, limit), truncated: true };
40
+ }
41
+ export function finishTool(message, input) {
42
+ const { outputExcerpt, truncated } = truncateExcerpt(input.output);
43
+ const blocks = message.blocks.map((block) => {
44
+ if (block.kind !== "tool" || block.toolCallId !== input.toolCallId)
45
+ return block;
46
+ return {
47
+ ...block,
48
+ status: input.status,
49
+ outputExcerpt,
50
+ truncated,
51
+ endedAt: input.endedAt,
52
+ ...(input.exitCode === undefined ? {} : { exitCode: input.exitCode }),
53
+ };
54
+ });
55
+ return { ...message, blocks };
56
+ }
57
+ export function appendError(message, code, text) {
58
+ return { ...message, blocks: [...message.blocks, { kind: "error", code, message: text }] };
59
+ }
60
+ /** 快照里最后一条消息的纯文本(给状态栏/调试用) */
61
+ export function messageText(message) {
62
+ return message.blocks
63
+ .map((block) => block.kind === "text" ? block.text : block.kind === "error" ? `[${block.code}] ${block.message}` : "")
64
+ .join("");
65
+ }
66
+ /** 追加思考文本(与正文分开:正文说结论,思考说过程) */
67
+ export function appendThinking(message, text) {
68
+ const blocks = [...message.blocks];
69
+ const last = blocks[blocks.length - 1];
70
+ if (last && last.kind === "thinking")
71
+ blocks[blocks.length - 1] = { kind: "thinking", text: last.text + text };
72
+ else
73
+ blocks.push({ kind: "thinking", text });
74
+ return { ...message, blocks };
75
+ }