@xiaobuyu/nodesign 0.0.1

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 (372) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +140 -0
  3. package/bin/nodesign.js +176 -0
  4. package/package.json +104 -0
  5. package/server/api/README.md +45 -0
  6. package/server/api/_guard.js +75 -0
  7. package/server/api/admin.js +187 -0
  8. package/server/api/assets/docx-page.js +103 -0
  9. package/server/api/assets/helpers.js +22 -0
  10. package/server/api/assets/notes.js +144 -0
  11. package/server/api/assets.js +896 -0
  12. package/server/api/board.js +57 -0
  13. package/server/api/browse.js +138 -0
  14. package/server/api/canvas.js +444 -0
  15. package/server/api/chatai.js +324 -0
  16. package/server/api/exports/build-standalone.js +980 -0
  17. package/server/api/exports/cards.js +118 -0
  18. package/server/api/exports/docx-pdf.js +46 -0
  19. package/server/api/exports/export-page.js +142 -0
  20. package/server/api/exports/handoff.js +147 -0
  21. package/server/api/exports.js +690 -0
  22. package/server/api/instruction.js +75 -0
  23. package/server/api/local.js +115 -0
  24. package/server/api/me.js +116 -0
  25. package/server/api/memory.js +185 -0
  26. package/server/api/pending-changes.js +366 -0
  27. package/server/api/plugins.js +164 -0
  28. package/server/api/projects.js +220 -0
  29. package/server/api/publish.js +64 -0
  30. package/server/api/recent.js +73 -0
  31. package/server/api/sessions.js +589 -0
  32. package/server/api/skills.js +77 -0
  33. package/server/api/standalone-fit.js +168 -0
  34. package/server/api/turn-compose.js +180 -0
  35. package/server/api/turn-inflight.js +44 -0
  36. package/server/api/turn.js +669 -0
  37. package/server/auth/README.md +17 -0
  38. package/server/auth/middleware.js +138 -0
  39. package/server/auth/origin-guard.js +71 -0
  40. package/server/auth/session.js +108 -0
  41. package/server/auth/tier.js +113 -0
  42. package/server/auth/users-store.js +348 -0
  43. package/server/edit/README.md +29 -0
  44. package/server/engine/README.md +113 -0
  45. package/server/engine/agent/agent-shared.js +550 -0
  46. package/server/engine/agent/auto-mode-default-hard-deny.txt +4 -0
  47. package/server/engine/agent/auto-mode-rules.js +78 -0
  48. package/server/engine/agent/context.js +418 -0
  49. package/server/engine/agent/events.js +464 -0
  50. package/server/engine/agent/hooks/canvas-validate.js +199 -0
  51. package/server/engine/agent/hooks/failure.js +120 -0
  52. package/server/engine/agent/hooks/file-events.js +75 -0
  53. package/server/engine/agent/hooks/lifecycle.js +233 -0
  54. package/server/engine/agent/hooks/post-canvas-focus.js +80 -0
  55. package/server/engine/agent/hooks/post-guidance.js +206 -0
  56. package/server/engine/agent/hooks/post-subagent-report.js +104 -0
  57. package/server/engine/agent/hooks/post-trim.js +58 -0
  58. package/server/engine/agent/hooks/pre-board-neighborhood.js +34 -0
  59. package/server/engine/agent/hooks/pre-defaults.js +84 -0
  60. package/server/engine/agent/hooks/pre-injectors.js +277 -0
  61. package/server/engine/agent/hooks/pre-performance-log-guard.js +78 -0
  62. package/server/engine/agent/hooks/pre-starter-files.js +61 -0
  63. package/server/engine/agent/hooks/pre-workspace-scope-guard.js +78 -0
  64. package/server/engine/agent/hooks/site-validate.js +94 -0
  65. package/server/engine/agent/hooks/tool-prompts.js +31 -0
  66. package/server/engine/agent/hooks/turn-state-memory.js +56 -0
  67. package/server/engine/agent/hooks/user-prompt-submit.js +215 -0
  68. package/server/engine/agent/hooks.js +354 -0
  69. package/server/engine/agent/init-contract.js +69 -0
  70. package/server/engine/agent/isolation.js +159 -0
  71. package/server/engine/agent/model-context.js +359 -0
  72. package/server/engine/agent/model-table.js +292 -0
  73. package/server/engine/agent/plugin-loader.js +266 -0
  74. package/server/engine/agent/prompts/nodesign-prelude.md +336 -0
  75. package/server/engine/agent/prompts/tools/ask-user-question-protocol.md +71 -0
  76. package/server/engine/agent/prompts/tools/direct-edit-protocol.md +98 -0
  77. package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +583 -0
  78. package/server/engine/agent/prompts/tools/generate-image-cookbook.md +483 -0
  79. package/server/engine/agent/prompts/tools/hybrid-reference.md +100 -0
  80. package/server/engine/agent/prompts/tools/paint-still-cookbook.md +270 -0
  81. package/server/engine/agent/prompts/tools/roll-film-cookbook.md +70 -0
  82. package/server/engine/agent/prompts/tools/site-reference.md +106 -0
  83. package/server/engine/agent/prompts/tools/tweaks-syntax.md +125 -0
  84. package/server/engine/agent/prompts/tools/vision-checker-dispatch.md +48 -0
  85. package/server/engine/agent/session-loop.js +1024 -0
  86. package/server/engine/agent/session-model.js +164 -0
  87. package/server/engine/agent/skill.js +204 -0
  88. package/server/engine/agent/system-prompts.js +86 -0
  89. package/server/engine/agent/task-events.js +78 -0
  90. package/server/engine/agents/ds-extractor.md +132 -0
  91. package/server/engine/agents/explorer.md +190 -0
  92. package/server/engine/agents/index.js +230 -0
  93. package/server/engine/agents/schemas/design-system.json +156 -0
  94. package/server/engine/agents/schemas/tweak-schema.json +113 -0
  95. package/server/engine/agents/tweak-proposer.md +127 -0
  96. package/server/engine/agents/vision-checker.md +254 -0
  97. package/server/engine/browse/capture.js +422 -0
  98. package/server/engine/browse/card.js +137 -0
  99. package/server/engine/browse/handover.js +59 -0
  100. package/server/engine/browse/page-digest.js +119 -0
  101. package/server/engine/browse/refs.js +196 -0
  102. package/server/engine/browse/registry.js +307 -0
  103. package/server/engine/browse/screencast.js +170 -0
  104. package/server/engine/browse/state.js +121 -0
  105. package/server/engine/chatai/chat-log.js +79 -0
  106. package/server/engine/chatai/index.js +193 -0
  107. package/server/engine/chatai/openai-compat.js +152 -0
  108. package/server/engine/chatai/orchestrate.js +309 -0
  109. package/server/engine/chatai/perform.js +40 -0
  110. package/server/engine/chatai/summarize.js +79 -0
  111. package/server/engine/mcp/capability-gate.js +52 -0
  112. package/server/engine/mcp/index.js +339 -0
  113. package/server/engine/mcp/param-sanitizer.js +142 -0
  114. package/server/engine/mcp/tools/arrange-on-board.js +124 -0
  115. package/server/engine/mcp/tools/artifact-session.js +278 -0
  116. package/server/engine/mcp/tools/browse-computer.js +421 -0
  117. package/server/engine/mcp/tools/browse-find-batch.js +183 -0
  118. package/server/engine/mcp/tools/browse-screenshot.js +157 -0
  119. package/server/engine/mcp/tools/browse.js +480 -0
  120. package/server/engine/mcp/tools/build-docx.js +101 -0
  121. package/server/engine/mcp/tools/clear-pending-changes.js +99 -0
  122. package/server/engine/mcp/tools/create-on-board.js +155 -0
  123. package/server/engine/mcp/tools/crystallize-skill.js +168 -0
  124. package/server/engine/mcp/tools/deliver-files.js +0 -0
  125. package/server/engine/mcp/tools/explain-style.js +237 -0
  126. package/server/engine/mcp/tools/export-handoff.js +133 -0
  127. package/server/engine/mcp/tools/expose-tweaks.js +149 -0
  128. package/server/engine/mcp/tools/generate-image.js +842 -0
  129. package/server/engine/mcp/tools/get-computed-styles.js +164 -0
  130. package/server/engine/mcp/tools/get-pending-changes.js +205 -0
  131. package/server/engine/mcp/tools/h3box-ssh.js +84 -0
  132. package/server/engine/mcp/tools/helpers/acquire-page.js +82 -0
  133. package/server/engine/mcp/tools/helpers/motion-lab.js +554 -0
  134. package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -0
  135. package/server/engine/mcp/tools/helpers/perception-page.js +215 -0
  136. package/server/engine/mcp/tools/helpers/reference-download.js +103 -0
  137. package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -0
  138. package/server/engine/mcp/tools/helpers/rembg.js +273 -0
  139. package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -0
  140. package/server/engine/mcp/tools/highlight.js +68 -0
  141. package/server/engine/mcp/tools/list-pages.js +222 -0
  142. package/server/engine/mcp/tools/lookup-tags.js +59 -0
  143. package/server/engine/mcp/tools/navigate-to-page.js +60 -0
  144. package/server/engine/mcp/tools/organize-board.js +64 -0
  145. package/server/engine/mcp/tools/paint-still.js +434 -0
  146. package/server/engine/mcp/tools/pin-to-board.js +130 -0
  147. package/server/engine/mcp/tools/preview-deck.js +130 -0
  148. package/server/engine/mcp/tools/profile-scroll.js +305 -0
  149. package/server/engine/mcp/tools/publish-site.js +102 -0
  150. package/server/engine/mcp/tools/query-elements.js +195 -0
  151. package/server/engine/mcp/tools/read-board.js +103 -0
  152. package/server/engine/mcp/tools/read-document.js +93 -0
  153. package/server/engine/mcp/tools/read-page.js +264 -0
  154. package/server/engine/mcp/tools/read-tavern-json.js +143 -0
  155. package/server/engine/mcp/tools/record-decision.js +140 -0
  156. package/server/engine/mcp/tools/relate-on-board.js +110 -0
  157. package/server/engine/mcp/tools/remove-background.js +391 -0
  158. package/server/engine/mcp/tools/report-issue.js +115 -0
  159. package/server/engine/mcp/tools/roll-film.js +266 -0
  160. package/server/engine/mcp/tools/screenshot-docx.js +138 -0
  161. package/server/engine/mcp/tools/screenshot-url.js +195 -0
  162. package/server/engine/mcp/tools/screenshot.js +583 -0
  163. package/server/engine/mcp/tools/tier-gate.js +96 -0
  164. package/server/engine/mcp/tools/trace-motion.js +215 -0
  165. package/server/engine/mcp/tools/web-search.js +548 -0
  166. package/server/engine/motion/inventory.js +349 -0
  167. package/server/engine/perception/session.js +235 -0
  168. package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -0
  169. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +192 -0
  170. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -0
  171. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -0
  172. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -0
  173. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -0
  174. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -0
  175. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -0
  176. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -0
  177. package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +162 -0
  178. package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -0
  179. package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -0
  180. package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +228 -0
  181. 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 +78 -0
  182. package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -0
  183. package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +323 -0
  184. package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -0
  185. package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -0
  186. package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -0
  187. package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -0
  188. package/server/engine/runs/active-runs.js +698 -0
  189. package/server/engine/runs/live-turn.js +252 -0
  190. package/server/engine/runs/store.js +364 -0
  191. package/server/engine/runs/turn-relay.js +217 -0
  192. package/server/engine/runtime/workspace.js +164 -0
  193. package/server/index.js +235 -0
  194. package/server/lib/artifact-file-path.js +80 -0
  195. package/server/lib/artifact-target.js +284 -0
  196. package/server/lib/asset-refs.js +239 -0
  197. package/server/lib/async-queue.js +104 -0
  198. package/server/lib/auto-relations.js +130 -0
  199. package/server/lib/binding-types.js +60 -0
  200. package/server/lib/board-kind-sizes.js +62 -0
  201. package/server/lib/board-relations.js +133 -0
  202. package/server/lib/browse-proxy.js +180 -0
  203. package/server/lib/canvas-id.js +36 -0
  204. package/server/lib/cover.js +227 -0
  205. package/server/lib/danbooru-tags.js +291 -0
  206. package/server/lib/doc-extract.js +156 -0
  207. package/server/lib/docx/build-from-source.js +260 -0
  208. package/server/lib/docx/build.js +490 -0
  209. package/server/lib/docx/dump-styles.js +287 -0
  210. package/server/lib/docx/fonts/nodesign-cjk.conf +164 -0
  211. package/server/lib/docx/merge-runs.js +129 -0
  212. package/server/lib/docx/numbering.js +218 -0
  213. package/server/lib/docx/order.js +152 -0
  214. package/server/lib/docx/rawzip.js +172 -0
  215. package/server/lib/docx/render.js +92 -0
  216. package/server/lib/docx/text-lint.js +225 -0
  217. package/server/lib/docx/tokens.js +335 -0
  218. package/server/lib/docx/units.js +29 -0
  219. package/server/lib/docx/xml.js +291 -0
  220. package/server/lib/docx-pages.js +187 -0
  221. package/server/lib/export-collect.js +289 -0
  222. package/server/lib/export-package.js +210 -0
  223. package/server/lib/html-srcset.js +145 -0
  224. package/server/lib/image-variant.js +460 -0
  225. package/server/lib/ingress/forward-openai-chat.js +305 -0
  226. package/server/lib/ingress/openai-chat.js +491 -0
  227. package/server/lib/ingress/session-notice.js +66 -0
  228. package/server/lib/ingress/session-routes.js +68 -0
  229. package/server/lib/ingress/slot-probe.js +130 -0
  230. package/server/lib/ingress/upstream-billing.js +52 -0
  231. package/server/lib/ingress/upstream-fail-streak.js +72 -0
  232. package/server/lib/ingress/upstream-truncation.js +48 -0
  233. package/server/lib/issues-store.js +182 -0
  234. package/server/lib/kinds/deck.js +72 -0
  235. package/server/lib/kinds/docx.js +283 -0
  236. package/server/lib/kinds/file-kinds.js +113 -0
  237. package/server/lib/kinds/index.js +202 -0
  238. package/server/lib/kinds/site.js +161 -0
  239. package/server/lib/model-ingress.js +543 -0
  240. package/server/lib/moderation.js +255 -0
  241. package/server/lib/notice-store.js +103 -0
  242. package/server/lib/plugin-install.js +104 -0
  243. package/server/lib/plugin-validator.js +721 -0
  244. package/server/lib/publish-store.js +115 -0
  245. package/server/lib/quick-summary.js +52 -0
  246. package/server/lib/quota.js +270 -0
  247. package/server/lib/rate-window.js +29 -0
  248. package/server/lib/reference-assets.js +92 -0
  249. package/server/lib/region-shot.js +135 -0
  250. package/server/lib/safe-path.js +73 -0
  251. package/server/lib/sdk-session.js +33 -0
  252. package/server/lib/showcase-store.js +92 -0
  253. package/server/lib/site-publish.js +465 -0
  254. package/server/lib/ssrf-guard.js +432 -0
  255. package/server/lib/task-scan.js +158 -0
  256. package/server/lib/tavern-json.js +154 -0
  257. package/server/lib/video-variant.js +237 -0
  258. package/server/lib/workspace-path.js +33 -0
  259. package/server/ops/fix-sdk-musl.mjs +40 -0
  260. package/server/ops/install-macos-fonts.sh +114 -0
  261. package/server/ops/macos-fonts.conf +336 -0
  262. package/server/ops/sandbox-shim/bwrap +57 -0
  263. package/server/projects/assets-summary.js +119 -0
  264. package/server/projects/auto-name.js +53 -0
  265. package/server/projects/board-store.js +640 -0
  266. package/server/projects/move-entry.js +103 -0
  267. package/server/projects/store.js +270 -0
  268. package/server/projects/ui-config.js +54 -0
  269. package/server/projects/workspace-templates.js +53 -0
  270. package/server/projects/workspace.js +1025 -0
  271. package/server/runtime/capabilities.js +153 -0
  272. package/server/runtime/local-config.js +177 -0
  273. package/server/runtime/local-env.js +88 -0
  274. package/server/runtime/platform.js +342 -0
  275. package/server/runtime/profile.js +72 -0
  276. package/server/services/rembg-launcher.js +313 -0
  277. package/server/services/rembg-service.py +334 -0
  278. package/server/shared/README.md +25 -0
  279. package/server/shared/deck.js +54 -0
  280. package/server/shared/time.js +49 -0
  281. package/server/style-pipeline/README.md +53 -0
  282. package/server/ws/broker.js +36 -0
  283. package/server/ws/browse-channel.js +177 -0
  284. package/server/ws/index.js +386 -0
  285. package/web/README.md +89 -0
  286. package/web/dist/assets/BrowserWindow-Dc7J7CYc.js +11 -0
  287. package/web/dist/assets/DeckWindow-4G0xX7P3.js +16 -0
  288. package/web/dist/assets/DocxWindow-BXQsoPkh.js +16 -0
  289. package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  290. package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  291. package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  292. package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  293. package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  294. package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  295. package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  296. package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  297. package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  298. package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  299. package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  300. package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  301. package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  302. package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  303. package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  304. package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  305. package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  306. package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  307. package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  308. package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  309. package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  310. package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  311. package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  312. package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  313. package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  314. package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  315. package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  316. package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  317. package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  318. package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  319. package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  320. package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  321. package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  322. package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  323. package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  324. package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  325. package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  326. package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  327. package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  328. package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  329. package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  330. package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  331. package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  332. package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  333. package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  334. package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  335. package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  336. package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  337. package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  338. package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  339. package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  340. package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  341. package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  342. package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  343. package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  344. package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  345. package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  346. package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  347. package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  348. package/web/dist/assets/SiteWindow-D6eS-2gM.js +18 -0
  349. package/web/dist/assets/again-D8tfgKKO.webp +0 -0
  350. package/web/dist/assets/caveat-nd-CP6HlsNg.woff2 +0 -0
  351. package/web/dist/assets/clock-xJhvfYpS.webp +0 -0
  352. package/web/dist/assets/film-cut-BBKMwH7q.webp +0 -0
  353. package/web/dist/assets/film-still-BZ_DL9Tt.webp +0 -0
  354. package/web/dist/assets/index-C0KUf6Wd.css +1 -0
  355. package/web/dist/assets/index-Cbl3ATvp.js +2125 -0
  356. package/web/dist/assets/ink-desk-BRbxeDsR.webp +0 -0
  357. package/web/dist/assets/ink-night-Bz4BT3XH.webp +0 -0
  358. package/web/dist/assets/ink-portrait-C0DM4V8Q.webp +0 -0
  359. package/web/dist/assets/longcang-regular-C0uvTuKa.woff2 +0 -0
  360. package/web/dist/assets/lxgw-nd-bold-DbRfiTgo.woff2 +0 -0
  361. package/web/dist/assets/lxgw-nd-regular-CpMw9Ehg.woff2 +0 -0
  362. package/web/dist/assets/pending-edit-apply-DTjo2geW.js +31 -0
  363. package/web/dist/assets/question-8H4HSqxh.webp +0 -0
  364. package/web/dist/assets/reject-BaPJqElj.webp +0 -0
  365. package/web/dist/assets/rotate-cw-LMT3x1Zj.js +11 -0
  366. package/web/dist/assets/rp-portrait-CbrGpew2.webp +0 -0
  367. package/web/dist/assets/rp-street-C4jVKjxx.webp +0 -0
  368. package/web/dist/assets/square-dashed-mouse-pointer-CBoAkfhi.js +11 -0
  369. package/web/dist/assets/tangle-DIxHVNuT.webp +0 -0
  370. package/web/dist/assets/thisone-BylZKglV.webp +0 -0
  371. package/web/dist/assets/thumb-DO1wfK1I.webp +0 -0
  372. package/web/dist/index.html +18 -0
@@ -0,0 +1,1024 @@
1
+ /**
2
+ * server/engine/agent/session-loop.js — Long-running query session loop
3
+ *
4
+ * SDK streamInput 模式:一个 SDK Query 持续吃 user message 跨多 turn,conversation
5
+ * state 留在 SDK binary 内存里,**不依赖 jsonl resume**。这是 NoDesign 主代理唯一
6
+ * 入口(曾有 per-turn 的 loop.js runAgent,2026-05-03 后已彻底移除)。
7
+ *
8
+ * 解决 per-turn query 架构的两个痛点:
9
+ * 1. cancel 时 jsonl 残缺 → 下个 turn resume 失败丢上下文(streamInput 不 resume)
10
+ * 2. 用户在 agent 跑时无法追加消息(streamInput 排队天然支持)
11
+ *
12
+ * 设计要点:
13
+ * - 不接 brief / userContentBlocks —— 用 inputQueue(AsyncQueue)作 prompt source
14
+ * - 不接 resumeSessionId —— streamInput 模式下 SDK 自己保 conversation state
15
+ * - per-turn lifecycle 管理:result message = turn 边界,emit run.done 但 query 不退
16
+ * - cancel 走 query.interrupt() —— SDK 出 result with terminal_reason='aborted_*'
17
+ * → 当前 turn emit run.cancelled → 继续等下条 user message
18
+ * - close session:inputQueue.close() → for-await-of 自然退出 → finally 清理
19
+ *
20
+ * 共享 ctx 策略(妥协):
21
+ * 一个 sharedCtx 横跨多 turn,每个 turn 边界处覆盖 runId + 重置 counters。
22
+ * 这样 hooks / mcp 闭包持有的 ctx 引用稳定,emit 时 enrich 当前 turn runId。
23
+ * 非 thread-safe(SDK stream 串行处理 message,OK)。
24
+ */
25
+
26
+ import path from 'node:path';
27
+ import fs from 'node:fs/promises';
28
+ import { query } from '@anthropic-ai/claude-agent-sdk';
29
+
30
+ import { AgentContext, freshTurnCounters } from './context.js';
31
+ import { Events } from './events.js';
32
+ import { createRun, markRunStarted, markRunSucceeded, markRunFailed, mergeRunMetadata, setRunMetrics, setRunModelUsage } from '../runs/store.js';
33
+ import { getProject } from '../../projects/store.js';
34
+ import { randomUUID } from 'node:crypto';
35
+ import {
36
+ registerQuerySession,
37
+ attachSessionQuery,
38
+ unregisterQuerySession,
39
+ getCurrentTurnRunId,
40
+ setCurrentTurnRunId,
41
+ registerPendingQuestion,
42
+ registerPendingElicitation,
43
+ getSessionPermissionMode,
44
+ getSessionLastActivity,
45
+ closeQuerySession,
46
+ markSessionActivity,
47
+ } from '../runs/active-runs.js';
48
+ import {
49
+ promoteNextPendingRunId, claimRunByUuid, releaseCurrentTurnRunId, getPendingRunCount,
50
+ closeMergedRun, publishQueueDepth, pushUnclaimedMessage,
51
+ } from '../runs/turn-relay.js';
52
+ // skill 起手文件拷贝已挪 hooks.js PreToolUse(Skill/Bash)(2026-07-27),
53
+ // session-loop 不再直接依赖 skill.js;skillId 参数仅作兼容保留。
54
+ import { loadInstalledPlugins } from './plugin-loader.js';
55
+ import { createHooks } from './hooks.js';
56
+ import { buildIsolationOptions, prepareAgentDirs, sandboxShimEnv } from './isolation.js';
57
+ import { createNodesignMcpServer } from '../mcp/index.js';
58
+ import { assertInitContract } from './init-contract.js';
59
+ import { createAgents, resolveDefaultFastModel } from '../agents/index.js';
60
+ import { resolveSdkSpoofModel, pickThinkingConfig, resolveModelRoute, isUncensoredModel } from './model-context.js';
61
+ import { resolveSessionModel } from './session-model.js';
62
+ import { getOrStartIngress, registerIngressSession, unregisterIngressSession } from '../../lib/model-ingress.js';
63
+ import { takeUpstreamBilling } from '../../lib/ingress/upstream-billing.js';
64
+ import { takeUpstreamTruncation } from '../../lib/ingress/upstream-truncation.js';
65
+ import { registerSessionNotice, unregisterSessionNotice } from '../../lib/ingress/session-notice.js';
66
+ import { clampFirstClause } from '../../lib/quick-summary.js';
67
+ import { AsyncQueue } from '../../lib/async-queue.js';
68
+ import { platform } from '../../runtime/platform.js';
69
+ import { renderPrelude } from './system-prompts.js';
70
+ import {
71
+ DEFAULT_TOOL_ALLOWLIST,
72
+ STREAMING_ENABLED,
73
+ handleSDKMessage,
74
+ detectArtifact,
75
+ } from './agent-shared.js';
76
+ import { autoNameProjectFromSession } from '../../projects/auto-name.js';
77
+ // 合流并集(2026-08-13):commitWorkspace/taskManifest 是扁平化这边的,
78
+ // getUserById/levelFor 是 main 的每用户内容尺度旋钮(78ceaac);
79
+ // main 的 listTasks 已随任务层退役,不再引入
80
+ import { commitTaskWorkspace, commitWorkspace, PROJECTS_DATA_ROOT } from '../../projects/workspace.js';
81
+ import { taskManifest } from '../../lib/artifact-target.js';
82
+ import { getUserById } from '../../auth/users-store.js';
83
+ import { can, defaultModerationLevel } from '../../auth/tier.js';
84
+
85
+ /**
86
+ * 半截续接时替 agent 说的那句话(见 maybeContinueTruncated)。写成"系统提示"而不是装成用户说话:
87
+ * 模型看得见前面那段半截是自己说的,让它接着写,别从头重来(重来一遍用户要看两份)。
88
+ */
89
+ const CONTINUATION_PROMPT = '[系统] 上一条回复在传输途中被上游中断了,没有说完。请从被截断的地方接着写完,不要重复已经说过的内容,也不要为此道歉。如果上一条其实已经把话说完了,就继续执行手头的任务。';
90
+ import { levelFor } from '../../lib/moderation.js';
91
+
92
+ /**
93
+ * 起一个 session-level long-running SDK query。runs 是 per-turn 概念(SDK 每见
94
+ * 到一条 user message 起一轮 LLM 调用直到 stop_reason='end_turn')。
95
+ *
96
+ * **必须**外部维护 inputQueue —— 调用方(turn.js)提前 push 第一条 message 后再
97
+ * 调 runSession,session-loop 立即拉到处理。
98
+ *
99
+ * @param {object} opts
100
+ * @param {string} opts.sessionId
101
+ * @param {string} opts.projectId
102
+ * @param {string} opts.sessionWorkspaceRoot
103
+ * @param {import('./events.js').EventBus} opts.eventBus
104
+ * @param {import('../../lib/async-queue.js').AsyncQueue} opts.inputQueue
105
+ * @param {string} [opts.skillId='deskskill-engine-mini']
106
+ * @param {string} [opts.sessionTitle]
107
+ * @param {string[]} [opts.toolAllowlist=DEFAULT_TOOL_ALLOWLIST]
108
+ * @param {string} [opts.initialPermissionMode]
109
+ * @param {string} [opts.initialRunId] - 首条 turn 的 run record id;若给则 register
110
+ * 完立即设 currentRunId,避免 turn.js race
111
+ * condition(push 早于 register 没法关联 runId)
112
+ * @returns {Promise<void>} - inputQueue 关闭时 resolve
113
+ */
114
+ /**
115
+ * SDK 自发 turn 的开启信号:真实的模型/对话活动(assistant 输出、流式增量、
116
+ * SDK 注入的 user 消息)。task_notification / task_progress / notification 等
117
+ * 旁路事件**不算** —— 通知之后 SDK 不一定真的唤起模型,铸了 run 却等不来
118
+ * result 收尾就是僵尸 run。
119
+ */
120
+ function isBackgroundTurnOpener(message) {
121
+ return message?.type === 'assistant'
122
+ || message?.type === 'stream_event'
123
+ || message?.type === 'user';
124
+ }
125
+
126
+ export async function runSession({
127
+ sessionId,
128
+ projectId,
129
+ ownerId = null, // 项目 owner;订阅通路在 OAuth 决策那一行按 auth/tier.js 断言资格(见下)
130
+ sessionWorkspaceRoot,
131
+ eventBus,
132
+ inputQueue,
133
+ skillId = 'deskskill-engine-mini',
134
+ sessionTitle = null,
135
+ toolAllowlist = DEFAULT_TOOL_ALLOWLIST,
136
+ initialPermissionMode = null,
137
+ initialRunId = null,
138
+ }) {
139
+ if (!sessionId) throw new Error('runSession: sessionId required');
140
+ if (!sessionWorkspaceRoot) throw new Error('runSession: sessionWorkspaceRoot required');
141
+ if (!inputQueue || !(inputQueue instanceof AsyncQueue)) {
142
+ throw new Error('runSession: inputQueue (AsyncQueue) required');
143
+ }
144
+ if (!eventBus) throw new Error('runSession: eventBus required');
145
+
146
+ // 2026-08-07 扁平化:cwd 就是项目工作区,`sharedRoot` 和它是同一个目录。
147
+ // 旧代码在这里用 `../../shared` 从会话沙盒爬回共享目录 —— 那条相对路径现在
148
+ // 会爬到数据根之外,两个名字保留只是为了不动下游几十处引用。
149
+ const cwdRoot = sessionWorkspaceRoot;
150
+ const sharedRoot = cwdRoot;
151
+ const sessionMetaRoot = path.join(cwdRoot, '.nd', sessionId);
152
+
153
+ const sessionAbortController = new AbortController();
154
+ // initialPermissionMode 落进 active-runs,canUseTool 通过 getSessionPermissionMode 读
155
+ // 当前 mode(auto 模式升级口按它分流)。turn.js 入口的 mode 校正会同步更新本字段。
156
+ // (plan mode 08-21 整体移除;这里不再有 'plan' 分支)
157
+ const initialModeNormalized = 'bypassPermissions';
158
+ // sessionToken:身份证。closeQuerySession 已同步让出 sid 后用户立即重发起新
159
+ // runSession → 新 register 拿到新 token;旧 runSession finally 调 unregister 带
160
+ // 旧 token 比对不匹配 → noop 不误删新 entry。
161
+ const sessionToken = registerQuerySession(sessionId, {
162
+ abortController: sessionAbortController,
163
+ inputQueue,
164
+ initialPermissionMode: initialModeNormalized,
165
+ });
166
+ // 关键 race guard:registerQuerySession 拒绝重复注册(同 sid 已活跃)→ 这次
167
+ // runSession 是冗余调用(前端 race / 后端 fallback / resume race),直接 early
168
+ // return 不 spawn 第二个 SDK binary。否则两个 binary 并行 Write 同 canvas.html
169
+ // 就是用户报告的"独立 main 进程在 write"。
170
+ if (!sessionToken) {
171
+ console.warn(
172
+ `[session-loop] runSession sid=${sessionId.slice(0, 8)} skipped — already active. `
173
+ + `Caller (turn.js) should have used pushUserMessage instead of startNewRunSession.`
174
+ );
175
+ // 这条消息 push 进了一个无人消费的新 inputQueue —— 不能静默丢。标 run 失败 +
176
+ // emit run.error 让前端弹提示,用户重发即走 pushUserMessage 正常路径。
177
+ if (initialRunId) {
178
+ try { markRunFailed(initialRunId, 'duplicate session registration race'); } catch { /* */ }
179
+ }
180
+ try {
181
+ eventBus.publish({
182
+ type: 'run.error',
183
+ sessionId,
184
+ ...(initialRunId ? { runId: initialRunId } : {}),
185
+ message: '会话正忙,这条消息没有进入队列,请重发一次',
186
+ code: 'DUPLICATE_SESSION',
187
+ ts: new Date().toISOString(),
188
+ });
189
+ } catch { /* */ }
190
+ return;
191
+ }
192
+ // initialRunId:register 后立刻设 currentRunId,让 for-await-of 第一次见到
193
+ // SDK 转发首条 user message 时直接知道当前 turn 的 runId(否则 turn.js 那边
194
+ // 必须在 register 之后才能调 pushUserMessage —— race window)
195
+ if (initialRunId) setCurrentTurnRunId(sessionId, initialRunId);
196
+
197
+ // session-level start event(Phase 2,前端识别 query alive)
198
+ eventBus.publish({ type: 'run.query.start', sessionId, ts: new Date().toISOString() });
199
+
200
+ // sharedCtx:跨 turn 复用。每个 turn 边界覆盖 runId + 重置 counters。
201
+ // hooks / mcp 闭包持稳定引用即可。
202
+ // sessionId 传入让 ctx.emit 自动 enrich event.sessionId,WS handler 按 sid 过滤
203
+ // 防多 session / 多 tab 跨 session 串扰(project bus 共享)。
204
+ // model 优先级:调用方显式 > session-config.json(用户在 picker 选的,随会话
205
+ // 持久)> env 全局默认。这条链现在只写在 session-model.js 一处 —— 以前它在这里、
206
+ // turn.js、canvas.js 各有一份写法不同的复制品,对不上的时候没人发现。
207
+ const { model: resolvedModel } = await resolveSessionModel(sessionMetaRoot);
208
+ const model = resolvedModel;
209
+ const sdkModel = resolveSdkSpoofModel(model);
210
+
211
+ // appModel env:session-level,由 try 块内 + finally 配对管理。详见 line 558 注释。
212
+
213
+ const sharedCtx = new AgentContext({
214
+ runId: '__session_pending__',
215
+ skillId,
216
+ eventBus,
217
+ abortController: sessionAbortController,
218
+ workspaceRoot: cwdRoot,
219
+ sessionId,
220
+ appModel: model,
221
+ });
222
+
223
+ // ── init 段(2026-07-27 起整体 try/catch)——
224
+ // 老代码这些 await 在主 try 块之外,任一抛错 → Promise reject 只被 turn.js
225
+ // console.error,没有 run.start 也没有 run.error,run 行永远 pending,
226
+ // 前端完全零反馈(丢状态路径 P5)。现在失败时补 run.error + markRunFailed。
227
+ let wsRoot, baseUrlForBinary, apiKeyForBinary, fastModel, compactWindow, isResume, installed;
228
+ let noticeHandler = null; // ingress → 会话的通知回调(注销时按身份比对,别误删新会话的)
229
+ try {
230
+ wsRoot = await sharedCtx.workspace.ensure();
231
+
232
+ // 起手文件拷贝(canvas.template.html 等)2026-07-27 起不再在 init 无条件做 ——
233
+ // 挪到 hooks.js 的 PreToolUse(Skill/Bash):agent 真的开始 deck 工作
234
+ // (加载 deskskill / cp 模板)才拷。非 deck 会话(便签 / 整理画布)cwd 干净。
235
+
236
+ // ── 通路由模型表决定(2026-08-19 重建,前身是全局 NODESIGN_GATEWAY_URL 开关)──
237
+ // 订阅模型:什么都不注入(ANTHROPIC_API_KEY 一出现 binary 就弃 OAuth)。
238
+ // API 模型:BASE_URL 指进程内通用入口(model-ingress),入口按请求 body.model
239
+ // 查表换上游换钥匙;binary 侧的 API_KEY 只是"逼它进 API 模式"的占位符。
240
+ // helper(title 总结 / auto-compact / promptSuggestions)与 subagent 因此
241
+ // 天然全通:它们的请求同样进入口、同样被反查路由 —— 不再依赖旧版那个
242
+ // 跨会话互写的 NODESIGN_CURRENT_APP_MODEL 进程全局 env。
243
+ const route = resolveModelRoute(model);
244
+ if (route.mode === 'api') {
245
+ const ingress = await getOrStartIngress(); // 起不来就让 init 失败,别静默直连
246
+ baseUrlForBinary = `${ingress.baseUrl}/__nd/${encodeURIComponent(sessionId)}`;
247
+ apiKeyForBinary = 'nd-ingress-managed';
248
+ // SDK 内部 helper 可能用不在表里的 Claude 名发请求(config 目录默认模型),
249
+ // 注册会话 fast 兜底路由让它们改道而不是 502。finally 配对注销。
250
+ registerIngressSession(sessionId, model);
251
+ // 上游抖的时候(Zen 一次 503 能挂 50~140 秒)会话里什么都不动,用户只看到一个转不停的绿点。
252
+ // 给 ingress 一条推消息的通道,让它把"正在重试"说出来。节流在 session-notice.js,finally 配对注销。
253
+ noticeHandler = ({ key, text, priority }) => {
254
+ try { sharedCtx.emit(Events.notification(key || 'upstream_retry', text, priority || 'warn')); } catch { /* 通知不该弄死会话 */ }
255
+ };
256
+ registerSessionNotice(sessionId, noticeHandler);
257
+ // API 路的 fast model 必须同表可路由(订阅 haiku 在 API 模式会 404),
258
+ // 所以 env 覆盖在这条路上不生效 —— 表是唯一真相。
259
+ fastModel = route.fastModel;
260
+ // 把**真实**上下文窗口钉给 SDK。不设的话 SDK 只能按 spoof alias 猜,而
261
+ // alias 的容量和上游真实 n_ctx 基本对不上:猜小了白扔容量,猜大了会一路
262
+ // 涨到超过上游 n_ctx 再炸。实测规律见 resolveModelRoute 注释。
263
+ compactWindow = route.window;
264
+ } else {
265
+ // ⭐ 订阅通路的资格断言就放在做 OAuth 决策的这一行(08-21 晚)。API 边界(turn.js /
266
+ // sessions.js 的 allowedModelsFor)是第一道闸;这里是第二道:runSession 以前不认识
267
+ // 用户,只看会话模型决定走不走 ~/.claude 的 OAuth —— quick-summary 那次泄漏(写死
268
+ // haiku 起独立 SDK 会话,08-19 拆)正是这个形状。现在 owner 没资格就 init 失败,
269
+ // 而不是静默烧站主的订阅。owner 为空(无主项目;生产 08-21 实查 0 个)也 fail-closed。
270
+ const owner = ownerId ? getUserById(ownerId) : null;
271
+ if (!can(owner, 'subscription')) {
272
+ throw new Error(`订阅通路资格不足:项目 owner=${ownerId || '(无)'} 档位不含 subscription,模型=${model}`);
273
+ }
274
+ baseUrlForBinary = process.env.ANTHROPIC_BASE_URL;
275
+ apiKeyForBinary = process.env.ANTHROPIC_API_KEY;
276
+ fastModel = process.env.NODESIGN_FAST_MODEL || resolveDefaultFastModel(model);
277
+ // 订阅模型的真名 SDK 自己认得,别去覆盖它已知正确的默认值
278
+ compactWindow = null;
279
+ }
280
+
281
+ // 检测 jsonl 是否已存在 —— 决定走 resume(已存在)还是 sessionId(新建)
282
+ // 之前的 bug:session-loop 永远传 sessionId,但如果用户 close session 后又
283
+ // 用同 sid 起 query(hasActiveQuerySession=false 走 startNewRunSession),
284
+ // SDK binary 看 jsonl 已存在抛 "Session ID ... is already in use",
285
+ // 子进程死,nodejs 端 stdin write EPIPE 整个 server 挂。
286
+ isResume = await jsonlExistsForSession(cwdRoot, sessionId);
287
+
288
+ // 扫已装 plugin(内置 + 用户级 + project 级),返 SDK options 直接用的形态。
289
+ // 装新 plugin 只有重启 session 才生效(v1 接受,详见 plan § "Hot-reload v2")。
290
+ // skillId 参数(传入的 'deskskill-engine-mini')保留兼容,但实际 skills 列表以
291
+ // installed.skills 为准 —— 包含所有已装 plugin 内的 skill name 合集。
292
+ // 用户级 plugin 按**项目 owner** 取,不是按"当前请求者"—— 同一个项目谁来跑
293
+ // (owner 自己、后台自发回合、admin 代看)都该是同一套 skill,不然会话行为
294
+ // 会随观看者变。owner 为空(历史项目没回填全)→ 只跳过用户级,别退回共享根。
295
+ installed = await loadInstalledPlugins({
296
+ projectId,
297
+ userId: projectId ? getProject(projectId)?.ownerId : null,
298
+ });
299
+ console.log(
300
+ `[session-loop] plugins=[${installed.plugins.map(p => p.path.split('/').pop()).join(', ')}] `
301
+ + `skills=[${installed.skills.join(', ')}] `
302
+ + `(builtin=${installed.diagnostics.builtin} user=${installed.diagnostics.user} project=${installed.diagnostics.project})`
303
+ );
304
+ } catch (err) {
305
+ console.error(`[session-loop] init failed sid=${sessionId.slice(0, 8)}:`, err.message);
306
+ if (initialRunId) {
307
+ sharedCtx.runId = initialRunId; // emit 带正确 runId,前端才不会 stale-guard 吞掉
308
+ try { markRunFailed(initialRunId, `init: ${err.message || 'unknown'}`); } catch { /* */ }
309
+ }
310
+ sharedCtx.emit(Events.error(`会话初始化失败:${err.message}`, 'INIT_FAILED', err.stack));
311
+ // registerIngressSession 在本 try 内、其后还有可抛的 await —— 这里配对注销,
312
+ // 否则 init 失败的 API 会话在 ingress 的 sessionRoutes 里残留(2026-08-19 评审抓的洞)。
313
+ // 主路径的注销在下方大 try 的 finally;两处都是幂等 delete,不怕重复。
314
+ unregisterIngressSession(sessionId);
315
+ unregisterSessionNotice(sessionId, noticeHandler);
316
+ takeUpstreamTruncation(sessionId); // 别留标记给下一个同 sid 的会话
317
+ unregisterQuerySession(sessionId, sessionToken);
318
+ try {
319
+ eventBus.publish({ type: 'run.query.end', sessionId, reason: 'init_failed', ts: new Date().toISOString() });
320
+ } catch { /* */ }
321
+ throw err;
322
+ }
323
+
324
+ // MCP server 实例落变量:开局契约自检要从**传给 query 的同一个实例**上取预期
325
+ // 工具名(server.toolNames,见 mcp/index.js)——不另立第二份清单。
326
+ const nodesignServer = createNodesignMcpServer({ workspaceRoot: wsRoot, sharedRoot, projectId, sessionId, ctx: sharedCtx });
327
+
328
+ // npm 缓存 + 沙盒可写 tmp($TMPDIR / pip 缓存):细节与教训见 isolation.js
329
+ const agentDirs = await prepareAgentDirs({ dataRoot: PROJECTS_DATA_ROOT, projectId, sessionId });
330
+
331
+ const sdkEnv = {
332
+ ...process.env,
333
+ ANTHROPIC_BASE_URL: baseUrlForBinary,
334
+ // 订阅模型:apiKeyForBinary = process.env 原值(通常 undefined)——binary 见到
335
+ // ANTHROPIC_API_KEY 会弃用 ~/.claude 订阅 OAuth,所以订阅路绝不能注入。
336
+ // API 模型:占位符(真钥匙在 model-ingress 按上游注入,不经 binary)。
337
+ ANTHROPIC_API_KEY: apiKeyForBinary,
338
+ CLAUDE_AGENT_SDK_CLIENT_APP: 'nodesign/0.0.1',
339
+ CLAUDE_CONFIG_DIR: platform.claudeConfigDir,
340
+ // auto-memory 强制开启分支(binary gate:DISABLE 置 falsy 值 = force on,
341
+ // 绕过 CLAUDE_CODE_SIMPLE 等后置门;前置门 U$/zl 若拦住则此招无效 → 走自建 B 计划)
342
+ CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
343
+ // 工具搜索:非 alwaysLoad 的 MCP 工具延迟加载(省 ~25-30k 常驻 schema tokens),
344
+ // agent 用 ToolSearch 按需取。白名单见 mcp/index.js ALWAYS_LOAD_TOOLS
345
+ ENABLE_TOOL_SEARCH: 'true',
346
+ // npm_config_cache / CLAUDE_CODE_TMPDIR / PIP_CACHE_DIR(见 isolation.js)
347
+ ...agentDirs.envPatch,
348
+ // auto 模式分类器用哪个模型。判"这个动作越不越界"是需要判断力的活,
349
+ // 默认 opus —— 这一步省钱等于把闸门交给一个更笨的看门人。
350
+ ...(platform.autoModeEnabled ? { CLAUDE_CODE_AUTO_MODE_MODEL: platform.autoModeModel } : {}),
351
+ // bwrap 垫片:绕开 apply-seccomp 的 unshare 竞态(见 isolation.js / ops/sandbox-shim)
352
+ ...sandboxShimEnv({ dataRoot: PROJECTS_DATA_ROOT }),
353
+ // 快速 helper model(SDK 内部 helper:task title 总结、auto-compaction 等)。
354
+ // 通路见上方 route 注释:订阅 = env 可覆盖;API = 表内 fastModel。
355
+ ...(fastModel ? { ANTHROPIC_SMALL_FAST_MODEL: fastModel } : {}),
356
+ // 真实上下文窗口(仅 API 路;订阅路留空让 SDK 用它自己的正确默认)
357
+ ...(compactWindow ? { CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(compactWindow) } : {}),
358
+ };
359
+
360
+ const sdkOptions = {
361
+ cwd: cwdRoot,
362
+ abortController: sessionAbortController,
363
+ // --replay-user-messages(2026-08-20):让 CLI 把每条用户消息在**真正并进对话
364
+ // 的那一刻**原样回显(带我们 push 时盖的 uuid)。这是 run 记账的 turn 边界锚 ——
365
+ // 没有它就只能靠"一条消息一个 result"计数接力,而 CLI 会把 turn 进行中追加的
366
+ // 消息并进当前轮,链一错就永不自愈。机制全文见 active-runs.js claimRunByUuid。
367
+ // SDK 没把这面旗做成具名选项,走 extraArgs 透传(值 null = 无参布尔旗)。
368
+ extraArgs: { 'replay-user-messages': null },
369
+ // 新建 → sessionId 让 SDK 用我们的 sid;已存在 → resume 续 jsonl 历史
370
+ ...(isResume ? { resume: sessionId } : { sessionId }),
371
+ // title 仅在新建时有效(resume 用持久化的 title)
372
+ ...(sessionTitle && !isResume ? { title: sessionTitle.slice(0, 80) } : {}),
373
+ // additionalDirectories:cwd 外但允许 Read 的目录。
374
+ // - sharedRoot:project 共享资源(assets / agent-memory / .claude/)
375
+ // - 每个已装 plugin 根:让 agent 能 Read patterns / references 等 SKILL.md 附件
376
+ // (SDK Skill 工具只加载 SKILL.md body 自身,附件靠 agent 主动 Read,
377
+ // 要求路径在 sandbox 范围内 — 详见 memory nodesign_sdk_plugin_routes.md)
378
+ additionalDirectories: [
379
+ ...(sharedRoot ? [sharedRoot] : []),
380
+ ...installed.plugins.map(p => p.path),
381
+ ],
382
+
383
+ env: sdkEnv,
384
+
385
+ // sdkModel = appModel spoofing alias(kimi-k2.6 → claude-opus-4-7[1m])。
386
+ // 让 SDK 内部 rawMaxTokens=1M,autoCompactWindow=230400 不再被卡 200k;
387
+ // proxy 出口把 alias 还原成真 appModel 给 gateway。详见 model-context.js。
388
+ model: sdkModel,
389
+ tools: toolAllowlist,
390
+ // systemPrompt.append 只放 NODESIGN_PRELUDE(平台协议 / 路径地图 / 工作流硬规则) ——
391
+ // 语义层"平台强制、用户不可覆盖"。SKILL.md(设计方法论) 走 SDK 原生 plugins+skills:
392
+ // - plugins:加载 server/engine/plugins/nodesign(含 .claude-plugin/plugin.json + skills/)
393
+ // - skills:把 deskskill-engine-mini 加进 main session 的 skill catalog
394
+ //
395
+ // SDK 行为(sdk.d.ts:1649-1671 / 2598):SDK 在 system prompt 里给 agent 看到 skill listing
396
+ // (含 frontmatter description,单条截到 1536 字符),agent 自主决定何时通过内置 `Skill`
397
+ // 工具加载 body 进 context。**SDK 自己注入 listing,host 不该再在 prelude 里写硬规则强制
398
+ // invoke** —— description 写好让 agent 主动判断即可。
399
+ //
400
+ // 历史:2026-05-18 之前是 `append: [PRELUDE, skill.systemPrompt].join('\n\n---\n\n')` ——
401
+ // SKILL.md body 全文每 turn 恒驻在 system prompt 里。改造后 system prompt 静态前缀更稳
402
+ // (省 cache),SKILL.md body 只在 agent 真需要决策时进入 context。详见
403
+ // memory/nodesign_system_prompt_architecture.md。
404
+ systemPrompt: {
405
+ type: 'preset',
406
+ preset: 'claude_code',
407
+ // 成人段随项目 owner 的外审档联动(agent-shared.renderPrelude);
408
+ // 找不到 owner 时落 loose 默认,绝不落 off。
409
+ //
410
+ // uncensored 走模型表的标记位(model-context.isUncensoredModel),不在这里
411
+ // 判模型名 —— 那是模型属性,写在这儿就是给那张表开第二个真相源。为 true 的
412
+ // 行拿到的是精简版底线:本地无审查权重跑在自己盒子上、gate 'localGen' 只对
413
+ // 获批账号开、产物不外发,完整那节的前提(对外开放平台)根本不成立。
414
+ append: (() => {
415
+ const owner = projectId ? getUserById(getProject(projectId)?.ownerId) : null;
416
+ // 档位按模型通路取旋钮(08-20 两旋钮:订阅 / 本地与中转),model 是上面已解析的会话模型
417
+ // 无主项目 fail-closed 到 tier.js 的默认(strict),别落 loose(生产 08-21 实查 0 个无主项目)
418
+ return renderPrelude(owner ? levelFor(owner, model) : defaultModerationLevel(null), {
419
+ uncensored: isUncensoredModel(model),
420
+ });
421
+ })(),
422
+ },
423
+ plugins: installed.plugins,
424
+ skills: installed.skills,
425
+
426
+ // 2026-05-18 安全:关 inline shell execution。SDK 默认允许 skill / slash command 内
427
+ // inline shell 命令(Anthropic 标准 skill 协议的一部分,如 setup script)—— 但 NoDesign
428
+ // 允许用户上传 plugin,若不关 = 用户上传的 SKILL.md 含 shell 命令会被 SDK 真的执行 = RCE。
429
+ // 内置 deskskill-engine-mini 不依赖 inline shell,关掉无功能损失。
430
+ // 详见 memory nodesign_sdk_skills_options_internals.md「安全相关 SDK option」。
431
+ disableSkillShellExecution: true,
432
+
433
+ // resume 时不传 permissionMode:SDK 会从 JSONL 读原 session flags + 检查
434
+ // bypassPermissions 必须有 --dangerously-skip-permissions 启动才允许。如果
435
+ // 老 session 是在没这个 flag 的版本下创建的(老 SDK / 老代码),现在硬传
436
+ // permissionMode: 'bypassPermissions' 会让 SDK 抛:
437
+ // "Cannot set permission mode to bypassPermissions because the session
438
+ // was not launched with --dangerously-skip-permissions"
439
+ // 这个错没有 runId 会被前端 stale guard 吞掉 → 用户看到"完全没反应"。
440
+ // 修:resume 不传 permissionMode 让 SDK 用 JSONL 保存的;运行时切 mode 通过
441
+ // query.setPermissionMode + turn.js POST /turn 入口的 mode 校正路径完成。
442
+ // 默认模式来自 platform(exp 是 'auto' = 模型分类器判每次调用,生产仍是 'bypassPermissions')。
443
+ ...(isResume ? {} : { permissionMode: platform.permissionModeDefault }),
444
+ // 永远 true:与 permissionMode 正交——只是"允许运行时切 bypassPermissions"的安全
445
+ // 开关,启动当下的 mode 由 permissionMode 字段定。运行时切 mode(turn.js 入口校正)
446
+ // 必须有它,否则 query.setPermissionMode('bypassPermissions') 会被 SDK 拒:
447
+ // "session was not launched with --dangerously-skip-permissions"。
448
+ allowDangerouslySkipPermissions: true,
449
+
450
+ // 通用 permission gate
451
+ //
452
+ // ⚠️ SDK 0.2.x permission decision schema 要求 'allow' branch 必带 updatedInput
453
+ // (Zod union 严格验证);返 `{ behavior: 'allow' }` 缺 updatedInput 会触发
454
+ // ZodError 让工具被拒。'allow' 都要带 updatedInput(不改的话原样透传 input)。
455
+ //
456
+ canUseTool: async (toolName, input, options) => {
457
+ const currentMode = getSessionPermissionMode(sessionId);
458
+
459
+ // auto 模式的升级口:分类器自己拿不准的调用会落到这里(它自己判定要拦的
460
+ // 不会来,直接就拒了)。第一版**只记账不拦**——先看真实用量里都有谁会
461
+ // 升上来,再决定拦不拦;这期间分类器的硬拒照样生效。
462
+ // NODESIGN_AUTO_MODE_ESCALATION=deny 改成拦。
463
+ if (platform.autoModeEnabled && currentMode === 'auto') {
464
+ const 因 = options?.decisionReason || options?.title || '(没给原因)';
465
+ console.log(
466
+ `[auto-mode] 升级 sid=${sessionId.slice(0, 8)} tool=${toolName} `
467
+ + `理由=${String(因).replace(/\s+/g, ' ').slice(0, 200)}`,
468
+ );
469
+ if (platform.autoModeEscalation === 'deny' && toolName !== 'AskUserQuestion') {
470
+ return {
471
+ behavior: 'deny',
472
+ message:
473
+ `这个动作没通过平台的自动审批:${String(因).slice(0, 300)}\n`
474
+ + '换个不需要越界的做法;确实必须这么做的话,先跟用户说清楚你要做什么、为什么,让他决定。',
475
+ interrupt: false,
476
+ };
477
+ }
478
+ }
479
+
480
+ if (toolName !== 'AskUserQuestion') return { behavior: 'allow', updatedInput: input };
481
+ const toolUseId = options?.toolUseID;
482
+ if (!toolUseId) {
483
+ return { behavior: 'deny', message: 'AskUserQuestion missing toolUseID', interrupt: false };
484
+ }
485
+ let currentRunId = getCurrentTurnRunId(sessionId);
486
+ if (!currentRunId) {
487
+ // 后台自发 turn(task-notification 唤起)里 agent 问用户 —— 以前直接
488
+ // deny "no active turn",把带 preview 的候选卡逼退成纯文字。现在铸造
489
+ // 一个真 turn 再放行(mintBackgroundTurn 会 emit run.start 让前端拿到
490
+ // runId,answer 回路照常走)。
491
+ currentRunId = mintBackgroundTurn('AskUserQuestion');
492
+ }
493
+ sharedCtx.emit({ type: 'run.ask_user_question', toolUseId, input });
494
+ try {
495
+ const answers = await registerPendingQuestion(currentRunId, toolUseId);
496
+ return { behavior: 'allow', updatedInput: { ...input, answers } };
497
+ } catch (err) {
498
+ return { behavior: 'deny', message: err.message, interrupt: true };
499
+ }
500
+ },
501
+
502
+ // Phase B 批次 4:MCP elicitation 接通前端 Modal。
503
+ // 流程:MCP 工具调 server.elicitInput() → SDK 调这个回调 → 我们 emit
504
+ // run.elicitation_request 给前端 → ElicitationModal 弹出 → 用户填完 POST
505
+ // /elicit/:reqId/answer → provideElicitation 返回 { action, content } → SDK
506
+ // 拿到结果继续工具调用。
507
+ // 60s 超时是为了给用户填表时间(之前 5s 太短,未来真用 elicit 时永远没机会答);
508
+ // 仍兜底防 MCP 工具卡死整个 agent loop。
509
+ onElicitation: async (request, _options) => {
510
+ const reqId = randomUUID();
511
+ const currentRunId = getCurrentTurnRunId(sessionId);
512
+ try {
513
+ sharedCtx.emit({ type: 'run.elicitation_request', reqId, request, runId: currentRunId });
514
+ } catch { /* ignore */ }
515
+ if (!currentRunId) {
516
+ return { action: 'decline' };
517
+ }
518
+ try {
519
+ const p = registerPendingElicitation(currentRunId, reqId);
520
+ const timeoutPromise = new Promise(resolve =>
521
+ setTimeout(() => resolve({ action: 'decline' }), 60_000),
522
+ );
523
+ return await Promise.race([p, timeoutPromise]);
524
+ } catch {
525
+ return { action: 'decline' };
526
+ }
527
+ },
528
+
529
+ persistSession: true,
530
+ settingSources: ['project'],
531
+
532
+ // skipWebFetchPreflight 来自 runtime/platform.js(gateway key 模式永远关)
533
+ // 详细因果链见 platform.js 的 skipWebFetchPreflight 注释
534
+ settings: {
535
+ skipWebFetchPreflight: platform.skipWebFetchPreflight,
536
+ // 项目 memory(2026-07-28 正式启用):骑 SDK 原生 auto-memory ——
537
+ // 二级索引(MEMORY.md 常驻 + 主题文件按需召回)、召回监督器
538
+ // (run.memory_recall 前端已渲染)、auto-dream 后台固化全由 SDK 包办。
539
+ // ⚠️ 目录必须显式指到项目共享区:默认按 cwd 派生,而 cwd 是会话级的,
540
+ // 不指定则每个会话一座记忆孤岛;指定后全项目所有会话共享一套。
541
+ ...(sharedRoot ? {
542
+ autoMemoryEnabled: true,
543
+ // 落在 .claude/agent-memory/auto —— 跟前端记忆卡读的是同一棵树
544
+ // (2026-07-28 修:之前指到 shared/agent-memory/auto,SDK 记了前端也看不到)
545
+ autoMemoryDirectory: path.join(sharedRoot, '.claude', 'agent-memory', 'auto'),
546
+ } : {}),
547
+ },
548
+
549
+ includePartialMessages: STREAMING_ENABLED,
550
+ // 子代理时间轴(2026-07-28):转发子代理完整对话(text/thinking 也带
551
+ // parent_tool_use_id),前端按它拆「对话」主线和每个子代理的独立时间轴。
552
+ // 默认只透传 tool_use/tool_result(心跳级),不够渲染嵌套 transcript。
553
+ forwardSubagentText: true,
554
+
555
+ thinking: pickThinkingConfig(model),
556
+ effort: 'medium',
557
+ // streamInput 模式 query 横跨整个 session,maxTurns 是**全局累计**(每条
558
+ // user message 起一轮 agent loop,turn 数不重置)。15 太低 —— 用户聊几
559
+ // 轮就触顶导致 'error_max_turns' 误中断。改 50 给复杂 deck(多页 +
560
+ // 多次自检 + 子代理)足够余量;env override 给极端情况用
561
+ maxTurns: Number(process.env.NODESIGN_MAX_TURNS)
562
+ || 50,
563
+
564
+ // 不传 resume —— streamInput 模式 SDK 内存保 history,不依赖 jsonl
565
+ enableFileCheckpointing: true,
566
+ agentProgressSummaries: true,
567
+ promptSuggestions: true,
568
+ forwardSubagentText: true,
569
+
570
+ // maxBudgetUsd(2026-07-30 默认撤销):它只是给 agent 注"USD budget:
571
+ // $X/$N; remaining"的软提醒,SDK 不硬截断;数字还是按 SDK 硬编码价目表
572
+ // × spoofing 模型名算的虚价。订阅 OAuth 模式下实际不按 token 扣费,这个
573
+ // 虚价 reminder 只会给 agent 制造错误紧迫感(少派子代理 / 仓促收尾)——
574
+ // 不传,让 reminder 彻底消失。按量付费网关想要预算线时用
575
+ // NODESIGN_MAX_BUDGET_USD 显式开。
576
+ // (历史:Kimi 时代因 Opus 虚价 30× 把默认从 $10 拉到 $150,现连默认也不要了)
577
+ ...(() => {
578
+ const v = Number(process.env.NODESIGN_MAX_BUDGET_USD);
579
+ return Number.isFinite(v) && v > 0 ? { maxBudgetUsd: v } : {};
580
+ })(),
581
+
582
+ // 隔离两道闸(sandbox 管 Bash,permissions.deny 管 Read/Write 这类进程内工具)
583
+ // 全在 agent/isolation.js 里,改之前读那份文件头上的四条实测教训。
584
+ ...buildIsolationOptions({ cwdRoot, sharedRoot, ...agentDirs, dataRoot: PROJECTS_DATA_ROOT, env: sdkEnv }),
585
+
586
+ toolConfig: {
587
+ askUserQuestion: { previewFormat: 'html' },
588
+ },
589
+
590
+ // projectId 要传:PostToolUseFailure 记问题库时用它标归属(漏传的话
591
+ // issues 行的 project_id 全是 null,事后追不回是哪个项目踩的)
592
+ hooks: createHooks({ ctx: sharedCtx, workspaceRoot: wsRoot, sharedRoot, sessionId, projectId }),
593
+
594
+ mcpServers: {
595
+ nodesign: nodesignServer,
596
+ },
597
+
598
+ // mainModel = appModel ('kimi-k2.6'),sdkModel = SDK 视角 alias ('claude-opus-4-7[1m]')。
599
+ // vision-checker 用 sdkModel 让 SDK 信 1M context(绕开"喂真 kimi → SDK 不认 →
600
+ // rawMaxTokens fallback 200k");其余子代理走 fastModel,跟以前一致。
601
+ agents: createAgents({ mainModel: model, sdkModel, fastModel }),
602
+
603
+ stderr: (data) => {
604
+ console.error(`[session ${sessionId.slice(0, 8)}/claude.stderr]`, data.trim());
605
+ },
606
+ };
607
+
608
+ // ── per-turn lifecycle helpers ──
609
+
610
+ let activeTurnRunId = null;
611
+ let turnStartedAt = 0; // 本回合开始时刻(半截标记的新鲜度判据)
612
+ // 半截续接前存下的"第一轮的账"(结账时加回去)。⚠️ 必须声明在 finishTurn **之前**:
613
+ // finishTurn 引用它,写在下面的 try 块里会让闭包够不着 → 每个回合都 ReferenceError
614
+ // (真路径探针逮到的,node --check 和单测都看不见)。
615
+ let truncationCarry = null;
616
+
617
+ const startTurn = (runId) => {
618
+ activeTurnRunId = runId;
619
+ turnStartedAt = Date.now();
620
+ markSessionActivity(sessionId); // turn 边界 = 活跃信号
621
+ // 重置 sharedCtx 的 per-turn state
622
+ sharedCtx.runId = runId;
623
+ sharedCtx.counters = freshTurnCounters();
624
+ sharedCtx.startedAt = Date.now();
625
+ sharedCtx._cancelled = false; // context.js cancel 幂等 flag 重置
626
+ // 当前 turn id 写到 process.env(历史上给 proxy 透传 trace 头用;NoDesk 退役
627
+ // 后暂无消费方,保留是因为惯性成本为零、删了想再串链路要重接)。
628
+ // 旧的 NODESIGN_CURRENT_APP_MODEL 已随 model-ingress 重建退役:路由改为按
629
+ // 请求 body.model 查表,天然无跨会话互写问题。
630
+ process.env.NODESIGN_CURRENT_TURN_ID = runId;
631
+ markRunStarted(runId);
632
+ // 丢掉回合之外留下的半截标记:CLI 在两个回合之间还会用**主模型**打几发
633
+ // (SUGGESTION MODE 的猜你想问、标题生成等,真路径探针实测),那些也会被
634
+ // ingress 标成半截。不清的话下一轮一开场就吃到陈旧标记,平白续接一次
635
+ // (用户看到 agent 没头没脑地"接着写")。只有本回合内产生的标记才算数。
636
+ takeUpstreamTruncation(sessionId);
637
+ sharedCtx.emit(Events.start());
638
+ };
639
+
640
+ // ── 后台自发 turn 铸造(2026-07-29)──
641
+ // SDK 自己发起的 turn(后台 Task 子代理完成 → task-notification 重新唤起 agent)
642
+ // 没有经过 turn.js POST /turn,没人 createRun / 设 currentRunId。后果:
643
+ // 1. AskUserQuestion 被 canUseTool 以 "no active turn" 拒掉 —— explorer 跑完
644
+ // 准备好三张带 preview 的方向卡片,只能退化成纯文字描述(真实伤口)
645
+ // 2. 整个回合的事件挂在上一个已结束 run 的 runId 上,前端归属混乱
646
+ // 修法:检测到无主 turn 时铸造一个真 run record(createRun → setCurrentTurnRunId
647
+ // → startTurn),让 run.start/run.done、AskUserQuestion answer 回路、runs 审计
648
+ // 全部照常工作。前端 activeRun 由 run.start 设置,answer POST 天然有 runId 可用。
649
+ const mintBackgroundTurn = (reason) => {
650
+ // 归属:后台自发 turn 没有 req.user,用项目 owner(配额/审计口径一致)
651
+ let ownerId = null;
652
+ try { ownerId = getProject(projectId)?.ownerId ?? null; } catch { /* 归属查不到不挡后台回合 */ }
653
+ const run = createRun({
654
+ skillId,
655
+ brief: `(后台回合:${reason})`,
656
+ projectId,
657
+ userId: ownerId,
658
+ metadata: { background: true, mintReason: reason },
659
+ });
660
+ setCurrentTurnRunId(sessionId, run.id);
661
+ startTurn(run.id);
662
+ console.info(
663
+ `[session-loop] sid=${sessionId.slice(0, 8)} minted background turn run=${run.id} (${reason})`,
664
+ );
665
+ return run.id;
666
+ };
667
+
668
+
669
+ const finishTurn = async (status, info) => {
670
+ if (!activeTurnRunId) return;
671
+ const runId = activeTurnRunId;
672
+ // 取消 / 出错路径没走到上面那次 addCarry —— 在这里补上,别让续接前那一轮的账凭空消失
673
+ // (取消掉的回合一样烧了 token,配额口径要计)
674
+ if (truncationCarry) { sharedCtx.addCarry(truncationCarry); truncationCarry = null; }
675
+ if (status === 'success') {
676
+ const artifactPath = await detectArtifact(sharedCtx);
677
+ mergeRunMetadata(runId, { sdkSessionId: sharedCtx.sdkSessionId, ...sharedCtx.counters });
678
+ setRunMetrics(runId, sharedCtx.counters);
679
+ setRunModelUsage(runId, sharedCtx.counters.modelUsage);
680
+ try { markRunSucceeded(runId, { artifactPath }); } catch { /* idempotent */ }
681
+ sharedCtx.emit(Events.done(info?.finalText || '', artifactPath, sharedCtx.snapshot ? sharedCtx.snapshot() : { counters: sharedCtx.counters }));
682
+ // (2026-08-19 拆除:收场 recap —— 闲时精灵那句"刚才干了什么"。它唯一的
683
+ // 产出方式是起一发写死 claude-haiku-4-5 的一次性会话,不跟随会话模型,
684
+ // 本地/API 会话照样烧订阅额度且不进记账。闲时精灵改回写问候语,那是
685
+ // recap 缺席时本来就走的分支。理由全文见 lib/quick-summary.js 文件头。)
686
+ // 首页大输入框建出来的项目名是垫的:第一轮跑完拿 SDK helper 写的会话摘要
687
+ // 正名一次(只一次,用户改过名就不动)。失败不影响 turn。
688
+ autoNameProjectFromSession(projectId, sessionId)
689
+ .then((name) => {
690
+ if (name) sharedCtx.emit({ type: 'project.renamed', projectId, name });
691
+ })
692
+ .catch((err) => console.warn('[auto-name]', err.message));
693
+ } else if (status === 'cancelled') {
694
+ // 取消掉的 turn 也烧了 token —— counters 一样落库(配额视角是漏收)
695
+ mergeRunMetadata(runId, {
696
+ aborted: true, abortReason: info?.reason || 'user_cancel',
697
+ sdkSessionId: sharedCtx.sdkSessionId, ...sharedCtx.counters,
698
+ });
699
+ setRunMetrics(runId, sharedCtx.counters);
700
+ setRunModelUsage(runId, sharedCtx.counters.modelUsage);
701
+ try { markRunFailed(runId, `cancelled: ${info?.reason || 'user_cancel'}`); } catch { /* */ }
702
+ sharedCtx.emit({ type: 'run.cancelled', reason: info?.reason || 'user_cancel' });
703
+ } else if (status === 'error') {
704
+ mergeRunMetadata(runId, {
705
+ sdkSessionId: sharedCtx.sdkSessionId, ...sharedCtx.counters,
706
+ errorCode: info?.code, errorMessage: info?.message,
707
+ });
708
+ setRunMetrics(runId, sharedCtx.counters);
709
+ setRunModelUsage(runId, sharedCtx.counters.modelUsage);
710
+ try { markRunFailed(runId, info?.message || 'unknown'); } catch { /* */ }
711
+ sharedCtx.emit(Events.error(info?.message || 'unknown', info?.code, info?.stack));
712
+ }
713
+ // 工作区一轮一条 commit(2026-08-08)。
714
+ //
715
+ // 在这之前**只有"用户在画布上直接编辑 HTML"那一条路会提交**(canvas.js 的
716
+ // PUT),agent 写文件、mv 文件一次 commit 都不产生 —— 项目仓里基本只有一条
717
+ // init。现在它承担一件具体的活:画布物件的 id 就是工作区相对路径,agent
718
+ // 背着画布 `mv` 一个文件,那张卡的坐标 / 关系线 / 批注全断,而且因为
719
+ // board.objects 是稀疏的,断掉的条目**清都清不掉**。git 的改名检测是唯一
720
+ // 不用引入第二个真相源就能认出"这是同一个东西换了位置"的办法
721
+ // (见 board-store.js 的 reconcileBoardRenames),而它需要有 commit 可比。
722
+ //
723
+ // 失败只 warn:一个 commit 落不下不该让已经跑完的 turn 变成失败。
724
+ // **等它落完再往下走**:对账器(reconcileBoardRenames)靠这条 commit 才看得见
725
+ // agent 这一轮 mv 了什么。不等的话,turn 完成事件触发的那次产物重扫可能跑在
726
+ // commit 前面,改名这一轮就漏掉了。
727
+ await commitWorkspace(projectId, sessionId, `turn ${status}: ${new Date().toISOString()}`, { author: 'agent' })
728
+ .catch((err) => console.warn('[git] turn commit failed:', err.message));
729
+
730
+ activeTurnRunId = null;
731
+ turnStartedAt = 0;
732
+ markSessionActivity(sessionId); // turn 结束 = 活跃信号;下次 idle 计时重置
733
+ // 让出 currentRunId。排队的下一 turn **不在这里按计数晋升**(2026-08-20 起)——
734
+ // 由它自己的回显来认领(见 for-await 循环头的回显锚)。老接力在 CLI 并轮后
735
+ // 永久错一格(run 记账错位案,见 active-runs.js)。
736
+ releaseCurrentTurnRunId(sessionId);
737
+ // 清掉 turn id 环境变量;下个 turn 的 startTurn 会重设
738
+ delete process.env.NODESIGN_CURRENT_TURN_ID;
739
+ };
740
+
741
+ // ── idle timeout 兜底 ──
742
+ // 用户关 tab 后 WS-disconnect grace 是常规清理路径;这里再加一道:
743
+ // session 超过 IDLE_TIMEOUT 无任何活动(push message / turn 边界)→ 自动关。
744
+ // 防止"WS 还在但 user 走开几小时"的隐性占用。
745
+ const IDLE_TIMEOUT_MS = Number(process.env.NODESIGN_SESSION_IDLE_MS) || 30 * 60_000;
746
+ const IDLE_SCAN_INTERVAL_MS = Math.min(5 * 60_000, IDLE_TIMEOUT_MS);
747
+ const idleScanTimer = setInterval(() => {
748
+ const last = getSessionLastActivity(sessionId);
749
+ if (last == null) return; // session 已被 unregister,scan 等会儿自然结束
750
+ if (Date.now() - last > IDLE_TIMEOUT_MS) {
751
+ console.info(`[session-loop] sid=${sessionId.slice(0, 8)} idle > ${IDLE_TIMEOUT_MS}ms, closing`);
752
+ closeQuerySession(sessionId, 'idle_timeout');
753
+ }
754
+ }, IDLE_SCAN_INTERVAL_MS);
755
+ idleScanTimer.unref?.();
756
+
757
+ // ── main stream loop ──
758
+
759
+ let stream;
760
+ try {
761
+ stream = query({ prompt: inputQueue, options: sdkOptions });
762
+ attachSessionQuery(sessionId, stream);
763
+
764
+ // 铅笔精灵的手写短句(2026-08-14 日记本批):assistant 文本一到就把第一
765
+ // 小句压成一行推上画布,纯本地整形、零成本零延迟、同步出结果。
766
+ // 子代理的话不上精灵 —— 它们有自己的舞台便利贴。
767
+ // (2026-08-19 拆除后半段:原来这里还起一发 haiku 精修、到货再补一发
768
+ // refined:true 覆盖底稿。那发写死走订阅不跟随会话模型 —— 见
769
+ // lib/quick-summary.js 文件头。现在一 round 只有这一发。)
770
+ let lastSummarySrc = '';
771
+ const maybeSpriteSummary = (message) => {
772
+ if (message.parent_tool_use_id) return;
773
+ const text = (message.message?.content || [])
774
+ .filter(b => b?.type === 'text' && typeof b.text === 'string')
775
+ .map(b => b.text).join('\n').trim();
776
+ if (!text || text === lastSummarySrc) return;
777
+ lastSummarySrc = text;
778
+ const line = clampFirstClause(text);
779
+ if (line) sharedCtx.emit(Events.spriteSummary(sharedCtx.counters.turns, line));
780
+ };
781
+
782
+ // emitContextUsage:fire-and-forget per assistant message
783
+ let usageInFlight = false;
784
+ const emitContextUsage = () => {
785
+ if (usageInFlight) return;
786
+ usageInFlight = true;
787
+ stream.getContextUsage()
788
+ .then((usage) => { if (usage) sharedCtx.emit(Events.contextUsage(usage, sharedCtx.appModel)); })
789
+ .catch(() => { /* fail-soft */ })
790
+ .finally(() => { usageInFlight = false; });
791
+ };
792
+
793
+ // ── 半截续接(08-21 晚)──
794
+ // Zen 会在模型说到一半时把流掐了(无 finish_reason / 私货 finish 如 network_error),
795
+ // 正文已经吐了一半。转换层照旧按 end_turn 交付(假上游实测:有可见输出后再发 error 事件
796
+ // CLI **不重试**,只会把半截 + "Server error mid-response" 一起判 is_error 丢给用户),
797
+ // 于是半截答案就成了最终答案 —— agent 说半句话就收工。08-21 当天生产 4 次。
798
+ //
799
+ // 治法跟 OpenCode 1.18.21 对 unknown finish 的做法一样:半截那段原样留在历史里,
800
+ // 补一条用户消息让模型接着说,同一个 run 内再跑一轮(pushUnclaimedMessage 不认领 run)。
801
+ // 上限 MAX_CONTINUATIONS:上游持续半截时别把回合拖成无限循环。
802
+ // ⚠️ 别写 `Number(env) || 2`:设 0 想紧急关掉这个功能会落回 2(fable 评审 P2)
803
+ const envMax = Number(process.env.NODESIGN_TRUNCATION_CONTINUATIONS);
804
+ const MAX_CONTINUATIONS = Number.isFinite(envMax) && envMax >= 0 ? envMax : 2;
805
+ const continuationCounts = new Map(); // runId → 已续接次数
806
+ const maybeContinueTruncated = () => {
807
+ const mark = takeUpstreamTruncation(sessionId);
808
+ if (!mark) return false;
809
+ const runId = activeTurnRunId;
810
+ if (!runId) return false;
811
+ // 只认**本回合内**产生的标记。CLI 在回合之间还会用主模型打请求(猜你想问 / 标题),
812
+ // 上游一慢它们可能迟到;startTurn 那次清扫挡不住迟到的(fable 评审 P2)。
813
+ if (turnStartedAt && mark.at && mark.at < turnStartedAt) {
814
+ console.warn(`[session-loop] sid=${sessionId.slice(0, 8)} 丢弃回合外的半截标记(${mark.reason},早于本回合开始)`);
815
+ return false;
816
+ }
817
+ const used = continuationCounts.get(runId) || 0;
818
+ const sidShort = sessionId.slice(0, 8);
819
+ if (used >= MAX_CONTINUATIONS) {
820
+ console.warn(`[session-loop] sid=${sidShort} run=${runId} 半截续接已用满 ${used} 次(${mark.reason}),按现状收尾`);
821
+ sharedCtx.emit(Events.notification('upstream_truncated', `上游连着把回答掐断了 ${used + 1} 次,这段可能没说完。重发一次试试。`, 'warn'));
822
+ return false;
823
+ }
824
+ const pushed = pushUnclaimedMessage(sessionId, {
825
+ type: 'user',
826
+ message: { role: 'user', content: [{ type: 'text', text: CONTINUATION_PROMPT }] },
827
+ parent_tool_use_id: null,
828
+ });
829
+ if (!pushed) return false;
830
+ continuationCounts.set(runId, used + 1);
831
+ // 第一轮的账要结转,否则被下一个 result 的 absorbResult 整个覆盖(fable 评审 P0)
832
+ truncationCarry = sharedCtx.takeCarry();
833
+ console.warn(`[session-loop] sid=${sidShort} run=${runId} 上一条回复被上游掐断(${mark.reason})→ 自动续接第 ${used + 1}/${MAX_CONTINUATIONS} 次`);
834
+ sharedCtx.emit(Events.notification('upstream_truncated', '上游把回答掐断了,正在让它接着说完。', 'info'));
835
+ return true;
836
+ };
837
+
838
+ for await (const message of stream) {
839
+ // 每条 SDK message 都是活跃信号 —— 老逻辑只有 push / turn 边界算活跃,
840
+ // 单个 turn 跑超 30 分钟(多页 deck + 子代理)会被 idle timeout 掐死(P8)
841
+ markSessionActivity(sessionId);
842
+
843
+ // ── 回显锚(2026-08-20,run 记账错位案)──
844
+ // CLI 开了 --replay-user-messages:我们 push 的每条用户消息会在**真正被并进
845
+ // 对话的那一刻**回显回来(带 push 时盖的 uuid)。按 uuid 认领它的 run:
846
+ // promoted → 它的 turn 现在开始(下面的边界检测接手 startTurn)
847
+ // merged → 它被并进了正在跑的这一轮,就地关账,不另起 turn
848
+ // 机制与探针证据见 active-runs.js claimRunByUuid 上方的注释。
849
+ if (message.type === 'user' && message.uuid && !message.parent_tool_use_id) {
850
+ const claim = claimRunByUuid(sessionId, message.uuid);
851
+ if (claim?.outcome === 'merged') {
852
+ closeMergedRun({ runId: claim.runId, intoRunId: claim.intoRunId, sessionId, sdkSessionId: sharedCtx.sdkSessionId, eventBus });
853
+ } else if (claim?.outcome === 'unknown') {
854
+ console.warn(`[session-loop] sid=${sessionId.slice(0, 8)} replayed run=${claim.runId} not pending nor current (ignored)`);
855
+ }
856
+ if (claim) publishQueueDepth(eventBus, sessionId);
857
+ } else if (
858
+ !activeTurnRunId && !getCurrentTurnRunId(sessionId) && getPendingRunCount(sessionId) > 0
859
+ && (message.type === 'assistant' || message.type === 'stream_event')
860
+ ) {
861
+ // 回显锚缺席(CLI 没回显就开始说话了)→ FIFO 兜底,别让排队的 run 没人认领。
862
+ // 正常运行不该走到这里;走到了就是 CLI 行为变了,warn 让人看见。
863
+ const promoted = promoteNextPendingRunId(sessionId);
864
+ console.warn(`[session-loop] sid=${sessionId.slice(0, 8)} replay anchor missing, FIFO fallback promoted run=${promoted}`);
865
+ }
866
+
867
+ // 检测 turn 边界:currentRunId 切换 → 新 turn
868
+ const cid = getCurrentTurnRunId(sessionId);
869
+ if (cid && cid !== activeTurnRunId) {
870
+ // 新 turn 开始(前一 turn 应该已 finishTurn — 防御性兜底)
871
+ if (activeTurnRunId) {
872
+ await finishTurn('error', { message: 'turn boundary skipped without result', code: 'TURN_LEAK' });
873
+ }
874
+ startTurn(cid);
875
+ } else if (!cid && !activeTurnRunId && isBackgroundTurnOpener(message)) {
876
+ // SDK 自发 turn(后台 Task 完成通知唤起 agent)—— 没有用户消息、没有
877
+ // runId。铸造一个让整回合事件有正确归属(否则全挂在上一个已结束 run 上)。
878
+ mintBackgroundTurn(`sdk_${message.type}`);
879
+ }
880
+
881
+ // 开局契约自检:init 每次到达都对账(新建/resume 各来一次)
882
+ if (message.type === 'system' && message.subtype === 'init') {
883
+ // 开局契约自检(空壳钩子灭门案第 3 层)—— 全文见 init-contract.js
884
+ assertInitContract(message, { sessionId, projectId, isResume, initialPermissionMode, platform, sdkModel, nodesignServer });
885
+ }
886
+
887
+ handleSDKMessage(sharedCtx, message);
888
+
889
+ if (message.type === 'assistant') {
890
+ emitContextUsage();
891
+ maybeSpriteSummary(message);
892
+ }
893
+
894
+ if (message.type === 'result') {
895
+ // 计量断链修复(2026-07-30):result message 的 usage/total_cost_usd 是
896
+ // 本 turn 真增量,从前直接丢弃 → runs 表 token counters 常年全 0。
897
+ // cancelled 也吸收 —— 取消掉的 turn 已经烧了 token,配额要计
898
+ sharedCtx.absorbResult(message);
899
+ // 上游自报费用(/zen/go 的 cost)覆盖 SDK/表价算出来的数:取走 ingress 本轮累计(没报就 null,不动)
900
+ sharedCtx.applyUpstreamBilling(takeUpstreamBilling(sessionId));
901
+ // 半截续接过的回合:把续接前那一轮的账加回来(absorbResult 是赋值不是累加)。
902
+ // 放在 applyUpstreamBilling 之后 —— 上游自报 cost 只覆盖**本轮**那条,加完 carry 才是整回合的总账。
903
+ if (truncationCarry) { sharedCtx.addCarry(truncationCarry); truncationCarry = null; }
904
+ const isCancelled = message.terminal_reason === 'aborted_streaming'
905
+ || message.terminal_reason === 'aborted_tools';
906
+
907
+ if (isCancelled) {
908
+ await finishTurn('cancelled', { reason: message.terminal_reason });
909
+ } else if (message.subtype === 'success' && !message.is_error && maybeContinueTruncated()) {
910
+ // 半截续接:这一轮的收尾响应是"说到一半被上游掐了",已经推了续接消息,
911
+ // **不结账**(同一个 run 接着跑,下一个 result 才是真收尾)。
912
+ } else if (message.subtype === 'success' && message.is_error) {
913
+ // 08-21 晚:API 层以 4xx 收场(如 ingress 连续失败止损回的 400)时,SDK 给的是
914
+ // subtype=success + is_error=true + result=错误文案(假上游实测)。以前走下一分支当成功,
915
+ // run 标 succeeded、错误文案当最终回答。现在按 error 结账,文案原样给用户。
916
+ await finishTurn('error', { message: message.result || 'API error', code: 'API_ERROR' });
917
+ } else if (message.subtype === 'success') {
918
+ await finishTurn('success', { finalText: message.result || '' });
919
+ } else {
920
+ await finishTurn('error', {
921
+ message: `agent run failed: ${message.subtype}`
922
+ + (message.errors?.length ? ` — ${message.errors.join('; ')}` : ''),
923
+ code: message.subtype,
924
+ });
925
+ }
926
+ // turn 处理完 emit 当前 queue 积压(让前端"已排队 N 条"递减)
927
+ publishQueueDepth(eventBus, sessionId);
928
+ // 不 throw —— query 继续等下一条 user message
929
+ }
930
+ }
931
+
932
+ // for-await-of 自然结束(inputQueue.close 触发)→ session 完整收尾
933
+ if (activeTurnRunId) {
934
+ // input 关闭时还有 in-flight turn —— 当作 cancelled 收尾
935
+ await finishTurn('cancelled', { reason: 'session_closed' });
936
+ }
937
+ } catch (err) {
938
+ // 区分两种"抛错":
939
+ // 1. 用户主动 close session(abortController.abort() 触发 SDK binary
940
+ // 子进程被 SIGTERM kill → 抛"Claude Code process aborted by user")
941
+ // —— 这是预期行为,不是 error,不应该让前端弹"运行失败"toast
942
+ // 2. 真错(网络断、SDK init 失败、Kimi gateway 5xx 等)—— 走 error 路径
943
+ if (sessionAbortController.signal.aborted) {
944
+ // close session 路径:当前 turn 当 cancelled 收尾,不 emit run.error
945
+ if (activeTurnRunId) {
946
+ await finishTurn('cancelled', {
947
+ reason: sessionAbortController.signal.reason || 'session_closed',
948
+ });
949
+ }
950
+ // 静默退出 —— finally 仍 emit run.query.end 让前端识别 session 关了
951
+ } else {
952
+ // 真错路径
953
+ if (activeTurnRunId) {
954
+ await finishTurn('error', err);
955
+ } else if (initialRunId) {
956
+ // 错误发生在 startTurn 之前(如 SDK query() 启动权限冲突 / workspace.ensure
957
+ // 抛错等)→ activeTurnRunId 还是 null,sharedCtx.runId 仍是占位符
958
+ // '__session_pending__'。直接 emit run.error 会让 enriched event 带这个占位
959
+ // runId,前端 stale guard `evt.runId !== liveRunId` 把事件吞掉 → 用户看到
960
+ // "完全没反应"。修:手动把 sharedCtx.runId 设到本次 turn 的 initialRunId 让
961
+ // emit 出去的 run.error 带正确 runId 让前端能渲染错误。
962
+ sharedCtx.runId = initialRunId;
963
+ try { markRunFailed(initialRunId, err.message || 'unknown'); } catch { /* ignore */ }
964
+ }
965
+ sharedCtx.emit(Events.error(err.message, err.code, err.stack));
966
+ throw err;
967
+ }
968
+ } finally {
969
+ clearInterval(idleScanTimer);
970
+ unregisterIngressSession(sessionId); // API 会话的 fast 兜底路由配对注销(订阅会话 noop)
971
+ unregisterSessionNotice(sessionId, noticeHandler); // ingress → 会话的通知通道配对注销(按身份,别删掉新会话的)
972
+ takeUpstreamTruncation(sessionId); // 半截标记跟会话同生命周期,别留
973
+ // 带 token 比对:sid 若已被新 register 占用(closeQuerySession 已同步让位 +
974
+ // 用户重发起新 runSession),unregister 看到 _token 不匹配 → noop 不误删新 entry
975
+ unregisterQuerySession(sessionId, sessionToken);
976
+ // session-level end event(Phase 2)
977
+ try {
978
+ eventBus.publish({
979
+ type: 'run.query.end',
980
+ sessionId,
981
+ reason: sessionAbortController.signal.aborted
982
+ ? (sessionAbortController.signal.reason || 'aborted')
983
+ : 'closed',
984
+ ts: new Date().toISOString(),
985
+ });
986
+ } catch { /* */ }
987
+ }
988
+ }
989
+
990
+ /**
991
+ * 检查给定 sessionId 是否已经有 SDK jsonl 落盘 —— 决定走 resume 还是新建。
992
+ *
993
+ * SDK 落盘路径:<sessionRoot>/.claude/projects/<encoded-cwd>/<sid>.jsonl
994
+ * encoded-cwd 把 cwd 绝对路径里 '/' 换成 '-'。我们不复制 SDK 编码逻辑,
995
+ * 直接遍历 .claude/projects/* 看哪个子目录里有 <sid>.jsonl。
996
+ */
997
+ async function jsonlExistsForSession(sessionRoot, sessionId) {
998
+ // SDK 将 JSONL 存在 CLAUDE_CONFIG_DIR/projects/<encoded-cwd>/<sid>.jsonl
999
+ // 编码规则(grep 自 sdk.mjs):所有非字母数字字符转 '-'
1000
+ const encodedCwd = sessionRoot.replace(/[^a-zA-Z0-9]/g, '-');
1001
+ const globalJsonl = path.join(platform.claudeConfigDir, 'projects', encodedCwd, `${sessionId}.jsonl`);
1002
+ try {
1003
+ await fs.access(globalJsonl);
1004
+ return true;
1005
+ } catch { /* not at global location */ }
1006
+
1007
+ // fallback:检查本地 .claude/projects/(兼容旧行为 / sandbox 模式)
1008
+ const projectsDir = path.join(sessionRoot, '.claude', 'projects');
1009
+ let entries;
1010
+ try {
1011
+ entries = await fs.readdir(projectsDir, { withFileTypes: true });
1012
+ } catch {
1013
+ return false;
1014
+ }
1015
+ for (const e of entries) {
1016
+ if (!e.isDirectory()) continue;
1017
+ const f = path.join(projectsDir, e.name, `${sessionId}.jsonl`);
1018
+ try {
1019
+ await fs.access(f);
1020
+ return true;
1021
+ } catch { /* not here, try next */ }
1022
+ }
1023
+ return false;
1024
+ }