@xiaobuyu/nodesign 0.0.6 → 0.0.8

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 (383) hide show
  1. package/README.md +266 -79
  2. package/README.zh-CN.md +254 -67
  3. package/package.json +2 -2
  4. package/server/api/admin.js +7 -0
  5. package/server/api/assets/docx-page.js +5 -4
  6. package/server/api/assets/helpers.js +26 -0
  7. package/server/api/assets/notes.js +50 -0
  8. package/server/api/assets.js +31 -38
  9. package/server/api/board.js +29 -1
  10. package/server/api/browse.js +3 -2
  11. package/server/api/chatai.js +9 -8
  12. package/server/api/exports/cards.js +7 -6
  13. package/server/api/exports/docx-pdf.js +4 -1
  14. package/server/api/instruction.js +3 -3
  15. package/server/api/local.js +10 -8
  16. package/server/api/projects.js +19 -3
  17. package/server/api/publish.js +3 -2
  18. package/server/api/sessions-rewind.js +220 -0
  19. package/server/api/sessions.js +58 -194
  20. package/server/api/stage.js +35 -0
  21. package/server/api/turn-model-switch.js +109 -0
  22. package/server/api/turn.js +52 -88
  23. package/server/auth/middleware.js +29 -6
  24. package/server/auth/tier.js +27 -7
  25. package/server/auth/users-store.js +14 -1
  26. package/server/engine/agent/actor-trail.js +110 -0
  27. package/server/engine/agent/agent-shared.js +22 -52
  28. package/server/engine/agent/beat-state.js +61 -0
  29. package/server/engine/agent/cast.js +322 -0
  30. package/server/engine/agent/events.js +18 -1
  31. package/server/engine/agent/hooks/beat-gate.js +73 -0
  32. package/server/engine/agent/hooks/failure.js +11 -1
  33. package/server/engine/agent/hooks/lifecycle.js +16 -41
  34. package/server/engine/agent/hooks/post-guidance.js +4 -38
  35. package/server/engine/agent/hooks/post-trim.js +1 -0
  36. package/server/engine/agent/hooks/pre-board-dirty.js +29 -0
  37. package/server/engine/agent/hooks/pre-defaults.js +206 -2
  38. package/server/engine/agent/hooks/pre-injectors.js +19 -2
  39. package/server/engine/agent/hooks/pre-peer-guard.js +88 -0
  40. package/server/engine/agent/hooks/pre-unknown-params.js +82 -0
  41. package/server/engine/agent/hooks/pre-workspace-scope-guard.js +33 -0
  42. package/server/engine/agent/hooks/resident-role-lifecycle.js +100 -0
  43. package/server/engine/agent/hooks/slot-alias.js +30 -0
  44. package/server/engine/agent/hooks/user-prompt-submit.js +208 -47
  45. package/server/engine/agent/hooks.js +80 -14
  46. package/server/engine/agent/isolation.js +25 -2
  47. package/server/engine/agent/memory-config.js +87 -0
  48. package/server/engine/agent/model-context.js +102 -18
  49. package/server/engine/agent/model-table.js +361 -58
  50. package/server/engine/agent/prompts/nodesign-prelude.md +579 -70
  51. package/server/engine/agent/prompts/tools/direct-edit-protocol.md +1 -1
  52. package/server/engine/agent/prompts/tools/generate-image-cookbook.md +26 -9
  53. package/server/engine/agent/prompts/tools/paint-still-cookbook.md +1 -1
  54. package/server/engine/agent/prompts/tools/site-reference.md +15 -4
  55. package/server/engine/agent/role-card.js +175 -0
  56. package/server/engine/agent/rp-mode.js +17 -0
  57. package/server/engine/agent/session-loop.js +55 -32
  58. package/server/engine/agent/stage-status.js +73 -0
  59. package/server/engine/agent/subagent-flight.js +73 -0
  60. package/server/engine/agent/system-prompts.js +41 -0
  61. package/server/engine/agent/tool-input-stream.js +165 -0
  62. package/server/engine/agents/explorer.md +1 -1
  63. package/server/engine/agents/index.js +1 -1
  64. package/server/engine/mcp/actor.js +59 -0
  65. package/server/engine/mcp/capability-gate.js +21 -2
  66. package/server/engine/mcp/index.js +118 -33
  67. package/server/engine/mcp/mode-profile.js +139 -0
  68. package/server/engine/mcp/server-name.js +19 -0
  69. package/server/engine/mcp/tools/artifact-session.js +2 -1
  70. package/server/engine/mcp/tools/browse-computer.js +2 -1
  71. package/server/engine/mcp/tools/browse-find-batch.js +95 -16
  72. package/server/engine/mcp/tools/browse.js +25 -3
  73. package/server/engine/mcp/tools/cast-role.js +167 -0
  74. package/server/engine/mcp/tools/crystallize-skill.js +1 -1
  75. package/server/engine/mcp/tools/draw-trend.js +104 -0
  76. package/server/engine/mcp/tools/edit-board-schema.js +56 -0
  77. package/server/engine/mcp/tools/edit-board.js +569 -0
  78. package/server/engine/mcp/tools/generate-image.js +50 -70
  79. package/server/engine/mcp/tools/helpers/codex-imagegen.js +139 -0
  80. package/server/engine/mcp/tools/helpers/rembg.js +7 -2
  81. package/server/engine/mcp/tools/helpers/shot-pipeline.js +109 -0
  82. package/server/engine/mcp/tools/list-pages.js +11 -5
  83. package/server/engine/mcp/tools/look-at-board.js +4 -3
  84. package/server/engine/mcp/tools/open-sheet.js +242 -0
  85. package/server/engine/mcp/tools/organize-board.js +45 -6
  86. package/server/engine/mcp/tools/paint-still.js +1 -1
  87. package/server/engine/mcp/tools/pin-to-board.js +103 -19
  88. package/server/engine/mcp/tools/read-board.js +123 -16
  89. package/server/engine/mcp/tools/read-tavern-json.js +87 -19
  90. package/server/engine/mcp/tools/report-issue.js +15 -7
  91. package/server/engine/mcp/tools/role-memory.js +83 -0
  92. package/server/engine/mcp/tools/roll-dice.js +82 -0
  93. package/server/engine/mcp/tools/roll-film.js +1 -1
  94. package/server/engine/mcp/tools/screenshot.js +19 -9
  95. package/server/engine/mcp/tools/set-vars.js +150 -0
  96. package/server/engine/mcp/tools/sheet-replan.js +46 -0
  97. package/server/engine/mcp/tools/web-search.js +12 -16
  98. package/server/engine/mcp/tools/write-on-board-flow.js +108 -0
  99. package/server/engine/mcp/tools/write-on-board-place.js +211 -0
  100. package/server/engine/mcp/tools/write-on-board-role-anchor.js +48 -0
  101. package/server/engine/mcp/tools/write-on-board-schema.js +125 -0
  102. package/server/engine/mcp/tools/write-on-board.js +568 -99
  103. package/server/engine/plugins/nodesign/skills/blackboard-rp/SKILL.md +215 -0
  104. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +14 -7
  105. package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +44 -5
  106. package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +16 -0
  107. package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +24 -8
  108. package/server/engine/plugins/nodesign/skills/story-craft/SKILL.md +94 -0
  109. package/server/engine/plugins/nodesign/skills/story-import/SKILL.md +112 -0
  110. package/server/engine/plugins/nodesign/skills/story-intimacy/SKILL.md +72 -0
  111. package/server/engine/plugins/nodesign/skills/story-voice/SKILL.md +66 -0
  112. package/server/engine/plugins/nodesign/skills/story-voice/voices//345/206/267/347/241/254.md +33 -0
  113. package/server/engine/plugins/nodesign/skills/story-voice/voices//346/203/205/346/204/237/347/233/264/347/273/231.md +42 -0
  114. package/server/engine/plugins/nodesign/skills/story-voice/voices//346/204/217/350/257/206/346/265/201.md +42 -0
  115. package/server/engine/plugins/nodesign/skills/story-voice/voices//346/255/246/344/276/240/347/231/275/350/257/235.md +34 -0
  116. package/server/engine/plugins/nodesign/skills/story-voice/voices//347/224/237/346/264/273/346/265/201.md +35 -0
  117. package/server/engine/plugins/nodesign/skills/story-voice/voices//347/231/275/346/217/217.md +37 -0
  118. package/server/engine/plugins/nodesign/skills/story-voice/voices//350/210/236/345/217/260/345/211/247.md +32 -0
  119. package/server/engine/plugins/nodesign/skills/story-voice/voices//351/230/266/346/256/265/345/210/207/346/215/242.md +42 -0
  120. package/server/engine/runs/board-seater.js +272 -0
  121. package/server/engine/runs/board-tasklist.js +90 -48
  122. package/server/engine/runs/turn-relay.js +24 -2
  123. package/server/engine/runs/user-chalk-echo.js +44 -0
  124. package/server/index.js +2 -2
  125. package/server/lib/artifact-target.js +31 -14
  126. package/server/lib/board-anchor.js +49 -0
  127. package/server/lib/board-dirty.js +93 -0
  128. package/server/lib/board-excerpt.js +80 -0
  129. package/server/lib/board-follow.js +111 -0
  130. package/server/lib/board-groups.js +1 -5
  131. package/server/lib/board-hero.js +3 -1
  132. package/server/lib/board-kind-sizes.js +38 -3
  133. package/server/lib/board-lanes.js +58 -0
  134. package/server/lib/board-obstacles.js +80 -0
  135. package/server/lib/board-relations.js +5 -1
  136. package/server/lib/board-sheets.js +467 -0
  137. package/server/lib/board-shelf.js +86 -0
  138. package/server/lib/board-transform.js +62 -0
  139. package/server/lib/canvas-id.js +46 -4
  140. package/server/lib/chalk-flow.js +88 -0
  141. package/server/lib/chalk-rewrite.js +63 -0
  142. package/server/lib/chalk-size-pref.js +48 -0
  143. package/server/lib/chalk.js +42 -3
  144. package/server/lib/export-collect.js +3 -1
  145. package/server/lib/ingress/forward-openai-chat.js +16 -4
  146. package/server/lib/ingress/openai-chat.js +23 -12
  147. package/server/lib/ingress/session-routes.js +3 -3
  148. package/server/lib/ingress/stream-watchdog.js +53 -0
  149. package/server/lib/ingress/upstream-billing.js +15 -0
  150. package/server/lib/ingress/upstream-error-hints.js +36 -0
  151. package/server/lib/issues-store.js +3 -1
  152. package/server/lib/json-preview.js +58 -0
  153. package/server/lib/kinds/docx.js +11 -4
  154. package/server/lib/kinds/index.js +6 -16
  155. package/server/lib/kinds/site.js +40 -7
  156. package/server/lib/model-ingress.js +26 -1
  157. package/server/lib/moderation.js +10 -5
  158. package/server/lib/quota.js +26 -6
  159. package/server/lib/rect.js +50 -0
  160. package/server/lib/rewrite-refs.js +110 -0
  161. package/server/lib/screen.js +36 -0
  162. package/server/lib/sheet-state.js +19 -0
  163. package/server/lib/sketch-array.js +118 -0
  164. package/server/lib/sketch-hatch.js +99 -0
  165. package/server/lib/sketch-layout.js +269 -69
  166. package/server/lib/sketch-shapes.js +152 -0
  167. package/server/lib/sketch-stencils.js +170 -0
  168. package/server/lib/ssrf-guard.js +9 -0
  169. package/server/lib/state-table.js +237 -0
  170. package/server/lib/state-trend.js +94 -0
  171. package/server/lib/state-triggers.js +199 -0
  172. package/server/lib/task-scan.js +18 -0
  173. package/server/lib/tavern-json.js +48 -0
  174. package/server/lib/user-uploads.js +88 -0
  175. package/server/projects/board-limits.js +5 -3
  176. package/server/projects/board-sanitize.js +220 -10
  177. package/server/projects/board-store.js +137 -7
  178. package/server/projects/memory-migration.js +85 -0
  179. package/server/projects/move-entry.js +11 -1
  180. package/server/projects/session-jsonl.js +110 -0
  181. package/server/projects/store.js +56 -3
  182. package/server/projects/ui-config.js +2 -1
  183. package/server/projects/viewpoint-store.js +35 -3
  184. package/server/projects/workspace-slots.js +30 -0
  185. package/server/projects/workspace-templates.js +42 -10
  186. package/server/projects/workspace.js +25 -26
  187. package/server/runtime/capabilities.js +6 -2
  188. package/server/services/rembg-launcher.js +5 -2
  189. package/server/shared/locales.js +70 -0
  190. package/server/shared/messages-en.js +69 -0
  191. package/server/shared/messages.js +65 -0
  192. package/server/ws/broker.js +3 -0
  193. package/server/ws/index.js +43 -3
  194. package/web/README.md +1 -1
  195. package/web/dist/assets/{BrowserWindow-P_vIdh-G.js → BrowserWindow-CcaGRWz5.js} +1 -1
  196. package/web/dist/assets/DeckWindow-BIr2KGFT.js +6 -0
  197. package/web/dist/assets/{DocxWindow-DSAU1-tD.js → DocxWindow-B9KZmU5z.js} +2 -2
  198. package/web/dist/assets/{MermaidBlock-DR1GikCv.js → MermaidBlock-DrfRnz8E.js} +43 -43
  199. package/web/dist/assets/{SiteWindow-92uBuRRR.js → SiteWindow-CJOjCF7d.js} +6 -6
  200. package/web/dist/assets/{abnfDiagram-VCTEODGH-uA8Esix4.js → abnfDiagram-VCTEODGH-CUZbQGIg.js} +1 -1
  201. package/web/dist/assets/{arc-D_khH_xN.js → arc-C1-HSUny.js} +1 -1
  202. package/web/dist/assets/{architectureDiagram-5GKGNRK7-DydJHqOt.js → architectureDiagram-5GKGNRK7-B5nwvu60.js} +2 -2
  203. package/web/dist/assets/{blockDiagram-NRAW4CY4-B7wBbKiW.js → blockDiagram-NRAW4CY4-uPPuFLwR.js} +1 -1
  204. package/web/dist/assets/{c4Diagram-UCG6FXSJ-6mrH0x7Y.js → c4Diagram-UCG6FXSJ-C0mKBFlz.js} +1 -1
  205. package/web/dist/assets/channel-BxpWNOGU.js +1 -0
  206. package/web/dist/assets/{chunk-2Q5K7J3B-Cuikho5Q.js → chunk-2Q5K7J3B-Cs0rlZ4q.js} +1 -1
  207. package/web/dist/assets/{chunk-5VM5RSS4-3-KZ5AUz.js → chunk-5VM5RSS4-DtR63oYb.js} +1 -1
  208. package/web/dist/assets/{chunk-F27PBJKO-Bkwq2gGU.js → chunk-F27PBJKO-B3MRdezJ.js} +1 -1
  209. package/web/dist/assets/{chunk-G27WJ6UU-BLyQzdGE.js → chunk-G27WJ6UU-BtwntwpS.js} +1 -1
  210. package/web/dist/assets/{chunk-JWPE2WC7-B7HxxQds.js → chunk-JWPE2WC7-BHsTe5PD.js} +1 -1
  211. package/web/dist/assets/{chunk-LCL6LL3I-CtXrnUwF.js → chunk-LCL6LL3I-knY15Qdm.js} +1 -1
  212. package/web/dist/assets/{chunk-POPQ4Y6H-dxddZlXt.js → chunk-POPQ4Y6H-C51tgK_I.js} +1 -1
  213. package/web/dist/assets/{chunk-SVP7TREG-DpkmrgHn.js → chunk-SVP7TREG-CnwRZmF_.js} +1 -1
  214. package/web/dist/assets/{chunk-XXDRQBXY-BwZ7RbpQ.js → chunk-XXDRQBXY-BtXzVF-_.js} +1 -1
  215. package/web/dist/assets/classDiagram-DTDB5LWJ-DUEKmDfo.js +1 -0
  216. package/web/dist/assets/classDiagram-v2-JRS7N3AN-DUEKmDfo.js +1 -0
  217. package/web/dist/assets/{cose-bilkent-JH36ORCC-DGFOsMUt.js → cose-bilkent-JH36ORCC-CzWlGP6i.js} +1 -1
  218. package/web/dist/assets/{cynefin-OW5HDTMX-DUwn3ehS.js → cynefin-OW5HDTMX-vK64EGSv.js} +1 -1
  219. package/web/dist/assets/{cynefinDiagram-5FMLGOSQ-DlLAOZ9x.js → cynefinDiagram-5FMLGOSQ-Bs3g1Gvh.js} +1 -1
  220. package/web/dist/assets/{dagre-3AP2YEHR-CyourLfz.js → dagre-3AP2YEHR-BhgkYe8V.js} +1 -1
  221. package/web/dist/assets/{diagram-S7CK7UJ4-BTMbx2np.js → diagram-S7CK7UJ4-C_6mvD2V.js} +1 -1
  222. package/web/dist/assets/{diagram-UQ7AKVKN-UK94_1-P.js → diagram-UQ7AKVKN-qW7tssUW.js} +1 -1
  223. package/web/dist/assets/{diagram-VSXAHHWV-C7zS-v9v.js → diagram-VSXAHHWV-txJPHpvG.js} +1 -1
  224. package/web/dist/assets/{diagram-VX7I27RA-DKJRa6CK.js → diagram-VX7I27RA-CQTMsCnL.js} +1 -1
  225. package/web/dist/assets/{diagram-Z3DM3KII-kUbAfGAp.js → diagram-Z3DM3KII--cb1hoE6.js} +1 -1
  226. package/web/dist/assets/{ebnfDiagram-PWID7BFC-CVW16_vb.js → ebnfDiagram-PWID7BFC-Da6lDU77.js} +1 -1
  227. package/web/dist/assets/{erDiagram-SSCWMZ5O-BRQMFQ5y.js → erDiagram-SSCWMZ5O-DGCMrsQn.js} +1 -1
  228. package/web/dist/assets/{flowDiagram-A5DVABFB-S34LpLtK.js → flowDiagram-A5DVABFB-GdOL_qno.js} +1 -1
  229. package/web/dist/assets/{ganttDiagram-EL5Y4UJY-BFiqk1-G.js → ganttDiagram-EL5Y4UJY-Cx1F_jBK.js} +4 -4
  230. package/web/dist/assets/{gitGraphDiagram-WWUBYQGX-LZsrdyyv.js → gitGraphDiagram-WWUBYQGX-B5_6JhR3.js} +1 -1
  231. package/web/dist/assets/hand-CuvZZQGW.webp +0 -0
  232. package/web/dist/assets/index-BYKfMnmv.js +2759 -0
  233. package/web/dist/assets/index-CW7VsrVg.css +1 -0
  234. package/web/dist/assets/{infoDiagram-RXCK75RN-0DvN6KIp.js → infoDiagram-RXCK75RN-CjvKpSPA.js} +1 -1
  235. package/web/dist/assets/{ishikawaDiagram-5VMMS53U-CaY3A-Bx.js → ishikawaDiagram-5VMMS53U-CxLoAPhx.js} +1 -1
  236. package/web/dist/assets/{journeyDiagram-EYS64GPL-BBeiaCrj.js → journeyDiagram-EYS64GPL-BfvS1upJ.js} +1 -1
  237. package/web/dist/assets/{kanban-definition-3QL26DDD-ux42DRXQ.js → kanban-definition-3QL26DDD-DKPPu0w4.js} +1 -1
  238. package/web/dist/assets/{layout-CnXiF_NB.js → layout-Dhgck-Zo.js} +1 -1
  239. package/web/dist/assets/{linear-BMW0u6G6.js → linear-DMSQ-aUw.js} +1 -1
  240. package/web/dist/assets/lxgw-nd-app-bold-D1pKA1bR.woff2 +0 -0
  241. package/web/dist/assets/lxgw-nd-app-regular-BDe3d26v.woff2 +0 -0
  242. package/web/dist/assets/lxgw-nd-bold-Bufvlg03.woff2 +0 -0
  243. package/web/dist/assets/lxgw-nd-regular-DQwoI4OP.woff2 +0 -0
  244. package/web/dist/assets/lxgwwenkaiscreen-subset-100-DLPe7tEt.woff2 +0 -0
  245. package/web/dist/assets/lxgwwenkaiscreen-subset-101-d3VsZKWT.woff2 +0 -0
  246. package/web/dist/assets/lxgwwenkaiscreen-subset-102-ClzJuLq6.woff2 +0 -0
  247. package/web/dist/assets/lxgwwenkaiscreen-subset-103-DuDnQ6K-.woff2 +0 -0
  248. package/web/dist/assets/lxgwwenkaiscreen-subset-104-kPGW3Eem.woff2 +0 -0
  249. package/web/dist/assets/lxgwwenkaiscreen-subset-105-BzD54h-0.woff2 +0 -0
  250. package/web/dist/assets/lxgwwenkaiscreen-subset-106-BQ7fIzlx.woff2 +0 -0
  251. package/web/dist/assets/lxgwwenkaiscreen-subset-107-D8tDfFHI.woff2 +0 -0
  252. package/web/dist/assets/lxgwwenkaiscreen-subset-108-tGhkpwep.woff2 +0 -0
  253. package/web/dist/assets/lxgwwenkaiscreen-subset-109-7SMVSrly.woff2 +0 -0
  254. package/web/dist/assets/lxgwwenkaiscreen-subset-110-_uDKrFed.woff2 +0 -0
  255. package/web/dist/assets/lxgwwenkaiscreen-subset-111-Bio825RE.woff2 +0 -0
  256. package/web/dist/assets/lxgwwenkaiscreen-subset-112-D2ee0i-C.woff2 +0 -0
  257. package/web/dist/assets/lxgwwenkaiscreen-subset-113-JPFVDrga.woff2 +0 -0
  258. package/web/dist/assets/lxgwwenkaiscreen-subset-114-BcfxYA4w.woff2 +0 -0
  259. package/web/dist/assets/lxgwwenkaiscreen-subset-115-B1HeeOsd.woff2 +0 -0
  260. package/web/dist/assets/lxgwwenkaiscreen-subset-116-hY12tXpq.woff2 +0 -0
  261. package/web/dist/assets/lxgwwenkaiscreen-subset-117-BvuZwmre.woff2 +0 -0
  262. package/web/dist/assets/lxgwwenkaiscreen-subset-118-tzvs_H7j.woff2 +0 -0
  263. package/web/dist/assets/lxgwwenkaiscreen-subset-119-J2DWuYDN.woff2 +0 -0
  264. package/web/dist/assets/lxgwwenkaiscreen-subset-21-pDubCJV5.woff2 +0 -0
  265. package/web/dist/assets/lxgwwenkaiscreen-subset-22-ChAPMpqp.woff2 +0 -0
  266. package/web/dist/assets/lxgwwenkaiscreen-subset-23-BBx3oNpT.woff2 +0 -0
  267. package/web/dist/assets/lxgwwenkaiscreen-subset-24-CYt-Oagk.woff2 +0 -0
  268. package/web/dist/assets/lxgwwenkaiscreen-subset-25-Cx3j-ZUv.woff2 +0 -0
  269. package/web/dist/assets/lxgwwenkaiscreen-subset-26-B9AfZ3LL.woff2 +0 -0
  270. package/web/dist/assets/lxgwwenkaiscreen-subset-27-Bh2s9MNE.woff2 +0 -0
  271. package/web/dist/assets/lxgwwenkaiscreen-subset-28-C02JPoKP.woff2 +0 -0
  272. package/web/dist/assets/lxgwwenkaiscreen-subset-29-T_tbUGhk.woff2 +0 -0
  273. package/web/dist/assets/lxgwwenkaiscreen-subset-30-DMfyNzhz.woff2 +0 -0
  274. package/web/dist/assets/lxgwwenkaiscreen-subset-31-DFfdzbkg.woff2 +0 -0
  275. package/web/dist/assets/lxgwwenkaiscreen-subset-32-Bs1fz2iW.woff2 +0 -0
  276. package/web/dist/assets/lxgwwenkaiscreen-subset-33-cpB96OkZ.woff2 +0 -0
  277. package/web/dist/assets/lxgwwenkaiscreen-subset-34-BhinPYWX.woff2 +0 -0
  278. package/web/dist/assets/lxgwwenkaiscreen-subset-35-D_njRQqA.woff2 +0 -0
  279. package/web/dist/assets/lxgwwenkaiscreen-subset-36-DqXlLE3t.woff2 +0 -0
  280. package/web/dist/assets/lxgwwenkaiscreen-subset-37-j5LbvMph.woff2 +0 -0
  281. package/web/dist/assets/lxgwwenkaiscreen-subset-38-oO4jWdKm.woff2 +0 -0
  282. package/web/dist/assets/lxgwwenkaiscreen-subset-39-CabsX4rw.woff2 +0 -0
  283. package/web/dist/assets/lxgwwenkaiscreen-subset-4-PatS3YHh.woff2 +0 -0
  284. package/web/dist/assets/lxgwwenkaiscreen-subset-40-yrLg3C3e.woff2 +0 -0
  285. package/web/dist/assets/lxgwwenkaiscreen-subset-41-DBXmCQhc.woff2 +0 -0
  286. package/web/dist/assets/lxgwwenkaiscreen-subset-42-Bqij_UiQ.woff2 +0 -0
  287. package/web/dist/assets/lxgwwenkaiscreen-subset-43-D3kp0Ntc.woff2 +0 -0
  288. package/web/dist/assets/lxgwwenkaiscreen-subset-44-B_BNGvK4.woff2 +0 -0
  289. package/web/dist/assets/lxgwwenkaiscreen-subset-45-CZc0cQWs.woff2 +0 -0
  290. package/web/dist/assets/lxgwwenkaiscreen-subset-46-CHQX5Kd9.woff2 +0 -0
  291. package/web/dist/assets/lxgwwenkaiscreen-subset-47-Ci8gLrHA.woff2 +0 -0
  292. package/web/dist/assets/lxgwwenkaiscreen-subset-48-run3ugD3.woff2 +0 -0
  293. package/web/dist/assets/lxgwwenkaiscreen-subset-49-DgfiUb6u.woff2 +0 -0
  294. package/web/dist/assets/lxgwwenkaiscreen-subset-5-B0CtGbX_.woff2 +0 -0
  295. package/web/dist/assets/lxgwwenkaiscreen-subset-50-CdCG-POr.woff2 +0 -0
  296. package/web/dist/assets/lxgwwenkaiscreen-subset-51-L2okeQZ2.woff2 +0 -0
  297. package/web/dist/assets/lxgwwenkaiscreen-subset-52-BZAqyGJW.woff2 +0 -0
  298. package/web/dist/assets/lxgwwenkaiscreen-subset-53-B0xWqLeU.woff2 +0 -0
  299. package/web/dist/assets/lxgwwenkaiscreen-subset-54-BZVQX-EA.woff2 +0 -0
  300. package/web/dist/assets/lxgwwenkaiscreen-subset-55-093mkIUJ.woff2 +0 -0
  301. package/web/dist/assets/lxgwwenkaiscreen-subset-56-CbhWG8G-.woff2 +0 -0
  302. package/web/dist/assets/lxgwwenkaiscreen-subset-57-pLQuvo6c.woff2 +0 -0
  303. package/web/dist/assets/lxgwwenkaiscreen-subset-58-BHhoY50x.woff2 +0 -0
  304. package/web/dist/assets/lxgwwenkaiscreen-subset-59-D1_6DuIQ.woff2 +0 -0
  305. package/web/dist/assets/lxgwwenkaiscreen-subset-6-BnAelmYU.woff2 +0 -0
  306. package/web/dist/assets/lxgwwenkaiscreen-subset-60-HDBIuLTZ.woff2 +0 -0
  307. package/web/dist/assets/lxgwwenkaiscreen-subset-61-C-c13SeU.woff2 +0 -0
  308. package/web/dist/assets/lxgwwenkaiscreen-subset-62-kHRVOhZ2.woff2 +0 -0
  309. package/web/dist/assets/lxgwwenkaiscreen-subset-63-C0MCsvFv.woff2 +0 -0
  310. package/web/dist/assets/lxgwwenkaiscreen-subset-64-DZekguCj.woff2 +0 -0
  311. package/web/dist/assets/lxgwwenkaiscreen-subset-65-CtP6c3IU.woff2 +0 -0
  312. package/web/dist/assets/lxgwwenkaiscreen-subset-66-DLVWoMSf.woff2 +0 -0
  313. package/web/dist/assets/lxgwwenkaiscreen-subset-67-BVuOnayH.woff2 +0 -0
  314. package/web/dist/assets/lxgwwenkaiscreen-subset-68-DpxQ6Mnx.woff2 +0 -0
  315. package/web/dist/assets/lxgwwenkaiscreen-subset-69-qesBxLmC.woff2 +0 -0
  316. package/web/dist/assets/lxgwwenkaiscreen-subset-70-BBt1xN7h.woff2 +0 -0
  317. package/web/dist/assets/lxgwwenkaiscreen-subset-71-BWqZacwB.woff2 +0 -0
  318. package/web/dist/assets/lxgwwenkaiscreen-subset-72-B1wP_Yst.woff2 +0 -0
  319. package/web/dist/assets/lxgwwenkaiscreen-subset-73-C99T7jNZ.woff2 +0 -0
  320. package/web/dist/assets/lxgwwenkaiscreen-subset-74-DrUTsWBe.woff2 +0 -0
  321. package/web/dist/assets/lxgwwenkaiscreen-subset-75-C4M5XXQe.woff2 +0 -0
  322. package/web/dist/assets/lxgwwenkaiscreen-subset-76-Bq8spVWF.woff2 +0 -0
  323. package/web/dist/assets/lxgwwenkaiscreen-subset-77-D9vNupE2.woff2 +0 -0
  324. package/web/dist/assets/lxgwwenkaiscreen-subset-78-D6th7Gnu.woff2 +0 -0
  325. package/web/dist/assets/lxgwwenkaiscreen-subset-79-bezM85cS.woff2 +0 -0
  326. package/web/dist/assets/lxgwwenkaiscreen-subset-80-DD8pnhG2.woff2 +0 -0
  327. package/web/dist/assets/lxgwwenkaiscreen-subset-81-N6jtSyBs.woff2 +0 -0
  328. package/web/dist/assets/lxgwwenkaiscreen-subset-82-D6c180bZ.woff2 +0 -0
  329. package/web/dist/assets/lxgwwenkaiscreen-subset-83-em17AadM.woff2 +0 -0
  330. package/web/dist/assets/lxgwwenkaiscreen-subset-84-CJ8gdrMK.woff2 +0 -0
  331. package/web/dist/assets/lxgwwenkaiscreen-subset-85-9PPGO1eB.woff2 +0 -0
  332. package/web/dist/assets/lxgwwenkaiscreen-subset-86-CjWPf-u5.woff2 +0 -0
  333. package/web/dist/assets/lxgwwenkaiscreen-subset-87-8D_X_yOC.woff2 +0 -0
  334. package/web/dist/assets/lxgwwenkaiscreen-subset-88-9vtKFPBx.woff2 +0 -0
  335. package/web/dist/assets/lxgwwenkaiscreen-subset-89-BhE5jAKX.woff2 +0 -0
  336. package/web/dist/assets/lxgwwenkaiscreen-subset-90-D2Npfm5E.woff2 +0 -0
  337. package/web/dist/assets/lxgwwenkaiscreen-subset-91-C6Ft1-Eu.woff2 +0 -0
  338. package/web/dist/assets/lxgwwenkaiscreen-subset-97-CaExyQP9.woff2 +0 -0
  339. package/web/dist/assets/lxgwwenkaiscreen-subset-98-CPoVbsUd.woff2 +0 -0
  340. package/web/dist/assets/lxgwwenkaiscreen-subset-99-ZUQZGVgH.woff2 +0 -0
  341. package/web/dist/assets/{mindmap-definition-FBJOCRG2-hUxSpdKn.js → mindmap-definition-FBJOCRG2-CalfL_Kn.js} +1 -1
  342. package/web/dist/assets/{pegDiagram-XKGWAZYB-aCaQPH0w.js → pegDiagram-XKGWAZYB-D9mVWz7l.js} +1 -1
  343. package/web/dist/assets/pending-edit-apply-kwMFWsUe.js +31 -0
  344. package/web/dist/assets/{pieDiagram-E7YTZNPT-CmOEZ8zg.js → pieDiagram-E7YTZNPT-DELzbZNS.js} +1 -1
  345. package/web/dist/assets/{quadrantDiagram-AXDQQJYC-VW6fAhsV.js → quadrantDiagram-AXDQQJYC-TZqp4mfA.js} +1 -1
  346. package/web/dist/assets/{railroadDiagram-O6MQD6OU-CKJ7Ba5K.js → railroadDiagram-O6MQD6OU-DxX59f0i.js} +1 -1
  347. package/web/dist/assets/{requirementDiagram-EFPCY7ZU-pNGkq9Vw.js → requirementDiagram-EFPCY7ZU-DFdsJLIe.js} +1 -1
  348. package/web/dist/assets/{rotate-cw-KAfChbGV.js → rotate-cw-62liz7LX.js} +1 -1
  349. package/web/dist/assets/{sankeyDiagram-P5KCCOFB-BIMmDTfZ.js → sankeyDiagram-P5KCCOFB-ByYcd3fG.js} +1 -1
  350. package/web/dist/assets/{sequenceDiagram-WJ2MYXX4-BBW7wzKY.js → sequenceDiagram-WJ2MYXX4-Bj-ml1jS.js} +1 -1
  351. package/web/dist/assets/{sizeCapture-X5ZJPWSS-DdkXmLYR.js → sizeCapture-X5ZJPWSS-KhsXThyD.js} +1 -1
  352. package/web/dist/assets/{square-dashed-mouse-pointer-CDUlhUP7.js → square-dashed-mouse-pointer-D_VrqVpy.js} +1 -1
  353. package/web/dist/assets/{stateDiagram-HBIQ2CUA-W313Uz16.js → stateDiagram-HBIQ2CUA-BJlpHfWU.js} +1 -1
  354. package/web/dist/assets/stateDiagram-v2-4QOOHH4V-CR2kYAH2.js +1 -0
  355. package/web/dist/assets/{swimlanes-XN3QIQJK-C29TL26K.js → swimlanes-XN3QIQJK-DUB9OJVs.js} +1 -1
  356. package/web/dist/assets/swimlanesDiagram-VK2B7HYN-BitOxnG2.js +8 -0
  357. package/web/dist/assets/{timeline-definition-24CTP7MA-CF0JMObX.js → timeline-definition-24CTP7MA-C03NX82A.js} +1 -1
  358. package/web/dist/assets/{vennDiagram-4TSXK5OY-DrDFsaH3.js → vennDiagram-4TSXK5OY-B9pjzngp.js} +1 -1
  359. package/web/dist/assets/{wardleyDiagram-VM6X3IG4-CVrnRIS_.js → wardleyDiagram-VM6X3IG4-UqWq_mcT.js} +1 -1
  360. package/web/dist/assets/{xychartDiagram-S5SC5T6Z-BvkN1zsT.js → xychartDiagram-S5SC5T6Z-D2pDKtOv.js} +1 -1
  361. package/web/dist/index.html +2 -2
  362. package/server/api/memory.js +0 -185
  363. package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +0 -583
  364. package/server/engine/mcp/tools/arrange-on-board.js +0 -124
  365. package/server/engine/mcp/tools/create-on-board.js +0 -152
  366. package/server/engine/mcp/tools/edit-sketch.js +0 -167
  367. package/server/engine/mcp/tools/record-decision.js +0 -140
  368. package/server/engine/mcp/tools/relate-on-board.js +0 -112
  369. package/server/engine/mcp/tools/sketch-on-board.js +0 -337
  370. package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +0 -235
  371. package/server/engine/plugins/nodesign/skills/rp-craft/patterns//346/274/224/345/207/272/351/241/265-/346/234/200/345/260/217/345/256/236/347/216/260.html +0 -78
  372. package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +0 -268
  373. package/web/dist/assets/DeckWindow-BpLv5082.js +0 -16
  374. package/web/dist/assets/channel-DGkccMO1.js +0 -1
  375. package/web/dist/assets/classDiagram-DTDB5LWJ-BGscw3OD.js +0 -1
  376. package/web/dist/assets/classDiagram-v2-JRS7N3AN-BGscw3OD.js +0 -1
  377. package/web/dist/assets/index-C0KUf6Wd.css +0 -1
  378. package/web/dist/assets/index-GAamh1p4.js +0 -2152
  379. package/web/dist/assets/lxgw-nd-bold-DbRfiTgo.woff2 +0 -0
  380. package/web/dist/assets/lxgw-nd-regular-CpMw9Ehg.woff2 +0 -0
  381. package/web/dist/assets/pending-edit-apply-AcdOQ9Ij.js +0 -31
  382. package/web/dist/assets/stateDiagram-v2-4QOOHH4V-DDhJBZzf.js +0 -1
  383. package/web/dist/assets/swimlanesDiagram-VK2B7HYN-BfN-Xdmu.js +0 -8
@@ -0,0 +1,220 @@
1
+ /**
2
+ * server/api/sessions-rewind.js — 「回到某条消息之前」(2026-08-30 从 sessions.js
3
+ * 拆出,行数棘轮)。
4
+ *
5
+ * 它自成一件事:两个正交开关(回不回文件 / 截不截对话)× 三条执行路径(不碰 SDK /
6
+ * 用活口 query / 起临时 query),加上 jsonl 截断那套原子写。放回 sessions.js 会让
7
+ * 那个文件一半篇幅在讲回滚。
8
+ *
9
+ * `pendingRewinds` 也跟着搬过来 —— 它是这套流程的互斥牌,turn.js 拿它拒同 sid 新
10
+ * turn(防两个 SDK subprocess 同时写一份 jsonl,或 turn 写进正要被砍掉的那段尾巴)。
11
+ */
12
+
13
+ import { query } from '@anthropic-ai/claude-agent-sdk';
14
+ import { guardProject } from './_guard.js';
15
+ import { closeQuerySession, hasActiveQuerySession, getQuerySession } from '../engine/runs/active-runs.js';
16
+ import { getSessionWorkspace, validateSessionId } from '../projects/workspace.js';
17
+ import { jsonlExistsForSession, truncateJsonlAtMessage } from '../projects/session-jsonl.js';
18
+ import { AsyncQueue } from '../lib/async-queue.js';
19
+ import { getProjectBus } from '../ws/broker.js';
20
+ import { platform } from '../runtime/platform.js';
21
+
22
+ const GLOBAL_CLAUDE_CONFIG_DIR = platform.claudeConfigDir;
23
+
24
+ /**
25
+ * 进行中的 rewind 操作 sid 集合 —— 供 turn.js startNewRunSession 守卫使用,
26
+ * 防止同 sid 临时 rewind query 跟 normal turn query 同时启动撞 jsonl。
27
+ */
28
+ export const pendingRewinds = new Set();
29
+
30
+ /** 把 rewind 路由挂到 sessions 的 router 上(sessions.js 调一次)。 */
31
+ export function mountRewindRoute(router) {
32
+ // ── POST /:pid/sessions/:sid/rewind ──
33
+ // 回到某条用户消息之前。**两件事,两个开关,互相正交**(2026-08-30 拆开):
34
+ //
35
+ // files=true 产物回退:SDK Query.rewindFiles(userMessageId) 把工作区文件
36
+ // 回滚到该消息之前(后续 Edit/Write 全撤销)。SDK file
37
+ // checkpoint 写在 session jsonl(type='file-history-snapshot'),
38
+ // 跨进程持久化天然搞定。
39
+ // truncateConversation 对话回退:把 jsonl 截到该消息之前。显示与模型记忆读的都是
40
+ // =true 这份 jsonl,所以截了记忆才真的回退。
41
+ //
42
+ // 以前这两件绑死一起做。拆开是因为它们是两种不同的后悔:「这句话我说错了,重说」
43
+ // 只该动对话;「它照我说的做出来的东西我不要了」才该动文件。默认值仍是两个都 true
44
+ // ——「回到此处」的老行为一字不改。
45
+ //
46
+ // ⚠️ 产物只有一份(一个项目一个工作区),所以 files=true 是**项目级**的影响:
47
+ // 同项目其他会话看到的也是回退后的文件。调用方要在 UI 上把这件事说明白。
48
+ //
49
+ // 三条路径:
50
+ // 0. files=false → 压根不需要 SDK(关活口 query + 截 jsonl 即可),瞬间完成
51
+ // 1. active query 在跑 → 直接用现有 query.rewindFiles(最快,无 spawn 成本)
52
+ // 2. session 已 close(历史 session)→ 起临时 query (resume + drain) → rewindFiles → close
53
+ //
54
+ // 历史 session 也能 undo —— 之前返 410 是应用层偷懒,SDK 完全支持 resume + rewindFiles。
55
+ //
56
+ // body: { userMessageId, files?=true, truncateConversation?=true }
57
+ // 200 { canRewind, filesChanged?, insertions?, deletions?, conversationTruncated, removedEntries }
58
+ // 400 两个开关都 false(没让它做任何事)
59
+ // 404 { code: 'JSONL_MISSING' } jsonl 不存在(session 删了 / 部分创建)
60
+ // 409 { code: 'REWIND_BUSY' } 同 sid 已有 rewind 进行中
61
+ // 500 { code: 'REWIND_FAILED' } 临时 query 启动 / rewindFiles 失败
62
+ router.post('/:pid/sessions/:sid/rewind', async (req, res, next) => {
63
+ try {
64
+ validateSessionId(req.params.sid);
65
+ const project = guardProject(req, res);
66
+ if (!project) return;
67
+
68
+ const { userMessageId, files = true, truncateConversation = true } = req.body || {};
69
+ if (!userMessageId || typeof userMessageId !== 'string') {
70
+ return res.status(400).json({ error: 'userMessageId required' });
71
+ }
72
+ const wantFiles = files !== false;
73
+ const wantTruncate = truncateConversation !== false;
74
+ if (!wantFiles && !wantTruncate) {
75
+ return res.status(400).json({ error: 'nothing to do: files and truncateConversation both false' });
76
+ }
77
+
78
+ const { pid, sid } = req.params;
79
+
80
+ // ── 路径 0:只回对话 ──
81
+ // 不碰 SDK 就够了:关掉活口 query(下条消息从截断后的 jsonl resume,记忆才真的
82
+ // 回退),等它 flush 完把 jsonl 截了。省掉临时 query 那 3-5 秒的 spawn。
83
+ if (!wantFiles) {
84
+ const sessionRoot = getSessionWorkspace(pid, sid);
85
+ if (!await jsonlExistsForSession(sessionRoot, sid)) {
86
+ return res.status(404).json({ error: 'session jsonl not found', code: 'JSONL_MISSING' });
87
+ }
88
+ if (pendingRewinds.has(sid)) {
89
+ return res.status(409).json({ error: 'rewind in progress', code: 'REWIND_BUSY' });
90
+ }
91
+ // 挂牌同 SDK 路径:turn.js 见 pendingRewinds 有这个 sid 就拒新 turn。这条路
92
+ // 虽然不起临时 query,但它**写** jsonl —— 截断中途来一条 turn 会写进正要被
93
+ // 砍掉的那段尾巴上。
94
+ pendingRewinds.add(sid);
95
+ try {
96
+ if (hasActiveQuerySession(sid)) {
97
+ try { closeQuerySession(sid, 'rewind_truncate'); } catch { /* */ }
98
+ await new Promise((r) => setTimeout(r, 800));
99
+ }
100
+ const removed = await truncateJsonlAtMessage(sessionRoot, sid, userMessageId);
101
+ const payload = { canRewind: removed != null, filesChanged: [], conversationTruncated: removed != null, removedEntries: removed ?? 0 };
102
+ emitRewindFiles(pid, sid, payload);
103
+ return res.json(payload);
104
+ } finally {
105
+ pendingRewinds.delete(sid);
106
+ }
107
+ }
108
+
109
+ // 路径 1:active query 在跑 —— 直接用现有 query
110
+ const rec = getQuerySession(sid);
111
+ if (rec?.query && !rec.abortController.signal.aborted) {
112
+ const result = await rec.query.rewindFiles(userMessageId);
113
+ // 对话层同步回滚(2026-08-08「做完整」):rewindFiles 只回文件。显示与模型
114
+ // 记忆读的都是这份 jsonl —— 关掉活口 query(下条消息从截断后的 jsonl resume,
115
+ // 记忆才真的回退),等 SDK flush 后把 jsonl 截到该 user 消息之前。
116
+ // truncateConversation=false(fork 带产物回退那条路)时整段跳过:源会话的
117
+ // 对话要原样留着,动的只有磁盘。query 也不关 —— 不截 jsonl 就没有"让它重读"的必要。
118
+ let removed = null;
119
+ if (wantTruncate) {
120
+ try { closeQuerySession(sid, 'rewind_truncate'); } catch { /* */ }
121
+ await new Promise((r) => setTimeout(r, 800));
122
+ removed = await truncateJsonlAtMessage(getSessionWorkspace(pid, sid), sid, userMessageId);
123
+ }
124
+ const payload = { ...result, conversationTruncated: removed != null, removedEntries: removed ?? 0 };
125
+ emitRewindFiles(pid, sid, payload);
126
+ return res.json(payload);
127
+ }
128
+
129
+ // race guard:active session 存在但 query handle 未 attach(session 启动中
130
+ // 的窄 race window — registerQuerySession 已 set Map 但 attachSessionQuery
131
+ // 还没赋值 query 字段)→ 拒 409 让用户重试。如果直接 fallthrough 进路径 2
132
+ // 起临时 query,两个 SDK binary 会同时 attach 同一 jsonl 文件 → 错乱不可恢复。
133
+ if (hasActiveQuerySession(sid) && rec && !rec.abortController.signal.aborted) {
134
+ return res.status(409).json({
135
+ error: 'session is starting (query handle not yet attached), retry shortly',
136
+ code: 'SESSION_STARTING',
137
+ });
138
+ }
139
+
140
+ // 路径 2:起临时 query resume → rewindFiles → close
141
+ if (pendingRewinds.has(sid)) {
142
+ return res.status(409).json({ error: 'rewind in progress', code: 'REWIND_BUSY' });
143
+ }
144
+ const sessionRoot = getSessionWorkspace(pid, sid);
145
+ if (!await jsonlExistsForSession(sessionRoot, sid)) {
146
+ return res.status(404).json({ error: 'session jsonl not found', code: 'JSONL_MISSING' });
147
+ }
148
+
149
+ pendingRewinds.add(sid);
150
+ const inputQueue = new AsyncQueue();
151
+ let tempQuery = null;
152
+ let drain = null;
153
+ try {
154
+ tempQuery = query({
155
+ prompt: inputQueue,
156
+ options: {
157
+ resume: sid,
158
+ enableFileCheckpointing: true,
159
+ cwd: sessionRoot,
160
+ // 关键:跟 runSession 一致传 CLAUDE_CONFIG_DIR,否则 SDK 找不到 jsonl
161
+ env: { ...process.env, CLAUDE_CONFIG_DIR: GLOBAL_CLAUDE_CONFIG_DIR },
162
+ persistSession: true,
163
+ // 不传 hooks / mcpServers / agents / canUseTool —— 临时 query 不跑 turn
164
+ },
165
+ });
166
+ // fire-and-forget consume —— SDK control method 走 bidirectional protocol,
167
+ // stream 不消费会卡死 control RPC。drain 跑在后台,close 后自然结束。
168
+ drain = (async () => {
169
+ try { for await (const _ of tempQuery) { /* discard */ } }
170
+ catch { /* expected on close */ }
171
+ })();
172
+ // 15s timeout(SDK boot + jsonl load + control RPC ~3-5s 正常 → 3× margin)
173
+ const result = await Promise.race([
174
+ tempQuery.rewindFiles(userMessageId),
175
+ new Promise((_, rj) => setTimeout(() => rj(new Error('rewind timeout')), 15000)),
176
+ ]);
177
+ // 对话层回滚:先收干净临时 query(文件句柄/尾部 flush),再截断 jsonl
178
+ try { tempQuery.close(); } catch { /* */ }
179
+ try { inputQueue.close(); } catch { /* */ }
180
+ if (drain) { try { await drain; } catch { /* */ } }
181
+ tempQuery = null; drain = null;
182
+ let removed = null;
183
+ if (wantTruncate) {
184
+ await new Promise((r) => setTimeout(r, 300));
185
+ removed = await truncateJsonlAtMessage(sessionRoot, sid, userMessageId);
186
+ }
187
+ const payload = { ...result, conversationTruncated: removed != null, removedEntries: removed ?? 0 };
188
+ emitRewindFiles(pid, sid, payload);
189
+ res.json(payload);
190
+ } catch (err) {
191
+ console.warn(`[sessions.rewind] temp query failed (sid=${sid.slice(0, 8)}): ${err.message}`);
192
+ res.status(500).json({ error: err.message, code: 'REWIND_FAILED' });
193
+ } finally {
194
+ try { tempQuery?.close(); } catch { /* ignore */ }
195
+ try { inputQueue.close(); } catch { /* ignore */ }
196
+ if (drain) { try { await drain; } catch { /* ignore */ } }
197
+ pendingRewinds.delete(sid);
198
+ }
199
+ } catch (err) { next(err); }
200
+ });
201
+ }
202
+
203
+ /**
204
+ * rewindFiles 成功后 emit run.file_changed 事件让前端 iframe 自动 reload。
205
+ * 复用现有 event 类型 —— ProjectWorkspace.jsx 已 case 它(仅 .html 后缀 bump reloadToken),
206
+ * 0 前端事件代码改动。
207
+ */
208
+ function emitRewindFiles(pid, sid, result) {
209
+ if (!result?.canRewind || !Array.isArray(result.filesChanged) || !result.filesChanged.length) return;
210
+ const bus = getProjectBus(pid);
211
+ for (const filePath of result.filesChanged) {
212
+ bus.publish({
213
+ type: 'run.file_changed',
214
+ filePath,
215
+ event: 'change',
216
+ sessionId: sid,
217
+ ts: new Date().toISOString(),
218
+ });
219
+ }
220
+ }
@@ -22,7 +22,6 @@ import {
22
22
  renameSession,
23
23
  tagSession,
24
24
  deleteSession,
25
- query,
26
25
  } from '@anthropic-ai/claude-agent-sdk';
27
26
  import { validateProjectId, getProject, setActiveSession } from '../projects/store.js';
28
27
  import { guardProject, modelUserFor } from './_guard.js';
@@ -41,21 +40,21 @@ import {
41
40
  import { withConfigDir } from '../lib/sdk-session.js';
42
41
  import { patchBoard } from '../projects/board-store.js';
43
42
  import { platform } from '../runtime/platform.js';
44
- import { AsyncQueue } from '../lib/async-queue.js';
45
43
  import { getProjectBus } from '../ws/broker.js';
46
44
  import { getLastContextUsage } from '../engine/runs/live-turn.js';
47
45
  import { Events } from '../engine/agent/events.js';
48
- import { resolveSessionModel, applySessionModel } from '../engine/agent/session-model.js';
49
- import { selectableModelsFor, allowedModelsFor, isModelLockedFor, defaultModelFor, crossLaneSwitchReason } from '../engine/agent/model-context.js';
46
+ import { resolveSessionModel, applySessionModel, defaultModel } from '../engine/agent/session-model.js';
47
+ import { selectableModelsFor, allowedModelsFor, isModelLockedFor, defaultModelFor, modelSwitchRejection } from '../engine/agent/model-context.js';
50
48
 
51
- /**
52
- * 进行中的 rewind 操作 sid 集合 —— 供 turn.js startNewRunSession 守卫使用,
53
- * 防止同 sid 临时 rewind query 跟 normal turn query 同时启动撞 jsonl
54
- */
55
- export const pendingRewinds = new Set();
49
+
50
+ import { mountRewindRoute } from './sessions-rewind.js';
51
+ import { jsonlExistsForSession, truncateJsonlAtLastUserMessage } from '../projects/session-jsonl.js';
56
52
 
57
53
  const router = express.Router();
58
54
 
55
+ // 「回到某条消息之前」整块住在 sessions-rewind.js(行数棘轮,2026-08-30 拆出)
56
+ mountRewindRoute(router);
57
+
59
58
  // SDK session API 需要 CLAUDE_CONFIG_DIR 指向 JSONL 实际存储的全局目录
60
59
  // 来自 runtime/platform.js(跨平台决策单一来源)
61
60
  const GLOBAL_CLAUDE_CONFIG_DIR = platform.claudeConfigDir;
@@ -244,12 +243,23 @@ router.put('/:pid/sessions/:sid/model', async (req, res, next) => {
244
243
 
245
244
  await ensureSessionWorkspace(req.params.pid, req.params.sid);
246
245
  const metaDir = getSessionMetaDir(req.params.pid, req.params.sid);
246
+ // ⛔ 这条闸 08-25 之前是**死守卫**(08-21 装的时候就写错了位置):它写在 applySessionModel 之后,
247
+ // 又拿 apply **之后**读回来的 currentModel 去比 —— 那就是 raw 跟它自己比,crossLaneSwitchReason
248
+ // 的第一行 `fromModel === toModel` 直接返回 null,一次都没拦住过;而且就算能拦,文件已经写了、
249
+ // 空闲的 query 也已经被 applySessionModel 关掉重启了,409 只是句马后炮。
250
+ // 现在:**apply 之前**读、拿 apply 之前的有效模型比。`raw === null`(清覆盖回默认)同样要判 ——
251
+ // 从 Ox 会话清回订阅默认,是同一个病。
252
+ const before = await resolveSessionModel(metaDir);
253
+ const target = raw ?? defaultModel();
254
+ // 只对**跑过的会话**拦:这条闸防的是历史里那些没有 signature 的 thinking 块被回传给真 Anthropic
255
+ // (08-21 fable P3)。还没跑过的会话没有历史,拦它只会让人换不了模型。
256
+ const why = modelSwitchRejection({
257
+ from: before.model, to: target,
258
+ hasHistory: await jsonlExistsForSession(getSessionWorkspace(req.params.pid, req.params.sid), req.params.sid),
259
+ });
260
+ if (why) return res.status(409).json({ error: why, code: 'LANE_SWITCH' });
247
261
  const result = await applySessionModel(req.params.sid, metaDir, raw, 'picker');
248
- const { fallback, model: currentModel } = await resolveSessionModel(metaDir);
249
- if (raw !== null) {
250
- const why = crossLaneSwitchReason(currentModel, raw);
251
- if (why) return res.status(409).json({ error: why, code: 'LANE_SWITCH' });
252
- }
262
+ const { fallback } = await resolveSessionModel(metaDir);
253
263
  res.json({
254
264
  model: result.model,
255
265
  override: result.override,
@@ -272,9 +282,25 @@ router.post('/:pid/sessions/:sid/fork', async (req, res, next) => {
272
282
  const srcSessionRoot = getSessionWorkspace(req.params.pid, srcSid);
273
283
  const { upToMessageId, title } = req.body || {};
274
284
 
285
+ // 标题:不传就自己拼一个(08-30)。SDK 会把源会话的标题原样复制过来,于是
286
+ // 会话列表里两条同名,用户分不出哪条是刚分出来的。带 upToMessageId 的入口
287
+ // (气泡上的「从这里分叉」)拿不到标题也不该为它穿三层 props,所以在这拼。
288
+ let finalTitle = title;
289
+ if (!finalTitle && upToMessageId) {
290
+ const srcInfo = await withConfigDir(GLOBAL_CLAUDE_CONFIG_DIR, () =>
291
+ getSessionInfo(srcSid, { dir: srcSessionRoot }),
292
+ ).catch(() => null);
293
+ const base = srcInfo?.customTitle || srcInfo?.summary || '';
294
+ finalTitle = base ? `${base} · 分支` : '分支';
295
+ }
296
+
275
297
  // 1. SDK fork —— 在 GLOBAL_CLAUDE_CONFIG_DIR 下生成新 sid 的 jsonl
298
+ //
299
+ // ⚠️ 带 upToMessageId 时**不在这里设标题**:下面第 4 步的补刀是前缀截断,而
300
+ // forkSession 写的 custom-title 行排在最后那条用户消息之后,会被一起砍掉
301
+ // (探针实测:9 行 fork 补刀删 3 行,标题那行在里面)。所以补完刀再设。
276
302
  const result = await withConfigDir(GLOBAL_CLAUDE_CONFIG_DIR, () =>
277
- forkSession(srcSid, { dir: srcSessionRoot, upToMessageId, title }),
303
+ forkSession(srcSid, { dir: srcSessionRoot, upToMessageId, title: upToMessageId ? undefined : finalTitle }),
278
304
  );
279
305
  const newSid = result.sessionId;
280
306
  validateSessionId(newSid);
@@ -315,6 +341,23 @@ router.post('/:pid/sessions/:sid/fork', async (req, res, next) => {
315
341
  }
316
342
  }
317
343
 
344
+ // 4. 「到这条为止」在我们这儿是**不含**那条(08-30)。
345
+ //
346
+ // SDK 的 upToMessageId 是含的(探针实测),而这个接口的调用方是「从这条消息
347
+ // 分叉」—— 用户点的是自己那句想重说的话,含着它 fork 出来等于什么都没改。
348
+ // uuid 被 fork 重映射过,拿原 id 截不到,所以按"最后一条真用户消息"下刀。
349
+ // 不传 upToMessageId 的整条 fork(会话列表那个入口)不受影响。
350
+ if (upToMessageId) {
351
+ await truncateJsonlAtLastUserMessage(newSessionRoot, newSid);
352
+ // 标题在补刀之后才落得住(见上)。设不上不算失败 —— 分支本身已经好了,
353
+ // 列表里退回摘要显示而已。
354
+ if (finalTitle) {
355
+ await withConfigDir(GLOBAL_CLAUDE_CONFIG_DIR, () =>
356
+ renameSession(newSid, finalTitle, { dir: newSessionRoot }),
357
+ ).catch((err) => console.warn(`[fork] 设分支标题失败:${err.message}`));
358
+ }
359
+ }
360
+
318
361
  res.json({ sessionId: newSid });
319
362
  } catch (err) { next(err); }
320
363
  });
@@ -407,183 +450,4 @@ router.delete('/:pid/sessions/:sid', async (req, res, next) => {
407
450
  });
408
451
 
409
452
 
410
- // ── POST /:pid/sessions/:sid/rewind ──
411
- // SDK Query.rewindFiles(userMessageId) 控制方法 —— 把 session 内文件回滚到该
412
- // user message 之前的状态(后续 Edit/Write 全撤销)。SDK file checkpoint 写在
413
- // session jsonl(type='file-history-snapshot'),跨进程持久化天然搞定。
414
- //
415
- // 两条路径:
416
- // 1. active query 在跑 → 直接用现有 query.rewindFiles(最快,无 spawn 成本)
417
- // 2. session 已 close(历史 session)→ 起临时 query (resume + drain) → rewindFiles → close
418
- //
419
- // 历史 session 也能 undo —— 之前返 410 是应用层偷懒,SDK 完全支持 resume + rewindFiles。
420
- //
421
- // body: { userMessageId }
422
- // 200 { canRewind, filesChanged?, insertions?, deletions? }
423
- // 404 { code: 'JSONL_MISSING' } jsonl 不存在(session 删了 / 部分创建)
424
- // 409 { code: 'REWIND_BUSY' } 同 sid 已有 rewind 进行中
425
- // 500 { code: 'REWIND_FAILED' } 临时 query 启动 / rewindFiles 失败
426
- router.post('/:pid/sessions/:sid/rewind', async (req, res, next) => {
427
- try {
428
- validateSessionId(req.params.sid);
429
- const project = guardProject(req, res);
430
- if (!project) return;
431
-
432
- const { userMessageId } = req.body || {};
433
- if (!userMessageId || typeof userMessageId !== 'string') {
434
- return res.status(400).json({ error: 'userMessageId required' });
435
- }
436
-
437
- const { pid, sid } = req.params;
438
-
439
- // 路径 1:active query 在跑 —— 直接用现有 query
440
- const rec = getQuerySession(sid);
441
- if (rec?.query && !rec.abortController.signal.aborted) {
442
- const result = await rec.query.rewindFiles(userMessageId);
443
- // 对话层同步回滚(2026-08-08「做完整」):rewindFiles 只回文件。显示与模型
444
- // 记忆读的都是这份 jsonl —— 关掉活口 query(下条消息从截断后的 jsonl resume,
445
- // 记忆才真的回退),等 SDK flush 后把 jsonl 截到该 user 消息之前。
446
- try { closeQuerySession(sid, 'rewind_truncate'); } catch { /* */ }
447
- await new Promise((r) => setTimeout(r, 800));
448
- const removed = await truncateJsonlAtMessage(getSessionWorkspace(pid, sid), sid, userMessageId);
449
- const payload = { ...result, conversationTruncated: removed != null, removedEntries: removed ?? 0 };
450
- emitRewindFiles(pid, sid, payload);
451
- return res.json(payload);
452
- }
453
-
454
- // race guard:active session 存在但 query handle 未 attach(session 启动中
455
- // 的窄 race window — registerQuerySession 已 set Map 但 attachSessionQuery
456
- // 还没赋值 query 字段)→ 拒 409 让用户重试。如果直接 fallthrough 进路径 2
457
- // 起临时 query,两个 SDK binary 会同时 attach 同一 jsonl 文件 → 错乱不可恢复。
458
- if (hasActiveQuerySession(sid) && rec && !rec.abortController.signal.aborted) {
459
- return res.status(409).json({
460
- error: 'session is starting (query handle not yet attached), retry shortly',
461
- code: 'SESSION_STARTING',
462
- });
463
- }
464
-
465
- // 路径 2:起临时 query resume → rewindFiles → close
466
- if (pendingRewinds.has(sid)) {
467
- return res.status(409).json({ error: 'rewind in progress', code: 'REWIND_BUSY' });
468
- }
469
- const sessionRoot = getSessionWorkspace(pid, sid);
470
- if (!await jsonlExistsForSession(sessionRoot, sid)) {
471
- return res.status(404).json({ error: 'session jsonl not found', code: 'JSONL_MISSING' });
472
- }
473
-
474
- pendingRewinds.add(sid);
475
- const inputQueue = new AsyncQueue();
476
- let tempQuery = null;
477
- let drain = null;
478
- try {
479
- tempQuery = query({
480
- prompt: inputQueue,
481
- options: {
482
- resume: sid,
483
- enableFileCheckpointing: true,
484
- cwd: sessionRoot,
485
- // 关键:跟 runSession 一致传 CLAUDE_CONFIG_DIR,否则 SDK 找不到 jsonl
486
- env: { ...process.env, CLAUDE_CONFIG_DIR: GLOBAL_CLAUDE_CONFIG_DIR },
487
- persistSession: true,
488
- // 不传 hooks / mcpServers / agents / canUseTool —— 临时 query 不跑 turn
489
- },
490
- });
491
- // fire-and-forget consume —— SDK control method 走 bidirectional protocol,
492
- // stream 不消费会卡死 control RPC。drain 跑在后台,close 后自然结束。
493
- drain = (async () => {
494
- try { for await (const _ of tempQuery) { /* discard */ } }
495
- catch { /* expected on close */ }
496
- })();
497
- // 15s timeout(SDK boot + jsonl load + control RPC ~3-5s 正常 → 3× margin)
498
- const result = await Promise.race([
499
- tempQuery.rewindFiles(userMessageId),
500
- new Promise((_, rj) => setTimeout(() => rj(new Error('rewind timeout')), 15000)),
501
- ]);
502
- // 对话层回滚:先收干净临时 query(文件句柄/尾部 flush),再截断 jsonl
503
- try { tempQuery.close(); } catch { /* */ }
504
- try { inputQueue.close(); } catch { /* */ }
505
- if (drain) { try { await drain; } catch { /* */ } }
506
- tempQuery = null; drain = null;
507
- await new Promise((r) => setTimeout(r, 300));
508
- const removed = await truncateJsonlAtMessage(sessionRoot, sid, userMessageId);
509
- const payload = { ...result, conversationTruncated: removed != null, removedEntries: removed ?? 0 };
510
- emitRewindFiles(pid, sid, payload);
511
- res.json(payload);
512
- } catch (err) {
513
- console.warn(`[sessions.rewind] temp query failed (sid=${sid.slice(0, 8)}): ${err.message}`);
514
- res.status(500).json({ error: err.message, code: 'REWIND_FAILED' });
515
- } finally {
516
- try { tempQuery?.close(); } catch { /* ignore */ }
517
- try { inputQueue.close(); } catch { /* ignore */ }
518
- if (drain) { try { await drain; } catch { /* ignore */ } }
519
- pendingRewinds.delete(sid);
520
- }
521
- } catch (err) { next(err); }
522
- });
523
-
524
- /**
525
- * 检查 SDK jsonl 是否存在 —— 历史 session 在 ~/.claude/projects/<encoded-cwd>/<sid>.jsonl。
526
- * encodeCwdForSDK + GLOBAL_CLAUDE_CONFIG_DIR 都已在文件顶部定义。
527
- */
528
- /**
529
- * 对话层回滚(2026-08-08):把 jsonl 截断到 userMessageId 那条之前(含它与其后全部)。
530
- * jsonl 是追加式日志,截到 prefix = 它历史上真实存在过的状态,resume 天然自洽;
531
- * 之后的 file-history-snapshot 属于被撤销的编辑,一并丢弃是正确语义。
532
- * 原子写(tmp+rename)。找不到该 uuid 返 null(fail-soft:文件回滚仍算成功)。
533
- */
534
- async function truncateJsonlAtMessage(sessionRoot, sid, userMessageId) {
535
- const jsonlPath = path.join(GLOBAL_CLAUDE_CONFIG_DIR, 'projects', encodeCwdForSDK(sessionRoot), `${sid}.jsonl`);
536
- try {
537
- const raw = await fs.readFile(jsonlPath, 'utf8');
538
- const lines = raw.split('\n');
539
- let cut = -1;
540
- for (let i = 0; i < lines.length; i++) {
541
- if (!lines[i] || !lines[i].includes(userMessageId)) continue;
542
- try { if (JSON.parse(lines[i]).uuid === userMessageId) { cut = i; break; } } catch { /* 非 JSON 行跳过 */ }
543
- }
544
- if (cut < 0) {
545
- console.warn(`[sessions.rewind] uuid ${userMessageId.slice(0, 8)} 不在 jsonl 里,跳过对话截断`);
546
- return null;
547
- }
548
- const kept = lines.slice(0, cut).join('\n');
549
- const tmp = `${jsonlPath}.tmp-rewind`;
550
- await fs.writeFile(tmp, kept ? `${kept}\n` : '');
551
- await fs.rename(tmp, jsonlPath);
552
- return lines.filter(Boolean).length - lines.slice(0, cut).filter(Boolean).length;
553
- } catch (err) {
554
- console.warn(`[sessions.rewind] jsonl 截断失败(不影响文件回滚):${err.message}`);
555
- return null;
556
- }
557
- }
558
-
559
- async function jsonlExistsForSession(sessionRoot, sid) {
560
- const encoded = encodeCwdForSDK(sessionRoot);
561
- const jsonlPath = path.join(GLOBAL_CLAUDE_CONFIG_DIR, 'projects', encoded, `${sid}.jsonl`);
562
- try {
563
- await fs.access(jsonlPath);
564
- return true;
565
- } catch {
566
- return false;
567
- }
568
- }
569
-
570
- /**
571
- * rewindFiles 成功后 emit run.file_changed 事件让前端 iframe 自动 reload。
572
- * 复用现有 event 类型 —— ProjectWorkspace.jsx 已 case 它(仅 .html 后缀 bump reloadToken),
573
- * 0 前端事件代码改动。
574
- */
575
- function emitRewindFiles(pid, sid, result) {
576
- if (!result?.canRewind || !Array.isArray(result.filesChanged) || !result.filesChanged.length) return;
577
- const bus = getProjectBus(pid);
578
- for (const filePath of result.filesChanged) {
579
- bus.publish({
580
- type: 'run.file_changed',
581
- filePath,
582
- event: 'change',
583
- sessionId: sid,
584
- ts: new Date().toISOString(),
585
- });
586
- }
587
- }
588
-
589
453
  export default router;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * server/api/stage.js —— 用户在画布上对角色说话时,把这句话落在画布上(2026-08-29)
3
+ *
4
+ * 前身是 api/roles.js(名册 + 直投角色的收件箱)。收件箱 08-29 整族退役:角色不再
5
+ * 挂着等人说话,用户的话一律经主持人转交(它本来就要在这一拍写场面、排下一步)。
6
+ * 剩下的这一件事跟收件箱无关,是**画布对话的完整性** —— 用户从画布说的话得在画布上
7
+ * 留下痕迹,否则那条对话线上只有角色单方面的半边声道。
8
+ *
9
+ * 落痕先于转交:前端拿到这条的 rel 之后再把话发给主持人,主持人接得上线。
10
+ */
11
+
12
+ import express from 'express';
13
+ import { guardProject } from './_guard.js';
14
+ import { echoUserChalk } from '../engine/runs/user-chalk-echo.js';
15
+ import { getProjectBus } from '../ws/broker.js';
16
+
17
+ const router = express.Router();
18
+
19
+ router.post('/:pid/stage/echo', express.json({ limit: '64kb' }), async (req, res, next) => {
20
+ try {
21
+ if (!guardProject(req, res)) return;
22
+ const text = String(req.body?.text || '').trim();
23
+ if (!text) return res.status(400).json({ error: 'text required' });
24
+ if (text.length > 8000) return res.status(400).json({ error: 'text too long' });
25
+ const anchor = typeof req.body?.anchor === 'string' ? req.body.anchor : null;
26
+
27
+ const e = await echoUserChalk(req.params.pid, { text, anchor });
28
+ if (e.seated) {
29
+ getProjectBus(req.params.pid).publish({ type: 'board.updated', sessionId: null, summary: '你的话落在板上了' });
30
+ }
31
+ res.json({ ok: true, echo: e.rel });
32
+ } catch (err) { next(err); }
33
+ });
34
+
35
+ export default router;
@@ -0,0 +1,109 @@
1
+ import { guardProject, guardRunInProject, modelUserFor } from './_guard.js';
2
+ import { getQuery, getRun, getSessionIdByRunId } from '../engine/runs/active-runs.js';
3
+ import { ensureSessionWorkspace, getSessionMetaDir } from '../projects/workspace.js';
4
+ import { applySessionModel, resolveSessionModel, defaultModel } from '../engine/agent/session-model.js';
5
+ import { registerIngressSession } from '../lib/model-ingress.js';
6
+ import { allowedModelsFor, isModelLockedFor, resolveSdkSpoofModel, modelSwitchRejection, resolveModelRoute } from '../engine/agent/model-context.js';
7
+ import { msg } from '../shared/messages.js';
8
+
9
+ /**
10
+ * server/api/turn-model-switch.js — 运行中热切模型(08-25 从 turn.js 拆出,那边顶在 600 行棘轮上)。
11
+ *
12
+ * 只有这一个端点,但它自己就是一件完整的事:**turn 跑到一半换模型**,而 env(网关地址、钥匙)
13
+ * 在起 query 那一刻就定死了、SDK 的 setModel 改不动 —— 所以这里的闸比"选模型"那两条路多两道,
14
+ * 见 model-context.js 的 crossLaneSwitchReason(协议)与 hotSwitchLaneReason(通路)。
15
+ */
16
+ /**
17
+ * POST /api/projects/:pid/runs/:runId/model
18
+ *
19
+ * 运行中切 model(SDK query.setModel,当场对下一次 LLM 调用生效)。
20
+ *
21
+ * **目前只有 API 能到这里**:前端 picker 在 turn 运行中是禁用的,它走
22
+ * PUT /sessions/:sid/model(那条等空闲才重启 query)。这条留着是因为"turn 跑到
23
+ * 一半换模型"是它独有的能力,PUT 那条按设计做不到。前端那个没人调的 Turn.setModel
24
+ * 绑定已删(doc 里还写着 kimi 时代的 model 名,留着只会误导下一个人)。
25
+ *
26
+ * 2026-07-30:切完**必须同时落 session-config**。原来这条只改运行时不写文件,
27
+ * 于是"当前这轮是 Opus、下次 resume 变回 Sonnet",而且界面无从得知;跟另外两条
28
+ * 写模型的路加起来,同一个事实有三个互不知情的写者。现在统一走 applySessionModel,
29
+ * 它自己会判断要不要重启空闲 query(这里 query 正在跑,不会重启)。
30
+ *
31
+ * Body: { model: string | null } - null = 清掉覆盖回到全局默认
32
+ */
33
+ export async function hotSwitchModelHandler(req, res, next) {
34
+ try {
35
+ const project = guardProject(req, res);
36
+ if (!project) return;
37
+ if (!guardRunInProject(req, res)) return;
38
+
39
+ const { runId } = req.params;
40
+ const { model } = req.body || {};
41
+ if (model !== null && model !== undefined && typeof model !== 'string') {
42
+ return res.status(400).json({ error: 'model must be string or null' });
43
+ }
44
+
45
+ // ⭐ 清覆盖(model=null)也要按"切到全局默认那一行"来判、来切。以前这条直接 setModel(undefined):
46
+ // API 会话里等于让 SDK 用它自己的默认 Claude 名发请求,入口反查不到就兜底到本会话的 **helper 行** ——
47
+ // 表现是"点了『默认』,这一轮偷偷降级成 helper 模型",而落盘写的是全局默认,运行时和文件对不上。
48
+ const wanted = typeof model === 'string' ? model.trim() : '';
49
+ const target = wanted || defaultModel();
50
+
51
+ // 08-21:热切路以前不过白名单(任意字符串直达 SDK)。与 PUT /sessions/:sid/model 同口径
52
+ if (wanted) {
53
+ const modelUser = modelUserFor(req, project); // 资格按项目 owner 算(_guard.js)
54
+ if (isModelLockedFor(modelUser, wanted)) {
55
+ return res.status(403).json({ error: msg(req, '这个模型仅限 Pro 档,暂未对外开放'), code: 'MODEL_LOCKED', model: wanted });
56
+ }
57
+ if (!allowedModelsFor(modelUser).some((m) => m.id === wanted)) {
58
+ return res.status(400).json({ error: `unknown model: ${model}`, code: 'UNKNOWN_MODEL' });
59
+ }
60
+ }
61
+ const sidForLane = getSessionIdByRunId(runId);
62
+ if (sidForLane) {
63
+ const cur = await resolveSessionModel(getSessionMetaDir(project.id, sidForLane));
64
+ // running:true = 除了协议闸还要过通路闸(env 在起 query 那一刻定死,跨通路热切会拿订阅额度去跑 API 模型)。
65
+ // 这里的会话必然跑过(getQuery 拿得到活的 run),hasHistory 用默认的 true
66
+ const why = modelSwitchRejection({ from: cur.model, to: target, running: true });
67
+ if (why) return res.status(409).json({ error: why, code: 'LANE_SWITCH' });
68
+ }
69
+
70
+ const query = getQuery(runId);
71
+ if (!query) {
72
+ return res.status(404).json({
73
+ error: 'run not active',
74
+ code: 'RUN_NOT_ACTIVE',
75
+ });
76
+ }
77
+ if (typeof query.setModel !== 'function') {
78
+ return res.status(501).json({
79
+ error: 'SDK query handle missing setModel method',
80
+ code: 'METHOD_NOT_AVAILABLE',
81
+ });
82
+ }
83
+
84
+ const sid = getSessionIdByRunId(runId);
85
+ // API 行喂 SDK 的是 spoof 名(session-loop 同款),appModel 本身 binary 不认识
86
+ await query.setModel(resolveSdkSpoofModel(target));
87
+ // 运行时切完再落盘:setModel 失败就不该留下"配置说切了"的假象
88
+ let persisted = null;
89
+ if (sid) {
90
+ await ensureSessionWorkspace(project.id, sid);
91
+ persisted = await applySessionModel(sid, getSessionMetaDir(project.id, sid), model ?? null, 'runtime');
92
+ // ⭐ 入口的会话路由是**起 query 时**按当时的模型注册的(session-loop.js 的 registerIngressSession),
93
+ // 热切必须跟着改注册,否则切过去的请求按旧注册判:独占别名算 collision、共用别名算 fallback,
94
+ // 两种都改道回旧会话的 fast 行 —— 表现是"切了但这一轮没生效",而且 fallback 每个名字只记一次日志,
95
+ // 事后连线索都不容易翻到。上面的闸保证了这里两边同通路,所以只可能 api→api。
96
+ const route = resolveModelRoute(persisted?.model || target);
97
+ if (route.mode === 'api') {
98
+ registerIngressSession(sid, route.appModel);
99
+ // 记账那一侧也得跟着改:AgentContext.appModel 是起 query 时定死的,而 repriceUsageDeltas 按它
100
+ // 做「会话优先」归属(共用别名根本不在全表反查里,纠不回来)—— 不更新的话热切之后主行那笔账
101
+ // 会一直记在**切之前**那一行头上。⚠️ 只精确到下一次 absorb:本次差分窗口里切换前后的 usage
102
+ // 混在一起,会整体归到新行;这比"整轮全记错行"好,但不是零误差。
103
+ const run = getRun(runId);
104
+ if (run?.ctx) run.ctx.appModel = route.appModel;
105
+ }
106
+ }
107
+ res.json({ ok: true, model: persisted?.model ?? model, override: persisted?.override ?? null });
108
+ } catch (err) { next(err); }
109
+ }