@sanqianx/project-knowledge 5.0.24 → 5.0.25

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 (401) hide show
  1. package/.agents/plugins/marketplace.json +20 -20
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/CHANGELOG.md +1361 -1353
  4. package/LICENSE +201 -201
  5. package/NOTICE +5 -5
  6. package/README.md +349 -349
  7. package/_modules/knowledge-engine/package.json +1 -1
  8. package/_modules/knowledge-engine/src/coordinator.js +48 -3
  9. package/_modules/knowledge-engine/src/runtime/automation-config.js +140 -140
  10. package/_modules/knowledge-engine/src/runtime/commit-conversation-binder.js +238 -238
  11. package/_modules/knowledge-engine/src/runtime/commit-processing-ledger.js +26 -26
  12. package/_modules/knowledge-engine/src/runtime/commit-prompt.js +193 -193
  13. package/_modules/knowledge-engine/src/runtime/commit-reconciler.js +394 -394
  14. package/_modules/knowledge-engine/src/runtime/contracts.js +282 -282
  15. package/_modules/knowledge-engine/src/runtime/conversation-store.js +389 -389
  16. package/_modules/knowledge-engine/src/runtime/evidence-bundle.js +228 -228
  17. package/_modules/knowledge-engine/src/runtime/git-runner.js +62 -62
  18. package/_modules/knowledge-engine/src/runtime/knowledge-promotion.js +505 -505
  19. package/_modules/knowledge-engine/src/runtime/knowledge-retrieval-service.js +400 -400
  20. package/_modules/knowledge-engine/src/runtime/markdown-knowledge-indexer.js +171 -171
  21. package/_modules/knowledge-engine/src/runtime/scanner.js +298 -298
  22. package/_modules/knowledge-engine/ui/app.js +23 -2
  23. package/_site/README.md +30 -30
  24. package/_site/_test/agent-capture-mcp-ui-test.js +68 -68
  25. package/_site/_test/agent-capture-ui-test.js +101 -101
  26. package/_site/_test/ai-profile-resolver-test.js +137 -137
  27. package/_site/_test/ai-profile-test.js +75 -75
  28. package/_site/_test/ai-vendor-presets-test.js +91 -91
  29. package/_site/_test/automatic-context-test.js +70 -70
  30. package/_site/_test/automation-queue-test.js +14 -14
  31. package/_site/_test/automation-ui-test.js +40 -40
  32. package/_site/_test/background-task-registry-test.js +43 -43
  33. package/_site/_test/baseline-schema-test.js +91 -91
  34. package/_site/_test/bin-cli-test.js +230 -230
  35. package/_site/_test/bridge-adapter-test.js +95 -95
  36. package/_site/_test/bridge-consumer-service-test.js +310 -310
  37. package/_site/_test/candidate-bin-content-test.js +35 -35
  38. package/_site/_test/candidate-product-assets-test.js +13 -13
  39. package/_site/_test/candidate-release-contract-test.js +14 -14
  40. package/_site/_test/capture-hook-dedup-test.js +158 -158
  41. package/_site/_test/cdp-browser-failure-test.js +103 -103
  42. package/_site/_test/chat-claudecodeui-match-test.js +92 -92
  43. package/_site/_test/claude-executable-discovery-test.js +90 -90
  44. package/_site/_test/claude-session-lifecycle-sweep-test.js +252 -252
  45. package/_site/_test/claude-workbench-test.js +174 -174
  46. package/_site/_test/cli-instance-management-test.js +62 -62
  47. package/_site/_test/client-capture-restart-e2e-test.js +189 -189
  48. package/_site/_test/codex-conversation-projection-test.js +60 -60
  49. package/_site/_test/codex-marketplace-scoped-path-test.js +186 -186
  50. package/_site/_test/codex-notify-capture-e2e-test.js +101 -101
  51. package/_site/_test/codex-notify-fanout-test.js +141 -134
  52. package/_site/_test/codex-notify-upgrade-test.js +253 -253
  53. package/_site/_test/commit-boundary-freeze-test.js +147 -147
  54. package/_site/_test/commit-conversation-binding-test.js +99 -99
  55. package/_site/_test/commit-evidence-test.js +110 -110
  56. package/_site/_test/configuration-generation-test.js +22 -22
  57. package/_site/_test/context-pack-test.js +200 -200
  58. package/_site/_test/conversation-api-test.js +119 -119
  59. package/_site/_test/conversation-origin-test.js +15 -15
  60. package/_site/_test/conversation-store-test.js +138 -138
  61. package/_site/_test/cross-repo-isolation-e2e-test.js +187 -187
  62. package/_site/_test/desktop-browser-compat-test.js +28 -28
  63. package/_site/_test/desktop-hook-runtime-regression-test.js +204 -204
  64. package/_site/_test/embedding-config-test.js +32 -32
  65. package/_site/_test/explicit-commit-processor-test.js +37 -37
  66. package/_site/_test/fixtures/fake-browser-exit.js +10 -10
  67. package/_site/_test/fixtures/fake-browser-hang.js +10 -10
  68. package/_site/_test/fixtures/integration-busy-worker.cjs +6 -6
  69. package/_site/_test/fixtures/make-git-repos.js +92 -92
  70. package/_site/_test/fixtures/retrieval-gold-queries.json +32 -32
  71. package/_site/_test/folder-picker-output-test.js +19 -19
  72. package/_site/_test/fresh-hook-reimport-test.js +53 -53
  73. package/_site/_test/fresh-product-data-test.js +56 -56
  74. package/_site/_test/full-integration-e2e-test.js +167 -167
  75. package/_site/_test/git-path-compatibility-test.js +80 -80
  76. package/_site/_test/git-validation-test.js +81 -81
  77. package/_site/_test/glm-ui-refresh-review-test.js +313 -404
  78. package/_site/_test/harness-isolation-test.js +138 -0
  79. package/_site/_test/helpers/cdp-browser.js +383 -0
  80. package/_site/_test/helpers/cdp-network-capture.js +66 -0
  81. package/_site/_test/helpers/find-chrome.js +34 -0
  82. package/_site/_test/helpers/free-port.js +8 -0
  83. package/_site/_test/helpers/logging-ui-fixture.js +131 -0
  84. package/_site/_test/helpers/module-fixture.js +282 -0
  85. package/_site/_test/helpers/parallel-engine-advanced-engine-child.js +45 -0
  86. package/_site/_test/helpers/parallel-engine-advanced-fault-writer.js +171 -0
  87. package/_site/_test/helpers/parallel-engine-advanced-harness.js +245 -0
  88. package/_site/_test/helpers/parallel-engine-advanced-standin-analyzer.js +80 -0
  89. package/_site/_test/helpers/parallel-engine-advanced-ui-host.js +177 -0
  90. package/_site/_test/helpers/parallel-engine-final-harness.js +314 -0
  91. package/_site/_test/helpers/parallel-engine-final-standin-analyzer.js +159 -0
  92. package/_site/_test/helpers/parallel-knowledge-fixture.js +255 -0
  93. package/_site/_test/helpers/parallel-logging-acceptance-fixture.js +111 -0
  94. package/_site/_test/helpers/parallel-logging-acceptance-proxy.js +106 -0
  95. package/_site/_test/helpers/parallel-logging-fix-r2-proxy.js +205 -0
  96. package/_site/_test/helpers/parallel-logging-integration-5025-env.js +432 -0
  97. package/_site/_test/helpers/parallel-memory-analyzer.js +81 -0
  98. package/_site/_test/helpers/parallel-memory-event-bridge.js +128 -0
  99. package/_site/_test/helpers/parallel-memory-shell.js +470 -0
  100. package/_site/_test/helpers/parallel-nav-isolation-env.js +389 -0
  101. package/_site/_test/helpers/parallel-nav-module-ui.js +186 -0
  102. package/_site/_test/helpers/parallel-nav-proxy.js +148 -0
  103. package/_site/_test/helpers/parallel-nav-shell.js +361 -0
  104. package/_site/_test/helpers/parallel-qa-engine-endpoint-guard.js +206 -0
  105. package/_site/_test/helpers/parallel-qa-engine-endpoint-isolate.js +257 -0
  106. package/_site/_test/helpers/parallel-retrieval-followup-corpus.js +293 -0
  107. package/_site/_test/helpers/parallel-retrieval-followup-next-session.js +70 -0
  108. package/_site/_test/helpers/parallel-retrieval-followup-r2-shell.js +737 -0
  109. package/_site/_test/helpers/parallel-retrieval-followup-r2-wb-host.js +82 -0
  110. package/_site/_test/helpers/parallel-retrieval-followup-shell.js +279 -0
  111. package/_site/_test/helpers/parallel-workbench-fixture.js +371 -0
  112. package/_site/_test/helpers/parallel-workbench-host.js +80 -0
  113. package/_site/_test/helpers/parallel-workbench-integration-5025-fixture.js +408 -0
  114. package/_site/_test/helpers/parallel-workbench-integration-5025-harness.js +593 -0
  115. package/_site/_test/helpers/parallel-workbench-integration-5025-host.js +70 -0
  116. package/_site/_test/helpers/parallel-workbench-integration-5025-launcher.js +240 -0
  117. package/_site/_test/helpers/parallel-workbench-integration-5025-runtime-harness.js +630 -0
  118. package/_site/_test/helpers/parallel-workbench-source-map.js +196 -0
  119. package/_site/_test/helpers/spawn-server.js +106 -0
  120. package/_site/_test/helpers/version-visibility-fixture.js +251 -0
  121. package/_site/_test/hook-runtime-endpoint-test.js +88 -88
  122. package/_site/_test/hook-status-repair-api-test.js +152 -152
  123. package/_site/_test/hook-trigger-test.js +85 -85
  124. package/_site/_test/hook-worktree-test.js +42 -42
  125. package/_site/_test/import-preflight-api-test.js +234 -234
  126. package/_site/_test/index-builder-test.js +41 -41
  127. package/_site/_test/index-error-display-contract-test.js +10 -10
  128. package/_site/_test/index-writer-concurrency-test.js +135 -135
  129. package/_site/_test/integration-adapters-test.js +262 -262
  130. package/_site/_test/integration-auto-repair-test.js +113 -113
  131. package/_site/_test/integration-capture-setup-test.js +118 -118
  132. package/_site/_test/integration-intent-test.js +81 -81
  133. package/_site/_test/integration-runtime-binding-a2-test.js +566 -566
  134. package/_site/_test/integration-runtime-binding-a3-test.js +227 -227
  135. package/_site/_test/integration-runtime-binding-test.js +473 -473
  136. package/_site/_test/integration-status-scan-test.js +29 -29
  137. package/_site/_test/integration-surface-coverage-test.js +164 -164
  138. package/_site/_test/kb-framework-test.js +57 -57
  139. package/_site/_test/kbpath-follow-test.js +63 -63
  140. package/_site/_test/knowledge-db-test.js +144 -144
  141. package/_site/_test/knowledge-engine-client-test.js +120 -109
  142. package/_site/_test/knowledge-language-control-test.js +170 -170
  143. package/_site/_test/knowledge-maintenance-test.js +40 -40
  144. package/_site/_test/knowledge-promotion-recovery-test.js +260 -260
  145. package/_site/_test/knowledge-query-test.js +54 -54
  146. package/_site/_test/knowledge-retrieval-service-test.js +77 -77
  147. package/_site/_test/knowledge-scopes-test.js +37 -37
  148. package/_site/_test/knowledge-storage-location-test.js +28 -28
  149. package/_site/_test/knowledge-storage-startup-test.js +52 -52
  150. package/_site/_test/knowledge-store-logs-supervision-test.js +89 -89
  151. package/_site/_test/launcher-readiness-test.js +80 -80
  152. package/_site/_test/legacy-claude-session-alias-test.js +106 -106
  153. package/_site/_test/legacy-routes-removed-test.js +47 -47
  154. package/_site/_test/legacy-workbench-exclusion-test.js +92 -92
  155. package/_site/_test/live-gateway-test.js +48 -48
  156. package/_site/_test/local-marketplace-test.js +16 -16
  157. package/_site/_test/log-redaction-test.js +58 -58
  158. package/_site/_test/logging-api-test.js +89 -89
  159. package/_site/_test/logging-sse-no-gap-test.js +88 -88
  160. package/_site/_test/logging-ui-test.js +107 -107
  161. package/_site/_test/markdown-delta-overlay-test.js +85 -85
  162. package/_site/_test/markdown-maintenance-api-test.js +75 -75
  163. package/_site/_test/markdown-maintenance-test.js +11 -11
  164. package/_site/_test/mcp-server-test.js +149 -149
  165. package/_site/_test/model-context-windows-test.js +89 -89
  166. package/_site/_test/module-agent-start-proxy-test.js +65 -65
  167. package/_site/_test/module-artifact-integrity-test.js +27 -27
  168. package/_site/_test/module-boundary-test.js +30 -30
  169. package/_site/_test/module-bridge-eventbridge-test.js +202 -202
  170. package/_site/_test/module-dynamic-port-test.js +118 -118
  171. package/_site/_test/module-index-proxy-test.js +33 -33
  172. package/_site/_test/module-model-configuration-test.js +44 -44
  173. package/_site/_test/module-process-lifecycle-test.js +35 -35
  174. package/_site/_test/module-stream-proxy-test.js +34 -34
  175. package/_site/_test/module-terminal-restart-test.js +63 -63
  176. package/_site/_test/module-wiring-unbind-race-test.js +59 -59
  177. package/_site/_test/native-context-integrations-test.js +86 -86
  178. package/_site/_test/non-release-ci-test.js +34 -34
  179. package/_site/_test/offline-boundary-isolation-test.js +32 -32
  180. package/_site/_test/orphaned-claim-recovery-test.js +80 -80
  181. package/_site/_test/package-boundary-test.js +43 -43
  182. package/_site/_test/package-startup-test.js +56 -56
  183. package/_site/_test/packaged-ui-smoke-test.js +88 -88
  184. package/_site/_test/parallel-ci-r9-posix-socket-budget-test.js +156 -0
  185. package/_site/_test/parallel-engine-advanced-eng005-test.js +136 -0
  186. package/_site/_test/parallel-engine-advanced-eng006-test.js +171 -0
  187. package/_site/_test/parallel-engine-advanced-eng007-reconcile-test.js +231 -0
  188. package/_site/_test/parallel-engine-advanced-eng009-test.js +208 -0
  189. package/_site/_test/parallel-engine-advanced-eng010-test.js +213 -0
  190. package/_site/_test/parallel-engine-advanced-eng011-ui-probe.js +459 -0
  191. package/_site/_test/parallel-engine-final-eng002-test.js +255 -0
  192. package/_site/_test/parallel-engine-final-eng007-test.js +269 -0
  193. package/_site/_test/parallel-engine-final-eng011-test.js +197 -0
  194. package/_site/_test/parallel-engine-final-eng012-test.js +354 -0
  195. package/_site/_test/parallel-knowledge-client-error-code-test.js +184 -0
  196. package/_site/_test/parallel-knowledge-engine-test.js +353 -0
  197. package/_site/_test/parallel-knowledge-retrieval-test.js +292 -0
  198. package/_site/_test/parallel-knowledge-vector-test.js +168 -0
  199. package/_site/_test/parallel-linux-ci-r7-log007-day-test.js +110 -0
  200. package/_site/_test/parallel-logging-acceptance-log003-test.js +224 -0
  201. package/_site/_test/parallel-logging-acceptance-log004-test.js +231 -0
  202. package/_site/_test/parallel-logging-acceptance-log005-test.js +208 -0
  203. package/_site/_test/parallel-logging-acceptance-log006-test.js +214 -0
  204. package/_site/_test/parallel-logging-acceptance-log007-test.js +223 -0
  205. package/_site/_test/parallel-logging-acceptance-log008-test.js +180 -0
  206. package/_site/_test/parallel-logging-fix-r2-crash-probe.js +271 -0
  207. package/_site/_test/parallel-logging-fix-r2-gap-test.js +294 -0
  208. package/_site/_test/parallel-logging-fix-r2-normalhook-test.js +330 -0
  209. package/_site/_test/parallel-logging-integration-5025-portability-test.js +554 -0
  210. package/_site/_test/parallel-memory-candidate-test.js +301 -0
  211. package/_site/_test/parallel-memory-manual-test.js +197 -0
  212. package/_site/_test/parallel-memory-scope-test.js +228 -0
  213. package/_site/_test/parallel-memory-source-test.js +227 -0
  214. package/_site/_test/parallel-memory-sync-test.js +238 -0
  215. package/_site/_test/parallel-memory-view-r8-reload-gate-test.js +123 -0
  216. package/_site/_test/parallel-nav-failure-test.js +119 -0
  217. package/_site/_test/parallel-nav-isolation-test.js +308 -0
  218. package/_site/_test/parallel-nav-isolation-vector-path-test.js +90 -0
  219. package/_site/_test/parallel-nav-offline-test.js +272 -0
  220. package/_site/_test/parallel-nav-operability-test.js +257 -0
  221. package/_site/_test/parallel-nav-switch-test.js +247 -0
  222. package/_site/_test/parallel-nav-theme-test.js +130 -0
  223. package/_site/_test/parallel-nav-viewport-test.js +310 -0
  224. package/_site/_test/parallel-qa-engine-endpoint-guard-test.js +273 -0
  225. package/_site/_test/parallel-retrieval-followup-citation-test.js +219 -0
  226. package/_site/_test/parallel-retrieval-followup-corpus-test.js +287 -0
  227. package/_site/_test/parallel-workbench-independent-host.js +262 -0
  228. package/_site/_test/parallel-workbench-integration-5025-abort-probe.js +340 -0
  229. package/_site/_test/parallel-workbench-integration-5025-drafts-probe.js +588 -0
  230. package/_site/_test/parallel-workbench-integration-5025-env-check.js +317 -0
  231. package/_site/_test/parallel-workbench-integration-5025-model-probe.js +342 -0
  232. package/_site/_test/parallel-workbench-integration-5025-ordering-probe.js +326 -0
  233. package/_site/_test/parallel-workbench-model-probe.js +336 -0
  234. package/_site/_test/parallel-workbench-offline-test.js +169 -0
  235. package/_site/_test/parallel-workbench-provenance-trace.js +134 -0
  236. package/_site/_test/parallel-workbench-source-match.js +206 -0
  237. package/_site/_test/parallel-workbench-standalone-turn.js +349 -0
  238. package/_site/_test/path-consistency-test.js +151 -151
  239. package/_site/_test/pending-sweep-test.js +9 -9
  240. package/_site/_test/post-commit-automation-test.js +87 -87
  241. package/_site/_test/pr-consumer-contract-test.js +55 -55
  242. package/_site/_test/product-brand-contract-test.js +25 -25
  243. package/_site/_test/product-diagnostics-ui-test.js +93 -93
  244. package/_site/_test/product-empty-state-test.js +148 -0
  245. package/_site/_test/product-import-ui-test.js +219 -104
  246. package/_site/_test/product-navigation-test.js +152 -0
  247. package/_site/_test/product-progress-status-test.js +84 -84
  248. package/_site/_test/project-delete-recovery-test.js +64 -64
  249. package/_site/_test/project-domain-create-test.js +91 -0
  250. package/_site/_test/project-domain-scope-retrieval-test.js +214 -0
  251. package/_site/_test/project-git-init-confirm-test.js +115 -0
  252. package/_site/_test/project-goal-editor-test.js +144 -144
  253. package/_site/_test/project-import-duplicate-alias-test.js +134 -0
  254. package/_site/_test/project-import-unicode-restart-test.js +140 -0
  255. package/_site/_test/project-lifecycle-transaction-test.js +106 -106
  256. package/_site/_test/project-module-partial-recovery-test.js +222 -0
  257. package/_site/_test/project-remove-running-guard-test.js +79 -79
  258. package/_site/_test/project-store-test.js +90 -90
  259. package/_site/_test/project-unbind-late-read-ui-test.js +250 -0
  260. package/_site/_test/project-unbind-writer-guard-test.js +231 -0
  261. package/_site/_test/prompt-settings-test.js +115 -115
  262. package/_site/_test/protected-architecture-gate-test.js +122 -122
  263. package/_site/_test/refactor-characterization-test.js +36 -36
  264. package/_site/_test/registry-events-test.js +38 -38
  265. package/_site/_test/release-version-sync-test.js +115 -115
  266. package/_site/_test/requirement-binding-test.js +113 -113
  267. package/_site/_test/requirement-recorder-test.js +173 -173
  268. package/_site/_test/run-all-tests.js +173 -161
  269. package/_site/_test/runtime-endpoint-test.js +64 -64
  270. package/_site/_test/scanner-test.js +83 -83
  271. package/_site/_test/server-security-test.js +96 -96
  272. package/_site/_test/sessions-stream-test.js +93 -93
  273. package/_site/_test/shared-contracts-test.js +58 -58
  274. package/_site/_test/shared-scope-root-binding-test.js +74 -67
  275. package/_site/_test/simple-import-test.js +98 -98
  276. package/_site/_test/startup-analysis-disabled-test.js +16 -16
  277. package/_site/_test/storage-foundation-test.js +78 -78
  278. package/_site/_test/structured-logger-test.js +101 -101
  279. package/_site/_test/t07b-cln-005-coverage-test.js +212 -0
  280. package/_site/_test/team-knowledge-store-sparse-checkout-test.js +196 -196
  281. package/_site/_test/team-knowledge-store-test.js +132 -132
  282. package/_site/_test/test-server-cleanup-test.js +35 -35
  283. package/_site/_test/test-server-module-isolation-test.js +37 -37
  284. package/_site/_test/tracking-start-test.js +144 -144
  285. package/_site/_test/vector-storage-root-test.js +75 -75
  286. package/_site/_test/version-visibility-browser-probe.js +249 -0
  287. package/_site/_test/version-visibility-test.js +92 -0
  288. package/_site/_test/workbench-context-receipts-test.js +22 -22
  289. package/_site/_test/workbench-permission-test.js +117 -117
  290. package/_site/_test/zcode-capture-integration-test.js +112 -112
  291. package/_site/lib/ai-profile-resolver.js +78 -78
  292. package/_site/lib/ai-vendor-presets.js +83 -83
  293. package/_site/lib/ai-workspace.js +104 -104
  294. package/_site/lib/atomic-file.js +204 -204
  295. package/_site/lib/automation-config.js +140 -140
  296. package/_site/lib/bridge-adapter.js +260 -260
  297. package/_site/lib/bridge-consumer-service.js +402 -402
  298. package/_site/lib/capture-hook-config.js +108 -108
  299. package/_site/lib/claude-cli-runner.js +1599 -1599
  300. package/_site/lib/cli-instances.js +43 -43
  301. package/_site/lib/codex-context-status.js +17 -17
  302. package/_site/lib/codex-notify-config.js +193 -193
  303. package/_site/lib/commit-conversation-binder.js +247 -247
  304. package/_site/lib/commit-processing-ledger.js +27 -27
  305. package/_site/lib/commit-prompt.js +202 -202
  306. package/_site/lib/commit-reconciler.js +444 -444
  307. package/_site/lib/context-pack-builder.js +299 -299
  308. package/_site/lib/contracts.js +291 -291
  309. package/_site/lib/conversation-exclusions.js +204 -204
  310. package/_site/lib/conversation-query-service.js +161 -161
  311. package/_site/lib/conversation-store.js +387 -387
  312. package/_site/lib/data-dir.js +13 -13
  313. package/_site/lib/embedding-service.js +85 -85
  314. package/_site/lib/engine-connection.js +93 -93
  315. package/_site/lib/engine-knowledge-client.js +76 -72
  316. package/_site/lib/evidence-bundle.js +228 -228
  317. package/_site/lib/folder-picker-output.js +24 -24
  318. package/_site/lib/git-path.js +14 -14
  319. package/_site/lib/git-runner.js +62 -62
  320. package/_site/lib/github-team-store.js +1175 -1175
  321. package/_site/lib/hook-manager.js +203 -203
  322. package/_site/lib/index-builder.js +157 -157
  323. package/_site/lib/index-service.js +173 -173
  324. package/_site/lib/integration-auto-repair.js +77 -77
  325. package/_site/lib/integration-installer.js +1240 -1240
  326. package/_site/lib/integration-intent.js +88 -88
  327. package/_site/lib/integration-status-scan.js +99 -99
  328. package/_site/lib/integration-status-worker.js +14 -14
  329. package/_site/lib/kb-framework.js +191 -191
  330. package/_site/lib/kb-validator.js +124 -124
  331. package/_site/lib/knowledge-db.js +363 -363
  332. package/_site/lib/knowledge-promotion.js +435 -435
  333. package/_site/lib/knowledge-retrieval-service.js +401 -401
  334. package/_site/lib/knowledge-schema.js +121 -121
  335. package/_site/lib/knowledge-tool-runtime.js +366 -366
  336. package/_site/lib/live-gateway.js +78 -78
  337. package/_site/lib/llm-client.js +173 -173
  338. package/_site/lib/markdown-knowledge-indexer.js +230 -230
  339. package/_site/lib/model-context-windows.js +82 -82
  340. package/_site/lib/module-bridge.js +874 -874
  341. package/_site/lib/module-port-resolver.js +57 -57
  342. package/_site/lib/post-commit-automation.js +141 -141
  343. package/_site/lib/product-data.js +35 -35
  344. package/_site/lib/project-lifecycle-service.js +554 -554
  345. package/_site/lib/project-registry-store.js +129 -129
  346. package/_site/lib/project-store.js +268 -268
  347. package/_site/lib/prompt-registry.js +148 -148
  348. package/_site/lib/public-version-info.js +37 -0
  349. package/_site/lib/registry-events.js +16 -16
  350. package/_site/lib/requirement-adapters.js +10 -10
  351. package/_site/lib/requirement-binder.js +112 -112
  352. package/_site/lib/requirement-recorder.js +278 -278
  353. package/_site/lib/runtime-control.js +89 -89
  354. package/_site/lib/runtime-endpoint.js +160 -160
  355. package/_site/lib/scanner.js +298 -298
  356. package/_site/lib/server-app.js +1672 -1664
  357. package/_site/lib/settings-store.js +153 -153
  358. package/_site/lib/storage-layout.js +166 -166
  359. package/_site/lib/structured-logger.js +574 -574
  360. package/_site/lib/zcode-context-integration.js +60 -60
  361. package/_site/scripts/codex-notify-fanout.cjs +95 -95
  362. package/_site/scripts/folder-picker.ps1 +156 -156
  363. package/_site/scripts/hook-trigger.js +158 -158
  364. package/_site/scripts/install-module-candidates.js +69 -69
  365. package/_site/scripts/pack-module-candidates.js +89 -89
  366. package/_site/scripts/sync-product-content.js +28 -28
  367. package/_site/scripts/sync-release-version.js +164 -164
  368. package/_site/scripts/vendor-modules.js +49 -49
  369. package/_site/scripts/verify-module-runtime.js +50 -50
  370. package/_site/scripts/verify-product-runtime.js +36 -36
  371. package/_site/scripts/verify-retrieval-gold.js +83 -83
  372. package/_site/server.js +15 -15
  373. package/_site/start.bat +26 -26
  374. package/_site/stop.bat +11 -11
  375. package/bin/project-knowledge-integrations.js +254 -254
  376. package/bin/project-knowledge-kb.js +67 -67
  377. package/bin/project-knowledge-mcp.js +201 -201
  378. package/bin/project-knowledge.js +269 -170
  379. package/claude-prompts.json +21 -21
  380. package/docs/README.zh-CN.md +207 -205
  381. package/docs/assets/architecture.svg +181 -181
  382. package/docs/assets/logo.svg +49 -49
  383. package/docs/fresh-v5-start.md +44 -44
  384. package/docs/leader-glm-workbench-playbook.md +122 -122
  385. package/docs/project-registry-schema.md +33 -33
  386. package/docs/release-5.0.16-acceptance.md +176 -176
  387. package/docs/release-5.0.17-acceptance.md +117 -117
  388. package/docs/release-5.0.19-acceptance.md +39 -39
  389. package/docs/team-knowledge-mode-a-plan.md +73 -73
  390. package/docs/testing-strategy.md +33 -33
  391. package/module-runtime-manifest.json +66 -63
  392. package/package.json +113 -110
  393. package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
  394. package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
  395. package/plugins/project-knowledge/.mcp.json +1 -1
  396. package/templates/change.md +30 -30
  397. package/templates/project-readme.md +52 -52
  398. package/ui/index.html +3 -2
  399. package/ui/version-info.css +44 -0
  400. package/ui/version-info.js +24 -0
  401. package/vendor-manifest.json +55 -55
@@ -1,874 +1,874 @@
1
- 'use strict';
2
- /**
3
- * Module bridge: connectivity from project-knowledge to the embedded modules
4
- * (claude-ai-workbench Agent Terminal, vector-hub console, ai-coding-event-bridge
5
- * conversation console).
6
- *
7
- * The shell owns no features — it only connects and projects. This module is
8
- * that connection on the server side:
9
- * - reverse proxy for browser calls (keeps the UI same-origin; no CORS on modules)
10
- * - registration fan-out on project import (the "wiring table" lives in project state)
11
- * - aggregated project projection for the shell sidebar
12
- * - module removal with keepSessions semantics
13
- *
14
- * Configuration (env, loopback defaults):
15
- * KB_TERMINAL_URL default http://127.0.0.1:5760 (managed: dynamic when busy)
16
- * KB_VECTORHUB_URL default http://127.0.0.1:8787 (managed: dynamic when busy)
17
- * KB_EVENTBRIDGE_URL default http://127.0.0.1:8790 (managed: dynamic when busy)
18
- * KB_KNOWLEDGE_ENGINE_URL default http://127.0.0.1:5790 (managed: dynamic when busy)
19
- * An explicitly injected URL is treated as an external service and never
20
- * rewritten. Managed modules resolve a final URL before spawn: healthy
21
- * identity match reuses the port, otherwise the next free port from
22
- * preferred +0..+19 is taken; all busy fails loudly (NO_FREE_MODULE_PORT).
23
- * KB_MODULES_AUTOSTART '0' to stop spawning the vendored module services
24
- * (they start with the shell by default)
25
- * KB_TERMINAL_COMMAND explicit node command line for the terminal service
26
- * KB_VECTORHUB_COMMAND explicit node command line for the vector-hub service
27
- * KB_EVENTBRIDGE_COMMAND explicit node command line for the event-bridge console
28
- */
29
-
30
- const { spawn, spawnSync } = require('child_process');
31
- const net = require('net');
32
- const path = require('path');
33
- const { StorageLayout } = require('./storage-layout');
34
- const { resolveModuleUrl } = require('./module-port-resolver');
35
- const fs = require('fs');
36
- const crypto = require('crypto');
37
- const { once } = require('events');
38
- const { DomainError } = require('./contracts');
39
-
40
- const PROXY_TIMEOUT_MS = 10000;
41
- // Native client startup includes knowledge retrieval and a local server/CLI
42
- // handshake. It has a bounded setup budget, separate from ordinary reads/SSE.
43
- const AGENT_START_TIMEOUT_MS = 90000;
44
- // Registration drives a full embedding pass in vector-hub (remote API); a
45
- // fresh knowledge folder can easily exceed the interactive proxy budget.
46
- const REGISTER_TIMEOUT_MS = 60000;
47
- // Match knowledge-engine's index request budget for synchronous embedding.
48
- const INDEX_TIMEOUT_MS = 120000;
49
- // Native folder dialogs wait for the user without a bound.
50
- const PICKER_TIMEOUT_MS = 10 * 60 * 1000;
51
-
52
- // Vendored module runtimes shipped inside the npm package. Sibling-checkout
53
- // spawning is gone: the services are bundled, spawned from here, and resolved
54
- // through normal node_modules resolution
55
- // node_modules.
56
- // Module entry scripts resolve through normal node_modules dependencies
57
- // (claude-ai-workbench, @sanqianx/vector-hub, @sanqianx/ai-coding-event-bridge-
58
- // console) — the npm packages are the single source of these runtimes.
59
- const MODULE_ENTRIES = {
60
- terminal: 'claude-ai-workbench/packages/server/bin/agent-terminal-server.js',
61
- 'vector-hub': '@sanqianx/vector-hub/bin.js',
62
- 'event-bridge': '@sanqianx/ai-coding-event-bridge-console/src/bin.js',
63
- };
64
-
65
- function resolveModuleEntry(name) {
66
- if (name === 'knowledge-engine') {
67
- const runtime = path.resolve(__dirname, '..', '..', '_modules', 'knowledge-engine', 'src', 'bin.js');
68
- return fs.existsSync(runtime) ? runtime : null;
69
- }
70
- try { return require.resolve(MODULE_ENTRIES[name]); } catch { return null; }
71
- }
72
-
73
- function urlPort(baseUrl, fallback) {
74
- try { return new URL(baseUrl).port || String(fallback); } catch { return String(fallback); }
75
- }
76
-
77
- function urlHost(baseUrl) {
78
- try { return new URL(baseUrl).hostname || '127.0.0.1'; } catch { return '127.0.0.1'; }
79
- }
80
-
81
- function defaultTerminalUrl() {
82
- return String(process.env.KB_TERMINAL_URL || 'http://127.0.0.1:5760').replace(/\/+$/, '');
83
- }
84
-
85
- function defaultVectorHubUrl() {
86
- return String(process.env.KB_VECTORHUB_URL || 'http://127.0.0.1:8787').replace(/\/+$/, '');
87
- }
88
-
89
- function defaultEventBridgeUrl() {
90
- return String(process.env.KB_EVENTBRIDGE_URL || 'http://127.0.0.1:8790').replace(/\/+$/, '');
91
- }
92
-
93
- function defaultEngineUrl() { return String(process.env.KB_KNOWLEDGE_ENGINE_URL || 'http://127.0.0.1:5790').replace(/\/+$/, ''); }
94
-
95
- // Health identity markers: an HTTP 200 alone can come from any foreign
96
- // service that grabbed the port. Only a body that matches the product
97
- // counts as "our module already running" (reuse); anything else is
98
- // foreign and the resolver moves to another port instead of crash-looping.
99
- const MODULE_IDENTITY = {
100
- terminal: body => body && typeof body === 'object' && body.status === 'ok' && typeof body.apiVersion === 'string',
101
- vectorHub: body => body && typeof body === 'object' && Array.isArray(body.projects),
102
- eventBridge: body => body && typeof body === 'object' && typeof body.registeredProjects === 'number',
103
- engine: body => body && typeof body === 'object' && body.name === 'knowledge-engine',
104
- };
105
-
106
- // kind -> bridge URL property, explicit env override, spawn spec name
107
- const MODULE_URL_TARGETS = [
108
- { kind: 'terminal', prop: 'terminalUrl', env: 'KB_TERMINAL_URL', specName: 'terminal' },
109
- { kind: 'vectorHub', prop: 'vectorHubUrl', env: 'KB_VECTORHUB_URL', specName: 'vector-hub' },
110
- { kind: 'eventBridge', prop: 'eventBridgeUrl', env: 'KB_EVENTBRIDGE_URL', specName: 'event-bridge' },
111
- { kind: 'engine', prop: 'engineUrl', env: 'KB_KNOWLEDGE_ENGINE_URL', specName: 'knowledge-engine' },
112
- ];
113
-
114
- function specNameToKind(specName) {
115
- return MODULE_URL_TARGETS.find(target => target.specName === specName)?.kind || null;
116
- }
117
-
118
- function tcpPortFree(host, port) {
119
- return new Promise(resolve => {
120
- const tester = net.createServer();
121
- let done = false;
122
- const finish = free => { if (done) return; done = true; tester.close(() => resolve(free)); };
123
- tester.once('error', () => finish(false));
124
- tester.once('listening', () => finish(true));
125
- tester.listen(port, host);
126
- });
127
- }
128
-
129
- class ModuleBridge {
130
- constructor(options = {}) {
131
- this.logger = options.logger || { info: () => {}, warn: () => {}, error: () => {} };
132
- this.terminalUrl = options.terminalUrl || defaultTerminalUrl();
133
- this.vectorHubUrl = options.vectorHubUrl || defaultVectorHubUrl();
134
- this.eventBridgeUrl = options.eventBridgeUrl || defaultEventBridgeUrl();
135
- this.engineUrl = options.engineUrl || defaultEngineUrl();
136
- // Explicitly injected URLs (option or env) mean an external service;
137
- // only managed defaults participate in dynamic port resolution.
138
- this.explicitModuleUrls = {
139
- terminal: Boolean(options.terminalUrl || process.env.KB_TERMINAL_URL),
140
- vectorHub: Boolean(options.vectorHubUrl || process.env.KB_VECTORHUB_URL),
141
- eventBridge: Boolean(options.eventBridgeUrl || process.env.KB_EVENTBRIDGE_URL),
142
- engine: Boolean(options.engineUrl || process.env.KB_KNOWLEDGE_ENGINE_URL),
143
- };
144
- this.moduleUrlSources = { terminal: 'pending', vectorHub: 'pending', eventBridge: 'pending', engine: 'pending' };
145
- this.runtimeToken = options.runtimeToken || crypto.randomBytes(32).toString('hex');
146
- this.registryStore = options.registryStore || null;
147
- this.projectStore = options.projectStore || null;
148
- this.projectWiringQueue = new Map();
149
- this.settingsStore = options.settingsStore || null;
150
- this.dataDir = options.dataDir || null;
151
- this.supervised = [];
152
- }
153
-
154
- _terminalApi(pathname) {
155
- // /api/terminal/<rest> -> <terminal>/api/claude-workbench/v1/<rest>
156
- const rest = pathname.replace(/^\/api\/terminal\/?/, '');
157
- return `${this.terminalUrl}/api/claude-workbench/v1/${rest}`;
158
- }
159
-
160
- _vectorHubApi(pathname) {
161
- // /api/vectorhub/<rest> -> <vectorhub>/api/<rest>
162
- const rest = pathname.replace(/^\/api\/vectorhub\/?/, '');
163
- return `${this.vectorHubUrl}/api/${rest}`;
164
- }
165
-
166
- _eventBridgeApi(pathname) {
167
- // /api/eventbridge/<rest> -> <event-bridge console>/api/<rest>
168
- const rest = pathname.replace(/^\/api\/eventbridge\/?/, '');
169
- return `${this.eventBridgeUrl}/api/${rest}`;
170
- }
171
-
172
- async _fetch(url, init = {}) {
173
- const controller = new AbortController();
174
- const timer = setTimeout(() => controller.abort(), init.timeoutMs || PROXY_TIMEOUT_MS);
175
- try {
176
- return await fetch(url, { ...init, signal: controller.signal });
177
- } finally {
178
- clearTimeout(timer);
179
- }
180
- }
181
-
182
- /** Same-origin proxy. SSE has a headers deadline, never a whole-turn deadline. */
183
- async proxy(req, res, target) {
184
- const chunks = [];
185
- for await (const chunk of req) chunks.push(chunk);
186
- const body = chunks.length ? Buffer.concat(chunks) : undefined;
187
- // The native folder pickers hold the request open until the user closes
188
- // the dialog — they need a human-scale budget, not the API budget.
189
- const isPicker = /\/(system\/)?pick-folder/.test(target);
190
- const targetUrl = new URL(target);
191
- const pathname = targetUrl.pathname;
192
- const isAgentStart = req.method === 'POST' && /^\/api\/claude-workbench\/v1\/sessions(?:\/[^/]+\/input)?\/?$/.test(pathname);
193
- const isIndex = req.method === 'POST' && pathname === '/api/sync' && targetUrl.origin === new URL(this.vectorHubUrl).origin;
194
- const timeoutMs = isPicker ? PICKER_TIMEOUT_MS : isAgentStart ? AGENT_START_TIMEOUT_MS : isIndex ? INDEX_TIMEOUT_MS : PROXY_TIMEOUT_MS;
195
- const startedAt = Date.now();
196
- let upstream;
197
- const controller = new AbortController();
198
- const timer = setTimeout(() => controller.abort(), timeoutMs);
199
- const disconnected = () => { if (!res.writableEnded) controller.abort(); };
200
- res.once?.('close', disconnected);
201
- try {
202
- upstream = await fetch(target, {
203
- method: req.method,
204
- signal: controller.signal,
205
- headers: { ...(body ? { 'Content-Type': req.headers['content-type'] || 'application/json' } : {}), ...(req.headers['last-event-id'] ? { 'Last-Event-ID': req.headers['last-event-id'] } : {}) },
206
- body: ['GET', 'HEAD'].includes(req.method) ? undefined : body,
207
- });
208
- } catch (error) {
209
- clearTimeout(timer);
210
- res.off?.('close', disconnected);
211
- if (res.destroyed) return;
212
- const details = { failureKind: controller.signal.aborted ? 'UPSTREAM_TIMEOUT' : 'NETWORK_ERROR', timeoutMs };
213
- this.logger.warn('module.proxy.failed', '模块请求未完成,请检查连接或客户端启动。', {
214
- component: 'module-bridge', phase: 'proxy', durationMs: Date.now() - startedAt,
215
- context: { ...details, method: req.method, path: pathname },
216
- });
217
- res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
218
- res.end(JSON.stringify({ ok: false, error: 'MODULE_UNREACHABLE', target: targetUrl.origin + pathname, details }));
219
- return;
220
- }
221
- const contentType = upstream.headers.get('content-type') || 'application/json; charset=utf-8';
222
- if (contentType.includes('text/event-stream')) {
223
- clearTimeout(timer);
224
- res.writeHead(upstream.status, { 'Content-Type': contentType, 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
225
- res.flushHeaders?.();
226
- try {
227
- for await (const chunk of upstream.body) {
228
- if (res.destroyed) break;
229
- if (!res.write(Buffer.from(chunk))) await once(res, 'drain', { signal: controller.signal });
230
- }
231
- if (!res.destroyed) res.end();
232
- } catch { if (!res.destroyed) res.destroy(); }
233
- finally { controller.abort(); res.off?.('close', disconnected); }
234
- } else {
235
- try {
236
- const buf = Buffer.from(await upstream.arrayBuffer());
237
- res.writeHead(upstream.status, { 'Content-Type': contentType });
238
- res.end(buf);
239
- } finally { clearTimeout(timer); res.off?.('close', disconnected); }
240
- }
241
- }
242
-
243
- proxyTerminal(req, res, pathname, search) {
244
- return this.proxy(req, res, this._terminalApi(pathname) + (search || ''));
245
- }
246
-
247
- proxyVectorHub(req, res, pathname, search) {
248
- return this.proxy(req, res, this._vectorHubApi(pathname) + (search || ''));
249
- }
250
-
251
- proxyEventBridge(req, res, pathname, search) {
252
- return this.proxy(req, res, this._eventBridgeApi(pathname) + (search || ''));
253
- }
254
-
255
- proxyEngine(req, res, pathname, search) {
256
- if (this.engineCompatible === false) throw new DomainError('ENGINE_CONNECTION_PENDING', '知识引擎连接尚未确认,请查看系统状态;不会读取旧服务中的知识。', { status: 503 });
257
- return this.proxy(req, res, `${this.engineUrl}/api/${pathname.replace(/^\/api\/engine\/?/, '')}${search || ''}`);
258
- }
259
-
260
- proxyModuleUi(req, res, module, rest, search) {
261
- const base = { terminal: this.terminalUrl, vectorhub: this.vectorHubUrl, eventbridge: this.eventBridgeUrl, engine: this.engineUrl }[module];
262
- let decoded;
263
- try { decoded = decodeURIComponent(rest); } catch { decoded = null; }
264
- // Reject encoded separators/dot segments before URL normalization. Static
265
- // mounts must never become a second route to a module's private API.
266
- if (!base || req.method !== 'GET' || decoded === null || /[\\\x00-\x1f]/.test(decoded) || decoded.split('/').some(part => ['.', '..', 'api', 'internal'].includes(part.toLowerCase())) || /%[0-9a-f]{2}/i.test(decoded)) { res.statusCode = 404; res.end('Not found'); return; }
267
- const target = new URL(`${base}/${rest}${search || ''}`);
268
- if (target.origin !== new URL(base).origin) { res.statusCode = 404; res.end('Not found'); return; }
269
- return this.proxy(req, res, target.href);
270
- }
271
-
272
- async _listTerminalProjects({ requireAvailable = false } = {}) {
273
- try {
274
- const response = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/projects`, { timeoutMs: 2500 });
275
- if (!response.ok) throw new Error('TERMINAL_REGISTRY_UNAVAILABLE');
276
- const payload = await response.json();
277
- const list = Array.isArray(payload) ? payload : payload.projects;
278
- if (!Array.isArray(list)) throw new Error('TERMINAL_REGISTRY_INVALID');
279
- return list;
280
- } catch { return requireAvailable ? null : []; }
281
- }
282
-
283
- async _listVectorHubProjects() {
284
- try {
285
- const response = await this._fetch(`${this.vectorHubUrl}/api/projects`, { timeoutMs: 2500 });
286
- if (!response.ok) return [];
287
- const payload = await response.json();
288
- return Array.isArray(payload.projects) ? payload.projects : [];
289
- } catch { return []; }
290
- }
291
-
292
- async _listEventBridgeProjects() {
293
- try {
294
- const response = await this._fetch(`${this.eventBridgeUrl}/api/projects`, { timeoutMs: 2500 });
295
- if (!response.ok) return [];
296
- const payload = await response.json();
297
- return Array.isArray(payload.projects) ? payload.projects : [];
298
- } catch { return []; }
299
- }
300
-
301
- async _listEngineProjects() {
302
- if (this.engineCompatible === false) return [];
303
- try {
304
- const response = await this._fetch(`${this.engineUrl}/api/projects`, { timeoutMs: 2500 });
305
- if (!response.ok) return [];
306
- const payload = await response.json();
307
- return Array.isArray(payload.projects) ? payload.projects : [];
308
- } catch { return []; }
309
- }
310
-
311
- /** Aggregated projection for the shell sidebar: pkb registry is the spine. */
312
- async aggregatedProjects() {
313
- const healthJob = this.modulesHealth();
314
- const [terminalProjects, vectorHubProjects, eventBridgeProjects, engineProjects] = await Promise.all([
315
- this._listTerminalProjects(),
316
- this._listVectorHubProjects(),
317
- this._listEventBridgeProjects(),
318
- this._listEngineProjects(),
319
- ]);
320
- const ids = this.registryStore ? this.registryStore.listIds() : [];
321
- const byWorkspace = new Map(terminalProjects.map(p => [String(p.rootPath || '').toLowerCase(), p]));
322
- const byName = new Map(vectorHubProjects.map(p => [String(p.name), p]));
323
- const ebByPath = new Map(eventBridgeProjects.filter(p => p.path).map(p => [String(p.path).toLowerCase(), p]));
324
- const projects = ids.map(projectId => {
325
- const config = this.projectStore ? (this.projectStore.readConfig(projectId) || {}) : {};
326
- const workspacePath = String(config.repoPath || '');
327
- const knowledgePath = String(config.knowledgePath || '');
328
- const state = this.projectStore ? this.projectStore.readState(projectId) : {};
329
- const wiring = state.modules || {};
330
- const terminal = (wiring.terminal && wiring.terminal.projectId ? terminalProjects.find(p => p.id === wiring.terminal.projectId) : null)
331
- || byWorkspace.get(workspacePath.toLowerCase())
332
- || null;
333
- const vectorHubName = (wiring.vectorHub && wiring.vectorHub.projectName) || projectId;
334
- const vectorHub = byName.get(vectorHubName) || null;
335
- const eventBridge = ebByPath.get(workspacePath.toLowerCase())
336
- || (wiring.eventBridge && wiring.eventBridge.projectId ? eventBridgeProjects.find(p => p.id === wiring.eventBridge.projectId) : null)
337
- || null;
338
- return {
339
- projectId,
340
- name: config.displayName || projectId,
341
- workspacePath,
342
- knowledgePath,
343
- brainId: config.brainId || 'brain-default',
344
- domainId: config.domainId || null,
345
- modules: {
346
- terminal: terminal ? { registered: true, projectId: terminal.id, name: terminal.name } : { registered: false, status: wiring.terminal && wiring.terminal.status },
347
- vectorHub: vectorHub ? { registered: true, projectName: vectorHub.name, docCount: vectorHub.docCount, chunkCount: vectorHub.chunkCount, watching: !!vectorHub.watching } : { registered: false, status: wiring.vectorHub && wiring.vectorHub.status },
348
- eventBridge: eventBridge ? { registered: true, projectId: eventBridge.id, turns: eventBridge.turns, auto: !!eventBridge.auto } : { registered: false, status: wiring.eventBridge && wiring.eventBridge.status },
349
- engine: engineProjects.some(p => p.projectId === projectId) ? { registered: true, projectId } : { registered: false, status: wiring.engine && wiring.engine.status },
350
- },
351
- };
352
- });
353
- const health = (await healthJob).modules;
354
- return {
355
- projects,
356
- terminalAvailable: health.terminal.up, vectorHubAvailable: health.vectorHub.up, eventBridgeAvailable: health.eventBridge.up, engineAvailable: health.engine.up,
357
- };
358
- }
359
-
360
- /**
361
- * Classify what is listening on a module URL.
362
- * 'module' HTTP 2xx + product identity marker -> safe to reuse
363
- * 'foreign' HTTP responded but identity mismatch -> never reuse
364
- * 'unreachable' no HTTP answer (free port or non-HTTP listener)
365
- */
366
- async _probeClass(base, kind) {
367
- const route = kind === 'terminal' ? '/api/claude-workbench/v1/health' : '/api/health';
368
- const marker = MODULE_IDENTITY[kind];
369
- if (!marker) return 'unreachable';
370
- try {
371
- const response = await this._fetch(`${base}${route}`, { timeoutMs: 2000 });
372
- if (!response.ok) return 'unreachable';
373
- let body;
374
- try { body = await response.json(); } catch { return 'foreign'; }
375
- return marker(body) ? 'module' : 'foreign';
376
- } catch { return 'unreachable'; }
377
- }
378
-
379
- async _probe(base, kind) {
380
- const resolvedKind = kind || (base === this.terminalUrl ? 'terminal' : null);
381
- if (!resolvedKind) return false;
382
- return (await this._probeClass(base, resolvedKind)) === 'module';
383
- }
384
-
385
- async modulesHealth() {
386
- const [terminal, vectorHub, eventBridge, engine] = await Promise.all([
387
- this._probe(this.terminalUrl, 'terminal'),
388
- this._probe(this.vectorHubUrl, 'vectorHub'),
389
- this._probe(this.eventBridgeUrl, 'eventBridge'),
390
- this._probe(this.engineUrl, 'engine'),
391
- ]);
392
- return { ok: true, modules: {
393
- terminal: { url: this.terminalUrl, up: terminal, source: this.moduleUrlSources.terminal },
394
- vectorHub: { url: this.vectorHubUrl, up: vectorHub, source: this.moduleUrlSources.vectorHub },
395
- eventBridge: { url: this.eventBridgeUrl, up: eventBridge, source: this.moduleUrlSources.eventBridge },
396
- engine: { url: this.engineUrl, up: engine, source: this.moduleUrlSources.engine },
397
- } };
398
- }
399
-
400
- syncEngineConfiguration({ force = false } = {}) {
401
- const signature = () => { try { return crypto.createHash('sha256').update(JSON.stringify(this.settingsStore?.read() || {})).digest('hex'); } catch { return ''; } };
402
- if (this.configSyncPromise) {
403
- if (force || signature() !== this.configSyncSettings) this.configSyncDirty = true;
404
- return this.configSyncPromise;
405
- }
406
- const pending = (async () => {
407
- let result;
408
- do { this.configSyncDirty = false; this.configSyncSettings = signature(); result = await this._syncEngineConfiguration(); } while (this.configSyncDirty && !this.stopped);
409
- return result;
410
- })().finally(() => { if (this.configSyncPromise === pending) this.configSyncPromise = null; });
411
- this.configSyncPromise = pending;
412
- return pending;
413
- }
414
-
415
- resetRuntimeConfiguration() {
416
- this.configFingerprints = {};
417
- this.configGeneration = (this.configGeneration || 0) + 1;
418
- if (this.configSyncPromise) this.configSyncDirty = true;
419
- }
420
-
421
- async _syncEngineConfiguration() {
422
- const applyConfiguration = async (key, url, body) => {
423
- this.configFingerprints ||= {};
424
- const generation = this.configGeneration;
425
- const fingerprint = crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex');
426
- if (this.configFingerprints[key]?.fingerprint === fingerprint) return this.configFingerprints[key].result;
427
- const response = await this._fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.runtimeToken}` }, body: JSON.stringify(body) });
428
- const result = { ok: response.ok, data: response.ok ? await response.json().catch(() => ({})) : null };
429
- if (result.ok && this.configGeneration === generation) this.configFingerprints[key] = { fingerprint, result };
430
- else delete this.configFingerprints[key];
431
- return result;
432
- };
433
- const dataDir = this.dataDir || require('./data-dir').getDataDir();
434
- let indexPath = null;
435
- try { indexPath = new StorageLayout({ dataDir }).getVectorRootPath(this.settingsStore?.read()); }
436
- catch { /* Corrupt settings must not switch storage or prevent credential revocation. */ }
437
- const status = { scopes: false, scopeStatus: { status: 'pending' }, model: false, embedding: false, indexStorage: { expectedPath: indexPath, actualPath: null, status: 'pending' } };
438
- const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${this.runtimeToken}` };
439
- try {
440
- if (this.settingsStore) {
441
- const settings = this.settingsStore.read();
442
- if (settings.knowledge.rootPath) {
443
- const brain = settings.brain;
444
- const root = path.join(settings.knowledge.rootPath, '_shared');
445
- const response = await this._fetch(`${this.engineUrl}/api/snapshot?summary=1&limit=1`);
446
- if (!response.ok) throw new DomainError('ENGINE_UNAVAILABLE', '共享知识接线暂时不可用。');
447
- const prior = (await response.json()).sharedScopes || [];
448
- // Root settings govern new directories and derived indexes, never move
449
- // existing knowledge. Retain engine-acknowledged bindings in the spine.
450
- const binding = (item, kind) => {
451
- const existing = prior.find(scope => scope.id === item.id && scope.kind === kind && scope.brainId === brain.id);
452
- if (existing && !path.isAbsolute(existing.knowledgePath || '')) throw new DomainError('DATA_CORRUPT', '共享目录绑定损坏。');
453
- return item.knowledgePath || existing?.knowledgePath || path.join(root, item.id);
454
- };
455
- const scopes = [{ id: brain.id, kind: 'brain', brainId: brain.id, name: brain.name, knowledgePath: binding(brain, 'brain') }, ...brain.domains.map(d => ({ id: d.id, kind: 'domain', brainId: brain.id, domainId: d.id, name: d.name, knowledgePath: binding(d, 'domain') }))];
456
- const applied = await applyConfiguration('scopes', `${this.engineUrl}/api/scopes`, { scopes });
457
- if (!applied.ok) throw new DomainError('SHARED_SCOPE_APPLY_FAILED', '共享目录接线未完成。');
458
- await this.settingsStore.bindSharedKnowledgePaths?.(scopes);
459
- status.scopes = true; status.scopeStatus = { status: 'ready' };
460
- }
461
- }
462
- } catch (error) {
463
- status.scopeStatus = { status: 'pending', code: ['DATA_CORRUPT', 'IMMUTABLE_FIELD', 'ENGINE_UNAVAILABLE', 'SHARED_SCOPE_APPLY_FAILED'].includes(error.code) ? error.code : 'ENGINE_UNAVAILABLE' };
464
- this.logger.warn('modules.scopes.pending', 'Shared knowledge wiring is pending; existing data is retained.', { context: { code: status.scopeStatus.code } });
465
- // A scope failure must not prevent revoking a stale model key.
466
- }
467
- try {
468
- let profile = null;
469
- try {
470
- const response = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/internal/maintenance-config`, { method: 'POST', headers, body: '{}' });
471
- if (response.ok) profile = await response.json();
472
- } catch { /* Missing configuration must clear a previously resolved key. */ }
473
- const response = await applyConfiguration('model', `${this.engineUrl}/api/runtime-config`, { projectId: 'default', profile });
474
- status.model = response.ok && !!profile;
475
- } catch { /* Status projection only: never log resolved profiles or raw errors. */ }
476
- try {
477
- let settings = null;
478
- try {
479
- const resolved = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/internal/embedding-config`, { method: 'POST', headers, body: '{}' });
480
- if (resolved.ok) settings = await resolved.json();
481
- } catch { /* Revocation wins over a previously cached runtime key. */ }
482
- const applied = await applyConfiguration('embedding', `${this.vectorHubUrl}/api/internal/runtime-config`, { settings, ...(indexPath ? { storageRoot: indexPath } : {}) });
483
- status.embedding = applied.ok && !!settings;
484
- if (applied.ok) {
485
- const result = applied.data;
486
- status.indexStorage.actualPath = result.rootPath || null;
487
- status.indexStorage.status = result.rootPath && path.resolve(result.rootPath) === indexPath ? 'ready' : 'pending';
488
- }
489
- } catch { /* Public status contains no resolved configuration. */ }
490
- this.configStatus = status;
491
- return status;
492
- }
493
-
494
- /**
495
- * Register one pkb project into both modules and persist the wiring table.
496
- * Best-effort per module: a module that is down is recorded as 'pending'
497
- * (retry on the next register call or health-check pass), never a hard error.
498
- * KB_MODULES_REGISTER=0 disables fan-out entirely (test isolation).
499
- */
500
- _withProjectWiring(projectId, action) {
501
- const prior = this.projectWiringQueue.get(projectId) || Promise.resolve();
502
- const operation = prior.catch(() => {}).then(action);
503
- const settled = operation.finally(() => { if (this.projectWiringQueue.get(projectId) === settled) this.projectWiringQueue.delete(projectId); });
504
- this.projectWiringQueue.set(projectId, settled);
505
- return settled;
506
- }
507
-
508
- registerProjectLinks(input) {
509
- return this._withProjectWiring(input.projectId, () => {
510
- // A supervisor may have captured this project before a concurrent unbind.
511
- // Recheck the spine after acquiring the same lease as the removal path.
512
- if (this.registryStore && !this.registryStore.readDisplaySnapshot(input.projectId)) return Object.fromEntries(['terminal', 'vectorHub', 'eventBridge', 'engine'].map(key => [key, { ok: true, status: 'removed', skipped: 'project-not-registered' }]));
513
- return this._registerProjectLinks(input);
514
- });
515
- }
516
-
517
- async _registerProjectLinks({ projectId, name, workspacePath, knowledgePath }) {
518
- const result = { terminal: { ok: false }, vectorHub: { ok: false }, eventBridge: { ok: false }, engine: { ok: false } };
519
- if (String(process.env.KB_MODULES_REGISTER || '') === '0') {
520
- result.terminal = { ok: false, status: 'disabled' };
521
- result.vectorHub = { ok: false, status: 'disabled' };
522
- result.eventBridge = { ok: false, status: 'disabled' };
523
- result.engine = { ok: false, status: 'disabled' };
524
- return result;
525
- }
526
- // 幂等登记:终端已有同 rootPath 的项目时复用,绝不重复创建
527
- const terminalProjects = await this._listTerminalProjects({ requireAvailable: true });
528
- const existingTerminal = terminalProjects?.find(p => String(p.rootPath || '').toLowerCase() === String(workspacePath || '').toLowerCase());
529
- if (terminalProjects === null) {
530
- // Startup/reconnect failures do not prove the workspace is absent. Keep
531
- // its saved context so a retry cannot strand existing sessions/drafts.
532
- result.terminal = { ok: false, status: 'pending' };
533
- } else if (existingTerminal) {
534
- result.terminal = { ok: true, projectId: existingTerminal.id, status: 'registered', reused: true };
535
- } else {
536
- try {
537
- const response = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/projects`, {
538
- method: 'POST',
539
- headers: { 'Content-Type': 'application/json' },
540
- body: JSON.stringify({ name: name || projectId, rootPath: workspacePath }),
541
- });
542
- const payload = response.status === 201 || response.ok ? await response.json().catch(() => ({})) : {};
543
- result.terminal = { ok: response.ok, projectId: payload.id || payload.projectId || null, status: response.ok ? 'registered' : 'error' };
544
- } catch {
545
- result.terminal = { ok: false, status: 'pending' };
546
- }
547
- }
548
- const existingVector = (await this._listVectorHubProjects()).find(p => p.name === projectId && String(p.sourceDir || '').toLowerCase() === String(knowledgePath || '').toLowerCase());
549
- if (existingVector) result.vectorHub = { ok: true, projectName: projectId, status: 'registered', reused: true };
550
- else try {
551
- const response = await this._fetch(`${this.vectorHubUrl}/api/import`, {
552
- method: 'POST',
553
- headers: { 'Content-Type': 'application/json' },
554
- timeoutMs: REGISTER_TIMEOUT_MS,
555
- body: JSON.stringify({ sourceDir: knowledgePath, projectName: projectId, watch: false }),
556
- });
557
- result.vectorHub = { ok: response.ok, projectName: projectId, status: response.ok ? 'registered' : 'error' };
558
- } catch {
559
- result.vectorHub = { ok: false, status: 'pending' };
560
- }
561
- // 幂等登记:会话控制台按 path(Git work tree)复用已有登记。store 遵循
562
- // event-bridge 的推荐布局 <知识库>/dev-conversations;无知识库地址时交给
563
- // 控制台的默认 store(全局 journal),登记本身不受阻。
564
- const existingEventBridge = (await this._listEventBridgeProjects())
565
- .find(p => !p.auto && String(p.path || '').toLowerCase() === String(workspacePath || '').toLowerCase());
566
- if (existingEventBridge) {
567
- result.eventBridge = { ok: true, projectId: existingEventBridge.id, status: 'registered', reused: true };
568
- } else {
569
- try {
570
- const body = { path: workspacePath };
571
- if (knowledgePath) body.store = path.join(knowledgePath, 'dev-conversations');
572
- const response = await this._fetch(`${this.eventBridgeUrl}/api/projects`, {
573
- method: 'POST',
574
- headers: { 'Content-Type': 'application/json' },
575
- body: JSON.stringify(body),
576
- });
577
- const payload = response.status === 201 || response.ok ? await response.json().catch(() => ({})) : {};
578
- const registeredProject = payload.project || {};
579
- result.eventBridge = { ok: response.ok, projectId: registeredProject.id || null, status: response.ok ? 'registered' : 'error', errorCode: (!response.ok && payload.error && payload.error.code) || undefined };
580
- } catch {
581
- result.eventBridge = { ok: false, status: 'pending' };
582
- }
583
- }
584
- const config = this.projectStore ? this.projectStore.readConfig(projectId) : {};
585
- const state = this.projectStore ? this.projectStore.readState(projectId) : {};
586
- try {
587
- const baselines = { ...(state.conversationBaselineCursors || {}), global: state.conversationBaselineCursor ?? 0 };
588
- const journalId = result.eventBridge.projectId || state.modules?.eventBridge?.projectId;
589
- if (journalId && baselines[journalId] === undefined) {
590
- const watermark = result.eventBridge.reused && await this._fetch(`${this.eventBridgeUrl}/api/journal/high-watermark?journalId=${encodeURIComponent(journalId)}`);
591
- const current = result.eventBridge.reused ? watermark.ok && await watermark.json() : { sequence: 0 };
592
- if (!current || !Number.isSafeInteger(current.sequence)) throw new Error('JOURNAL_BASELINE_UNAVAILABLE');
593
- baselines[journalId] = current.sequence;
594
- if (this.projectStore) await this.projectStore.updateState(projectId, draft => { draft.conversationBaselineCursors = baselines; });
595
- }
596
- const response = await this._fetch(`${this.engineUrl}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId, name: name || projectId, repoPath: workspacePath, knowledgePath, knowledgeLanguage: config.knowledgeLanguage || 'zh-CN', brainId: config.brainId || 'brain-default', domainId: config.domainId || null, repoIdentity: config.repoIdentity, journalId: journalId || null, vectorProjectId: projectId, trackingStartCommit: state.trackingStartCommit || state.lastAnalyzedCommit || null, conversationBaselineCursor: state.conversationBaselineCursor ?? 0, conversationBaselineCursors: baselines, profileId: config.aiProfileId || null }) });
597
- result.engine = { ok: response.ok, projectId, status: response.ok ? 'registered' : 'error' };
598
- } catch { result.engine = { ok: false, status: 'pending' }; }
599
- await this._writeWiring(projectId, wiring => {
600
- wiring.terminal = { ...wiring.terminal, ...result.terminal, name: name || projectId, rootPath: workspacePath, updatedAt: new Date().toISOString() };
601
- wiring.vectorHub = { ...wiring.vectorHub, ...result.vectorHub, sourceDir: knowledgePath, updatedAt: new Date().toISOString() };
602
- wiring.eventBridge = { ...wiring.eventBridge, ...result.eventBridge, path: workspacePath, store: knowledgePath ? path.join(knowledgePath, 'dev-conversations') : '', updatedAt: new Date().toISOString() };
603
- wiring.engine = { ...wiring.engine, ...result.engine, updatedAt: new Date().toISOString() };
604
- });
605
- this.logger.info('modules.register', 'Module registration finished.', { context: { projectId, terminal: result.terminal.status, vectorHub: result.vectorHub.status, eventBridge: result.eventBridge.status } });
606
- return result;
607
- }
608
-
609
- /** Remove module registrations. Local data is preserved by contract: vector-hub
610
- * only drops its derived index (source untouched); terminal removes the project
611
- * with keepSessions=1 so session records survive; the event-bridge console only
612
- * unregisters the project — journals and sealed commit documents stay on disk. */
613
- removeProjectLinks(input) {
614
- return this._withProjectWiring(input.projectId, async () => {
615
- const result = await this._removeProjectLinks(input);
616
- // The caller owns registry/lifecycle changes. Hold the wiring lease until
617
- // its transaction settles, so delayed reconfiguration cannot resurrect it.
618
- await input.onRemoved?.(result);
619
- return result;
620
- });
621
- }
622
-
623
- async _removeProjectLinks({ projectId, requireEngine = true }) {
624
- const wiring = (this.projectStore ? this.projectStore.readState(projectId).modules : null) || {};
625
- const result = { terminal: { ok: false }, vectorHub: { ok: false }, eventBridge: { ok: false }, engine: { ok: false } };
626
- // Stop the writer first, before removing hooks/index registrations.
627
- try {
628
- if (!requireEngine) result.engine = { ok: true, status: 'not-owner' };
629
- else {
630
- const response = await this._fetch(`${this.engineUrl}/api/projects/${encodeURIComponent(projectId)}`, { method: 'DELETE' });
631
- if (response.status === 409) throw new DomainError('PROJECT_BUSY', '此项目正在执行知识维护,请等待写入结束后再解绑。未开始的排队任务会随解绑取消。', { status: 409, retryable: true });
632
- result.engine = { ok: response.ok || response.status === 404, status: response.ok || response.status === 404 ? 'removed' : 'pending' };
633
- }
634
- } catch (error) { if (error.code === 'PROJECT_BUSY') throw error; result.engine = { ok: false, status: 'pending' }; }
635
- if (!result.engine.ok) throw new DomainError('PROJECT_BUSY', '知识引擎暂时不可用,无法安全停止写入;请恢复后再解绑。', { status: 409, retryable: true });
636
- let terminalId = wiring.terminal && wiring.terminal.projectId;
637
- if (!terminalId) {
638
- const workspacePath = wiring.terminal && wiring.terminal.rootPath;
639
- const match = (await this._listTerminalProjects())
640
- .find(p => (workspacePath && String(p.rootPath || '').toLowerCase() === String(workspacePath).toLowerCase()) || p.name === projectId);
641
- terminalId = match && match.id;
642
- }
643
- if (terminalId) {
644
- try {
645
- const response = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/projects/${encodeURIComponent(terminalId)}?keepSessions=1`, { method: 'DELETE' });
646
- result.terminal = { ok: response.ok || response.status === 204 };
647
- } catch { result.terminal = { ok: false, status: 'pending' }; }
648
- } else {
649
- result.terminal = { ok: true, skipped: 'not-registered' };
650
- }
651
- const vectorHubName = (wiring.vectorHub && wiring.vectorHub.projectName) || projectId;
652
- try {
653
- const response = await this._fetch(`${this.vectorHubUrl}/api/project?name=${encodeURIComponent(vectorHubName)}`, { method: 'DELETE' });
654
- result.vectorHub = { ok: response.ok || response.status === 204 || response.status === 404 };
655
- } catch { result.vectorHub = { ok: false, status: 'pending' }; }
656
- let eventBridgeId = wiring.eventBridge && wiring.eventBridge.projectId;
657
- if (!eventBridgeId) {
658
- const workspacePath = wiring.eventBridge && wiring.eventBridge.path;
659
- const match = (await this._listEventBridgeProjects())
660
- .find(p => !p.auto && workspacePath && String(p.path || '').toLowerCase() === String(workspacePath).toLowerCase());
661
- eventBridgeId = match && match.id;
662
- }
663
- if (eventBridgeId) {
664
- try {
665
- const response = await this._fetch(`${this.eventBridgeUrl}/api/projects?id=${encodeURIComponent(eventBridgeId)}`, { method: 'DELETE' });
666
- // 404 means already unregistered (or auto entry) — removal goal reached.
667
- result.eventBridge = { ok: response.ok || response.status === 204 || response.status === 404 };
668
- } catch { result.eventBridge = { ok: false, status: 'pending' }; }
669
- } else {
670
- result.eventBridge = { ok: true, skipped: 'not-registered' };
671
- }
672
- await this._writeWiring(projectId, wiring => {
673
- if (result.terminal.ok) wiring.terminal = { status: 'removed', updatedAt: new Date().toISOString() };
674
- if (result.vectorHub.ok) wiring.vectorHub = { status: 'removed', updatedAt: new Date().toISOString() };
675
- if (result.eventBridge.ok) wiring.eventBridge = { status: 'removed', updatedAt: new Date().toISOString() };
676
- if (result.engine.ok) wiring.engine = { status: 'removed', updatedAt: new Date().toISOString() };
677
- });
678
- this.logger.info('modules.remove', 'Module de-registration finished.', { context: { projectId, terminal: result.terminal, vectorHub: result.vectorHub, eventBridge: result.eventBridge } });
679
- return result;
680
- }
681
-
682
- async _writeWiring(projectId, mutator) {
683
- if (!this.projectStore) return;
684
- await this.projectStore.updateState(projectId, state => {
685
- state.modules = state.modules || {};
686
- mutator(state.modules);
687
- });
688
- }
689
-
690
- /**
691
- * T3.5 process orchestration: spawn the vendored module services as
692
- * supervised child processes. ON by default now that the services ship in
693
- * the package — opt out with KB_MODULES_AUTOSTART=0. A service that is
694
- * already listening on its URL is never spawned twice.
695
- */
696
- async startSupervised() {
697
- if (String(process.env.KB_MODULES_AUTOSTART || '') === '0') return;
698
- await this._resolveManagedUrls();
699
- await Promise.all(this._supervisedSpecs().map(async spec => {
700
- const kind = specNameToKind(spec.name);
701
- if (kind && await this._probeClass(spec.url, kind) === 'module') {
702
- this.logger.info('modules.supervisor', 'Module service already up; not spawning.', { context: { name: spec.name, url: spec.url } });
703
- this.onReady?.(spec.name); return;
704
- }
705
- this._spawnSupervised(spec);
706
- }));
707
- }
708
-
709
- /**
710
- * Resolve final managed URLs BEFORE any spec is built or a process is
711
- * spawned, so inter-module env wiring (engine URL, bridge URL, ...) uses
712
- * the final dynamic ports. Sequential on purpose: parallel scans could
713
- * race two kinds into the same overlapping candidate range
714
- * (8787..8806 vs 8790..8809).
715
- */
716
- async _resolveManagedUrls() {
717
- for (const target of MODULE_URL_TARGETS) {
718
- const preferredUrl = this[target.prop];
719
- const resolved = await resolveModuleUrl({
720
- kind: target.kind,
721
- preferredUrl,
722
- explicit: this.explicitModuleUrls[target.kind],
723
- portFree: port => tcpPortFree(urlHost(preferredUrl), port),
724
- probe: url => this._probeClass(url, target.kind),
725
- });
726
- this[target.prop] = resolved.url;
727
- this.moduleUrlSources[target.kind] = resolved.source;
728
- if (resolved.source !== 'external') {
729
- this.logger.info('modules.port_resolution', 'Module URL resolved.', { context: { kind: target.kind, source: resolved.source, url: resolved.url, port: resolved.port, offset: resolved.offset } });
730
- }
731
- }
732
- }
733
-
734
- _supervisedSpecs() {
735
- const specs = [];
736
- // C2 minimal wiring: the terminal service receives the non-secret product
737
- // knowledge root via KB_KNOWLEDGE_DATA_DIR. Its own AGENT_TERMINAL_DATA_DIR
738
- // stays the dedicated workbench subtree and is never widened.
739
- const knowledgeDataDir = this.dataDir || require('./data-dir').getDataDir();
740
- if (process.env.KB_TERMINAL_COMMAND) {
741
- specs.push({ name: 'terminal', command: process.env.KB_TERMINAL_COMMAND, url: this.terminalUrl, env: { KB_KNOWLEDGE_DATA_DIR: knowledgeDataDir, KB_WORKBENCH_RUNTIME_TOKEN: this.runtimeToken, KB_KNOWLEDGE_ENGINE_URL: this.engineUrl, KB_EVENT_BRIDGE_URL: this.eventBridgeUrl } });
742
- } else {
743
- const terminalEntry = resolveModuleEntry('terminal');
744
- if (terminalEntry) {
745
- specs.push({
746
- name: 'terminal',
747
- command: `node ${JSON.stringify(terminalEntry)}`,
748
- url: this.terminalUrl,
749
- env: { AGENT_TERMINAL_DATA_DIR: path.join(knowledgeDataDir, 'workbench'), KB_KNOWLEDGE_DATA_DIR: knowledgeDataDir, AGENT_TERMINAL_PORT: urlPort(this.terminalUrl, 5760), AGENT_TERMINAL_HOST: urlHost(this.terminalUrl), KB_WORKBENCH_RUNTIME_TOKEN: this.runtimeToken, KB_KNOWLEDGE_ENGINE_URL: this.engineUrl, KB_EVENT_BRIDGE_URL: this.eventBridgeUrl },
750
- });
751
- } else {
752
- this.logger.warn('modules.supervisor', 'claude-ai-workbench runtime not installed; expecting an external service.', {});
753
- }
754
- }
755
- // vector-hub declares engines >= 22; spawning it on older runtimes would
756
- // crash-loop the supervisor for nothing.
757
- if (process.env.KB_VECTORHUB_COMMAND) {
758
- specs.push({ name: 'vector-hub', command: process.env.KB_VECTORHUB_COMMAND, url: this.vectorHubUrl, env: { KB_VECTOR_MANAGED: '1', KB_VECTOR_RUNTIME_TOKEN: this.runtimeToken } });
759
- } else if (parseInt(process.versions.node, 10) < 22) {
760
- this.logger.warn('modules.supervisor', 'vector-hub needs Node >= 22; not spawning the vendored runtime.', { context: { node: process.versions.node } });
761
- } else {
762
- const vectorHubEntry = resolveModuleEntry('vector-hub');
763
- if (!vectorHubEntry) {
764
- this.logger.warn('modules.supervisor', '@sanqianx/vector-hub runtime not installed; expecting an external service.', {});
765
- } else {
766
- const dataDir = this.dataDir || require('./data-dir').getDataDir();
767
- const configRoot = path.join(dataDir, 'vectorhub');
768
- const root = new StorageLayout({ dataDir }).getVectorRootPath(this.settingsStore?.read());
769
- specs.push({
770
- name: 'vector-hub',
771
- command: `node ${JSON.stringify(vectorHubEntry)} serve --host ${urlHost(this.vectorHubUrl)} --port ${urlPort(this.vectorHubUrl, 8787)} --root ${JSON.stringify(root)}`,
772
- url: this.vectorHubUrl,
773
- env: { VECTOR_HUB_ROOT: root, VECTOR_HUB_CONFIG: path.join(configRoot, 'config.json'), VECTOR_HUB_LEGACY_CONFIG: path.join(configRoot, 'no-legacy-config.json'), KB_VECTOR_MANAGED: '1', KB_VECTOR_RUNTIME_TOKEN: this.runtimeToken },
774
- });
775
- }
776
- }
777
- // ai-coding-event-bridge console: plain-JS CommonJS on Node >= 18 — no
778
- // build gate, no runtime floor beyond the shell's own. The home flag keeps
779
- // the console on the same bridge home the in-process BridgeAdapter uses,
780
- // so conversations captured by PK show up in the embedded explorer. The
781
- // folder-picker warm-up is an interactive-only feature the shell never
782
- // routes here (import uses the terminal/vector-hub pickers) — skip it.
783
- if (process.env.KB_EVENTBRIDGE_COMMAND) {
784
- specs.push({ name: 'event-bridge', command: process.env.KB_EVENTBRIDGE_COMMAND, url: this.eventBridgeUrl, env: { KB_EVENT_BRIDGE_RUNTIME_TOKEN: this.runtimeToken } });
785
- } else {
786
- const eventBridgeEntry = resolveModuleEntry('event-bridge');
787
- if (!eventBridgeEntry) {
788
- this.logger.warn('modules.supervisor', '@sanqianx/ai-coding-event-bridge-console runtime not installed; expecting an external service.', {});
789
- } else {
790
- const homeArgs = ` --home ${JSON.stringify(process.env.AI_CODING_EVENT_BRIDGE_HOME || path.join(this.dataDir || require('./data-dir').getDataDir(), 'events'))}`;
791
- specs.push({
792
- name: 'event-bridge',
793
- command: `node ${JSON.stringify(eventBridgeEntry)} serve --host ${urlHost(this.eventBridgeUrl)} --port ${urlPort(this.eventBridgeUrl, 8790)}${homeArgs}`,
794
- url: this.eventBridgeUrl,
795
- env: { BRIDGE_CONSOLE_PICK_FOLDER: '0', KB_EVENT_BRIDGE_RUNTIME_TOKEN: this.runtimeToken },
796
- });
797
- }
798
- }
799
- const engineEntry = resolveModuleEntry('knowledge-engine');
800
- if (process.env.KB_KNOWLEDGE_ENGINE_COMMAND || engineEntry) specs.push({ name: 'knowledge-engine', command: process.env.KB_KNOWLEDGE_ENGINE_COMMAND || `node ${JSON.stringify(engineEntry)} serve --port ${urlPort(this.engineUrl, 5790)} --host ${urlHost(this.engineUrl)}`, url: this.engineUrl, env: { KNOWLEDGE_ENGINE_DATA: path.join(this.dataDir || require('./data-dir').getDataDir(), 'knowledge-engine'), KB_ENGINE_MANAGED: '1', KNOWLEDGE_ENGINE_CONSUME: '0', KB_ENGINE_RUNTIME_TOKEN: this.runtimeToken, KB_EVENT_BRIDGE_URL: this.eventBridgeUrl, KB_VECTOR_HUB_URL: this.vectorHubUrl, NODE_PATH: path.resolve(__dirname, '..', '..', 'node_modules') } });
801
- return specs;
802
- }
803
-
804
- _spawnSupervised(spec) {
805
- if (this.stopped) return;
806
- // Strip the quoting: spawn takes argv, not a shell line — a literal
807
- // `"path"` argument makes node load a file whose name contains quotes.
808
- const parts = (spec.command.match(/"[^"]+"|\S+/g) || [])
809
- .map(part => { if (!part.startsWith('"') || !part.endsWith('"')) return part; try { return JSON.parse(part); } catch { return part.slice(1, -1); } });
810
- const nodeProcess = parts[0] === 'node' || parts[0] === process.execPath;
811
- const child = spawn(nodeProcess ? process.execPath : parts[0], parts.slice(1), {
812
- // Module banners (ports, data roots) are diagnostics — inherit stdout
813
- // so they land on the console in --fg mode and in launcher.log when the
814
- // shell itself runs detached.
815
- stdio: nodeProcess ? ['ignore', 'inherit', 'inherit', 'ipc'] : ['ignore', 'inherit', 'inherit'],
816
- windowsHide: true,
817
- env: { ...process.env, ...(spec.env || {}) },
818
- });
819
- child.on('message', message => {
820
- if (message?.type !== 'kb:diagnostic' || !message.record || !['terminal', 'knowledge-engine'].includes(spec.name)) return;
821
- const record = message.record;
822
- if (!['debug', 'info', 'warn', 'error'].includes(record.level) || typeof record.event !== 'string' || typeof record.message !== 'string') return;
823
- const component = spec.name === 'terminal' ? 'claude-ai-workbench' : 'knowledge-engine';
824
- try { Promise.resolve(this.logger[record.level]?.(record.event, record.message, { component, projectId: record.projectId, runId: record.runId, operationId: record.operationId, commitSha: record.commitSha, phase: record.phase, error: record.error, context: record.context })).catch(() => {}); } catch {}
825
- });
826
- let attempts = spec._attempts || 0;
827
- child.on('error', error => {
828
- this.supervised = this.supervised.filter(existing => existing !== child);
829
- this.logger.warn('modules.supervisor', 'Module process could not start.', { context: { name: spec.name, code: error.code || 'SPAWN_FAILED' } });
830
- });
831
- child.on('exit', code => {
832
- this.onStateChange?.(spec.name);
833
- this.supervised = this.supervised.filter(existing => existing !== child);
834
- attempts += 1;
835
- if (attempts > 5 || this.stopped) {
836
- this.logger.warn('modules.supervisor', 'Module service stopped.', { context: { name: spec.name, code, attempts } });
837
- return;
838
- }
839
- const delay = Math.min(1000 * 2 ** attempts, 15000);
840
- this.logger.warn('modules.supervisor', 'Module service exited; restarting.', { context: { name: spec.name, code, retryInMs: delay } });
841
- const timer = setTimeout(() => { this.restartTimers.delete(timer); this._spawnSupervised({ ...spec, _attempts: attempts }); }, delay);
842
- this.restartTimers = this.restartTimers || new Set(); this.restartTimers.add(timer);
843
- });
844
- this.supervised.push(child);
845
- this.logger.info('modules.supervisor', 'Module service spawned.', { context: { name: spec.name, pid: child.pid } });
846
- let attemptsReady = 0;
847
- const checkReady = async () => {
848
- if (this.stopped || child.exitCode !== null || ++attemptsReady > 20) return;
849
- const kind = specNameToKind(spec.name);
850
- if (kind && await this._probeClass(spec.url, kind) === 'module') { this.resetRuntimeConfiguration(); this.onReady?.(spec.name); return; }
851
- const timer = setTimeout(() => { this.restartTimers.delete(timer); checkReady(); }, 500);
852
- this.restartTimers ||= new Set(); this.restartTimers.add(timer);
853
- };
854
- void checkReady();
855
- }
856
-
857
- async stopSupervised() {
858
- this.stopped = true;
859
- for (const timer of this.restartTimers || []) clearTimeout(timer);
860
- await Promise.all(this.supervised.map(child => new Promise(resolve => {
861
- if (child.exitCode !== null || !child.pid) return resolve();
862
- const timer = setTimeout(() => {
863
- if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
864
- else try { child.kill('SIGKILL'); } catch {}
865
- resolve();
866
- }, 5000);
867
- child.once('exit', () => { clearTimeout(timer); resolve(); });
868
- try { if (child.connected) child.send({ type: 'kb:shutdown' }, error => { if (error) try { child.kill(); } catch {} }); else child.kill(); } catch { try { child.kill(); } catch {} }
869
- })));
870
- this.supervised = [];
871
- }
872
- }
873
-
874
- module.exports = { ModuleBridge, defaultTerminalUrl, defaultVectorHubUrl, defaultEventBridgeUrl, defaultEngineUrl };
1
+ 'use strict';
2
+ /**
3
+ * Module bridge: connectivity from project-knowledge to the embedded modules
4
+ * (claude-ai-workbench Agent Terminal, vector-hub console, ai-coding-event-bridge
5
+ * conversation console).
6
+ *
7
+ * The shell owns no features — it only connects and projects. This module is
8
+ * that connection on the server side:
9
+ * - reverse proxy for browser calls (keeps the UI same-origin; no CORS on modules)
10
+ * - registration fan-out on project import (the "wiring table" lives in project state)
11
+ * - aggregated project projection for the shell sidebar
12
+ * - module removal with keepSessions semantics
13
+ *
14
+ * Configuration (env, loopback defaults):
15
+ * KB_TERMINAL_URL default http://127.0.0.1:5760 (managed: dynamic when busy)
16
+ * KB_VECTORHUB_URL default http://127.0.0.1:8787 (managed: dynamic when busy)
17
+ * KB_EVENTBRIDGE_URL default http://127.0.0.1:8790 (managed: dynamic when busy)
18
+ * KB_KNOWLEDGE_ENGINE_URL default http://127.0.0.1:5790 (managed: dynamic when busy)
19
+ * An explicitly injected URL is treated as an external service and never
20
+ * rewritten. Managed modules resolve a final URL before spawn: healthy
21
+ * identity match reuses the port, otherwise the next free port from
22
+ * preferred +0..+19 is taken; all busy fails loudly (NO_FREE_MODULE_PORT).
23
+ * KB_MODULES_AUTOSTART '0' to stop spawning the vendored module services
24
+ * (they start with the shell by default)
25
+ * KB_TERMINAL_COMMAND explicit node command line for the terminal service
26
+ * KB_VECTORHUB_COMMAND explicit node command line for the vector-hub service
27
+ * KB_EVENTBRIDGE_COMMAND explicit node command line for the event-bridge console
28
+ */
29
+
30
+ const { spawn, spawnSync } = require('child_process');
31
+ const net = require('net');
32
+ const path = require('path');
33
+ const { StorageLayout } = require('./storage-layout');
34
+ const { resolveModuleUrl } = require('./module-port-resolver');
35
+ const fs = require('fs');
36
+ const crypto = require('crypto');
37
+ const { once } = require('events');
38
+ const { DomainError } = require('./contracts');
39
+
40
+ const PROXY_TIMEOUT_MS = 10000;
41
+ // Native client startup includes knowledge retrieval and a local server/CLI
42
+ // handshake. It has a bounded setup budget, separate from ordinary reads/SSE.
43
+ const AGENT_START_TIMEOUT_MS = 90000;
44
+ // Registration drives a full embedding pass in vector-hub (remote API); a
45
+ // fresh knowledge folder can easily exceed the interactive proxy budget.
46
+ const REGISTER_TIMEOUT_MS = 60000;
47
+ // Match knowledge-engine's index request budget for synchronous embedding.
48
+ const INDEX_TIMEOUT_MS = 120000;
49
+ // Native folder dialogs wait for the user without a bound.
50
+ const PICKER_TIMEOUT_MS = 10 * 60 * 1000;
51
+
52
+ // Vendored module runtimes shipped inside the npm package. Sibling-checkout
53
+ // spawning is gone: the services are bundled, spawned from here, and resolved
54
+ // through normal node_modules resolution
55
+ // node_modules.
56
+ // Module entry scripts resolve through normal node_modules dependencies
57
+ // (claude-ai-workbench, @sanqianx/vector-hub, @sanqianx/ai-coding-event-bridge-
58
+ // console) — the npm packages are the single source of these runtimes.
59
+ const MODULE_ENTRIES = {
60
+ terminal: 'claude-ai-workbench/packages/server/bin/agent-terminal-server.js',
61
+ 'vector-hub': '@sanqianx/vector-hub/bin.js',
62
+ 'event-bridge': '@sanqianx/ai-coding-event-bridge-console/src/bin.js',
63
+ };
64
+
65
+ function resolveModuleEntry(name) {
66
+ if (name === 'knowledge-engine') {
67
+ const runtime = path.resolve(__dirname, '..', '..', '_modules', 'knowledge-engine', 'src', 'bin.js');
68
+ return fs.existsSync(runtime) ? runtime : null;
69
+ }
70
+ try { return require.resolve(MODULE_ENTRIES[name]); } catch { return null; }
71
+ }
72
+
73
+ function urlPort(baseUrl, fallback) {
74
+ try { return new URL(baseUrl).port || String(fallback); } catch { return String(fallback); }
75
+ }
76
+
77
+ function urlHost(baseUrl) {
78
+ try { return new URL(baseUrl).hostname || '127.0.0.1'; } catch { return '127.0.0.1'; }
79
+ }
80
+
81
+ function defaultTerminalUrl() {
82
+ return String(process.env.KB_TERMINAL_URL || 'http://127.0.0.1:5760').replace(/\/+$/, '');
83
+ }
84
+
85
+ function defaultVectorHubUrl() {
86
+ return String(process.env.KB_VECTORHUB_URL || 'http://127.0.0.1:8787').replace(/\/+$/, '');
87
+ }
88
+
89
+ function defaultEventBridgeUrl() {
90
+ return String(process.env.KB_EVENTBRIDGE_URL || 'http://127.0.0.1:8790').replace(/\/+$/, '');
91
+ }
92
+
93
+ function defaultEngineUrl() { return String(process.env.KB_KNOWLEDGE_ENGINE_URL || 'http://127.0.0.1:5790').replace(/\/+$/, ''); }
94
+
95
+ // Health identity markers: an HTTP 200 alone can come from any foreign
96
+ // service that grabbed the port. Only a body that matches the product
97
+ // counts as "our module already running" (reuse); anything else is
98
+ // foreign and the resolver moves to another port instead of crash-looping.
99
+ const MODULE_IDENTITY = {
100
+ terminal: body => body && typeof body === 'object' && body.status === 'ok' && typeof body.apiVersion === 'string',
101
+ vectorHub: body => body && typeof body === 'object' && Array.isArray(body.projects),
102
+ eventBridge: body => body && typeof body === 'object' && typeof body.registeredProjects === 'number',
103
+ engine: body => body && typeof body === 'object' && body.name === 'knowledge-engine',
104
+ };
105
+
106
+ // kind -> bridge URL property, explicit env override, spawn spec name
107
+ const MODULE_URL_TARGETS = [
108
+ { kind: 'terminal', prop: 'terminalUrl', env: 'KB_TERMINAL_URL', specName: 'terminal' },
109
+ { kind: 'vectorHub', prop: 'vectorHubUrl', env: 'KB_VECTORHUB_URL', specName: 'vector-hub' },
110
+ { kind: 'eventBridge', prop: 'eventBridgeUrl', env: 'KB_EVENTBRIDGE_URL', specName: 'event-bridge' },
111
+ { kind: 'engine', prop: 'engineUrl', env: 'KB_KNOWLEDGE_ENGINE_URL', specName: 'knowledge-engine' },
112
+ ];
113
+
114
+ function specNameToKind(specName) {
115
+ return MODULE_URL_TARGETS.find(target => target.specName === specName)?.kind || null;
116
+ }
117
+
118
+ function tcpPortFree(host, port) {
119
+ return new Promise(resolve => {
120
+ const tester = net.createServer();
121
+ let done = false;
122
+ const finish = free => { if (done) return; done = true; tester.close(() => resolve(free)); };
123
+ tester.once('error', () => finish(false));
124
+ tester.once('listening', () => finish(true));
125
+ tester.listen(port, host);
126
+ });
127
+ }
128
+
129
+ class ModuleBridge {
130
+ constructor(options = {}) {
131
+ this.logger = options.logger || { info: () => {}, warn: () => {}, error: () => {} };
132
+ this.terminalUrl = options.terminalUrl || defaultTerminalUrl();
133
+ this.vectorHubUrl = options.vectorHubUrl || defaultVectorHubUrl();
134
+ this.eventBridgeUrl = options.eventBridgeUrl || defaultEventBridgeUrl();
135
+ this.engineUrl = options.engineUrl || defaultEngineUrl();
136
+ // Explicitly injected URLs (option or env) mean an external service;
137
+ // only managed defaults participate in dynamic port resolution.
138
+ this.explicitModuleUrls = {
139
+ terminal: Boolean(options.terminalUrl || process.env.KB_TERMINAL_URL),
140
+ vectorHub: Boolean(options.vectorHubUrl || process.env.KB_VECTORHUB_URL),
141
+ eventBridge: Boolean(options.eventBridgeUrl || process.env.KB_EVENTBRIDGE_URL),
142
+ engine: Boolean(options.engineUrl || process.env.KB_KNOWLEDGE_ENGINE_URL),
143
+ };
144
+ this.moduleUrlSources = { terminal: 'pending', vectorHub: 'pending', eventBridge: 'pending', engine: 'pending' };
145
+ this.runtimeToken = options.runtimeToken || crypto.randomBytes(32).toString('hex');
146
+ this.registryStore = options.registryStore || null;
147
+ this.projectStore = options.projectStore || null;
148
+ this.projectWiringQueue = new Map();
149
+ this.settingsStore = options.settingsStore || null;
150
+ this.dataDir = options.dataDir || null;
151
+ this.supervised = [];
152
+ }
153
+
154
+ _terminalApi(pathname) {
155
+ // /api/terminal/<rest> -> <terminal>/api/claude-workbench/v1/<rest>
156
+ const rest = pathname.replace(/^\/api\/terminal\/?/, '');
157
+ return `${this.terminalUrl}/api/claude-workbench/v1/${rest}`;
158
+ }
159
+
160
+ _vectorHubApi(pathname) {
161
+ // /api/vectorhub/<rest> -> <vectorhub>/api/<rest>
162
+ const rest = pathname.replace(/^\/api\/vectorhub\/?/, '');
163
+ return `${this.vectorHubUrl}/api/${rest}`;
164
+ }
165
+
166
+ _eventBridgeApi(pathname) {
167
+ // /api/eventbridge/<rest> -> <event-bridge console>/api/<rest>
168
+ const rest = pathname.replace(/^\/api\/eventbridge\/?/, '');
169
+ return `${this.eventBridgeUrl}/api/${rest}`;
170
+ }
171
+
172
+ async _fetch(url, init = {}) {
173
+ const controller = new AbortController();
174
+ const timer = setTimeout(() => controller.abort(), init.timeoutMs || PROXY_TIMEOUT_MS);
175
+ try {
176
+ return await fetch(url, { ...init, signal: controller.signal });
177
+ } finally {
178
+ clearTimeout(timer);
179
+ }
180
+ }
181
+
182
+ /** Same-origin proxy. SSE has a headers deadline, never a whole-turn deadline. */
183
+ async proxy(req, res, target) {
184
+ const chunks = [];
185
+ for await (const chunk of req) chunks.push(chunk);
186
+ const body = chunks.length ? Buffer.concat(chunks) : undefined;
187
+ // The native folder pickers hold the request open until the user closes
188
+ // the dialog — they need a human-scale budget, not the API budget.
189
+ const isPicker = /\/(system\/)?pick-folder/.test(target);
190
+ const targetUrl = new URL(target);
191
+ const pathname = targetUrl.pathname;
192
+ const isAgentStart = req.method === 'POST' && /^\/api\/claude-workbench\/v1\/sessions(?:\/[^/]+\/input)?\/?$/.test(pathname);
193
+ const isIndex = req.method === 'POST' && pathname === '/api/sync' && targetUrl.origin === new URL(this.vectorHubUrl).origin;
194
+ const timeoutMs = isPicker ? PICKER_TIMEOUT_MS : isAgentStart ? AGENT_START_TIMEOUT_MS : isIndex ? INDEX_TIMEOUT_MS : PROXY_TIMEOUT_MS;
195
+ const startedAt = Date.now();
196
+ let upstream;
197
+ const controller = new AbortController();
198
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
199
+ const disconnected = () => { if (!res.writableEnded) controller.abort(); };
200
+ res.once?.('close', disconnected);
201
+ try {
202
+ upstream = await fetch(target, {
203
+ method: req.method,
204
+ signal: controller.signal,
205
+ headers: { ...(body ? { 'Content-Type': req.headers['content-type'] || 'application/json' } : {}), ...(req.headers['last-event-id'] ? { 'Last-Event-ID': req.headers['last-event-id'] } : {}) },
206
+ body: ['GET', 'HEAD'].includes(req.method) ? undefined : body,
207
+ });
208
+ } catch (error) {
209
+ clearTimeout(timer);
210
+ res.off?.('close', disconnected);
211
+ if (res.destroyed) return;
212
+ const details = { failureKind: controller.signal.aborted ? 'UPSTREAM_TIMEOUT' : 'NETWORK_ERROR', timeoutMs };
213
+ this.logger.warn('module.proxy.failed', '模块请求未完成,请检查连接或客户端启动。', {
214
+ component: 'module-bridge', phase: 'proxy', durationMs: Date.now() - startedAt,
215
+ context: { ...details, method: req.method, path: pathname },
216
+ });
217
+ res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
218
+ res.end(JSON.stringify({ ok: false, error: 'MODULE_UNREACHABLE', target: targetUrl.origin + pathname, details }));
219
+ return;
220
+ }
221
+ const contentType = upstream.headers.get('content-type') || 'application/json; charset=utf-8';
222
+ if (contentType.includes('text/event-stream')) {
223
+ clearTimeout(timer);
224
+ res.writeHead(upstream.status, { 'Content-Type': contentType, 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
225
+ res.flushHeaders?.();
226
+ try {
227
+ for await (const chunk of upstream.body) {
228
+ if (res.destroyed) break;
229
+ if (!res.write(Buffer.from(chunk))) await once(res, 'drain', { signal: controller.signal });
230
+ }
231
+ if (!res.destroyed) res.end();
232
+ } catch { if (!res.destroyed) res.destroy(); }
233
+ finally { controller.abort(); res.off?.('close', disconnected); }
234
+ } else {
235
+ try {
236
+ const buf = Buffer.from(await upstream.arrayBuffer());
237
+ res.writeHead(upstream.status, { 'Content-Type': contentType });
238
+ res.end(buf);
239
+ } finally { clearTimeout(timer); res.off?.('close', disconnected); }
240
+ }
241
+ }
242
+
243
+ proxyTerminal(req, res, pathname, search) {
244
+ return this.proxy(req, res, this._terminalApi(pathname) + (search || ''));
245
+ }
246
+
247
+ proxyVectorHub(req, res, pathname, search) {
248
+ return this.proxy(req, res, this._vectorHubApi(pathname) + (search || ''));
249
+ }
250
+
251
+ proxyEventBridge(req, res, pathname, search) {
252
+ return this.proxy(req, res, this._eventBridgeApi(pathname) + (search || ''));
253
+ }
254
+
255
+ proxyEngine(req, res, pathname, search) {
256
+ if (this.engineCompatible === false) throw new DomainError('ENGINE_CONNECTION_PENDING', '知识引擎连接尚未确认,请查看系统状态;不会读取旧服务中的知识。', { status: 503 });
257
+ return this.proxy(req, res, `${this.engineUrl}/api/${pathname.replace(/^\/api\/engine\/?/, '')}${search || ''}`);
258
+ }
259
+
260
+ proxyModuleUi(req, res, module, rest, search) {
261
+ const base = { terminal: this.terminalUrl, vectorhub: this.vectorHubUrl, eventbridge: this.eventBridgeUrl, engine: this.engineUrl }[module];
262
+ let decoded;
263
+ try { decoded = decodeURIComponent(rest); } catch { decoded = null; }
264
+ // Reject encoded separators/dot segments before URL normalization. Static
265
+ // mounts must never become a second route to a module's private API.
266
+ if (!base || req.method !== 'GET' || decoded === null || /[\\\x00-\x1f]/.test(decoded) || decoded.split('/').some(part => ['.', '..', 'api', 'internal'].includes(part.toLowerCase())) || /%[0-9a-f]{2}/i.test(decoded)) { res.statusCode = 404; res.end('Not found'); return; }
267
+ const target = new URL(`${base}/${rest}${search || ''}`);
268
+ if (target.origin !== new URL(base).origin) { res.statusCode = 404; res.end('Not found'); return; }
269
+ return this.proxy(req, res, target.href);
270
+ }
271
+
272
+ async _listTerminalProjects({ requireAvailable = false } = {}) {
273
+ try {
274
+ const response = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/projects`, { timeoutMs: 2500 });
275
+ if (!response.ok) throw new Error('TERMINAL_REGISTRY_UNAVAILABLE');
276
+ const payload = await response.json();
277
+ const list = Array.isArray(payload) ? payload : payload.projects;
278
+ if (!Array.isArray(list)) throw new Error('TERMINAL_REGISTRY_INVALID');
279
+ return list;
280
+ } catch { return requireAvailable ? null : []; }
281
+ }
282
+
283
+ async _listVectorHubProjects() {
284
+ try {
285
+ const response = await this._fetch(`${this.vectorHubUrl}/api/projects`, { timeoutMs: 2500 });
286
+ if (!response.ok) return [];
287
+ const payload = await response.json();
288
+ return Array.isArray(payload.projects) ? payload.projects : [];
289
+ } catch { return []; }
290
+ }
291
+
292
+ async _listEventBridgeProjects() {
293
+ try {
294
+ const response = await this._fetch(`${this.eventBridgeUrl}/api/projects`, { timeoutMs: 2500 });
295
+ if (!response.ok) return [];
296
+ const payload = await response.json();
297
+ return Array.isArray(payload.projects) ? payload.projects : [];
298
+ } catch { return []; }
299
+ }
300
+
301
+ async _listEngineProjects() {
302
+ if (this.engineCompatible === false) return [];
303
+ try {
304
+ const response = await this._fetch(`${this.engineUrl}/api/projects`, { timeoutMs: 2500 });
305
+ if (!response.ok) return [];
306
+ const payload = await response.json();
307
+ return Array.isArray(payload.projects) ? payload.projects : [];
308
+ } catch { return []; }
309
+ }
310
+
311
+ /** Aggregated projection for the shell sidebar: pkb registry is the spine. */
312
+ async aggregatedProjects() {
313
+ const healthJob = this.modulesHealth();
314
+ const [terminalProjects, vectorHubProjects, eventBridgeProjects, engineProjects] = await Promise.all([
315
+ this._listTerminalProjects(),
316
+ this._listVectorHubProjects(),
317
+ this._listEventBridgeProjects(),
318
+ this._listEngineProjects(),
319
+ ]);
320
+ const ids = this.registryStore ? this.registryStore.listIds() : [];
321
+ const byWorkspace = new Map(terminalProjects.map(p => [String(p.rootPath || '').toLowerCase(), p]));
322
+ const byName = new Map(vectorHubProjects.map(p => [String(p.name), p]));
323
+ const ebByPath = new Map(eventBridgeProjects.filter(p => p.path).map(p => [String(p.path).toLowerCase(), p]));
324
+ const projects = ids.map(projectId => {
325
+ const config = this.projectStore ? (this.projectStore.readConfig(projectId) || {}) : {};
326
+ const workspacePath = String(config.repoPath || '');
327
+ const knowledgePath = String(config.knowledgePath || '');
328
+ const state = this.projectStore ? this.projectStore.readState(projectId) : {};
329
+ const wiring = state.modules || {};
330
+ const terminal = (wiring.terminal && wiring.terminal.projectId ? terminalProjects.find(p => p.id === wiring.terminal.projectId) : null)
331
+ || byWorkspace.get(workspacePath.toLowerCase())
332
+ || null;
333
+ const vectorHubName = (wiring.vectorHub && wiring.vectorHub.projectName) || projectId;
334
+ const vectorHub = byName.get(vectorHubName) || null;
335
+ const eventBridge = ebByPath.get(workspacePath.toLowerCase())
336
+ || (wiring.eventBridge && wiring.eventBridge.projectId ? eventBridgeProjects.find(p => p.id === wiring.eventBridge.projectId) : null)
337
+ || null;
338
+ return {
339
+ projectId,
340
+ name: config.displayName || projectId,
341
+ workspacePath,
342
+ knowledgePath,
343
+ brainId: config.brainId || 'brain-default',
344
+ domainId: config.domainId || null,
345
+ modules: {
346
+ terminal: terminal ? { registered: true, projectId: terminal.id, name: terminal.name } : { registered: false, status: wiring.terminal && wiring.terminal.status },
347
+ vectorHub: vectorHub ? { registered: true, projectName: vectorHub.name, docCount: vectorHub.docCount, chunkCount: vectorHub.chunkCount, watching: !!vectorHub.watching } : { registered: false, status: wiring.vectorHub && wiring.vectorHub.status },
348
+ eventBridge: eventBridge ? { registered: true, projectId: eventBridge.id, turns: eventBridge.turns, auto: !!eventBridge.auto } : { registered: false, status: wiring.eventBridge && wiring.eventBridge.status },
349
+ engine: engineProjects.some(p => p.projectId === projectId) ? { registered: true, projectId } : { registered: false, status: wiring.engine && wiring.engine.status },
350
+ },
351
+ };
352
+ });
353
+ const health = (await healthJob).modules;
354
+ return {
355
+ projects,
356
+ terminalAvailable: health.terminal.up, vectorHubAvailable: health.vectorHub.up, eventBridgeAvailable: health.eventBridge.up, engineAvailable: health.engine.up,
357
+ };
358
+ }
359
+
360
+ /**
361
+ * Classify what is listening on a module URL.
362
+ * 'module' HTTP 2xx + product identity marker -> safe to reuse
363
+ * 'foreign' HTTP responded but identity mismatch -> never reuse
364
+ * 'unreachable' no HTTP answer (free port or non-HTTP listener)
365
+ */
366
+ async _probeClass(base, kind) {
367
+ const route = kind === 'terminal' ? '/api/claude-workbench/v1/health' : '/api/health';
368
+ const marker = MODULE_IDENTITY[kind];
369
+ if (!marker) return 'unreachable';
370
+ try {
371
+ const response = await this._fetch(`${base}${route}`, { timeoutMs: 2000 });
372
+ if (!response.ok) return 'unreachable';
373
+ let body;
374
+ try { body = await response.json(); } catch { return 'foreign'; }
375
+ return marker(body) ? 'module' : 'foreign';
376
+ } catch { return 'unreachable'; }
377
+ }
378
+
379
+ async _probe(base, kind) {
380
+ const resolvedKind = kind || (base === this.terminalUrl ? 'terminal' : null);
381
+ if (!resolvedKind) return false;
382
+ return (await this._probeClass(base, resolvedKind)) === 'module';
383
+ }
384
+
385
+ async modulesHealth() {
386
+ const [terminal, vectorHub, eventBridge, engine] = await Promise.all([
387
+ this._probe(this.terminalUrl, 'terminal'),
388
+ this._probe(this.vectorHubUrl, 'vectorHub'),
389
+ this._probe(this.eventBridgeUrl, 'eventBridge'),
390
+ this._probe(this.engineUrl, 'engine'),
391
+ ]);
392
+ return { ok: true, modules: {
393
+ terminal: { url: this.terminalUrl, up: terminal, source: this.moduleUrlSources.terminal },
394
+ vectorHub: { url: this.vectorHubUrl, up: vectorHub, source: this.moduleUrlSources.vectorHub },
395
+ eventBridge: { url: this.eventBridgeUrl, up: eventBridge, source: this.moduleUrlSources.eventBridge },
396
+ engine: { url: this.engineUrl, up: engine, source: this.moduleUrlSources.engine },
397
+ } };
398
+ }
399
+
400
+ syncEngineConfiguration({ force = false } = {}) {
401
+ const signature = () => { try { return crypto.createHash('sha256').update(JSON.stringify(this.settingsStore?.read() || {})).digest('hex'); } catch { return ''; } };
402
+ if (this.configSyncPromise) {
403
+ if (force || signature() !== this.configSyncSettings) this.configSyncDirty = true;
404
+ return this.configSyncPromise;
405
+ }
406
+ const pending = (async () => {
407
+ let result;
408
+ do { this.configSyncDirty = false; this.configSyncSettings = signature(); result = await this._syncEngineConfiguration(); } while (this.configSyncDirty && !this.stopped);
409
+ return result;
410
+ })().finally(() => { if (this.configSyncPromise === pending) this.configSyncPromise = null; });
411
+ this.configSyncPromise = pending;
412
+ return pending;
413
+ }
414
+
415
+ resetRuntimeConfiguration() {
416
+ this.configFingerprints = {};
417
+ this.configGeneration = (this.configGeneration || 0) + 1;
418
+ if (this.configSyncPromise) this.configSyncDirty = true;
419
+ }
420
+
421
+ async _syncEngineConfiguration() {
422
+ const applyConfiguration = async (key, url, body) => {
423
+ this.configFingerprints ||= {};
424
+ const generation = this.configGeneration;
425
+ const fingerprint = crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex');
426
+ if (this.configFingerprints[key]?.fingerprint === fingerprint) return this.configFingerprints[key].result;
427
+ const response = await this._fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.runtimeToken}` }, body: JSON.stringify(body) });
428
+ const result = { ok: response.ok, data: response.ok ? await response.json().catch(() => ({})) : null };
429
+ if (result.ok && this.configGeneration === generation) this.configFingerprints[key] = { fingerprint, result };
430
+ else delete this.configFingerprints[key];
431
+ return result;
432
+ };
433
+ const dataDir = this.dataDir || require('./data-dir').getDataDir();
434
+ let indexPath = null;
435
+ try { indexPath = new StorageLayout({ dataDir }).getVectorRootPath(this.settingsStore?.read()); }
436
+ catch { /* Corrupt settings must not switch storage or prevent credential revocation. */ }
437
+ const status = { scopes: false, scopeStatus: { status: 'pending' }, model: false, embedding: false, indexStorage: { expectedPath: indexPath, actualPath: null, status: 'pending' } };
438
+ const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${this.runtimeToken}` };
439
+ try {
440
+ if (this.settingsStore) {
441
+ const settings = this.settingsStore.read();
442
+ if (settings.knowledge.rootPath) {
443
+ const brain = settings.brain;
444
+ const root = path.join(settings.knowledge.rootPath, '_shared');
445
+ const response = await this._fetch(`${this.engineUrl}/api/snapshot?summary=1&limit=1`);
446
+ if (!response.ok) throw new DomainError('ENGINE_UNAVAILABLE', '共享知识接线暂时不可用。');
447
+ const prior = (await response.json()).sharedScopes || [];
448
+ // Root settings govern new directories and derived indexes, never move
449
+ // existing knowledge. Retain engine-acknowledged bindings in the spine.
450
+ const binding = (item, kind) => {
451
+ const existing = prior.find(scope => scope.id === item.id && scope.kind === kind && scope.brainId === brain.id);
452
+ if (existing && !path.isAbsolute(existing.knowledgePath || '')) throw new DomainError('DATA_CORRUPT', '共享目录绑定损坏。');
453
+ return item.knowledgePath || existing?.knowledgePath || path.join(root, item.id);
454
+ };
455
+ const scopes = [{ id: brain.id, kind: 'brain', brainId: brain.id, name: brain.name, knowledgePath: binding(brain, 'brain') }, ...brain.domains.map(d => ({ id: d.id, kind: 'domain', brainId: brain.id, domainId: d.id, name: d.name, knowledgePath: binding(d, 'domain') }))];
456
+ const applied = await applyConfiguration('scopes', `${this.engineUrl}/api/scopes`, { scopes });
457
+ if (!applied.ok) throw new DomainError('SHARED_SCOPE_APPLY_FAILED', '共享目录接线未完成。');
458
+ await this.settingsStore.bindSharedKnowledgePaths?.(scopes);
459
+ status.scopes = true; status.scopeStatus = { status: 'ready' };
460
+ }
461
+ }
462
+ } catch (error) {
463
+ status.scopeStatus = { status: 'pending', code: ['DATA_CORRUPT', 'IMMUTABLE_FIELD', 'ENGINE_UNAVAILABLE', 'SHARED_SCOPE_APPLY_FAILED'].includes(error.code) ? error.code : 'ENGINE_UNAVAILABLE' };
464
+ this.logger.warn('modules.scopes.pending', 'Shared knowledge wiring is pending; existing data is retained.', { context: { code: status.scopeStatus.code } });
465
+ // A scope failure must not prevent revoking a stale model key.
466
+ }
467
+ try {
468
+ let profile = null;
469
+ try {
470
+ const response = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/internal/maintenance-config`, { method: 'POST', headers, body: '{}' });
471
+ if (response.ok) profile = await response.json();
472
+ } catch { /* Missing configuration must clear a previously resolved key. */ }
473
+ const response = await applyConfiguration('model', `${this.engineUrl}/api/runtime-config`, { projectId: 'default', profile });
474
+ status.model = response.ok && !!profile;
475
+ } catch { /* Status projection only: never log resolved profiles or raw errors. */ }
476
+ try {
477
+ let settings = null;
478
+ try {
479
+ const resolved = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/internal/embedding-config`, { method: 'POST', headers, body: '{}' });
480
+ if (resolved.ok) settings = await resolved.json();
481
+ } catch { /* Revocation wins over a previously cached runtime key. */ }
482
+ const applied = await applyConfiguration('embedding', `${this.vectorHubUrl}/api/internal/runtime-config`, { settings, ...(indexPath ? { storageRoot: indexPath } : {}) });
483
+ status.embedding = applied.ok && !!settings;
484
+ if (applied.ok) {
485
+ const result = applied.data;
486
+ status.indexStorage.actualPath = result.rootPath || null;
487
+ status.indexStorage.status = result.rootPath && path.resolve(result.rootPath) === indexPath ? 'ready' : 'pending';
488
+ }
489
+ } catch { /* Public status contains no resolved configuration. */ }
490
+ this.configStatus = status;
491
+ return status;
492
+ }
493
+
494
+ /**
495
+ * Register one pkb project into both modules and persist the wiring table.
496
+ * Best-effort per module: a module that is down is recorded as 'pending'
497
+ * (retry on the next register call or health-check pass), never a hard error.
498
+ * KB_MODULES_REGISTER=0 disables fan-out entirely (test isolation).
499
+ */
500
+ _withProjectWiring(projectId, action) {
501
+ const prior = this.projectWiringQueue.get(projectId) || Promise.resolve();
502
+ const operation = prior.catch(() => {}).then(action);
503
+ const settled = operation.finally(() => { if (this.projectWiringQueue.get(projectId) === settled) this.projectWiringQueue.delete(projectId); });
504
+ this.projectWiringQueue.set(projectId, settled);
505
+ return settled;
506
+ }
507
+
508
+ registerProjectLinks(input) {
509
+ return this._withProjectWiring(input.projectId, () => {
510
+ // A supervisor may have captured this project before a concurrent unbind.
511
+ // Recheck the spine after acquiring the same lease as the removal path.
512
+ if (this.registryStore && !this.registryStore.readDisplaySnapshot(input.projectId)) return Object.fromEntries(['terminal', 'vectorHub', 'eventBridge', 'engine'].map(key => [key, { ok: true, status: 'removed', skipped: 'project-not-registered' }]));
513
+ return this._registerProjectLinks(input);
514
+ });
515
+ }
516
+
517
+ async _registerProjectLinks({ projectId, name, workspacePath, knowledgePath }) {
518
+ const result = { terminal: { ok: false }, vectorHub: { ok: false }, eventBridge: { ok: false }, engine: { ok: false } };
519
+ if (String(process.env.KB_MODULES_REGISTER || '') === '0') {
520
+ result.terminal = { ok: false, status: 'disabled' };
521
+ result.vectorHub = { ok: false, status: 'disabled' };
522
+ result.eventBridge = { ok: false, status: 'disabled' };
523
+ result.engine = { ok: false, status: 'disabled' };
524
+ return result;
525
+ }
526
+ // 幂等登记:终端已有同 rootPath 的项目时复用,绝不重复创建
527
+ const terminalProjects = await this._listTerminalProjects({ requireAvailable: true });
528
+ const existingTerminal = terminalProjects?.find(p => String(p.rootPath || '').toLowerCase() === String(workspacePath || '').toLowerCase());
529
+ if (terminalProjects === null) {
530
+ // Startup/reconnect failures do not prove the workspace is absent. Keep
531
+ // its saved context so a retry cannot strand existing sessions/drafts.
532
+ result.terminal = { ok: false, status: 'pending' };
533
+ } else if (existingTerminal) {
534
+ result.terminal = { ok: true, projectId: existingTerminal.id, status: 'registered', reused: true };
535
+ } else {
536
+ try {
537
+ const response = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/projects`, {
538
+ method: 'POST',
539
+ headers: { 'Content-Type': 'application/json' },
540
+ body: JSON.stringify({ name: name || projectId, rootPath: workspacePath }),
541
+ });
542
+ const payload = response.status === 201 || response.ok ? await response.json().catch(() => ({})) : {};
543
+ result.terminal = { ok: response.ok, projectId: payload.id || payload.projectId || null, status: response.ok ? 'registered' : 'error' };
544
+ } catch {
545
+ result.terminal = { ok: false, status: 'pending' };
546
+ }
547
+ }
548
+ const existingVector = (await this._listVectorHubProjects()).find(p => p.name === projectId && String(p.sourceDir || '').toLowerCase() === String(knowledgePath || '').toLowerCase());
549
+ if (existingVector) result.vectorHub = { ok: true, projectName: projectId, status: 'registered', reused: true };
550
+ else try {
551
+ const response = await this._fetch(`${this.vectorHubUrl}/api/import`, {
552
+ method: 'POST',
553
+ headers: { 'Content-Type': 'application/json' },
554
+ timeoutMs: REGISTER_TIMEOUT_MS,
555
+ body: JSON.stringify({ sourceDir: knowledgePath, projectName: projectId, watch: false }),
556
+ });
557
+ result.vectorHub = { ok: response.ok, projectName: projectId, status: response.ok ? 'registered' : 'error' };
558
+ } catch {
559
+ result.vectorHub = { ok: false, status: 'pending' };
560
+ }
561
+ // 幂等登记:会话控制台按 path(Git work tree)复用已有登记。store 遵循
562
+ // event-bridge 的推荐布局 <知识库>/dev-conversations;无知识库地址时交给
563
+ // 控制台的默认 store(全局 journal),登记本身不受阻。
564
+ const existingEventBridge = (await this._listEventBridgeProjects())
565
+ .find(p => !p.auto && String(p.path || '').toLowerCase() === String(workspacePath || '').toLowerCase());
566
+ if (existingEventBridge) {
567
+ result.eventBridge = { ok: true, projectId: existingEventBridge.id, status: 'registered', reused: true };
568
+ } else {
569
+ try {
570
+ const body = { path: workspacePath };
571
+ if (knowledgePath) body.store = path.join(knowledgePath, 'dev-conversations');
572
+ const response = await this._fetch(`${this.eventBridgeUrl}/api/projects`, {
573
+ method: 'POST',
574
+ headers: { 'Content-Type': 'application/json' },
575
+ body: JSON.stringify(body),
576
+ });
577
+ const payload = response.status === 201 || response.ok ? await response.json().catch(() => ({})) : {};
578
+ const registeredProject = payload.project || {};
579
+ result.eventBridge = { ok: response.ok, projectId: registeredProject.id || null, status: response.ok ? 'registered' : 'error', errorCode: (!response.ok && payload.error && payload.error.code) || undefined };
580
+ } catch {
581
+ result.eventBridge = { ok: false, status: 'pending' };
582
+ }
583
+ }
584
+ const config = this.projectStore ? this.projectStore.readConfig(projectId) : {};
585
+ const state = this.projectStore ? this.projectStore.readState(projectId) : {};
586
+ try {
587
+ const baselines = { ...(state.conversationBaselineCursors || {}), global: state.conversationBaselineCursor ?? 0 };
588
+ const journalId = result.eventBridge.projectId || state.modules?.eventBridge?.projectId;
589
+ if (journalId && baselines[journalId] === undefined) {
590
+ const watermark = result.eventBridge.reused && await this._fetch(`${this.eventBridgeUrl}/api/journal/high-watermark?journalId=${encodeURIComponent(journalId)}`);
591
+ const current = result.eventBridge.reused ? watermark.ok && await watermark.json() : { sequence: 0 };
592
+ if (!current || !Number.isSafeInteger(current.sequence)) throw new Error('JOURNAL_BASELINE_UNAVAILABLE');
593
+ baselines[journalId] = current.sequence;
594
+ if (this.projectStore) await this.projectStore.updateState(projectId, draft => { draft.conversationBaselineCursors = baselines; });
595
+ }
596
+ const response = await this._fetch(`${this.engineUrl}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId, name: name || projectId, repoPath: workspacePath, knowledgePath, knowledgeLanguage: config.knowledgeLanguage || 'zh-CN', brainId: config.brainId || 'brain-default', domainId: config.domainId || null, repoIdentity: config.repoIdentity, journalId: journalId || null, vectorProjectId: projectId, trackingStartCommit: state.trackingStartCommit || state.lastAnalyzedCommit || null, conversationBaselineCursor: state.conversationBaselineCursor ?? 0, conversationBaselineCursors: baselines, profileId: config.aiProfileId || null }) });
597
+ result.engine = { ok: response.ok, projectId, status: response.ok ? 'registered' : 'error' };
598
+ } catch { result.engine = { ok: false, status: 'pending' }; }
599
+ await this._writeWiring(projectId, wiring => {
600
+ wiring.terminal = { ...wiring.terminal, ...result.terminal, name: name || projectId, rootPath: workspacePath, updatedAt: new Date().toISOString() };
601
+ wiring.vectorHub = { ...wiring.vectorHub, ...result.vectorHub, sourceDir: knowledgePath, updatedAt: new Date().toISOString() };
602
+ wiring.eventBridge = { ...wiring.eventBridge, ...result.eventBridge, path: workspacePath, store: knowledgePath ? path.join(knowledgePath, 'dev-conversations') : '', updatedAt: new Date().toISOString() };
603
+ wiring.engine = { ...wiring.engine, ...result.engine, updatedAt: new Date().toISOString() };
604
+ });
605
+ this.logger.info('modules.register', 'Module registration finished.', { context: { projectId, terminal: result.terminal.status, vectorHub: result.vectorHub.status, eventBridge: result.eventBridge.status } });
606
+ return result;
607
+ }
608
+
609
+ /** Remove module registrations. Local data is preserved by contract: vector-hub
610
+ * only drops its derived index (source untouched); terminal removes the project
611
+ * with keepSessions=1 so session records survive; the event-bridge console only
612
+ * unregisters the project — journals and sealed commit documents stay on disk. */
613
+ removeProjectLinks(input) {
614
+ return this._withProjectWiring(input.projectId, async () => {
615
+ const result = await this._removeProjectLinks(input);
616
+ // The caller owns registry/lifecycle changes. Hold the wiring lease until
617
+ // its transaction settles, so delayed reconfiguration cannot resurrect it.
618
+ await input.onRemoved?.(result);
619
+ return result;
620
+ });
621
+ }
622
+
623
+ async _removeProjectLinks({ projectId, requireEngine = true }) {
624
+ const wiring = (this.projectStore ? this.projectStore.readState(projectId).modules : null) || {};
625
+ const result = { terminal: { ok: false }, vectorHub: { ok: false }, eventBridge: { ok: false }, engine: { ok: false } };
626
+ // Stop the writer first, before removing hooks/index registrations.
627
+ try {
628
+ if (!requireEngine) result.engine = { ok: true, status: 'not-owner' };
629
+ else {
630
+ const response = await this._fetch(`${this.engineUrl}/api/projects/${encodeURIComponent(projectId)}`, { method: 'DELETE' });
631
+ if (response.status === 409) throw new DomainError('PROJECT_BUSY', '此项目正在执行知识维护,请等待写入结束后再解绑。未开始的排队任务会随解绑取消。', { status: 409, retryable: true });
632
+ result.engine = { ok: response.ok || response.status === 404, status: response.ok || response.status === 404 ? 'removed' : 'pending' };
633
+ }
634
+ } catch (error) { if (error.code === 'PROJECT_BUSY') throw error; result.engine = { ok: false, status: 'pending' }; }
635
+ if (!result.engine.ok) throw new DomainError('PROJECT_BUSY', '知识引擎暂时不可用,无法安全停止写入;请恢复后再解绑。', { status: 409, retryable: true });
636
+ let terminalId = wiring.terminal && wiring.terminal.projectId;
637
+ if (!terminalId) {
638
+ const workspacePath = wiring.terminal && wiring.terminal.rootPath;
639
+ const match = (await this._listTerminalProjects())
640
+ .find(p => (workspacePath && String(p.rootPath || '').toLowerCase() === String(workspacePath).toLowerCase()) || p.name === projectId);
641
+ terminalId = match && match.id;
642
+ }
643
+ if (terminalId) {
644
+ try {
645
+ const response = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/projects/${encodeURIComponent(terminalId)}?keepSessions=1`, { method: 'DELETE' });
646
+ result.terminal = { ok: response.ok || response.status === 204 };
647
+ } catch { result.terminal = { ok: false, status: 'pending' }; }
648
+ } else {
649
+ result.terminal = { ok: true, skipped: 'not-registered' };
650
+ }
651
+ const vectorHubName = (wiring.vectorHub && wiring.vectorHub.projectName) || projectId;
652
+ try {
653
+ const response = await this._fetch(`${this.vectorHubUrl}/api/project?name=${encodeURIComponent(vectorHubName)}`, { method: 'DELETE' });
654
+ result.vectorHub = { ok: response.ok || response.status === 204 || response.status === 404 };
655
+ } catch { result.vectorHub = { ok: false, status: 'pending' }; }
656
+ let eventBridgeId = wiring.eventBridge && wiring.eventBridge.projectId;
657
+ if (!eventBridgeId) {
658
+ const workspacePath = wiring.eventBridge && wiring.eventBridge.path;
659
+ const match = (await this._listEventBridgeProjects())
660
+ .find(p => !p.auto && workspacePath && String(p.path || '').toLowerCase() === String(workspacePath).toLowerCase());
661
+ eventBridgeId = match && match.id;
662
+ }
663
+ if (eventBridgeId) {
664
+ try {
665
+ const response = await this._fetch(`${this.eventBridgeUrl}/api/projects?id=${encodeURIComponent(eventBridgeId)}`, { method: 'DELETE' });
666
+ // 404 means already unregistered (or auto entry) — removal goal reached.
667
+ result.eventBridge = { ok: response.ok || response.status === 204 || response.status === 404 };
668
+ } catch { result.eventBridge = { ok: false, status: 'pending' }; }
669
+ } else {
670
+ result.eventBridge = { ok: true, skipped: 'not-registered' };
671
+ }
672
+ await this._writeWiring(projectId, wiring => {
673
+ if (result.terminal.ok) wiring.terminal = { status: 'removed', updatedAt: new Date().toISOString() };
674
+ if (result.vectorHub.ok) wiring.vectorHub = { status: 'removed', updatedAt: new Date().toISOString() };
675
+ if (result.eventBridge.ok) wiring.eventBridge = { status: 'removed', updatedAt: new Date().toISOString() };
676
+ if (result.engine.ok) wiring.engine = { status: 'removed', updatedAt: new Date().toISOString() };
677
+ });
678
+ this.logger.info('modules.remove', 'Module de-registration finished.', { context: { projectId, terminal: result.terminal, vectorHub: result.vectorHub, eventBridge: result.eventBridge } });
679
+ return result;
680
+ }
681
+
682
+ async _writeWiring(projectId, mutator) {
683
+ if (!this.projectStore) return;
684
+ await this.projectStore.updateState(projectId, state => {
685
+ state.modules = state.modules || {};
686
+ mutator(state.modules);
687
+ });
688
+ }
689
+
690
+ /**
691
+ * T3.5 process orchestration: spawn the vendored module services as
692
+ * supervised child processes. ON by default now that the services ship in
693
+ * the package — opt out with KB_MODULES_AUTOSTART=0. A service that is
694
+ * already listening on its URL is never spawned twice.
695
+ */
696
+ async startSupervised() {
697
+ if (String(process.env.KB_MODULES_AUTOSTART || '') === '0') return;
698
+ await this._resolveManagedUrls();
699
+ await Promise.all(this._supervisedSpecs().map(async spec => {
700
+ const kind = specNameToKind(spec.name);
701
+ if (kind && await this._probeClass(spec.url, kind) === 'module') {
702
+ this.logger.info('modules.supervisor', 'Module service already up; not spawning.', { context: { name: spec.name, url: spec.url } });
703
+ this.onReady?.(spec.name); return;
704
+ }
705
+ this._spawnSupervised(spec);
706
+ }));
707
+ }
708
+
709
+ /**
710
+ * Resolve final managed URLs BEFORE any spec is built or a process is
711
+ * spawned, so inter-module env wiring (engine URL, bridge URL, ...) uses
712
+ * the final dynamic ports. Sequential on purpose: parallel scans could
713
+ * race two kinds into the same overlapping candidate range
714
+ * (8787..8806 vs 8790..8809).
715
+ */
716
+ async _resolveManagedUrls() {
717
+ for (const target of MODULE_URL_TARGETS) {
718
+ const preferredUrl = this[target.prop];
719
+ const resolved = await resolveModuleUrl({
720
+ kind: target.kind,
721
+ preferredUrl,
722
+ explicit: this.explicitModuleUrls[target.kind],
723
+ portFree: port => tcpPortFree(urlHost(preferredUrl), port),
724
+ probe: url => this._probeClass(url, target.kind),
725
+ });
726
+ this[target.prop] = resolved.url;
727
+ this.moduleUrlSources[target.kind] = resolved.source;
728
+ if (resolved.source !== 'external') {
729
+ this.logger.info('modules.port_resolution', 'Module URL resolved.', { context: { kind: target.kind, source: resolved.source, url: resolved.url, port: resolved.port, offset: resolved.offset } });
730
+ }
731
+ }
732
+ }
733
+
734
+ _supervisedSpecs() {
735
+ const specs = [];
736
+ // C2 minimal wiring: the terminal service receives the non-secret product
737
+ // knowledge root via KB_KNOWLEDGE_DATA_DIR. Its own AGENT_TERMINAL_DATA_DIR
738
+ // stays the dedicated workbench subtree and is never widened.
739
+ const knowledgeDataDir = this.dataDir || require('./data-dir').getDataDir();
740
+ if (process.env.KB_TERMINAL_COMMAND) {
741
+ specs.push({ name: 'terminal', command: process.env.KB_TERMINAL_COMMAND, url: this.terminalUrl, env: { KB_KNOWLEDGE_DATA_DIR: knowledgeDataDir, KB_WORKBENCH_RUNTIME_TOKEN: this.runtimeToken, KB_KNOWLEDGE_ENGINE_URL: this.engineUrl, KB_EVENT_BRIDGE_URL: this.eventBridgeUrl } });
742
+ } else {
743
+ const terminalEntry = resolveModuleEntry('terminal');
744
+ if (terminalEntry) {
745
+ specs.push({
746
+ name: 'terminal',
747
+ command: `node ${JSON.stringify(terminalEntry)}`,
748
+ url: this.terminalUrl,
749
+ env: { AGENT_TERMINAL_DATA_DIR: path.join(knowledgeDataDir, 'workbench'), KB_KNOWLEDGE_DATA_DIR: knowledgeDataDir, AGENT_TERMINAL_PORT: urlPort(this.terminalUrl, 5760), AGENT_TERMINAL_HOST: urlHost(this.terminalUrl), KB_WORKBENCH_RUNTIME_TOKEN: this.runtimeToken, KB_KNOWLEDGE_ENGINE_URL: this.engineUrl, KB_EVENT_BRIDGE_URL: this.eventBridgeUrl },
750
+ });
751
+ } else {
752
+ this.logger.warn('modules.supervisor', 'claude-ai-workbench runtime not installed; expecting an external service.', {});
753
+ }
754
+ }
755
+ // vector-hub declares engines >= 22; spawning it on older runtimes would
756
+ // crash-loop the supervisor for nothing.
757
+ if (process.env.KB_VECTORHUB_COMMAND) {
758
+ specs.push({ name: 'vector-hub', command: process.env.KB_VECTORHUB_COMMAND, url: this.vectorHubUrl, env: { KB_VECTOR_MANAGED: '1', KB_VECTOR_RUNTIME_TOKEN: this.runtimeToken } });
759
+ } else if (parseInt(process.versions.node, 10) < 22) {
760
+ this.logger.warn('modules.supervisor', 'vector-hub needs Node >= 22; not spawning the vendored runtime.', { context: { node: process.versions.node } });
761
+ } else {
762
+ const vectorHubEntry = resolveModuleEntry('vector-hub');
763
+ if (!vectorHubEntry) {
764
+ this.logger.warn('modules.supervisor', '@sanqianx/vector-hub runtime not installed; expecting an external service.', {});
765
+ } else {
766
+ const dataDir = this.dataDir || require('./data-dir').getDataDir();
767
+ const configRoot = path.join(dataDir, 'vectorhub');
768
+ const root = new StorageLayout({ dataDir }).getVectorRootPath(this.settingsStore?.read());
769
+ specs.push({
770
+ name: 'vector-hub',
771
+ command: `node ${JSON.stringify(vectorHubEntry)} serve --host ${urlHost(this.vectorHubUrl)} --port ${urlPort(this.vectorHubUrl, 8787)} --root ${JSON.stringify(root)}`,
772
+ url: this.vectorHubUrl,
773
+ env: { VECTOR_HUB_ROOT: root, VECTOR_HUB_CONFIG: path.join(configRoot, 'config.json'), VECTOR_HUB_LEGACY_CONFIG: path.join(configRoot, 'no-legacy-config.json'), KB_VECTOR_MANAGED: '1', KB_VECTOR_RUNTIME_TOKEN: this.runtimeToken },
774
+ });
775
+ }
776
+ }
777
+ // ai-coding-event-bridge console: plain-JS CommonJS on Node >= 18 — no
778
+ // build gate, no runtime floor beyond the shell's own. The home flag keeps
779
+ // the console on the same bridge home the in-process BridgeAdapter uses,
780
+ // so conversations captured by PK show up in the embedded explorer. The
781
+ // folder-picker warm-up is an interactive-only feature the shell never
782
+ // routes here (import uses the terminal/vector-hub pickers) — skip it.
783
+ if (process.env.KB_EVENTBRIDGE_COMMAND) {
784
+ specs.push({ name: 'event-bridge', command: process.env.KB_EVENTBRIDGE_COMMAND, url: this.eventBridgeUrl, env: { KB_EVENT_BRIDGE_RUNTIME_TOKEN: this.runtimeToken } });
785
+ } else {
786
+ const eventBridgeEntry = resolveModuleEntry('event-bridge');
787
+ if (!eventBridgeEntry) {
788
+ this.logger.warn('modules.supervisor', '@sanqianx/ai-coding-event-bridge-console runtime not installed; expecting an external service.', {});
789
+ } else {
790
+ const homeArgs = ` --home ${JSON.stringify(process.env.AI_CODING_EVENT_BRIDGE_HOME || path.join(this.dataDir || require('./data-dir').getDataDir(), 'events'))}`;
791
+ specs.push({
792
+ name: 'event-bridge',
793
+ command: `node ${JSON.stringify(eventBridgeEntry)} serve --host ${urlHost(this.eventBridgeUrl)} --port ${urlPort(this.eventBridgeUrl, 8790)}${homeArgs}`,
794
+ url: this.eventBridgeUrl,
795
+ env: { BRIDGE_CONSOLE_PICK_FOLDER: '0', KB_EVENT_BRIDGE_RUNTIME_TOKEN: this.runtimeToken },
796
+ });
797
+ }
798
+ }
799
+ const engineEntry = resolveModuleEntry('knowledge-engine');
800
+ if (process.env.KB_KNOWLEDGE_ENGINE_COMMAND || engineEntry) specs.push({ name: 'knowledge-engine', command: process.env.KB_KNOWLEDGE_ENGINE_COMMAND || `node ${JSON.stringify(engineEntry)} serve --port ${urlPort(this.engineUrl, 5790)} --host ${urlHost(this.engineUrl)}`, url: this.engineUrl, env: { KNOWLEDGE_ENGINE_DATA: path.join(this.dataDir || require('./data-dir').getDataDir(), 'knowledge-engine'), KB_ENGINE_MANAGED: '1', KNOWLEDGE_ENGINE_CONSUME: '0', KB_ENGINE_RUNTIME_TOKEN: this.runtimeToken, KB_EVENT_BRIDGE_URL: this.eventBridgeUrl, KB_VECTOR_HUB_URL: this.vectorHubUrl, NODE_PATH: path.resolve(__dirname, '..', '..', 'node_modules') } });
801
+ return specs;
802
+ }
803
+
804
+ _spawnSupervised(spec) {
805
+ if (this.stopped) return;
806
+ // Strip the quoting: spawn takes argv, not a shell line — a literal
807
+ // `"path"` argument makes node load a file whose name contains quotes.
808
+ const parts = (spec.command.match(/"[^"]+"|\S+/g) || [])
809
+ .map(part => { if (!part.startsWith('"') || !part.endsWith('"')) return part; try { return JSON.parse(part); } catch { return part.slice(1, -1); } });
810
+ const nodeProcess = parts[0] === 'node' || parts[0] === process.execPath;
811
+ const child = spawn(nodeProcess ? process.execPath : parts[0], parts.slice(1), {
812
+ // Module banners (ports, data roots) are diagnostics — inherit stdout
813
+ // so they land on the console in --fg mode and in launcher.log when the
814
+ // shell itself runs detached.
815
+ stdio: nodeProcess ? ['ignore', 'inherit', 'inherit', 'ipc'] : ['ignore', 'inherit', 'inherit'],
816
+ windowsHide: true,
817
+ env: { ...process.env, ...(spec.env || {}) },
818
+ });
819
+ child.on('message', message => {
820
+ if (message?.type !== 'kb:diagnostic' || !message.record || !['terminal', 'knowledge-engine'].includes(spec.name)) return;
821
+ const record = message.record;
822
+ if (!['debug', 'info', 'warn', 'error'].includes(record.level) || typeof record.event !== 'string' || typeof record.message !== 'string') return;
823
+ const component = spec.name === 'terminal' ? 'claude-ai-workbench' : 'knowledge-engine';
824
+ try { Promise.resolve(this.logger[record.level]?.(record.event, record.message, { component, projectId: record.projectId, runId: record.runId, operationId: record.operationId, commitSha: record.commitSha, phase: record.phase, error: record.error, context: record.context })).catch(() => {}); } catch {}
825
+ });
826
+ let attempts = spec._attempts || 0;
827
+ child.on('error', error => {
828
+ this.supervised = this.supervised.filter(existing => existing !== child);
829
+ this.logger.warn('modules.supervisor', 'Module process could not start.', { context: { name: spec.name, code: error.code || 'SPAWN_FAILED' } });
830
+ });
831
+ child.on('exit', code => {
832
+ this.onStateChange?.(spec.name);
833
+ this.supervised = this.supervised.filter(existing => existing !== child);
834
+ attempts += 1;
835
+ if (attempts > 5 || this.stopped) {
836
+ this.logger.warn('modules.supervisor', 'Module service stopped.', { context: { name: spec.name, code, attempts } });
837
+ return;
838
+ }
839
+ const delay = Math.min(1000 * 2 ** attempts, 15000);
840
+ this.logger.warn('modules.supervisor', 'Module service exited; restarting.', { context: { name: spec.name, code, retryInMs: delay } });
841
+ const timer = setTimeout(() => { this.restartTimers.delete(timer); this._spawnSupervised({ ...spec, _attempts: attempts }); }, delay);
842
+ this.restartTimers = this.restartTimers || new Set(); this.restartTimers.add(timer);
843
+ });
844
+ this.supervised.push(child);
845
+ this.logger.info('modules.supervisor', 'Module service spawned.', { context: { name: spec.name, pid: child.pid } });
846
+ let attemptsReady = 0;
847
+ const checkReady = async () => {
848
+ if (this.stopped || child.exitCode !== null || ++attemptsReady > 20) return;
849
+ const kind = specNameToKind(spec.name);
850
+ if (kind && await this._probeClass(spec.url, kind) === 'module') { this.resetRuntimeConfiguration(); this.onReady?.(spec.name); return; }
851
+ const timer = setTimeout(() => { this.restartTimers.delete(timer); checkReady(); }, 500);
852
+ this.restartTimers ||= new Set(); this.restartTimers.add(timer);
853
+ };
854
+ void checkReady();
855
+ }
856
+
857
+ async stopSupervised() {
858
+ this.stopped = true;
859
+ for (const timer of this.restartTimers || []) clearTimeout(timer);
860
+ await Promise.all(this.supervised.map(child => new Promise(resolve => {
861
+ if (child.exitCode !== null || !child.pid) return resolve();
862
+ const timer = setTimeout(() => {
863
+ if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
864
+ else try { child.kill('SIGKILL'); } catch {}
865
+ resolve();
866
+ }, 5000);
867
+ child.once('exit', () => { clearTimeout(timer); resolve(); });
868
+ try { if (child.connected) child.send({ type: 'kb:shutdown' }, error => { if (error) try { child.kill(); } catch {} }); else child.kill(); } catch { try { child.kill(); } catch {} }
869
+ })));
870
+ this.supervised = [];
871
+ }
872
+ }
873
+
874
+ module.exports = { ModuleBridge, defaultTerminalUrl, defaultVectorHubUrl, defaultEventBridgeUrl, defaultEngineUrl };