mixdog 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (404) hide show
  1. package/.claude-plugin/marketplace.json +31 -0
  2. package/.claude-plugin/plugin.json +20 -0
  3. package/.gitattributes +34 -0
  4. package/.mcp.json +14 -0
  5. package/ARCHITECTURE.md +77 -0
  6. package/CHANGELOG.md +7 -0
  7. package/CONTRIBUTING.md +45 -0
  8. package/DATA-FLOW.md +79 -0
  9. package/LICENSE +21 -0
  10. package/README.md +389 -0
  11. package/SECURITY.md +138 -0
  12. package/UNINSTALL.md +112 -0
  13. package/agents/maintenance.md +5 -0
  14. package/agents/memory-classification.md +30 -0
  15. package/agents/scheduler-task.md +18 -0
  16. package/agents/webhook-handler.md +27 -0
  17. package/agents/worker.md +24 -0
  18. package/bin/bridge +133 -0
  19. package/bin/statusline-launcher.mjs +78 -0
  20. package/bin/statusline-lib.mjs +550 -0
  21. package/bin/statusline.mjs +607 -0
  22. package/bun.lock +802 -0
  23. package/commands/config.md +16 -0
  24. package/commands/doctor.md +13 -0
  25. package/commands/setup.md +17 -0
  26. package/defaults/cycle3-review-prompt.md +90 -0
  27. package/defaults/hidden-roles.json +65 -0
  28. package/defaults/memory-chunk-prompt.md +63 -0
  29. package/defaults/memory-promote-prompt.md +135 -0
  30. package/defaults/mixdog-config.template.json +27 -0
  31. package/defaults/user-workflow.json +8 -0
  32. package/defaults/user-workflow.md +12 -0
  33. package/hooks/hooks.json +73 -0
  34. package/hooks/lib/active-instance.cjs +77 -0
  35. package/hooks/lib/permission-evaluator.cjs +411 -0
  36. package/hooks/lib/permission-route.cjs +63 -0
  37. package/hooks/lib/permission-rules.cjs +170 -0
  38. package/hooks/lib/settings-loader.cjs +116 -0
  39. package/hooks/post-tool-use.cjs +84 -0
  40. package/hooks/pre-mcp-sandbox.cjs +158 -0
  41. package/hooks/pre-tool-subagent.cjs +253 -0
  42. package/hooks/session-start.cjs +1372 -0
  43. package/hooks/turn-timer.cjs +82 -0
  44. package/lib/claude-md-writer.cjs +386 -0
  45. package/lib/config-cjs.cjs +61 -0
  46. package/lib/hook-pipe-path.cjs +10 -0
  47. package/lib/keychain-cjs.cjs +263 -0
  48. package/lib/plugin-paths.cjs +61 -0
  49. package/lib/rules-builder.cjs +241 -0
  50. package/lib/text-utils.cjs +61 -0
  51. package/native/README.md +117 -0
  52. package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
  53. package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
  54. package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
  55. package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
  56. package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
  57. package/package.json +107 -0
  58. package/prompts/code-review.txt +16 -0
  59. package/prompts/security-audit.txt +17 -0
  60. package/rules/bridge/00-common.md +39 -0
  61. package/rules/bridge/20-skip-protocol.md +18 -0
  62. package/rules/bridge/30-explorer.md +33 -0
  63. package/rules/bridge/40-cycle1-agent.md +52 -0
  64. package/rules/bridge/41-cycle2-agent.md +62 -0
  65. package/rules/bridge/42-cycle3-agent.md +44 -0
  66. package/rules/lead/00-tool-lead.md +61 -0
  67. package/rules/lead/01-general.md +23 -0
  68. package/rules/lead/02-channels.md +49 -0
  69. package/rules/lead/03-team.md +27 -0
  70. package/rules/lead/04-workflow.md +20 -0
  71. package/rules/shared/00-language.md +14 -0
  72. package/rules/shared/01-tool.md +138 -0
  73. package/scripts/bootstrap.mjs +184 -0
  74. package/scripts/bridge-unify-smoke.mjs +308 -0
  75. package/scripts/build-runtime-linux.sh +348 -0
  76. package/scripts/build-runtime-macos.sh +217 -0
  77. package/scripts/build-runtime-windows.ps1 +242 -0
  78. package/scripts/builtin-utils-smoke.mjs +392 -0
  79. package/scripts/check-json.mjs +45 -0
  80. package/scripts/check-syntax-changed.mjs +102 -0
  81. package/scripts/check-syntax.mjs +58 -0
  82. package/scripts/code-graph-batch.test.mjs +33 -0
  83. package/scripts/config-preserve-smoke.mjs +180 -0
  84. package/scripts/doctor.mjs +484 -0
  85. package/scripts/edit-normalize-fuzz.mjs +130 -0
  86. package/scripts/edit-normalize-smoke.mjs +401 -0
  87. package/scripts/edit-operation-smoke.mjs +369 -0
  88. package/scripts/edit2-smoke.mjs +63 -0
  89. package/scripts/fuzzy-e2e.mjs +28 -0
  90. package/scripts/fuzzy-smoke.mjs +26 -0
  91. package/scripts/generate-runtime-manifest.mjs +166 -0
  92. package/scripts/guard-smoke.mjs +66 -0
  93. package/scripts/hidden-role-schema-smoke.mjs +162 -0
  94. package/scripts/hook-routing-smoke.mjs +29 -0
  95. package/scripts/inject-input.ps1 +204 -0
  96. package/scripts/io-complex-smoke.mjs +667 -0
  97. package/scripts/io-explore-bench.mjs +424 -0
  98. package/scripts/io-guardrails-smoke.mjs +205 -0
  99. package/scripts/io-mini-bench-baseline.json +11 -0
  100. package/scripts/io-mini-bench.mjs +216 -0
  101. package/scripts/io-route-harness.mjs +933 -0
  102. package/scripts/io-telemetry-report.mjs +691 -0
  103. package/scripts/mutation-bench.mjs +564 -0
  104. package/scripts/mutation-io-smoke.mjs +1081 -0
  105. package/scripts/native-patch-bridge-smoke.mjs +288 -0
  106. package/scripts/native-patch-smoke.mjs +304 -0
  107. package/scripts/patch-interior-context-smoke.mjs +49 -0
  108. package/scripts/patch-newline-utf8-smoke.mjs +157 -0
  109. package/scripts/perf-hook-smoke.mjs +71 -0
  110. package/scripts/permission-eval-smoke.mjs +426 -0
  111. package/scripts/prep-patch.mjs +53 -0
  112. package/scripts/prep-shim.mjs +96 -0
  113. package/scripts/provider-cache-smoke.mjs +687 -0
  114. package/scripts/report-runtime-health.mjs +132 -0
  115. package/scripts/run-mcp.mjs +1547 -0
  116. package/scripts/salvage-v4a-shatter.test.mjs +58 -0
  117. package/scripts/scoped-cache-io-smoke.mjs +103 -0
  118. package/scripts/shell-policy-round3-smoke.mjs +46 -0
  119. package/scripts/smoke-runtime-negative.ps1 +100 -0
  120. package/scripts/smoke-runtime-negative.sh +95 -0
  121. package/scripts/stall-policy-smoke.mjs +50 -0
  122. package/scripts/start-memory-worker.mjs +23 -0
  123. package/scripts/statusline-launcher-smoke.mjs +82 -0
  124. package/scripts/stress-atomic-write.mjs +1028 -0
  125. package/scripts/test-config-rmw-restore.mjs +122 -0
  126. package/scripts/test-fault-inject.mjs +164 -0
  127. package/scripts/test-large-file.mjs +174 -0
  128. package/scripts/tool-edge-smoke.mjs +209 -0
  129. package/scripts/uninstall.mjs +201 -0
  130. package/scripts/webhook-selfheal-smoke.mjs +29 -0
  131. package/scripts/write-overwrite-guard-smoke.mjs +56 -0
  132. package/server-main.mjs +3055 -0
  133. package/server.mjs +468 -0
  134. package/setup/config-merge.mjs +254 -0
  135. package/setup/install.mjs +120 -0
  136. package/setup/launch-core.mjs +507 -0
  137. package/setup/launch.mjs +101 -0
  138. package/setup/setup-server.mjs +3206 -0
  139. package/setup/setup.html +3693 -0
  140. package/skills/retro-skill-proposer/SKILL.md +92 -0
  141. package/skills/schedule-add/SKILL.md +77 -0
  142. package/skills/setup/SKILL.md +346 -0
  143. package/skills/webhook-add/SKILL.md +81 -0
  144. package/src/agent/bridge-stall-watchdog.mjs +337 -0
  145. package/src/agent/index.mjs +2138 -0
  146. package/src/agent/orchestrator/activity-bus.mjs +38 -0
  147. package/src/agent/orchestrator/ai-wrapped-dispatch.mjs +1010 -0
  148. package/src/agent/orchestrator/bridge-retry.mjs +220 -0
  149. package/src/agent/orchestrator/bridge-trace.mjs +583 -0
  150. package/src/agent/orchestrator/cache-mtime.mjs +58 -0
  151. package/src/agent/orchestrator/config.mjs +358 -0
  152. package/src/agent/orchestrator/context/collect.mjs +651 -0
  153. package/src/agent/orchestrator/dispatch-persist.mjs +549 -0
  154. package/src/agent/orchestrator/drain-registry.mjs +50 -0
  155. package/src/agent/orchestrator/explore-validator.mjs +8 -0
  156. package/src/agent/orchestrator/internal-roles.mjs +118 -0
  157. package/src/agent/orchestrator/internal-tools.mjs +88 -0
  158. package/src/agent/orchestrator/jobs.mjs +116 -0
  159. package/src/agent/orchestrator/mcp/client.mjs +364 -0
  160. package/src/agent/orchestrator/providers/anthropic-betas.mjs +21 -0
  161. package/src/agent/orchestrator/providers/anthropic-oauth.mjs +1745 -0
  162. package/src/agent/orchestrator/providers/anthropic.mjs +437 -0
  163. package/src/agent/orchestrator/providers/gemini.mjs +1175 -0
  164. package/src/agent/orchestrator/providers/grok-oauth.mjs +782 -0
  165. package/src/agent/orchestrator/providers/model-catalog.mjs +241 -0
  166. package/src/agent/orchestrator/providers/openai-compat.mjs +1467 -0
  167. package/src/agent/orchestrator/providers/openai-oauth-ws.mjs +1890 -0
  168. package/src/agent/orchestrator/providers/openai-oauth.mjs +1307 -0
  169. package/src/agent/orchestrator/providers/openai-ws.mjs +104 -0
  170. package/src/agent/orchestrator/providers/registry.mjs +192 -0
  171. package/src/agent/orchestrator/providers/retry-classifier.mjs +325 -0
  172. package/src/agent/orchestrator/session/abort-lookup.mjs +13 -0
  173. package/src/agent/orchestrator/session/cache/post-edit-marks.mjs +42 -0
  174. package/src/agent/orchestrator/session/cache/prefetch-cache.mjs +142 -0
  175. package/src/agent/orchestrator/session/cache/read-cache.mjs +319 -0
  176. package/src/agent/orchestrator/session/cache/scoped-cache-outcome.mjs +11 -0
  177. package/src/agent/orchestrator/session/cache/scoped-cache.mjs +361 -0
  178. package/src/agent/orchestrator/session/cache/util.mjs +49 -0
  179. package/src/agent/orchestrator/session/loop.mjs +1478 -0
  180. package/src/agent/orchestrator/session/manager.mjs +1975 -0
  181. package/src/agent/orchestrator/session/read-dedup.mjs +6 -0
  182. package/src/agent/orchestrator/session/result-classification.mjs +65 -0
  183. package/src/agent/orchestrator/session/save-session-worker.mjs +18 -0
  184. package/src/agent/orchestrator/session/store.mjs +624 -0
  185. package/src/agent/orchestrator/session/stream-watchdog.mjs +130 -0
  186. package/src/agent/orchestrator/session/tool-result-offload.mjs +166 -0
  187. package/src/agent/orchestrator/session/trim.mjs +491 -0
  188. package/src/agent/orchestrator/smart-bridge/CACHE-SHARD.md +115 -0
  189. package/src/agent/orchestrator/smart-bridge/bridge-llm.mjs +327 -0
  190. package/src/agent/orchestrator/smart-bridge/cache-obs.mjs +150 -0
  191. package/src/agent/orchestrator/smart-bridge/cache-strategy.mjs +228 -0
  192. package/src/agent/orchestrator/smart-bridge/index.mjs +215 -0
  193. package/src/agent/orchestrator/smart-bridge/profiles.mjs +37 -0
  194. package/src/agent/orchestrator/smart-bridge/registry.mjs +348 -0
  195. package/src/agent/orchestrator/smart-bridge/session-builder.mjs +116 -0
  196. package/src/agent/orchestrator/stall-policy.mjs +195 -0
  197. package/src/agent/orchestrator/tool-loop-guard.mjs +75 -0
  198. package/src/agent/orchestrator/tools/bash-policy-scan.mjs +77 -0
  199. package/src/agent/orchestrator/tools/bash-session.mjs +721 -0
  200. package/src/agent/orchestrator/tools/builtin/advisory-lock.mjs +171 -0
  201. package/src/agent/orchestrator/tools/builtin/arg-guard.mjs +455 -0
  202. package/src/agent/orchestrator/tools/builtin/atomic-write.mjs +236 -0
  203. package/src/agent/orchestrator/tools/builtin/bash-tool.mjs +480 -0
  204. package/src/agent/orchestrator/tools/builtin/binary-file.mjs +76 -0
  205. package/src/agent/orchestrator/tools/builtin/builtin-tools.mjs +256 -0
  206. package/src/agent/orchestrator/tools/builtin/cache-layers.mjs +386 -0
  207. package/src/agent/orchestrator/tools/builtin/cwd-utils.mjs +37 -0
  208. package/src/agent/orchestrator/tools/builtin/device-paths.mjs +154 -0
  209. package/src/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +292 -0
  210. package/src/agent/orchestrator/tools/builtin/diff-utils.mjs +109 -0
  211. package/src/agent/orchestrator/tools/builtin/edit-base-guard.mjs +58 -0
  212. package/src/agent/orchestrator/tools/builtin/edit-byte-plan.mjs +240 -0
  213. package/src/agent/orchestrator/tools/builtin/edit-byte-utils.mjs +113 -0
  214. package/src/agent/orchestrator/tools/builtin/edit-commit.mjs +74 -0
  215. package/src/agent/orchestrator/tools/builtin/edit-context-utils.mjs +242 -0
  216. package/src/agent/orchestrator/tools/builtin/edit-diagnostics.mjs +211 -0
  217. package/src/agent/orchestrator/tools/builtin/edit-engine.mjs +1364 -0
  218. package/src/agent/orchestrator/tools/builtin/edit-failure-context.mjs +126 -0
  219. package/src/agent/orchestrator/tools/builtin/edit-hint.mjs +141 -0
  220. package/src/agent/orchestrator/tools/builtin/edit-match-utils.mjs +194 -0
  221. package/src/agent/orchestrator/tools/builtin/edit-partial-write.mjs +60 -0
  222. package/src/agent/orchestrator/tools/builtin/edit-stale-refresh.mjs +168 -0
  223. package/src/agent/orchestrator/tools/builtin/edit-tool.mjs +173 -0
  224. package/src/agent/orchestrator/tools/builtin/edit-utf8-guard.mjs +48 -0
  225. package/src/agent/orchestrator/tools/builtin/fs-reachability.mjs +48 -0
  226. package/src/agent/orchestrator/tools/builtin/fuzzy-match.mjs +99 -0
  227. package/src/agent/orchestrator/tools/builtin/glob-walk.mjs +170 -0
  228. package/src/agent/orchestrator/tools/builtin/grep-formatting.mjs +113 -0
  229. package/src/agent/orchestrator/tools/builtin/hash-utils.mjs +6 -0
  230. package/src/agent/orchestrator/tools/builtin/list-formatting.mjs +7 -0
  231. package/src/agent/orchestrator/tools/builtin/list-tool.mjs +593 -0
  232. package/src/agent/orchestrator/tools/builtin/native-edit-runner.mjs +89 -0
  233. package/src/agent/orchestrator/tools/builtin/notebook-edit-tool.mjs +300 -0
  234. package/src/agent/orchestrator/tools/builtin/open-config-tool.mjs +26 -0
  235. package/src/agent/orchestrator/tools/builtin/path-diagnostics.mjs +152 -0
  236. package/src/agent/orchestrator/tools/builtin/path-locks.mjs +35 -0
  237. package/src/agent/orchestrator/tools/builtin/path-utils.mjs +201 -0
  238. package/src/agent/orchestrator/tools/builtin/read-args.mjs +103 -0
  239. package/src/agent/orchestrator/tools/builtin/read-batch.mjs +172 -0
  240. package/src/agent/orchestrator/tools/builtin/read-constants.mjs +40 -0
  241. package/src/agent/orchestrator/tools/builtin/read-formatting.mjs +118 -0
  242. package/src/agent/orchestrator/tools/builtin/read-image-resize.mjs +189 -0
  243. package/src/agent/orchestrator/tools/builtin/read-image.mjs +88 -0
  244. package/src/agent/orchestrator/tools/builtin/read-lines.mjs +12 -0
  245. package/src/agent/orchestrator/tools/builtin/read-mode-tool.mjs +455 -0
  246. package/src/agent/orchestrator/tools/builtin/read-open.mjs +190 -0
  247. package/src/agent/orchestrator/tools/builtin/read-range-index.mjs +271 -0
  248. package/src/agent/orchestrator/tools/builtin/read-ranges.mjs +26 -0
  249. package/src/agent/orchestrator/tools/builtin/read-single-tool.mjs +728 -0
  250. package/src/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +173 -0
  251. package/src/agent/orchestrator/tools/builtin/read-special-files.mjs +268 -0
  252. package/src/agent/orchestrator/tools/builtin/read-streaming.mjs +602 -0
  253. package/src/agent/orchestrator/tools/builtin/read-tool.mjs +530 -0
  254. package/src/agent/orchestrator/tools/builtin/read-windows.mjs +107 -0
  255. package/src/agent/orchestrator/tools/builtin/rename-tool.mjs +196 -0
  256. package/src/agent/orchestrator/tools/builtin/rg-runner.mjs +422 -0
  257. package/src/agent/orchestrator/tools/builtin/search-builders.mjs +158 -0
  258. package/src/agent/orchestrator/tools/builtin/search-tool.mjs +869 -0
  259. package/src/agent/orchestrator/tools/builtin/shell-analysis.mjs +653 -0
  260. package/src/agent/orchestrator/tools/builtin/shell-jobs.mjs +936 -0
  261. package/src/agent/orchestrator/tools/builtin/shell-output.mjs +36 -0
  262. package/src/agent/orchestrator/tools/builtin/shell-runtime.mjs +214 -0
  263. package/src/agent/orchestrator/tools/builtin/snapshot-helpers.mjs +143 -0
  264. package/src/agent/orchestrator/tools/builtin/snapshot-store.mjs +206 -0
  265. package/src/agent/orchestrator/tools/builtin/snapshot-validation.mjs +98 -0
  266. package/src/agent/orchestrator/tools/builtin/text-stats.mjs +69 -0
  267. package/src/agent/orchestrator/tools/builtin/windows-roots.mjs +23 -0
  268. package/src/agent/orchestrator/tools/builtin/write-tool.mjs +401 -0
  269. package/src/agent/orchestrator/tools/builtin.mjs +500 -0
  270. package/src/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +39 -0
  271. package/src/agent/orchestrator/tools/code-graph-tool-defs.mjs +24 -0
  272. package/src/agent/orchestrator/tools/code-graph.mjs +4095 -0
  273. package/src/agent/orchestrator/tools/cwd-tool.mjs +298 -0
  274. package/src/agent/orchestrator/tools/destructive-warning.mjs +323 -0
  275. package/src/agent/orchestrator/tools/edit-normalize.mjs +603 -0
  276. package/src/agent/orchestrator/tools/env-scrub.mjs +100 -0
  277. package/src/agent/orchestrator/tools/graph-binary-fetcher.mjs +144 -0
  278. package/src/agent/orchestrator/tools/graph-manifest.json +26 -0
  279. package/src/agent/orchestrator/tools/host-input.mjs +204 -0
  280. package/src/agent/orchestrator/tools/mutation-content-cache.mjs +67 -0
  281. package/src/agent/orchestrator/tools/mutation-planner.mjs +75 -0
  282. package/src/agent/orchestrator/tools/next-call-utils.mjs +48 -0
  283. package/src/agent/orchestrator/tools/patch-binary-fetcher.mjs +133 -0
  284. package/src/agent/orchestrator/tools/patch-manifest.json +26 -0
  285. package/src/agent/orchestrator/tools/patch-tool-defs.mjs +20 -0
  286. package/src/agent/orchestrator/tools/patch.mjs +2754 -0
  287. package/src/agent/orchestrator/tools/progress-message.mjs +118 -0
  288. package/src/agent/orchestrator/tools/result-compression.mjs +279 -0
  289. package/src/agent/orchestrator/tools/shell-command.mjs +865 -0
  290. package/src/agent/orchestrator/tools/shell-exec-policy.mjs +89 -0
  291. package/src/agent/orchestrator/tools/shell-policy-danger-target.mjs +27 -0
  292. package/src/agent/orchestrator/tools/shell-policy-imports.mjs +7 -0
  293. package/src/agent/orchestrator/tools/shell-policy.mjs +345 -0
  294. package/src/agent/orchestrator/tools/shell-snapshot.mjs +313 -0
  295. package/src/agent/orchestrator/workflow-store.mjs +93 -0
  296. package/src/agent/tool-defs.mjs +103 -0
  297. package/src/channels/backends/discord.mjs +784 -0
  298. package/src/channels/data/voice-runtime-manifest.json +138 -0
  299. package/src/channels/index.mjs +3229 -0
  300. package/src/channels/lib/cli-worker-host.mjs +12 -0
  301. package/src/channels/lib/config-lock.mjs +13 -0
  302. package/src/channels/lib/config.mjs +292 -0
  303. package/src/channels/lib/drop-trace.mjs +71 -0
  304. package/src/channels/lib/event-pipeline.mjs +81 -0
  305. package/src/channels/lib/event-queue.mjs +345 -0
  306. package/src/channels/lib/executor.mjs +168 -0
  307. package/src/channels/lib/format.mjs +188 -0
  308. package/src/channels/lib/holidays.mjs +138 -0
  309. package/src/channels/lib/hook-pipe-server.mjs +802 -0
  310. package/src/channels/lib/interaction-workflows.mjs +184 -0
  311. package/src/channels/lib/memory-client.mjs +149 -0
  312. package/src/channels/lib/output-forwarder.mjs +765 -0
  313. package/src/channels/lib/runtime-paths.mjs +479 -0
  314. package/src/channels/lib/scheduler.mjs +723 -0
  315. package/src/channels/lib/session-control.mjs +36 -0
  316. package/src/channels/lib/session-discovery.mjs +103 -0
  317. package/src/channels/lib/settings.mjs +11 -0
  318. package/src/channels/lib/state-file.mjs +68 -0
  319. package/src/channels/lib/status-snapshot.mjs +219 -0
  320. package/src/channels/lib/tool-format.mjs +140 -0
  321. package/src/channels/lib/transcript-discovery.mjs +195 -0
  322. package/src/channels/lib/voice-runtime-fetcher.mjs +734 -0
  323. package/src/channels/lib/webhook.mjs +1179 -0
  324. package/src/channels/lib/whisper-server.mjs +477 -0
  325. package/src/channels/tool-defs.mjs +170 -0
  326. package/src/daemon/host.mjs +118 -0
  327. package/src/daemon/mcp-transport.mjs +47 -0
  328. package/src/daemon/session.mjs +100 -0
  329. package/src/daemon/thin-client.mjs +71 -0
  330. package/src/daemon/transport.mjs +163 -0
  331. package/src/memory/data/runtime-manifest.json +40 -0
  332. package/src/memory/index.mjs +3305 -0
  333. package/src/memory/lib/agent-ipc.mjs +93 -0
  334. package/src/memory/lib/bridge-trace-queries.mjs +120 -0
  335. package/src/memory/lib/core-memory-store.mjs +330 -0
  336. package/src/memory/lib/embedding-provider.mjs +269 -0
  337. package/src/memory/lib/embedding-worker.mjs +323 -0
  338. package/src/memory/lib/llm-worker-host.mjs +17 -0
  339. package/src/memory/lib/memory-cycle.mjs +11 -0
  340. package/src/memory/lib/memory-cycle1.mjs +641 -0
  341. package/src/memory/lib/memory-cycle2.mjs +1284 -0
  342. package/src/memory/lib/memory-cycle3.mjs +540 -0
  343. package/src/memory/lib/memory-embed.mjs +299 -0
  344. package/src/memory/lib/memory-extraction.mjs +5 -0
  345. package/src/memory/lib/memory-maintenance-store.mjs +32 -0
  346. package/src/memory/lib/memory-ops-policy.mjs +190 -0
  347. package/src/memory/lib/memory-recall-id-patch.mjs +15 -0
  348. package/src/memory/lib/memory-recall-read-query.mjs +7 -0
  349. package/src/memory/lib/memory-recall-scope-filter.mjs +63 -0
  350. package/src/memory/lib/memory-recall-store.mjs +621 -0
  351. package/src/memory/lib/memory-retrievers.mjs +112 -0
  352. package/src/memory/lib/memory-score.mjs +71 -0
  353. package/src/memory/lib/memory-text-utils.mjs +58 -0
  354. package/src/memory/lib/memory.mjs +412 -0
  355. package/src/memory/lib/model-profile.mjs +85 -0
  356. package/src/memory/lib/pg/adapter.mjs +308 -0
  357. package/src/memory/lib/pg/process.mjs +360 -0
  358. package/src/memory/lib/pg/supervisor.mjs +396 -0
  359. package/src/memory/lib/project-id-resolver.mjs +86 -0
  360. package/src/memory/lib/runtime-fetcher.mjs +442 -0
  361. package/src/memory/lib/trace-store.mjs +728 -0
  362. package/src/memory/tool-defs.mjs +79 -0
  363. package/src/search/index.mjs +1173 -0
  364. package/src/search/lib/backends/anthropic-oauth.mjs +98 -0
  365. package/src/search/lib/backends/exa.mjs +50 -0
  366. package/src/search/lib/backends/firecrawl.mjs +61 -0
  367. package/src/search/lib/backends/gemini-api.mjs +83 -0
  368. package/src/search/lib/backends/grok-oauth.mjs +86 -0
  369. package/src/search/lib/backends/index.mjs +150 -0
  370. package/src/search/lib/backends/openai-api.mjs +144 -0
  371. package/src/search/lib/backends/openai-oauth.mjs +98 -0
  372. package/src/search/lib/backends/openai-web-search.mjs +76 -0
  373. package/src/search/lib/backends/tavily.mjs +55 -0
  374. package/src/search/lib/backends/xai-api.mjs +113 -0
  375. package/src/search/lib/cache.mjs +131 -0
  376. package/src/search/lib/config.mjs +192 -0
  377. package/src/search/lib/formatter.mjs +115 -0
  378. package/src/search/lib/provider-usage.mjs +67 -0
  379. package/src/search/lib/providers.mjs +47 -0
  380. package/src/search/lib/search-intent.mjs +109 -0
  381. package/src/search/lib/setup-handler.mjs +261 -0
  382. package/src/search/lib/state.mjs +201 -0
  383. package/src/search/lib/web-tools.mjs +1207 -0
  384. package/src/search/tool-defs.mjs +83 -0
  385. package/src/setup/defender-exclusion.mjs +183 -0
  386. package/src/shared/abort-controller.mjs +15 -0
  387. package/src/shared/atomic-file.mjs +420 -0
  388. package/src/shared/config.mjs +350 -0
  389. package/src/shared/daemon-recycle.mjs +108 -0
  390. package/src/shared/disable-claude-builtins.mjs +88 -0
  391. package/src/shared/err-text.mjs +12 -0
  392. package/src/shared/llm/cost.mjs +66 -0
  393. package/src/shared/llm/http-agent.mjs +123 -0
  394. package/src/shared/llm/index.mjs +41 -0
  395. package/src/shared/llm/pid-cleanup.mjs +27 -0
  396. package/src/shared/llm/usage-log.mjs +47 -0
  397. package/src/shared/plugin-paths.mjs +58 -0
  398. package/src/shared/schedules-store.mjs +70 -0
  399. package/src/shared/seed.mjs +119 -0
  400. package/src/shared/user-cwd.mjs +213 -0
  401. package/src/shared/user-data-guard.mjs +238 -0
  402. package/src/status/aggregator.mjs +584 -0
  403. package/src/status/server.mjs +413 -0
  404. package/tools.json +1653 -0
@@ -0,0 +1,3206 @@
1
+ #!/usr/bin/env bun
2
+ import { exec, execSync, spawn, spawnSync } from 'child_process';
3
+ import { existsSync, readFileSync, writeFileSync, createWriteStream, mkdirSync, renameSync, unlinkSync, readdirSync, rmSync, statSync, openSync, readSync, closeSync } from 'fs';
4
+ import { join, dirname, basename } from 'path';
5
+ import { homedir, arch, platform } from 'os';
6
+ import { fileURLToPath } from 'url';
7
+ import http from 'http';
8
+ import https from 'https';
9
+ import { DEFAULT_MAINTENANCE, MAINTENANCE_SLOTS, DEFAULT_PRESETS, getPluginData } from '../src/agent/orchestrator/config.mjs';
10
+ import { getOpenAIOAuthModelCatalogError, hasOpenAIOAuthCredentials } from '../src/agent/orchestrator/providers/openai-oauth.mjs';
11
+ import { hasAnthropicOAuthCredentials } from '../src/agent/orchestrator/providers/anthropic-oauth.mjs';
12
+ import { hasGrokOAuthCredentials, loginOAuth as loginGrokOAuth } from '../src/agent/orchestrator/providers/grok-oauth.mjs';
13
+ import { resolvePluginData } from '../src/shared/plugin-paths.mjs';
14
+ import { listSchedules } from '../src/shared/schedules-store.mjs';
15
+ import { ensureDataSeeds } from '../src/shared/seed.mjs';
16
+ import { backupUserData, markUserDataInitialized, shouldSeedMissingUserData } from '../src/shared/user-data-guard.mjs';
17
+ import { tmpdir } from 'os';
18
+ import { readSection, writeSection, updateSection, saveSecret, deleteSecret, hasStoredSecret, SECRET_ACCOUNTS, getSearchApiKey, getAgentApiKey, getDiscordToken, getWebhookAuthtoken, diagnoseDiscordTokenValue } from '../src/shared/config.mjs';
19
+ import { applyDefaults as applyChannelsDefaults } from '../src/channels/lib/config.mjs';
20
+ import { validateCronExpression } from '../src/channels/lib/scheduler.mjs';
21
+ import { mergeAgentConfig, mergeMemoryConfig, mergeSearchConfig, mergeConfig, mergeEndpointConfig, mergeWebhookEndpointConfig } from './config-merge.mjs';
22
+
23
+ // C2 — Origin/Referer guard for mutating routes.
24
+ // Returns true when the request is safe to handle (same-origin loopback UI,
25
+ // or direct curl/native-client that sends no browser Origin or Referer).
26
+ // Empty Origin alone is no longer trusted — browsers always send Origin on
27
+ // cross-origin requests; same-origin browser requests may omit Origin but
28
+ // will carry a matching Referer, handled by the loopback regex below.
29
+ function isAllowedOrigin(req) {
30
+ const origin = req.headers.origin || '';
31
+ const referer = req.headers.referer || '';
32
+ // No Origin AND no Referer → direct curl / native client → allow.
33
+ if (!origin && !referer) return true;
34
+ // Origin present → must match our loopback UI port.
35
+ if (origin) return /^http:\/\/(localhost|127\.0\.0\.1):3458(\/|$)/.test(origin);
36
+ // Origin absent but Referer present → allow only if Referer is loopback UI.
37
+ return /^http:\/\/(localhost|127\.0\.0\.1):3458(\/|$)/.test(referer);
38
+ }
39
+
40
+ // sanitizeName — reject path-traversal in user-supplied names used as
41
+ // directory/filename components (schedules, webhooks, presets, etc.).
42
+ function sanitizeName(n) {
43
+ if (!n || typeof n !== 'string') return null;
44
+ if (n !== basename(n)) return null;
45
+ if (n.includes('..') || n.startsWith('.')) return null;
46
+ return n;
47
+ }
48
+
49
+ const __dirname = dirname(fileURLToPath(import.meta.url));
50
+ const isWin = process.platform === 'win32';
51
+
52
+ // MIXDOG_DEBUG_SETUP=1 gates verbose tracing for the chrome launcher and the
53
+ // /open / /req HTTP path. The previous unconditional console.error calls
54
+ // produced a wall of [open-debug] / [req-trace] noise on every config-UI
55
+ // session even when nothing was wrong; gating preserves the diagnostic
56
+ // power without filling supervisor.log on the happy path.
57
+ function debugSetup(msg) {
58
+ if (!process.env.MIXDOG_DEBUG_SETUP) return;
59
+ try { console.error(`${new Date().toISOString()} ${msg}`); } catch { /* best-effort */ }
60
+ }
61
+ const home = homedir();
62
+
63
+ // -- Channels paths --
64
+ const DATA_DIR = resolvePluginData();
65
+ const MIXDOG_CONFIG_PATH = join(DATA_DIR, 'mixdog-config.json');
66
+ const STATUS_SNAPSHOT_PATH = join(DATA_DIR, 'channels', 'status-snapshot.json');
67
+
68
+ // -- Workflow paths --
69
+ const USER_WORKFLOW_PATH = join(DATA_DIR, 'user-workflow.json');
70
+ const USER_WORKFLOW_MD_PATH = join(DATA_DIR, 'user-workflow.md');
71
+
72
+ // Plugin-shipped defaults loaded from <plugin-root>/defaults/. Keeps the
73
+ // canonical user-facing seed templates editable as plain files instead of
74
+ // inline string constants. See defaults/user-workflow.{json,md}.
75
+ const DEFAULTS_DIR = join(__dirname, '..', 'defaults');
76
+ const DEFAULT_USER_WORKFLOW = JSON.parse(readFileSync(join(DEFAULTS_DIR, 'user-workflow.json'), 'utf8'));
77
+ const DEFAULT_USER_WORKFLOW_MD = readFileSync(join(DEFAULTS_DIR, 'user-workflow.md'), 'utf8');
78
+
79
+ const PORT = 3458;
80
+ const APP_WIDTH = 950;
81
+ const APP_HEIGHT = 900;
82
+ const HTML_PATH = join(__dirname, 'setup.html');
83
+
84
+ // Drop runtime-provider model caches on boot and after provider saves so the
85
+ // Config UI re-fetches fresh catalogs. Caches can get stuck on partial/stale
86
+ // responses (e.g. Codex /backend-api/codex/models returning just one model).
87
+ function dropRuntimeModelCaches() {
88
+ for (const name of ['openai-oauth-models.json', 'anthropic-oauth-models.json', 'grok-oauth-models.json']) {
89
+ try { rmSync(join(getPluginData(), name), { force: true }); } catch {}
90
+ }
91
+ }
92
+ dropRuntimeModelCaches();
93
+
94
+ // Seed user-workflow.json and user-workflow.md on first launch so Smart
95
+ // Bridge has sensible role→preset mappings and the Lead has a baseline
96
+ // workflow description out of the box. Leaves existing files untouched.
97
+ try {
98
+ if (!existsSync(USER_WORKFLOW_PATH) && shouldSeedMissingUserData(DATA_DIR, 'user-workflow.json')) {
99
+ mkdirSync(DATA_DIR, { recursive: true });
100
+ writeFileSync(USER_WORKFLOW_PATH, JSON.stringify(DEFAULT_USER_WORKFLOW, null, 2));
101
+ markUserDataInitialized(DATA_DIR);
102
+ }
103
+ if (!existsSync(USER_WORKFLOW_MD_PATH) && shouldSeedMissingUserData(DATA_DIR, 'user-workflow.md')) {
104
+ mkdirSync(DATA_DIR, { recursive: true });
105
+ writeFileSync(USER_WORKFLOW_MD_PATH, DEFAULT_USER_WORKFLOW_MD);
106
+ markUserDataInitialized(DATA_DIR);
107
+ }
108
+ } catch {}
109
+
110
+ // Seed plugin-owned scaffolding files (memory-config.json, etc.).
111
+ // Idempotent — ensureDataSeeds skips existing files; fatal throw propagates
112
+ // anything that already exists, so the agent/index.mjs call and this one
113
+ // can both run without colliding.
114
+ ensureDataSeeds(DATA_DIR);
115
+
116
+ // -- Helpers --
117
+
118
+ function readJsonFile(path) {
119
+ try { return JSON.parse(readFileSync(path, 'utf8')); }
120
+ catch { return {}; }
121
+ }
122
+
123
+ function writeJsonFile(path, data) {
124
+ mkdirSync(dirname(path), { recursive: true });
125
+ if (path === MIXDOG_CONFIG_PATH || path === USER_WORKFLOW_PATH) {
126
+ try { backupUserData(DATA_DIR, path === USER_WORKFLOW_PATH ? 'pre-workflow-write' : 'pre-config-write'); } catch {}
127
+ }
128
+ const tmp = path + '.tmp';
129
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
130
+ renameSync(tmp, path);
131
+ if (path === MIXDOG_CONFIG_PATH || path === USER_WORKFLOW_PATH) {
132
+ try { markUserDataInitialized(DATA_DIR); } catch {}
133
+ try { backupUserData(DATA_DIR, path === USER_WORKFLOW_PATH ? 'post-workflow-write' : 'post-config-write'); } catch {}
134
+ }
135
+ }
136
+
137
+ function readConfig() { return readSection('channels'); }
138
+ function writeConfig(data) { writeSection('channels', data); }
139
+
140
+ function readAgentConfig() { return readSection('agent'); }
141
+ function writeAgentConfig(data) { writeSection('agent', data); }
142
+
143
+ function readMemoryConfig() { return readSection('memory'); }
144
+ function writeMemoryConfig(data) { writeSection('memory', data); }
145
+
146
+ function readSearchConfig() { return readSection('search'); }
147
+ function writeSearchConfig(data) { writeSection('search', data); }
148
+
149
+ // Provider availability — credential resolution follows the invariant declared
150
+ // in Core Memory: `search.credentials → agent.providers.<x>-oauth →
151
+ // agent.providers.<x>.apiKey`, first match. Returns the subset of the 9
152
+ // supported providers whose credentials are already registered, so the Setup
153
+ // UI dropdown can show only selectable options.
154
+ const SUPPORTED_PROVIDERS = [
155
+ 'anthropic-oauth', 'openai-oauth', 'openai-api', 'gemini-api', 'xai-api', 'grok-oauth',
156
+ 'tavily', 'firecrawl', 'exa',
157
+ ];
158
+ const AGENT_KEY_PROVIDER_IDS = ['openai', 'anthropic', 'gemini', 'deepseek', 'xai', 'nvidia'];
159
+ const SEARCH_KEY_PROVIDER_IDS = ['firecrawl', 'tavily', 'exa'];
160
+ const AGENT_PROVIDER_ENV = Object.freeze({
161
+ openai: 'OPENAI_API_KEY',
162
+ anthropic: 'ANTHROPIC_API_KEY',
163
+ gemini: 'GEMINI_API_KEY',
164
+ deepseek: 'DEEPSEEK_API_KEY',
165
+ xai: 'XAI_API_KEY',
166
+ nvidia: 'NVIDIA_API_KEY',
167
+ });
168
+
169
+ function envSecretPresent(account) {
170
+ const key = 'MIXDOG_' + String(account || '').replace(/[.\s]+/g, '_').toUpperCase();
171
+ if (process.env[key]) return true;
172
+ const agentMatch = String(account || '').match(/^agent\.([^.]+)\.apiKey$/);
173
+ if (agentMatch) {
174
+ const std = AGENT_PROVIDER_ENV[agentMatch[1]];
175
+ if (std && process.env[std]) return true;
176
+ }
177
+ return false;
178
+ }
179
+
180
+ function getSecretByAccount(account) {
181
+ if (account === SECRET_ACCOUNTS.discordToken) return getDiscordToken();
182
+ if (account === SECRET_ACCOUNTS.webhookAuth) return getWebhookAuthtoken();
183
+ const searchMatch = String(account || '').match(/^search\.([^.]+)\.apiKey$/);
184
+ if (searchMatch) return getSearchApiKey(searchMatch[1]);
185
+ const agentMatch = String(account || '').match(/^agent\.([^.]+)\.apiKey$/);
186
+ if (agentMatch) return getAgentApiKey(agentMatch[1]);
187
+ return null;
188
+ }
189
+
190
+ function keychainSecretPresent(account) {
191
+ if (hasStoredSecret(account)) return true;
192
+ // Cross-check through the canonical getters. On Windows the setup server can
193
+ // occasionally see a false negative from the low-level presence check while
194
+ // the normal read path succeeds; the UI cares whether the credential is
195
+ // actually available to runtime.
196
+ if (envSecretPresent(account)) return false;
197
+ return !!getSecretByAccount(account);
198
+ }
199
+
200
+ function secretPresence(account) {
201
+ return {
202
+ keychain: keychainSecretPresent(account),
203
+ env: envSecretPresent(account),
204
+ };
205
+ }
206
+
207
+ function fullSecretStatus(channelsConfig = null) {
208
+ const agent = {};
209
+ for (const id of AGENT_KEY_PROVIDER_IDS) agent[id] = secretPresence(SECRET_ACCOUNTS.agentApiKey(id));
210
+ const search = {};
211
+ for (const id of SEARCH_KEY_PROVIDER_IDS) search[id] = secretPresence(SECRET_ACCOUNTS.searchApiKey(id));
212
+ const discordToken = secretPresence(SECRET_ACCOUNTS.discordToken);
213
+ const discordProblem = diagnoseDiscordTokenValue(getDiscordToken(), channelsConfig || {});
214
+ if (discordProblem) {
215
+ discordToken.invalid = true;
216
+ discordToken.problem = discordProblem;
217
+ }
218
+ return {
219
+ channels: {
220
+ discordToken,
221
+ webhookAuth: secretPresence(SECRET_ACCOUNTS.webhookAuth),
222
+ },
223
+ agent,
224
+ memory: {
225
+ // Memory provider keys reuse the same provider keychain accounts as Agent.
226
+ agent,
227
+ },
228
+ search,
229
+ };
230
+ }
231
+
232
+ function saveSecretChecked(account, rawValue, label) {
233
+ const value = typeof rawValue === 'string' ? rawValue.trim() : '';
234
+ if (!value) {
235
+ return { attempted: false, stored: keychainSecretPresent(account), env: envSecretPresent(account), inputLength: 0 };
236
+ }
237
+ saveSecret(account, value);
238
+ const stored = keychainSecretPresent(account);
239
+ if (!stored && !envSecretPresent(account)) {
240
+ throw new Error(`${label}: keychain write completed but secret is not readable`);
241
+ }
242
+ return { attempted: true, stored, env: envSecretPresent(account), inputLength: value.length };
243
+ }
244
+
245
+ function deleteSecretIfCurrentValue(account, value) {
246
+ if (!value) return false;
247
+ try {
248
+ if (getSecretByAccount(account) !== value) return false;
249
+ deleteSecret(account);
250
+ return true;
251
+ } catch {
252
+ return false;
253
+ }
254
+ }
255
+
256
+ function saveDiscordTokenChecked(rawValue, config) {
257
+ const value = typeof rawValue === 'string' ? rawValue.trim() : '';
258
+ if (!value) {
259
+ return { attempted: false, stored: keychainSecretPresent(SECRET_ACCOUNTS.discordToken), env: envSecretPresent(SECRET_ACCOUNTS.discordToken), inputLength: 0 };
260
+ }
261
+ const problem = diagnoseDiscordTokenValue(value, config || {});
262
+ if (problem) {
263
+ const removed = deleteSecretIfCurrentValue(SECRET_ACCOUNTS.discordToken, value);
264
+ throw new Error(`channels.discord.token: ${problem}${removed ? ' Removed the invalid saved value.' : ''}`);
265
+ }
266
+ return saveSecretChecked(SECRET_ACCOUNTS.discordToken, value, 'channels.discord.token');
267
+ }
268
+
269
+ function pruneInvalidDiscordToken(config) {
270
+ const value = getDiscordToken();
271
+ const problem = diagnoseDiscordTokenValue(value, config || {});
272
+ if (!problem) return null;
273
+ const removed = deleteSecretIfCurrentValue(SECRET_ACCOUNTS.discordToken, value);
274
+ return { invalid: true, removed, problem };
275
+ }
276
+
277
+ function computeAvailableProviders() {
278
+ const isAvailable = (id) => {
279
+ if (id === 'anthropic-oauth') return hasAnthropicOAuthCredentials();
280
+ if (id === 'openai-oauth') return hasOpenAIOAuthCredentials();
281
+ if (id === 'grok-oauth') return hasGrokOAuthCredentials();
282
+ if (id === 'openai-api') return !!getAgentApiKey('openai');
283
+ if (id === 'gemini-api') return !!getAgentApiKey('gemini');
284
+ if (id === 'xai-api') return !!getAgentApiKey('xai');
285
+ if (id === 'firecrawl' || id === 'tavily' || id === 'exa') return !!getSearchApiKey(id);
286
+ return false;
287
+ };
288
+ return SUPPORTED_PROVIDERS.filter(isAvailable);
289
+ }
290
+
291
+ function readUserWorkflow() {
292
+ if (!existsSync(USER_WORKFLOW_PATH)) return DEFAULT_USER_WORKFLOW;
293
+ try { return JSON.parse(readFileSync(USER_WORKFLOW_PATH, 'utf8')); }
294
+ catch { return DEFAULT_USER_WORKFLOW; }
295
+ }
296
+ // Phase C Ship 3 — the `worker` role is reserved and non-deletable. Smart
297
+ // Bridge's router dispatches any request with `role: "worker"` to the
298
+ // `worker-full` profile; if the role goes missing the router has nowhere to
299
+ // send Worker calls. Every persist path funnels through here, so reinstating
300
+ // the role on save keeps the contract intact regardless of how the caller
301
+ // mutated the roster (UI drag-delete, raw MD edit, direct JSON PUT).
302
+ function writeUserWorkflow(data) {
303
+ const roles = Array.isArray(data?.roles) ? data.roles.slice() : [];
304
+ if (!roles.some(r => r?.name === 'worker')) {
305
+ const existing = readUserWorkflow();
306
+ const preservedWorker = existing?.roles?.find(r => r?.name === 'worker');
307
+ const seedWorker = DEFAULT_USER_WORKFLOW.roles.find(r => r?.name === 'worker');
308
+ roles.unshift(preservedWorker || seedWorker);
309
+ }
310
+ const sanitizedRoles = roles.map(r => {
311
+ if (!r || typeof r !== "object") return r;
312
+ const name = sanitizeName(r.name);
313
+ if (name == null) throw new Error('invalid role name: ' + r.name);
314
+ return { ...r, name };
315
+ });
316
+ // Intentional full replace: POST /workflow/save sends the complete workflow
317
+ // document; no unmanaged top-level sidecar fields on user-workflow.json.
318
+ writeJsonFile(USER_WORKFLOW_PATH, { ...data, roles: sanitizedRoles });
319
+ }
320
+
321
+ function readUserWorkflowMd() {
322
+ if (!existsSync(USER_WORKFLOW_MD_PATH)) return DEFAULT_USER_WORKFLOW_MD;
323
+ try { return readFileSync(USER_WORKFLOW_MD_PATH, 'utf8'); }
324
+ catch { return DEFAULT_USER_WORKFLOW_MD; }
325
+ }
326
+ function writeUserWorkflowMd(content) {
327
+ // Intentional full replace: the UI owns the entire user-workflow.md body.
328
+ mkdirSync(dirname(USER_WORKFLOW_MD_PATH), { recursive: true });
329
+ try { backupUserData(DATA_DIR, 'pre-workflow-md-write'); } catch {}
330
+ const tmp = USER_WORKFLOW_MD_PATH + '.tmp';
331
+ writeFileSync(tmp, content, 'utf8');
332
+ renameSync(tmp, USER_WORKFLOW_MD_PATH);
333
+ try { markUserDataInitialized(DATA_DIR); } catch {}
334
+ try { backupUserData(DATA_DIR, 'post-workflow-md-write'); } catch {}
335
+ }
336
+
337
+ // -- HTTPS helpers --
338
+
339
+ function httpGetJson(url, headers) {
340
+ return new Promise((resolve, reject) => {
341
+ const u = new URL(url);
342
+ const lib = u.protocol === 'https:' ? https : http;
343
+ const req = lib.request({
344
+ hostname: u.hostname, port: u.port, path: u.pathname + u.search,
345
+ method: 'GET', headers, timeout: 10000,
346
+ }, res => {
347
+ let body = '';
348
+ res.on('data', c => { body += c; });
349
+ res.on('end', () => { res.statusCode < 400 ? resolve(JSON.parse(body)) : reject(); });
350
+ });
351
+ req.on('error', reject);
352
+ req.on('timeout', () => { req.destroy(); reject(); });
353
+ req.end();
354
+ });
355
+ }
356
+
357
+ function httpPostJson(url, data, headers) {
358
+ return new Promise((resolve, reject) => {
359
+ const u = new URL(url);
360
+ const body = JSON.stringify(data);
361
+ const lib = u.protocol === 'https:' ? https : http;
362
+ const req = lib.request({
363
+ hostname: u.hostname, port: u.port, path: u.pathname,
364
+ method: 'POST',
365
+ headers: { ...headers, 'Content-Length': Buffer.byteLength(body) },
366
+ timeout: 10000,
367
+ }, res => {
368
+ let buf = '';
369
+ res.on('data', c => { buf += c; });
370
+ res.on('end', () => { res.statusCode < 400 ? resolve(JSON.parse(buf)) : reject(); });
371
+ });
372
+ req.on('error', reject);
373
+ req.on('timeout', () => { req.destroy(); reject(); });
374
+ req.write(body);
375
+ req.end();
376
+ });
377
+ }
378
+
379
+ function pingLocalHttp(url, timeoutMs = 1500) {
380
+ return new Promise((resolve) => {
381
+ try {
382
+ const u = new URL(url);
383
+ const req = http.request({
384
+ hostname: u.hostname, port: u.port,
385
+ path: u.pathname + u.search,
386
+ method: 'GET', timeout: timeoutMs,
387
+ }, res => { res.resume(); resolve(res.statusCode > 0 && res.statusCode < 500); });
388
+ req.on('error', () => resolve(false));
389
+ req.on('timeout', () => { req.destroy(); resolve(false); });
390
+ req.end();
391
+ } catch { resolve(false); }
392
+ });
393
+ }
394
+
395
+ // -- Agent key validation --
396
+
397
+ async function validateAgentKey(provider, key) {
398
+ if (!key) return 'empty';
399
+ try {
400
+ switch (provider) {
401
+ case 'openai':
402
+ await httpGetJson('https://api.openai.com/v1/models', { 'Authorization': `Bearer ${key}` });
403
+ return 'valid';
404
+ case 'anthropic':
405
+ await httpPostJson('https://api.anthropic.com/v1/messages',
406
+ { model: 'claude-haiku-4-5-20251001', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] },
407
+ { 'x-api-key': key, 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' });
408
+ return 'valid';
409
+ case 'gemini':
410
+ await httpGetJson(`https://generativelanguage.googleapis.com/v1beta/models?key=${key}`, {});
411
+ return 'valid';
412
+ case 'groq':
413
+ await httpGetJson('https://api.groq.com/openai/v1/models', { 'Authorization': `Bearer ${key}` });
414
+ return 'valid';
415
+ case 'openrouter':
416
+ await httpGetJson('https://openrouter.ai/api/v1/models', { 'Authorization': `Bearer ${key}` });
417
+ return 'valid';
418
+ case 'xai':
419
+ await httpPostJson('https://api.x.ai/v1/chat/completions',
420
+ { model: 'grok-3-mini-fast', messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 },
421
+ { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' });
422
+ return 'valid';
423
+ case 'nvidia':
424
+ await httpPostJson('https://integrate.api.nvidia.com/v1/chat/completions',
425
+ { model: 'meta/llama-3.3-70b-instruct', messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 },
426
+ { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' });
427
+ return 'valid';
428
+ default: return 'valid';
429
+ }
430
+ } catch { return 'invalid'; }
431
+ }
432
+
433
+ // -- Search key validation --
434
+
435
+ async function validateSearchKey(provider, key) {
436
+ if (!key) return 'empty';
437
+ try {
438
+ switch (provider) {
439
+ case 'serper':
440
+ await httpPostJson('https://google.serper.dev/search', { q: 'test' },
441
+ { 'X-API-KEY': key, 'Content-Type': 'application/json' });
442
+ return 'valid';
443
+ case 'brave':
444
+ await httpGetJson('https://api.search.brave.com/res/v1/web/search?q=test&count=1',
445
+ { 'X-Subscription-Token': key });
446
+ return 'valid';
447
+ case 'xai':
448
+ await httpPostJson('https://api.x.ai/v1/chat/completions',
449
+ { model: 'grok-3-mini-fast', messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 },
450
+ { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' });
451
+ return 'valid';
452
+ case 'perplexity':
453
+ await httpPostJson('https://api.perplexity.ai/chat/completions',
454
+ { model: 'sonar', messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 },
455
+ { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' });
456
+ return 'valid';
457
+ case 'firecrawl':
458
+ await httpGetJson('https://api.firecrawl.dev/v1/crawl', { 'Authorization': `Bearer ${key}` });
459
+ return 'valid';
460
+ case 'tavily':
461
+ await httpPostJson('https://api.tavily.com/search',
462
+ { api_key: key, query: 'test', max_results: 1 },
463
+ { 'Content-Type': 'application/json' });
464
+ return 'valid';
465
+ default: return 'valid';
466
+ }
467
+ } catch { return 'invalid'; }
468
+ }
469
+
470
+ // -- Auth detection (shared by agent & memory) --
471
+
472
+ async function detectAuth(config = {}) {
473
+ const result = {};
474
+ // Single source of truth: same predicate the runtime uses to decide
475
+ // whether to register the provider. Avoids the UI showing "Set" while
476
+ // runtime disables the provider (or vice versa) when only one of the
477
+ // candidate paths is populated.
478
+ result.codexOAuth = hasOpenAIOAuthCredentials();
479
+ result.anthropicOAuth = hasAnthropicOAuthCredentials();
480
+ result.grokOAuth = hasGrokOAuthCredentials();
481
+ const configDir = isWin
482
+ ? (process.env.LOCALAPPDATA || join(home, 'AppData', 'Local'))
483
+ : join(home, '.config');
484
+ result.copilot = existsSync(join(configDir, 'github-copilot', 'hosts.json'))
485
+ || existsSync(join(configDir, 'github-copilot', 'apps.json'));
486
+ result.envKeys = {};
487
+ for (const [name, envKey] of [
488
+ ['openai', 'OPENAI_API_KEY'], ['anthropic', 'ANTHROPIC_API_KEY'],
489
+ ['gemini', 'GEMINI_API_KEY'], ['deepseek', 'DEEPSEEK_API_KEY'],
490
+ ['xai', 'XAI_API_KEY'], ['nvidia', 'NVIDIA_API_KEY'],
491
+ ]) { result.envKeys[name] = !!process.env[envKey]; }
492
+ // GROK_API_KEY is the last-resort xAI env alias honored by getAgentApiKey('xai')
493
+ // (shared/config.mjs is the SSOT); mirror it here so a GROK_API_KEY-only env
494
+ // shows xAI as available. keyStored semantics (keychain-only) stay unchanged.
495
+ result.envKeys.xai = result.envKeys.xai || !!process.env.GROK_API_KEY;
496
+ // Keychain-stored provider keys. Only this boolean is sent to the browser —
497
+ // the secret value never leaves the server, so the UI can show "Set" without
498
+ // exposing the key.
499
+ result.keyStored = {};
500
+ for (const name of ['openai', 'anthropic', 'gemini', 'deepseek', 'xai', 'nvidia']) {
501
+ result.keyStored[name] = hasStoredSecret(SECRET_ACCOUNTS.agentApiKey(name));
502
+ }
503
+ const ollamaUrl = config?.providers?.ollama?.baseURL || 'http://localhost:11434/v1';
504
+ const lmstudioUrl = config?.providers?.lmstudio?.baseURL || 'http://localhost:1234/v1';
505
+ const [ollamaUp, lmstudioUp] = await Promise.all([
506
+ pingLocalHttp(ollamaUrl + '/models'),
507
+ pingLocalHttp(lmstudioUrl + '/models'),
508
+ ]);
509
+ result.ollama = ollamaUp;
510
+ result.lmstudio = lmstudioUp;
511
+ return result;
512
+ }
513
+
514
+ // -- Provider model listing --
515
+
516
+ // Static model fallback removed: model lists must come from the live
517
+ // provider (registry.mjs `provider.listModels()` or the provider's REST
518
+ // endpoint). Hard-coded lists drift behind real releases (e.g. opus-4-6
519
+ // vs opus-4-7) and silently mis-resolve saved presets. Empty result on
520
+ // missing credentials is the correct invariant — only show models the
521
+ // user can actually use.
522
+
523
+ // Try the live provider registry first (dynamic catalog via /v1/models,
524
+ // Codex /backend-api/codex/models, Gemini /v1beta/models). Returns null on
525
+ // any failure so the caller uses the direct HTTP endpoint handlers below.
526
+ // Preserves full metadata (tier, family, latest,
527
+ // contextWindow, reasoningLevels, pricing) so the UI can build tier-grouped
528
+ // dropdowns and adapt effort options per model.
529
+ //
530
+ // registry.mjs populates its provider Map only after initProviders(cfg) is
531
+ // called, so setup-server (which never runs the agent's normal boot path)
532
+ // must force-init before querying — otherwise getProvider() returns
533
+ // undefined and listModels() never runs.
534
+ // Provider IDs registry.mjs actually knows. Listing anything else makes
535
+ // initProviders throw `unknown enabled provider: …`, which the outer catch
536
+ // swallows — silently nuking every model lookup for every provider. Keep
537
+ // in sync with src/agent/orchestrator/providers/registry.mjs.
538
+ const _RUNTIME_PROVIDER_NAMES = [
539
+ 'anthropic', 'anthropic-oauth', 'openai', 'openai-oauth',
540
+ 'gemini', 'deepseek', 'xai', 'grok-oauth', 'nvidia',
541
+ 'ollama', 'lmstudio',
542
+ ];
543
+
544
+ async function getRuntimeProviderModels(providerId, cfg, opts = {}) {
545
+ try {
546
+ const { initProviders, getProvider } = await import('../src/agent/orchestrator/providers/registry.mjs');
547
+ const initCfg = {};
548
+ for (const name of _RUNTIME_PROVIDER_NAMES) {
549
+ initCfg[name] = { ...(cfg?.providers?.[name] || {}), enabled: true };
550
+ }
551
+ await initProviders(initCfg);
552
+ const provider = getProvider(providerId);
553
+ if (!provider) return null;
554
+ let models = null;
555
+ if (opts.forceRefresh && typeof provider._refreshModelCache === 'function') {
556
+ models = await provider._refreshModelCache();
557
+ }
558
+ if (!Array.isArray(models) || models.length === 0) models = await provider.listModels();
559
+ if (!Array.isArray(models) || models.length === 0) return null;
560
+ return models
561
+ .map(m => {
562
+ if (typeof m === 'string') return { id: m };
563
+ const id = m?.id || m?.name;
564
+ if (!id) return null;
565
+ return { ...m, id: String(id) };
566
+ })
567
+ .filter(Boolean);
568
+ } catch { return null; }
569
+ }
570
+
571
+ function _idOnly(id) { return id ? { id: String(id) } : null; }
572
+
573
+ // Per-provider id blocklist applied to dynamic and direct-HTTP catalogs.
574
+ // Pro tier models are surfaced by /v1/models but not usable through the
575
+ // standard chat/responses paths we support, so they get filtered out at
576
+ // catalog level rather than per-UI.
577
+ const _MODEL_ID_BLOCKLIST = {
578
+ openai: [/^gpt-\d+(\.\d+)?-pro(-|$)/i, /^o\d+-pro(-|$)/i, /^sora-\d+-pro(-|$)/i],
579
+ 'openai-oauth': [/^gpt-\d+(\.\d+)?-pro(-|$)/i],
580
+ };
581
+ function _applyModelBlocklist(providerId, models) {
582
+ const rules = _MODEL_ID_BLOCKLIST[providerId];
583
+ if (!rules || !Array.isArray(models)) return models;
584
+ return models.filter(m => {
585
+ const id = typeof m === 'string' ? m : m?.id;
586
+ if (!id) return true;
587
+ return !rules.some(re => re.test(id));
588
+ });
589
+ }
590
+
591
+ function _configuredProviderModels(providerId, cfg) {
592
+ const provider = normalizeAgentProviderId(providerId);
593
+ const presets = Array.isArray(cfg?.presets) ? cfg.presets : [];
594
+ const seen = new Set();
595
+ const out = [];
596
+ for (const p of presets) {
597
+ const modelId = String(p?.model || '').trim();
598
+ if (!modelId || seen.has(modelId)) continue;
599
+ if (normalizeAgentProviderId(p?.provider) !== provider) continue;
600
+ seen.add(modelId);
601
+ out.push(_modelFromConfiguredId(modelId, provider));
602
+ }
603
+ return out;
604
+ }
605
+
606
+ function _modelFromConfiguredId(id, provider) {
607
+ const family = _familyFromModelId(id);
608
+ return {
609
+ id,
610
+ display: _displayFromModelId(id),
611
+ provider,
612
+ family,
613
+ tier: /-\d{8}$/.test(id) ? 'dated' : /-\d+-\d+/.test(id) ? 'version' : undefined,
614
+ latest: true,
615
+ contextWindow: _contextWindowFromModelId(id),
616
+ mode: 'chat',
617
+ };
618
+ }
619
+
620
+ function _familyFromModelId(id) {
621
+ const claude = String(id || '').match(/^claude-(opus|sonnet|haiku)/i);
622
+ if (claude) return claude[1].toLowerCase();
623
+ const gpt = String(id || '').match(/^(gpt-\d+)/i);
624
+ if (gpt) return gpt[1].toLowerCase();
625
+ return undefined;
626
+ }
627
+
628
+ function _displayFromModelId(id) {
629
+ const m = String(id || '').match(/^claude-(opus|sonnet|haiku)-(\d+)-(\d+)(?:-\d{8})?$/i);
630
+ if (!m) return id;
631
+ const family = m[1].charAt(0).toUpperCase() + m[1].slice(1).toLowerCase();
632
+ return `Claude ${family} ${m[2]}.${m[3]}`;
633
+ }
634
+
635
+ function _contextWindowFromModelId(id) {
636
+ const v = String(id || '').toLowerCase();
637
+ if (/^claude-opus-4-(6|7|8)(?:$|-)/.test(v)) return 1000000;
638
+ if (/^claude-sonnet-4-6(?:$|-)/.test(v)) return 1000000;
639
+ if (/^claude-haiku-4-5/.test(v)) return 200000;
640
+ if (/^gpt-5(?:\.|-|$)/.test(v)) return 1000000;
641
+ return null;
642
+ }
643
+
644
+ function _hasAllConfiguredModels(providerId, cfg, models) {
645
+ const ids = new Set((models || []).map(m => typeof m === 'string' ? m : m?.id).filter(Boolean).map(String));
646
+ return _configuredProviderModels(providerId, cfg).every(m => ids.has(m.id));
647
+ }
648
+
649
+ function _mergeConfiguredModels(providerId, cfg, models) {
650
+ const out = Array.isArray(models) ? models.slice() : [];
651
+ const ids = new Set(out.map(m => typeof m === 'string' ? m : m?.id).filter(Boolean).map(String));
652
+ for (const model of _configuredProviderModels(providerId, cfg).reverse()) {
653
+ if (ids.has(model.id)) continue;
654
+ out.unshift(model);
655
+ ids.add(model.id);
656
+ }
657
+ return out;
658
+ }
659
+
660
+ async function listProviderModels(providerId, cfg) {
661
+ // Search backends use suffix-tagged IDs (openai-api / gemini-api / xai-api)
662
+ // that share their model catalog with the bare provider. Alias them ONLY
663
+ // for the direct-HTTP fallback path. The runtime registry has its own
664
+ // provider entry per auth shape (`anthropic-oauth` ≠ `anthropic`), so the
665
+ // runtime call must keep the original ID.
666
+ const HTTP_ALIAS = {
667
+ 'openai-api': 'openai',
668
+ 'gemini-api': 'gemini',
669
+ 'xai-api': 'xai',
670
+ };
671
+ const httpLookupId = HTTP_ALIAS[providerId] || providerId;
672
+ const pcfg = cfg?.providers?.[providerId] || cfg?.providers?.[httpLookupId] || {};
673
+ // 1. Runtime provider (dynamic catalog, cached 24h). Try the original ID
674
+ // first (oauth providers expose their own model catalog), then the bare
675
+ // alias (gemini-api → gemini etc. where the registry only knows the
676
+ // unsuffixed entry).
677
+ let runtime = await getRuntimeProviderModels(providerId, cfg);
678
+ if ((!runtime || runtime.length === 0) && httpLookupId !== providerId) {
679
+ runtime = await getRuntimeProviderModels(httpLookupId, cfg);
680
+ }
681
+ if (runtime && runtime.length > 0 && !_hasAllConfiguredModels(providerId, cfg, runtime)) {
682
+ const refreshed = await getRuntimeProviderModels(providerId, cfg, { forceRefresh: true });
683
+ if (refreshed && refreshed.length > 0) runtime = refreshed;
684
+ }
685
+ if (runtime && runtime.length > 0) {
686
+ return _applyModelBlocklist(providerId, _mergeConfiguredModels(providerId, cfg, runtime));
687
+ }
688
+ // 2. Direct HTTP model list for key-based providers.
689
+ const KNOWN_ENDPOINTS = {
690
+ openai: { url: 'https://api.openai.com/v1/models', auth: k => ({ 'Authorization': `Bearer ${k}` }) },
691
+ xai: { url: 'https://api.x.ai/v1/models', auth: k => ({ 'Authorization': `Bearer ${k}` }) },
692
+ deepseek: { url: 'https://api.deepseek.com/v1/models', auth: k => ({ 'Authorization': `Bearer ${k}` }) },
693
+ nvidia: { url: 'https://integrate.api.nvidia.com/v1/models', auth: k => ({ 'Authorization': `Bearer ${k}` }) },
694
+ };
695
+ const ep = KNOWN_ENDPOINTS[httpLookupId];
696
+ if (ep && pcfg.apiKey) {
697
+ try {
698
+ const json = await httpGetJson(ep.url, ep.auth(pcfg.apiKey));
699
+ const data = Array.isArray(json?.data) ? json.data : [];
700
+ // Preserve `created` (UNIX timestamp) so the UI can apply its 6-month
701
+ // freshness cutoff. Without it the dropdown silently includes legacy
702
+ // generations (gpt-3.5-turbo, gpt-4-0613, …) because the filter has
703
+ // no date to compare against.
704
+ const mapped = data
705
+ .map(m => {
706
+ const id = m?.id || m?.name;
707
+ if (!id) return null;
708
+ const entry = { id: String(id) };
709
+ if (typeof m.created === 'number' && m.created > 0) entry.created = m.created;
710
+ return entry;
711
+ })
712
+ .filter(Boolean)
713
+ .sort((a, b) => (b.created || 0) - (a.created || 0) || a.id.localeCompare(b.id));
714
+ return _applyModelBlocklist(providerId, mapped);
715
+ } catch { /* runtime+HTTP both unavailable → fall through to [] */ }
716
+ }
717
+
718
+ const LOCAL_DEFAULTS = { ollama: 'http://localhost:11434/v1/models', lmstudio: 'http://localhost:1234/v1/models' };
719
+ if (LOCAL_DEFAULTS[providerId]) {
720
+ const baseURL = pcfg.baseURL || LOCAL_DEFAULTS[providerId].replace(/\/models$/, '');
721
+ const url = `${baseURL.replace(/\/$/, '')}/models`;
722
+ try {
723
+ const json = await httpGetJson(url, {});
724
+ const data = Array.isArray(json?.data) ? json.data : [];
725
+ return data
726
+ .map(m => _idOnly(m.id || m.name))
727
+ .filter(Boolean)
728
+ .sort((a, b) => a.id.localeCompare(b.id));
729
+ } catch { return []; }
730
+ }
731
+ return [];
732
+ }
733
+
734
+ // -- Presets (shared logic for agent & memory) --
735
+
736
+ const VALID_TOOLS = new Set(['full', 'readonly', 'mcp']);
737
+ const VALID_EFFORTS = new Set(['none', 'low', 'medium', 'high', 'xhigh', 'max']);
738
+ const AGENT_PROVIDER_ALIASES = Object.freeze({
739
+ 'openai-api': 'openai',
740
+ 'gemini-api': 'gemini',
741
+ 'xai-api': 'xai',
742
+ });
743
+ const FAST_CAPABLE_PRESET_PROVIDERS = new Set([
744
+ 'anthropic',
745
+ 'anthropic-oauth',
746
+ 'openai',
747
+ 'openai-oauth',
748
+ ]);
749
+ function normalizeAgentProviderId(provider) {
750
+ const id = String(provider || '').trim();
751
+ return AGENT_PROVIDER_ALIASES[id] || id;
752
+ }
753
+
754
+ function normalizePreset(input) {
755
+ if (!input || typeof input !== 'object') throw new Error('preset must be an object');
756
+ const id = String(input.id || '').trim();
757
+ if (!id) throw new Error('preset.id is required');
758
+ if (!/^[a-zA-Z0-9._-]+$/.test(id)) throw new Error('preset.id must be alphanumeric (._- allowed)');
759
+ const model = String(input.model || '').trim();
760
+ if (!model) throw new Error('preset.model is required');
761
+ const provider = normalizeAgentProviderId(input.provider);
762
+ if (!provider) throw new Error('preset.provider is required');
763
+ const tools = String(input.tools || 'full');
764
+ if (!VALID_TOOLS.has(tools)) throw new Error(`preset.tools must be one of ${[...VALID_TOOLS].join(', ')}`);
765
+ const out = { id, type: 'bridge', model, provider, tools };
766
+ if (typeof input.name === 'string' && input.name.trim()) out.name = input.name.trim();
767
+ if (input.effort != null && input.effort !== '') {
768
+ const effort = String(input.effort);
769
+ if (!VALID_EFFORTS.has(effort)) throw new Error(`preset.effort must be one of ${[...VALID_EFFORTS].join(', ')}`);
770
+ out.effort = effort;
771
+ }
772
+ if (input.fast === true && FAST_CAPABLE_PRESET_PROVIDERS.has(provider)) out.fast = true;
773
+ return out;
774
+ }
775
+
776
+ function readAgentPresets() {
777
+ const cfg = readAgentConfig();
778
+ // Migrated/unified configs may have no agent.presets key (vs an explicit
779
+ // empty array, which the user may have intentionally cleared). Fall back
780
+ // to the seeded defaults only when the key is absent so the Custom
781
+ // Workflow dropdowns render real options on first load. An explicit
782
+ // empty array stays empty — matches the "No presets yet" UI path.
783
+ const raw = Array.isArray(cfg.presets)
784
+ ? cfg.presets
785
+ : DEFAULT_PRESETS.map((p) => ({ ...p }));
786
+ return raw.map((p) => {
787
+ try { return normalizePreset(p); }
788
+ catch { return p; }
789
+ });
790
+ }
791
+
792
+ function writeAgentPresets(list) {
793
+ const cfg = readAgentConfig();
794
+ // Intentional presets-array replace inside read-modify-write agent section
795
+ // (other agent keys preserved via writeAgentConfig).
796
+ cfg.presets = Array.isArray(list) ? list.map((p) => normalizePreset(p)) : [];
797
+ if ('defaultPreset' in cfg) delete cfg.defaultPreset;
798
+ const validKeys = cfg.presets.map(p => p.id || p.name).filter(Boolean);
799
+ if (!cfg.default || !validKeys.includes(cfg.default)) cfg.default = validKeys[0] || null;
800
+ writeAgentConfig(cfg);
801
+ }
802
+
803
+ function readMemoryPresets() {
804
+ const cfg = readMemoryConfig();
805
+ return Array.isArray(cfg.presets) ? cfg.presets : [];
806
+ }
807
+
808
+ function writeMemoryPresets(list) {
809
+ const cfg = readMemoryConfig();
810
+ // Intentional presets-array replace inside read-modify-write memory section.
811
+ cfg.presets = list;
812
+ writeMemoryConfig(cfg);
813
+ }
814
+
815
+ function getMemoryServicePort() {
816
+ const active = JSON.parse(readFileSync(join(tmpdir(), 'mixdog', 'active-instance.json'), 'utf8'));
817
+ const port = Number(active && active.memory_port);
818
+ if (!Number.isFinite(port) || port <= 0) {
819
+ throw new Error('active-instance.json missing memory_port');
820
+ }
821
+ return port;
822
+ }
823
+
824
+ function memoryServiceCall(method, urlPath, body, timeoutMs = 600000) {
825
+ return new Promise((resolve, reject) => {
826
+ const port = getMemoryServicePort();
827
+ const payload = body ? JSON.stringify(body) : null;
828
+ const req = http.request({
829
+ hostname: '127.0.0.1',
830
+ port,
831
+ path: urlPath,
832
+ method,
833
+ headers: payload
834
+ ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
835
+ : {},
836
+ timeout: Math.max(1, timeoutMs),
837
+ }, (res) => {
838
+ const chunks = [];
839
+ res.on('data', (c) => chunks.push(c));
840
+ res.on('end', () => {
841
+ const text = Buffer.concat(chunks).toString('utf8');
842
+ let parsed;
843
+ try { parsed = JSON.parse(text); }
844
+ catch (e) { reject(new Error(`memory-service ${urlPath} invalid JSON: ${e.message}`)); return; }
845
+ resolve({ statusCode: res.statusCode, body: parsed });
846
+ });
847
+ });
848
+ req.on('error', reject);
849
+ req.on('timeout', () => { req.destroy(new Error('memory-service timeout')); });
850
+ if (payload) req.write(payload);
851
+ req.end();
852
+ });
853
+ }
854
+
855
+ const WINDOWS_BROWSER_CANDIDATES = [
856
+ { label: 'Chrome (user)', env: 'LOCALAPPDATA', parts: ['Google', 'Chrome', 'Application', 'chrome.exe'] },
857
+ { label: 'Chrome (Program Files)', env: 'PROGRAMFILES', parts: ['Google', 'Chrome', 'Application', 'chrome.exe'] },
858
+ { label: 'Chrome (Program Files x86)', env: 'PROGRAMFILES(X86)', parts: ['Google', 'Chrome', 'Application', 'chrome.exe'] },
859
+ { label: 'Edge (user)', env: 'LOCALAPPDATA', parts: ['Microsoft', 'Edge', 'Application', 'msedge.exe'] },
860
+ { label: 'Edge (Program Files)', env: 'PROGRAMFILES', parts: ['Microsoft', 'Edge', 'Application', 'msedge.exe'] },
861
+ { label: 'Edge (Program Files x86)', env: 'PROGRAMFILES(X86)', parts: ['Microsoft', 'Edge', 'Application', 'msedge.exe'] },
862
+ { label: 'Brave (user)', env: 'LOCALAPPDATA', parts: ['BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'] },
863
+ { label: 'Brave (Program Files)', env: 'PROGRAMFILES', parts: ['BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'] },
864
+ { label: 'Brave (Program Files x86)', env: 'PROGRAMFILES(X86)', parts: ['BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'] },
865
+ { label: 'Vivaldi (user)', env: 'LOCALAPPDATA', parts: ['Vivaldi', 'Application', 'vivaldi.exe'] },
866
+ { label: 'Vivaldi (Program Files)', env: 'PROGRAMFILES', parts: ['Vivaldi', 'Application', 'vivaldi.exe'] },
867
+ { label: 'Vivaldi (Program Files x86)', env: 'PROGRAMFILES(X86)', parts: ['Vivaldi', 'Application', 'vivaldi.exe'] },
868
+ ];
869
+
870
+ function getBrowserPath() {
871
+ const checked = [];
872
+ const missingEnv = new Set();
873
+ const seenPaths = new Set();
874
+
875
+ for (const candidate of WINDOWS_BROWSER_CANDIDATES) {
876
+ const base = process.env[candidate.env];
877
+ if (!base) {
878
+ missingEnv.add(candidate.env);
879
+ continue;
880
+ }
881
+
882
+ const browserPath = join(base, ...candidate.parts);
883
+ if (seenPaths.has(browserPath)) continue;
884
+ seenPaths.add(browserPath);
885
+ checked.push({ label: candidate.label, path: browserPath });
886
+ if (existsSync(browserPath)) return browserPath;
887
+ }
888
+
889
+ const checkedText = checked.length
890
+ ? checked.map(item => `${item.label}: ${item.path}`).join('; ')
891
+ : 'no candidate paths because required environment variables were missing';
892
+ const missingText = missingEnv.size ? ` Missing env vars: ${[...missingEnv].join(', ')}.` : '';
893
+ console.error(`[setup] No supported Chromium browser found for Config UI app mode. Checked ${checked.length} path(s): ${checkedText}.${missingText}`);
894
+ return null;
895
+ }
896
+
897
+ // One stable chrome profile dir reused across /mixdog:config invocations.
898
+ //
899
+ // Previous design created a fresh `chrome-app-<unix-ms>` per call to sidestep
900
+ // the singleton-lock issue, but that forced chrome through its full first-run
901
+ // path every time: many short-lived helper subprocesses (component update,
902
+ // crash handler, network service, gpu, renderer warmups, etc.), each
903
+ // allocating a conhost window even with the cmd.exe console-inherit trick.
904
+ // Users saw this as repeated terminal flashes throughout the loading
905
+ // spinner. Stable profile means first-run happens once; subsequent launches
906
+ // open the popup directly with no helper churn.
907
+ //
908
+ // Singleton-lock collision is handled in the launch path: the vbs that
909
+ // ShellExecutes chrome first taskkill-s any existing `MIXDOG CONFIG` window,
910
+ // so the profile lock is released before the new chrome boots. The kill
911
+ // runs hidden+synchronous via WScript.Shell.Run, no extra spawn flash.
912
+ //
913
+ // Sweep legacy `chrome-app-<unix-ms>` dirs (from the fresh-profile era) so
914
+ // they don't bloat the data dir.
915
+ const CHROME_PROFILE_DIR = 'chrome-app-profile';
916
+ const CHROME_PROFILE_LEGACY_PREFIX = 'chrome-app-';
917
+ function ensureStableChromeProfileDir() {
918
+ const root = DATA_DIR;
919
+ // Sweep old `chrome-app-<digits>` directories (legacy, not the stable one).
920
+ try {
921
+ const entries = readdirSync(root, { withFileTypes: true });
922
+ for (const e of entries) {
923
+ if (!e.isDirectory()) continue;
924
+ if (e.name === CHROME_PROFILE_DIR) continue;
925
+ if (!e.name.startsWith(CHROME_PROFILE_LEGACY_PREFIX)) continue;
926
+ const suffix = e.name.slice(CHROME_PROFILE_LEGACY_PREFIX.length);
927
+ if (!/^\d+$/.test(suffix)) continue;
928
+ try { rmSync(join(root, e.name), { recursive: true, force: true }); } catch {}
929
+ }
930
+ } catch {}
931
+ const profileDir = join(root, CHROME_PROFILE_DIR);
932
+ try { mkdirSync(profileDir, { recursive: true }); } catch {}
933
+ // Suppress chrome's password-save prompt and autofill prompts inside the
934
+ // config UI popup. The setup window only renders local form fields (API
935
+ // keys, tokens) that chrome would otherwise treat as login fields and pop
936
+ // a "save password?" bubble on every edit. Writing the Preferences file
937
+ // before chrome's first launch with this profile dir is the only reliable
938
+ // way to disable the password manager — command-line flags alone don't
939
+ // suppress the bubble in current chrome versions. Only seed on first
940
+ // launch (file absent); preserve user-mutated state otherwise.
941
+ try {
942
+ const defaultDir = join(profileDir, 'Default');
943
+ mkdirSync(defaultDir, { recursive: true });
944
+ const prefsPath = join(defaultDir, 'Preferences');
945
+ if (!existsSync(prefsPath)) {
946
+ writeFileSync(
947
+ prefsPath,
948
+ JSON.stringify({
949
+ credentials_enable_service: false,
950
+ credentials_enable_autosignin: false,
951
+ profile: { password_manager_enabled: false },
952
+ autofill: { enabled: false, profile_enabled: false, credit_card_enabled: false },
953
+ }),
954
+ 'utf8',
955
+ );
956
+ }
957
+ } catch {}
958
+ return profileDir;
959
+ }
960
+
961
+ // killChromesUsingProfile() and the makeFreshChromeProfileDir alias were
962
+ // removed: with per-launch profile dirs there is no singleton contention
963
+ // to clean up, and the alias only ever pointed at ensureStableChromeProfileDir
964
+ // from a single call site.
965
+
966
+ // Terminate every chrome.exe whose command line references a mixdog
967
+ // per-launch profile dir (matched by prefix `chrome-app-`). Runs before
968
+ // each new `--app=` spawn so old popups die and release their keepalive
969
+ // HTTP connections to setup-server; without this the connections pile up
970
+ // in ESTABLISHED state and exhaust the listener's accept queue (visible
971
+ // to the user as `/mixdog:config` printing "Config UI" but every
972
+ // subsequent fetch — including F5 — hanging forever).
973
+ function killAllMixdogChromes() {
974
+ if (!isWin) return;
975
+ debugSetup(`[open-debug] killAllMixdogChromes spawnSync powershell`);
976
+ const script =
977
+ "Get-CimInstance Win32_Process -Filter \"Name='chrome.exe'\" -ErrorAction SilentlyContinue | " +
978
+ "Where-Object { $_.CommandLine -and $_.CommandLine -match 'chrome-app-' } | " +
979
+ "ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }";
980
+ try {
981
+ spawnSync(
982
+ 'powershell.exe',
983
+ ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', script],
984
+ { encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'ignore', 'ignore'], timeout: 6000 },
985
+ );
986
+ } catch {
987
+ // Best-effort. If chrome stays alive the next spawn may still succeed
988
+ // (different profile dir), at worst the user gets a duplicate window.
989
+ }
990
+ }
991
+
992
+ // Bring the most recently spawned MIXDOG CONFIG window to the foreground.
993
+ // Bring the newest MIXDOG CONFIG window to the foreground. WScript.Shell
994
+ // AppActivate only — no Add-Type / P-Invoke. Add-Type compiles C# at runtime
995
+ // via csc.exe, which flashes a conhost window and adds ~1-2s; AppActivate is a
996
+ // script-context activation Windows allows with no compile. A SendKeys('%')
997
+ // first synthesizes an Alt input event from this process, lifting the
998
+ // foreground restriction so AppActivate reliably promotes the window. Detached
999
+ // PowerShell — does not block the /open response. Polls up to ~3s (Chrome may
1000
+ // take 400-900ms to bind its window title after launch).
1001
+ function focusNewestMixdogWindow() {
1002
+ if (!isWin) return;
1003
+ debugSetup(`[open-debug] focusNewestMixdogWindow spawn powershell`);
1004
+ const psScript =
1005
+ "$sh = New-Object -ComObject WScript.Shell;\n" +
1006
+ "for ($i = 0; $i -lt 12; $i++) {\n" +
1007
+ " Start-Sleep -Milliseconds 250;\n" +
1008
+ " $p = Get-Process | Where-Object { $_.MainWindowTitle -eq 'MIXDOG CONFIG' } | Sort-Object StartTime -Descending | Select-Object -First 1;\n" +
1009
+ " if ($p -and $p.MainWindowHandle -ne 0) {\n" +
1010
+ " try { $sh.SendKeys('%') } catch {}\n" +
1011
+ " $sh.AppActivate($p.Id) | Out-Null;\n" +
1012
+ " break;\n" +
1013
+ " }\n" +
1014
+ "}";
1015
+ try {
1016
+ const child = spawn(
1017
+ 'powershell.exe',
1018
+ ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', psScript],
1019
+ { detached: true, stdio: 'ignore', windowsHide: true },
1020
+ );
1021
+ child.unref();
1022
+ } catch {
1023
+ // best-effort — window still opens, just may stay buried
1024
+ }
1025
+ }
1026
+
1027
+ function getCenteredWindowPosition() {
1028
+ if (!isWin) return null;
1029
+ debugSetup(`[open-debug] getCenteredWindowPosition spawnSync powershell`);
1030
+ // Center on whichever monitor the cursor is on, not unconditionally on
1031
+ // PrimaryScreen. With a two-monitor setup the popup used to land on the
1032
+ // primary at (805, 246) even when the user was working on the secondary —
1033
+ // so they reported the window as "not showing" while it was actually
1034
+ // visible on the other monitor. Pick the active monitor via
1035
+ // Cursor.Position → Screen.FromPoint.
1036
+ const script = [
1037
+ "[void][Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms')",
1038
+ "$p=[System.Windows.Forms.Cursor]::Position",
1039
+ "$s=[System.Windows.Forms.Screen]::FromPoint($p)",
1040
+ "$a=$s.WorkingArea",
1041
+ 'Write-Output "$($a.X),$($a.Y),$($a.Width),$($a.Height)"',
1042
+ ].join(';');
1043
+ try {
1044
+ const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', script], {
1045
+ encoding: 'utf8',
1046
+ windowsHide: true,
1047
+ stdio: ['ignore', 'pipe', 'ignore'],
1048
+ });
1049
+ if (result.status !== 0) return null;
1050
+ const [x, y, width, height] = (result.stdout || '').trim().split(',').map(Number);
1051
+ if ([x, y, width, height].some(Number.isNaN)) return null;
1052
+ return {
1053
+ x: Math.max(0, Math.round(x + ((width - APP_WIDTH) / 2))),
1054
+ y: Math.max(0, Math.round(y + ((height - APP_HEIGHT) / 2))),
1055
+ };
1056
+ } catch {
1057
+ return null;
1058
+ }
1059
+ }
1060
+
1061
+ function formatOpenError(error) {
1062
+ return error instanceof Error ? error.message : String(error);
1063
+ }
1064
+
1065
+ function describeSpawnSyncResult(result) {
1066
+ if (result.error) return formatOpenError(result.error);
1067
+ const details = [];
1068
+ if (typeof result.status === 'number') details.push(`exit status ${result.status}`);
1069
+ if (result.signal) details.push(`signal ${result.signal}`);
1070
+ const stderr = (result.stderr || '').toString().trim();
1071
+ const stdout = (result.stdout || '').toString().trim();
1072
+ if (stderr) details.push(`stderr: ${stderr}`);
1073
+ if (stdout) details.push(`stdout: ${stdout}`);
1074
+ return details.join('; ') || 'unknown launch failure';
1075
+ }
1076
+
1077
+ function logOpenFailure(method, message) {
1078
+ console.error(`[setup] Failed to open Config UI window via ${method}: ${message}`);
1079
+ }
1080
+
1081
+ function tryDetachedOpen(method, command, args, attempts) {
1082
+ return new Promise(resolve => {
1083
+ let child;
1084
+ let settled = false;
1085
+ const finish = ok => {
1086
+ if (settled) return;
1087
+ settled = true;
1088
+ resolve(ok);
1089
+ };
1090
+
1091
+ try {
1092
+ child = spawn(command, args, {
1093
+ detached: true,
1094
+ stdio: 'ignore',
1095
+ windowsHide: true,
1096
+ });
1097
+ } catch (error) {
1098
+ const message = formatOpenError(error);
1099
+ attempts.push({ method, ok: false, error: message });
1100
+ logOpenFailure(method, message);
1101
+ finish(false);
1102
+ return;
1103
+ }
1104
+
1105
+ child.once('error', error => {
1106
+ const message = formatOpenError(error);
1107
+ attempts.push({ method, ok: false, error: message });
1108
+ logOpenFailure(method, message);
1109
+ finish(false);
1110
+ });
1111
+ child.once('spawn', () => {
1112
+ child.unref();
1113
+ attempts.push({ method, ok: true });
1114
+ finish(true);
1115
+ });
1116
+ });
1117
+ }
1118
+
1119
+ function trySyncOpen(method, command, args, attempts) {
1120
+ debugSetup(`[open-debug] trySyncOpen method=${method} command=${command}`);
1121
+ let result;
1122
+ try {
1123
+ result = spawnSync(command, args, {
1124
+ encoding: 'utf8',
1125
+ windowsHide: true,
1126
+ stdio: ['ignore', 'pipe', 'pipe'],
1127
+ });
1128
+ } catch (error) {
1129
+ const message = formatOpenError(error);
1130
+ attempts.push({ method, ok: false, error: message });
1131
+ logOpenFailure(method, message);
1132
+ return false;
1133
+ }
1134
+
1135
+ if (!result.error && result.status === 0) {
1136
+ attempts.push({ method, ok: true });
1137
+ return true;
1138
+ }
1139
+
1140
+ const message = describeSpawnSyncResult(result);
1141
+ attempts.push({ method, ok: false, error: message });
1142
+ logOpenFailure(method, message);
1143
+ return false;
1144
+ }
1145
+
1146
+ function quotePowerShellString(value) {
1147
+ return `'${String(value).replace(/'/g, "''")}'`;
1148
+ }
1149
+
1150
+ // Serialize every window-open sequence through one in-process chain. All
1151
+ // /open requests (and the open-on-start path) funnel through this single
1152
+ // server process, so concurrent opens would otherwise race the FindChromePid
1153
+ // existence gate: open #2 runs while open #1's browser has spawned but is not
1154
+ // yet discoverable, sees pid=0, and deletes the LIVE owner's Singleton* files
1155
+ // then respawns. The lock holds (see openAppWindowSequence's win32 path) until
1156
+ // the spawned browser is discoverable or its launcher child exits, so by the
1157
+ // time the next sequence runs the existence check is stable.
1158
+ let openWindowChain = Promise.resolve();
1159
+ function openAppWindow() {
1160
+ // Run after the previous sequence settles (success OR failure), ignoring its
1161
+ // result; keep the chain alive with a rejection-swallowing tail so one
1162
+ // failed open never poisons later opens.
1163
+ const run = openWindowChain.then(openAppWindowSequence, openAppWindowSequence);
1164
+ openWindowChain = run.then(() => {}, () => {});
1165
+ return run;
1166
+ }
1167
+
1168
+ async function openAppWindowSequence() {
1169
+ const appUrl = `http://localhost:${PORT}`;
1170
+ const attempts = [];
1171
+
1172
+ if (isWin) {
1173
+ const browser = getBrowserPath();
1174
+ if (browser) {
1175
+ // Chrome `--app=` mode renders the frameless standalone popup the
1176
+ // user wants. Two pitfalls handled:
1177
+ // 1. Singleton lock: shared user-data-dir + stale chrome session →
1178
+ // IPC reuse → silent no-op when prior window was closed. Sidestep
1179
+ // via unique per-launch profile dir.
1180
+ // 2. Socket-leak deadlock: each chrome holds persistent keepalive
1181
+ // HTTP connections to setup-server. Repeated launches without
1182
+ // closing earlier popups accumulate ESTABLISHED/CLOSE_WAIT entries
1183
+ // until the server can't accept new requests (F5 hangs forever).
1184
+ // Sweep any mixdog-owned chrome before spawning so connections
1185
+ // are released and only one popup is ever live.
1186
+ // killAllMixdogChromes / getCenteredWindowPosition / focusNewestMixdogWindow
1187
+ // were each spawning a PowerShell child (3 console-flash sources per
1188
+ // /open). Removing them eliminates the conhost flashes. Trade-offs: old
1189
+ // popups stay alive until the user closes them, the new window opens at
1190
+ // chrome's default position rather than centered on the cursor's
1191
+ // monitor, and it spawns behind whatever was foregrounded. Use a fresh
1192
+ // per-launch profile dir to guarantee a new window even without the
1193
+ // singleton kill.
1194
+ const chromeProfile = ensureStableChromeProfileDir();
1195
+ debugSetup(`[open-debug] chromeProfile=${chromeProfile}`);
1196
+ const args = [
1197
+ `--app=${appUrl}`,
1198
+ `--user-data-dir=${chromeProfile}`,
1199
+ `--window-size=${APP_WIDTH},${APP_HEIGHT}`,
1200
+ '--no-first-run',
1201
+ '--no-default-browser-check',
1202
+ // Password/autofill suppression defensive belt to the Preferences
1203
+ // file suspenders above (some code paths key off the flag instead).
1204
+ '--password-store=basic',
1205
+ // Helper-subprocess minimization (verified safe). --no-zygote and
1206
+ // the other startup-stripping flags broke renderer init (blank
1207
+ // page), so this set is the maximum that keeps chrome rendering:
1208
+ // Audio in-process via --disable-features below.
1209
+ // --disable-logging / --log-level=3: suppress chrome's stderr
1210
+ // pipe so helpers don't allocate a console.
1211
+ // --enable-features=...:disable trims a few background services.
1212
+ // Removed in this revision:
1213
+ // --in-process-gpu: produced a white screen on the --app window
1214
+ // (renderer JS executed, .main.ready class landed, /generation
1215
+ // polled; but the compositor never painted). The cmd.exe show=0
1216
+ // wrapper above already hides every helper conhost, so this
1217
+ // optimization saved one process at the cost of breaking
1218
+ // rendering — keep the default out-of-process GPU.
1219
+ // --no-sandbox: did not reduce helper count (sandbox is per-
1220
+ // renderer, not a separate process). It only triggered chrome's
1221
+ // "unsupported command-line flag" warning bar on top of the
1222
+ // --app window. Zero ergonomic benefit, real UX regression.
1223
+ '--disable-logging',
1224
+ '--log-level=3',
1225
+ '--disable-features=PasswordManagerOnboarding,AutofillServerCommunication,PasswordCheck,AudioServiceOutOfProcess,RendererCodeIntegrity,CalculateNativeWinOcclusion',
1226
+ ];
1227
+
1228
+ // Direct chrome.exe spawn with detached + ignored stdio + windowsHide.
1229
+ // The previous `cmd /c start` path made chrome inherit cmd's console
1230
+ // handle; its --type=renderer/gpu helpers then attached conhost.exe
1231
+ // windows that flashed visibly on screen during open (5-10 flashes
1232
+ // observed). Direct spawn keeps the entire chrome process tree
1233
+ // consoleless. detached:true puts chrome in its own process group so
1234
+ // it stays alive after this wrapper exits.
1235
+ debugSetup(`[open-debug] chrome via wscript: ${browser}`);
1236
+ let chromeSpawnOk = false;
1237
+ try {
1238
+ // Bun 1.3.13 spawn() on Windows lets CreateProcess flash a console
1239
+ // for the child (and chrome's helpers) before windowsHide takes
1240
+ // effect, even with stdio:'ignore'. wscript.exe is a GUI-mode
1241
+ // scripting host with no console of its own, but Win11 can ignore
1242
+ // Shell.Application.ShellExecute(..., show=0) for cmd.exe and leave
1243
+ // the empty conhost visible. Use WMI Win32_Process.Create instead:
1244
+ // Win32_ProcessStartup.ShowWindow=0 is applied to the actual
1245
+ // CreateProcess call that creates cmd.exe, so cmd's console is born
1246
+ // hidden rather than hidden after ShellExecute creates it. Do NOT
1247
+ // set CREATE_NO_WINDOW; chrome helpers need a real hidden console to
1248
+ // inherit so they do not allocate their own conhost instances.
1249
+ const escVbs = s => String(s).replace(/"/g, '""');
1250
+ const argsStr = args.join(' ');
1251
+ // Warm open + cold-open invariant. The profile dir is stable per
1252
+ // install (CHROME_PROFILE_DIR) and chrome enforces ONE singleton per
1253
+ // --user-data-dir, so two facts decide the action:
1254
+ //
1255
+ // 1. A live mixdog chrome already owns the profile (FindChromePid by
1256
+ // --app + --user-data-dir, ignoring --type= helpers) → just focus
1257
+ // it. Killing+respawning a live window is wasteful and races the
1258
+ // /generation self-close poll. (warm open)
1259
+ //
1260
+ // 2. No live owner → the profile may still carry a STALE singleton
1261
+ // lock (SingletonLock/Socket/Cookie) left by a prior chrome that
1262
+ // was force-killed (takeover taskkill /T /F) or lost to sleep/
1263
+ // crash. A fresh `--app` launch then rendezvouses with that dead
1264
+ // instance over the singleton socket, forwards its URL, and exits
1265
+ // WITHOUT opening a window — the reported cold-open bug (URL
1266
+ // printed, no window, a later /open works once the lock is reaped).
1267
+ // Deleting the stale Singleton* files first guarantees chrome
1268
+ // boots a real window instead of IPC-forwarding to a ghost. This
1269
+ // is the invariant ("spawn into a clean singleton when no live
1270
+ // owner"), not a retry. The title-scoped taskkill is kept only as
1271
+ // a defensive belt for a same-title window with a non-matching
1272
+ // command line; it is a no-op in the common case.
1273
+ //
1274
+ // The taskkill → chrome chain runs under one hidden cmd.exe (one
1275
+ // cmd.exe per /open). `&` runs both regardless of exit code (taskkill
1276
+ // exits 128 when nothing matches — must not abort the chain). cmd /c
1277
+ // waits until chrome exits, then exits with it.
1278
+ const cmdArg = `/d /c taskkill /F /FI "WINDOWTITLE eq MIXDOG CONFIG" >NUL 2>&1 & "${browser}" ${argsStr} >NUL 2>&1`;
1279
+ const appNeedle = `--app=${appUrl}`;
1280
+ const profileNeedle = `--user-data-dir=${chromeProfile}`;
1281
+ // Process-name needle derived from the ACTUAL chosen browser exe
1282
+ // (chrome.exe / msedge.exe / brave.exe / vivaldi.exe). Hardcoding
1283
+ // chrome.exe made FindChromePid always return 0 under Edge/Brave/
1284
+ // Vivaldi, so the cold branch deleted a LIVE owner's Singleton* files
1285
+ // and respawned instead of focusing.
1286
+ const procName = basename(browser);
1287
+ const vbsLines = [
1288
+ 'Option Explicit',
1289
+ 'Const HIDDEN_WINDOW = 0',
1290
+ 'Dim Wmi, Startup, Wsh, cmdLine, cmdPid, rc, appNeedle, profileNeedle, existingPid, profileDir, procName',
1291
+ 'Set Wmi = GetObject("winmgmts:{impersonationLevel=impersonate}!\\\\.\\root\\cimv2")',
1292
+ 'Set Wsh = CreateObject("WScript.Shell")',
1293
+ `appNeedle = "${escVbs(appNeedle)}"`,
1294
+ `profileNeedle = "${escVbs(profileNeedle)}"`,
1295
+ `profileDir = "${escVbs(chromeProfile)}"`,
1296
+ `procName = "${escVbs(procName)}"`,
1297
+ 'existingPid = FindChromePid(Wmi, appNeedle, profileNeedle)',
1298
+ 'If existingPid = 0 Then',
1299
+ ' Call ClearSingletonLocks(profileDir)',
1300
+ ' Set Startup = Wmi.Get("Win32_ProcessStartup").SpawnInstance_',
1301
+ ' Startup.ShowWindow = HIDDEN_WINDOW',
1302
+ ` cmdLine = "cmd.exe ${escVbs(cmdArg)}"`,
1303
+ ' rc = Wmi.Get("Win32_Process").Create(cmdLine, Null, Startup, cmdPid)',
1304
+ ' If rc <> 0 Then WScript.Quit rc',
1305
+ 'End If',
1306
+ 'Call FocusMixdogWindow(Wmi, Wsh, appNeedle, profileNeedle)',
1307
+ '',
1308
+ 'Sub ClearSingletonLocks(profileDir)',
1309
+ ' Dim Fso, names, i, p',
1310
+ ' On Error Resume Next',
1311
+ ' Set Fso = CreateObject("Scripting.FileSystemObject")',
1312
+ ' names = Array("SingletonLock", "SingletonSocket", "SingletonCookie")',
1313
+ ' For i = 0 To UBound(names)',
1314
+ ' p = Fso.BuildPath(profileDir, names(i))',
1315
+ ' If Fso.FileExists(p) Then Fso.DeleteFile p, True',
1316
+ ' Next',
1317
+ ' On Error GoTo 0',
1318
+ 'End Sub',
1319
+ '',
1320
+ 'Sub FocusMixdogWindow(Wmi, Wsh, appNeedle, profileNeedle)',
1321
+ ' Dim i, pid, activated',
1322
+ ' On Error Resume Next',
1323
+ ' For i = 1 To 40',
1324
+ ' WScript.Sleep 150',
1325
+ ' pid = FindChromePid(Wmi, appNeedle, profileNeedle)',
1326
+ ' activated = False',
1327
+ ' If pid <> 0 Then',
1328
+ ' Wsh.SendKeys "%"',
1329
+ ' WScript.Sleep 25',
1330
+ ' activated = Wsh.AppActivate(CLng(pid))',
1331
+ ' End If',
1332
+ ' If Not activated And (pid <> 0 Or i > 8) Then',
1333
+ ' Wsh.SendKeys "%"',
1334
+ ' WScript.Sleep 25',
1335
+ ' activated = Wsh.AppActivate("MIXDOG CONFIG")',
1336
+ ' End If',
1337
+ ' If activated Then Exit For',
1338
+ ' Next',
1339
+ ' On Error GoTo 0',
1340
+ 'End Sub',
1341
+ '',
1342
+ 'Function FindChromePid(Wmi, appNeedle, profileNeedle)',
1343
+ ' Dim proc, newestPid, newestCreated, commandLine',
1344
+ ' newestPid = 0',
1345
+ ' newestCreated = ""',
1346
+ ' For Each proc In Wmi.ExecQuery("SELECT ProcessId,CommandLine,CreationDate FROM Win32_Process WHERE Name = \'" & procName & "\'")',
1347
+ ' commandLine = ""',
1348
+ ' If Not IsNull(proc.CommandLine) Then commandLine = CStr(proc.CommandLine)',
1349
+ ' If InStr(1, commandLine, appNeedle, vbTextCompare) > 0 Then',
1350
+ ' If InStr(1, commandLine, profileNeedle, vbTextCompare) > 0 Then',
1351
+ ' If InStr(1, commandLine, "--type=", vbTextCompare) = 0 Then',
1352
+ ' If newestCreated = "" Or CStr(proc.CreationDate) > newestCreated Then',
1353
+ ' newestCreated = CStr(proc.CreationDate)',
1354
+ ' newestPid = CLng(proc.ProcessId)',
1355
+ ' End If',
1356
+ ' End If',
1357
+ ' End If',
1358
+ ' End If',
1359
+ ' Next',
1360
+ ' FindChromePid = newestPid',
1361
+ 'End Function',
1362
+ ];
1363
+ const vbsPath = join(tmpdir(), `mixdog-chrome-launch-${Date.now()}.vbs`);
1364
+ writeFileSync(vbsPath, vbsLines.join('\r\n'), 'utf8');
1365
+ // Hold the open mutex until this launcher exits. The wscript host
1366
+ // runs ClearSingletonLocks→spawn→FocusMixdogWindow, whose loop polls
1367
+ // FindChromePid until the browser is discoverable (or its bounded
1368
+ // ~6s/40-tick loop expires), then exits. Awaiting it guarantees the
1369
+ // next queued open sees a stable existence check — the spawned
1370
+ // browser is discoverable (so it focuses) rather than racing the
1371
+ // pid=0 gate into a stale-Singleton delete of a live owner.
1372
+ //
1373
+ // Bounded await: if wscript/WMI/CreateProcess hangs before exiting,
1374
+ // an unbounded wait would leave openAppWindowSequence() pending →
1375
+ // openWindowChain stuck → every future /open queued forever (the
1376
+ // client-side requestOpen timeout never settles the server chain).
1377
+ // Race the child's exit against a server-side deadline a few seconds
1378
+ // above the VBS focus loop's own ~6s bound; on deadline, kill the
1379
+ // wscript child (tree) and report a failed open so the chain advances.
1380
+ // Killing wscript cannot touch the browser: the VBS spawns it via WMI
1381
+ // Win32_Process.Create (cmd.exe → browser), an independent process not
1382
+ // parented to wscript, so a tree-kill of wscript reaps only the focus
1383
+ // loop.
1384
+ const WSCRIPT_OPEN_DEADLINE_MS = 12000;
1385
+ const timedOut = await new Promise(resolve => {
1386
+ const wscriptChild = spawn('wscript.exe', ['//B', '//NoLogo', vbsPath], {
1387
+ stdio: 'ignore', windowsHide: true,
1388
+ });
1389
+ let settled = false;
1390
+ const finish = via => { if (settled) return; settled = true; clearTimeout(timer); resolve(via); };
1391
+ const timer = setTimeout(() => {
1392
+ // Tree-kill the wscript launcher only; the detached browser lives on.
1393
+ try { spawnSync('taskkill', ['/F', '/T', '/PID', String(wscriptChild.pid)], { windowsHide: true, stdio: 'ignore', timeout: 4000 }); } catch {}
1394
+ try { wscriptChild.kill(); } catch {}
1395
+ finish(true);
1396
+ }, WSCRIPT_OPEN_DEADLINE_MS);
1397
+ if (typeof timer.unref === 'function') timer.unref();
1398
+ wscriptChild.once('error', () => finish(false));
1399
+ wscriptChild.once('exit', () => finish(false));
1400
+ });
1401
+ if (timedOut) {
1402
+ const err = `wscript launcher did not exit within ${WSCRIPT_OPEN_DEADLINE_MS}ms; killed launcher (browser left running)`;
1403
+ attempts.push({ method: 'browser app mode (wscript)', ok: false, error: err });
1404
+ logOpenFailure('browser app mode (wscript)', err);
1405
+ } else {
1406
+ chromeSpawnOk = true;
1407
+ attempts.push({ method: 'browser app mode (wscript)', ok: true });
1408
+ }
1409
+ } catch (error) {
1410
+ attempts.push({ method: 'browser app mode (wscript)', ok: false, error: formatOpenError(error) });
1411
+ logOpenFailure('browser app mode (wscript)', formatOpenError(error));
1412
+ }
1413
+ if (chromeSpawnOk) {
1414
+ return { ok: true, method: 'browser app mode (wscript)', attempts };
1415
+ }
1416
+ } else {
1417
+ attempts.push({ method: 'browser app mode', ok: false, error: 'No supported Chromium browser path found' });
1418
+ }
1419
+
1420
+ if (trySyncOpen('cmd start', 'cmd', ['/c', 'start', '', appUrl], attempts)) {
1421
+ return {
1422
+ ok: true,
1423
+ method: 'cmd start',
1424
+ warning: browser ? undefined : 'Supported Chromium browser not found; opened with the default browser instead of app mode.',
1425
+ attempts,
1426
+ };
1427
+ }
1428
+
1429
+ const psCommand = `Start-Process -FilePath ${quotePowerShellString(appUrl)}`;
1430
+ if (trySyncOpen('PowerShell Start-Process', 'powershell.exe', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', psCommand], attempts)) {
1431
+ return { ok: true, method: 'PowerShell Start-Process', attempts };
1432
+ }
1433
+
1434
+ return { ok: false, error: 'Failed to launch Config UI window', attempts };
1435
+ }
1436
+
1437
+ if (process.platform === 'darwin') {
1438
+ const macResult = await new Promise(resolve => {
1439
+ const child = spawn('open', [appUrl], { stdio: 'ignore' });
1440
+ let timer = setTimeout(() => {
1441
+ try { child.kill(); } catch {}
1442
+ resolve({ ok: false, error: 'open-timeout' });
1443
+ }, 5000);
1444
+ child.once('error', err => resolve({ ok: false, error: err.message }));
1445
+ child.once('close', code => {
1446
+ clearTimeout(timer);
1447
+ resolve(code === 0 ? { ok: true } : { ok: false, error: `exit ${code}` });
1448
+ });
1449
+ });
1450
+ const macAttempt = { method: 'macOS open', ...macResult };
1451
+ if (!macResult.ok) logOpenFailure('macOS open', macResult.error);
1452
+ return { ...macResult, method: 'macOS open', attempts: [macAttempt] };
1453
+ }
1454
+
1455
+ const xdgResult = await new Promise(resolve => {
1456
+ const child = spawn('xdg-open', [appUrl], { stdio: 'ignore' });
1457
+ let timer = setTimeout(() => {
1458
+ try { child.kill(); } catch {}
1459
+ resolve({ ok: false, error: 'open-timeout' });
1460
+ }, 5000);
1461
+ child.once('error', err => resolve({ ok: false, error: err.message }));
1462
+ child.once('close', code => {
1463
+ clearTimeout(timer);
1464
+ resolve(code === 0 ? { ok: true } : { ok: false, error: `exit ${code}` });
1465
+ });
1466
+ });
1467
+ const xdgAttempt = { method: 'xdg-open', ...xdgResult };
1468
+ if (!xdgResult.ok) logOpenFailure('xdg-open', xdgResult.error);
1469
+ return { ...xdgResult, method: 'xdg-open', attempts: [xdgAttempt] };
1470
+ }
1471
+
1472
+ // -- CLI check --
1473
+
1474
+ // Direct spawn of `where.exe` / `which` (shell:false) skips Node's default
1475
+ // cmd.exe-wrapped exec on Windows. The cmd.exe wrapper allocates a console
1476
+ // that flashed conhost on /cli-check during config-UI boot even with
1477
+ // windowsHide:true. Direct spawn keeps the lookup consoleless.
1478
+ //
1479
+ // 6s timeout + auto-resolve {installed:false} guards against a where.exe
1480
+ // PATH-resolution hang. The /cli-check route is one of the 9 loaders the
1481
+ // setup-html boot path Promise.allSettled-s on before flipping .main.ready;
1482
+ // without a timeout, a hung lookup would leave the page on a white screen
1483
+ // indefinitely (loader never settles → spinner never hides → no UI render).
1484
+ // 2s was too tight: on cold-cache page-boot the bun spawn → where.exe →
1485
+ // close-event roundtrip was empirically 1.5-2.4s on Windows, so the timer
1486
+ // raced the close handler and intermittently returned `installed:false`
1487
+ // for an actually-installed binary (user-visible "ngrok not found" after
1488
+ // fresh /mixdog:config). 6s keeps the hang-guard intact while clearing
1489
+ // the spawn-overhead band by ~3x.
1490
+ function checkCli(name) {
1491
+ return new Promise(resolve => {
1492
+ const tool = isWin ? 'where.exe' : 'which';
1493
+ let settled = false;
1494
+ const finish = result => { if (!settled) { settled = true; resolve(result); } };
1495
+ let child;
1496
+ try {
1497
+ child = spawn(tool, [name], {
1498
+ windowsHide: true,
1499
+ stdio: ['ignore', 'pipe', 'ignore'],
1500
+ shell: false,
1501
+ });
1502
+ } catch {
1503
+ finish({ installed: false });
1504
+ return;
1505
+ }
1506
+ const timer = setTimeout(() => {
1507
+ try { child.kill('SIGKILL'); } catch {}
1508
+ finish({ installed: false });
1509
+ }, 6000);
1510
+ let out = '';
1511
+ if (child.stdout) child.stdout.on('data', chunk => { out += chunk; });
1512
+ child.once('error', () => { clearTimeout(timer); finish({ installed: false }); });
1513
+ child.once('close', code => {
1514
+ clearTimeout(timer);
1515
+ if (code !== 0 || !out.trim()) finish({ installed: false });
1516
+ else finish({ installed: true, path: out.trim().split(/\r?\n/)[0] });
1517
+ });
1518
+ });
1519
+ }
1520
+
1521
+ // -- HTTP body reader --
1522
+ // An empty/whitespace body resolves to {} (action endpoints POST with no
1523
+ // body). A NON-empty body that fails JSON.parse is rejected with a tagged
1524
+ // error instead of being silently coerced to {} — previously a truncated or
1525
+ // malformed payload parsed as {} and the save handler still returned success,
1526
+ // masking a failed save (defaults written, real data lost). The request
1527
+ // handler converts BadJsonError into a 400 so the client sees the failure.
1528
+ class BadJsonError extends Error {
1529
+ constructor(message) { super(message); this.name = 'BadJsonError'; this.statusCode = 400; }
1530
+ }
1531
+ function readBody(req) {
1532
+ return new Promise((resolve, reject) => {
1533
+ let body = '';
1534
+ req.on('data', c => { body += c; });
1535
+ req.on('end', () => {
1536
+ if (!body.trim()) { resolve({}); return; }
1537
+ try { resolve(JSON.parse(body)); }
1538
+ catch (e) { reject(new BadJsonError(`malformed JSON body: ${e.message}`)); }
1539
+ });
1540
+ req.on('error', reject);
1541
+ });
1542
+ }
1543
+
1544
+ // -- Server --
1545
+ let openGeneration = 0;
1546
+ let windowOpen = false;
1547
+
1548
+ const server = http.createServer(async (req, res) => {
1549
+ // Outer guard: most handlers await readBody() outside their own try/catch,
1550
+ // so a rejected body parse (BadJsonError) would otherwise be an unhandled
1551
+ // rejection that leaves the request hanging. Map it to a JSON error response
1552
+ // (400 for malformed input, 500 otherwise) so malformed saves fail loudly.
1553
+ try {
1554
+ await handleRequest(req, res);
1555
+ } catch (e) {
1556
+ const code = Number.isInteger(e?.statusCode) ? e.statusCode : 500;
1557
+ if (!res.headersSent) {
1558
+ res.writeHead(code, { 'Content-Type': 'application/json' });
1559
+ res.end(JSON.stringify({ ok: false, error: e?.message || String(e) }));
1560
+ } else {
1561
+ try { res.end(); } catch {}
1562
+ }
1563
+ }
1564
+ });
1565
+
1566
+ async function handleRequest(req, res) {
1567
+ const url = new URL(req.url, `http://localhost:${PORT}`);
1568
+ const path = url.pathname;
1569
+
1570
+ // Reflective CORS — echo Origin only when it is our loopback UI port;
1571
+ // otherwise omit the header so browsers block the cross-origin response.
1572
+ const _reqOrigin = req.headers.origin || '';
1573
+ if (_reqOrigin && /^http:\/\/(localhost|127\.0\.0\.1):3458(\/|$)/.test(_reqOrigin)) {
1574
+ res.setHeader('Access-Control-Allow-Origin', _reqOrigin);
1575
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
1576
+ }
1577
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
1578
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
1579
+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
1580
+
1581
+ // Global CSRF guard — POST/PUT/DELETE require an allowed origin.
1582
+ if ((req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE') && !isAllowedOrigin(req)) {
1583
+ res.writeHead(403, { 'Content-Type': 'application/json' });
1584
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
1585
+ return;
1586
+ }
1587
+
1588
+ if (req.method === 'GET' && path === '/') {
1589
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1590
+ res.end(readFileSync(HTML_PATH, 'utf8'));
1591
+ return;
1592
+ }
1593
+
1594
+ // Phase D-2 — Smart Bridge cache dashboard (provider × profile matrix).
1595
+ // Each workflow role exposes one `shards` map keyed by provider so callers
1596
+ // can show, for example, that a role is warm on anthropic-oauth but cold on
1597
+ // openai-oauth without either row overwriting the other.
1598
+ if (req.method === 'GET' && path === '/bridge/stats') {
1599
+ try {
1600
+ const { CacheRegistry } = await import('../src/agent/orchestrator/smart-bridge/registry.mjs');
1601
+ const registry = CacheRegistry.shared();
1602
+ const stats = registry.getStats();
1603
+ const profiles = {};
1604
+ let warmShards = 0;
1605
+ for (const [profileId, providers] of Object.entries(stats.profiles || {})) {
1606
+ const shards = {};
1607
+ for (const [provider, entry] of Object.entries(providers)) {
1608
+ const hit = entry.hitCount || 0;
1609
+ const miss = entry.missCount || 0;
1610
+ const total = hit + miss;
1611
+ const expiresInMs = Math.max(0, entry.expiresIn || 0);
1612
+ const warm = expiresInMs > 0 && entry.observedOnly !== true;
1613
+ if (warm) warmShards += 1;
1614
+ shards[provider] = {
1615
+ prefixHash: entry.prefixHash || null,
1616
+ hitCount: hit,
1617
+ missCount: miss,
1618
+ hitRate: total > 0 ? hit / total : 0,
1619
+ warm,
1620
+ expiresInMs,
1621
+ createdAt: entry.createdAt ? new Date(entry.createdAt).toISOString() : null,
1622
+ observedOnly: entry.observedOnly === true,
1623
+ };
1624
+ }
1625
+ profiles[profileId] = {
1626
+ id: profileId,
1627
+ taskType: null,
1628
+ behavior: null,
1629
+ fallbackPreset: null,
1630
+ description: '(workflow role)',
1631
+ shards,
1632
+ };
1633
+ }
1634
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1635
+ res.end(JSON.stringify({
1636
+ profileCount: stats.profileCount || Object.keys(profiles).length,
1637
+ shardCount: stats.shardCount || 0,
1638
+ warmShardCount: warmShards,
1639
+ openaiKeyCount: stats.openaiKeyCount || 0,
1640
+ updatedAt: registry.data.updatedAt,
1641
+ profiles,
1642
+ }));
1643
+ } catch (e) {
1644
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1645
+ res.end(JSON.stringify({ error: String(e?.message || e) }));
1646
+ }
1647
+ return;
1648
+ }
1649
+
1650
+ // ── GET /bridge/status ──────────────────────────────────────────────
1651
+ // Cheap, read-only, loopback-only. Returns mixdog runtime state as
1652
+ // either a JSON object (?format=json) or a single-line statusline
1653
+ // string (?format=text or Accept: text/plain).
1654
+ // No Origin guard needed — read-only endpoint (C2 convention, v0.1.14).
1655
+ // 0.1.26: aggregation logic lives in src/status/aggregator.mjs so the
1656
+ // MCP-embedded status server shares the same implementation.
1657
+ if (req.method === 'GET' && path === '/bridge/status') {
1658
+ try {
1659
+ const { buildBridgeStatus, renderBridgeStatusText } = await import('../src/status/aggregator.mjs');
1660
+ const wantText = url.searchParams.get('format') === 'text'
1661
+ || (req.headers['accept'] || '').includes('text/plain');
1662
+ const payload = await buildBridgeStatus(DATA_DIR);
1663
+ if (wantText) {
1664
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
1665
+ res.end(renderBridgeStatusText(payload));
1666
+ } else {
1667
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1668
+ res.end(JSON.stringify(payload));
1669
+ }
1670
+ } catch (e) {
1671
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1672
+ res.end(JSON.stringify({ error: String(e?.message || e) }));
1673
+ }
1674
+ return;
1675
+ }
1676
+
1677
+
1678
+ // ── GET /api/plugin-path ─────────────────────────────────────────────────
1679
+ // Returns the absolute directory of the plugin install (parent of setup/).
1680
+ // Used by setup.html to render the correct statusline.sh path in the snippet.
1681
+ if (req.method === 'GET' && path === '/api/plugin-path') {
1682
+ const pluginRoot = join(__dirname, '..');
1683
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1684
+ res.end(JSON.stringify({ path: pluginRoot }));
1685
+ return;
1686
+ }
1687
+
1688
+ if (req.method === 'GET' && path === '/config') {
1689
+ const raw = readConfig();
1690
+ const config = applyChannelsDefaults(raw);
1691
+ const invalidDiscordToken = pruneInvalidDiscordToken(config);
1692
+ // Re-hydrate secrets from the keychain so the UI password inputs round-
1693
+ // trip. mergeConfig() routes discord.token / webhook.authtoken to the
1694
+ // keychain on save and deletes them from the JSON (keychain is the
1695
+ // canonical source of truth); without re-hydration here, every reload
1696
+ // shows blank password fields and users assume the save failed.
1697
+ const dToken = getDiscordToken();
1698
+ if (dToken && !invalidDiscordToken) config.discord = { ...(config.discord || {}), token: dToken };
1699
+ const wAuth = getWebhookAuthtoken();
1700
+ if (wAuth) config.webhook = { ...(config.webhook || {}), authtoken: wAuth };
1701
+ if (invalidDiscordToken) config._secretDiagnostics = { discordToken: invalidDiscordToken };
1702
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1703
+ res.end(JSON.stringify(config));
1704
+ return;
1705
+ }
1706
+
1707
+ if (req.method === 'GET' && path === '/config/secrets') {
1708
+ const channelsConfig = applyChannelsDefaults(readConfig());
1709
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1710
+ res.end(JSON.stringify(fullSecretStatus(channelsConfig)));
1711
+ return;
1712
+ }
1713
+
1714
+ if (req.method === 'POST' && path === '/config') {
1715
+ if (!isAllowedOrigin(req)) {
1716
+ res.writeHead(403, { 'Content-Type': 'application/json' });
1717
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
1718
+ return;
1719
+ }
1720
+ const data = await readBody(req);
1721
+ const secrets = {};
1722
+ try {
1723
+ // RMW inside the config lock: compute mergeConfig() against the value
1724
+ // read under the same lock that guards the write, so overlapping saves
1725
+ // can't clobber each other (read-then-write outside the lock raced).
1726
+ let merged;
1727
+ updateSection('channels', (current) => {
1728
+ merged = mergeConfig(current, data, secrets);
1729
+ return merged;
1730
+ });
1731
+ console.log(` Config saved: channels secrets=${JSON.stringify(secrets)}`);
1732
+
1733
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1734
+ res.end(JSON.stringify({ ok: true, secrets, secretStatus: fullSecretStatus(merged) }));
1735
+ } catch (e) {
1736
+ process.stderr.write('[setup] /config failed: ' + (e?.stack || e?.message || String(e)) + '\n');
1737
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1738
+ res.end(JSON.stringify({ ok: false, error: e?.message || String(e), secrets }));
1739
+ }
1740
+ return;
1741
+ }
1742
+
1743
+ // -- B6 General module toggles (channels / memory / search / agent) --
1744
+ // Stored as a top-level `modules` section inside mixdog-config.json.
1745
+ // Missing keys default to enabled:true so pre-B6 configs keep all
1746
+ // modules on. Changes require a plugin restart to take effect.
1747
+ if (req.method === 'GET' && path === '/modules') {
1748
+ const raw = readSection('modules');
1749
+ const out = {};
1750
+ for (const name of ['channels', 'memory', 'search', 'agent']) {
1751
+ const entry = raw && typeof raw === 'object' ? raw[name] : null;
1752
+ const enabled = entry && typeof entry === 'object' && entry.enabled === false ? false : true;
1753
+ out[name] = { enabled };
1754
+ }
1755
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1756
+ res.end(JSON.stringify(out));
1757
+ return;
1758
+ }
1759
+
1760
+ if (req.method === 'POST' && path === '/modules') {
1761
+ if (!isAllowedOrigin(req)) {
1762
+ res.writeHead(403, { 'Content-Type': 'application/json' });
1763
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
1764
+ return;
1765
+ }
1766
+ const data = await readBody(req);
1767
+ const sanitized = {};
1768
+ for (const name of ['channels', 'memory', 'search', 'agent']) {
1769
+ const entry = data && typeof data === 'object' ? data[name] : null;
1770
+ const enabled = entry && typeof entry === 'object' && entry.enabled === false ? false : true;
1771
+ sanitized[name] = { enabled };
1772
+ }
1773
+ // Serialize through updateSection (file lock + atomic RMW + backup
1774
+ // restore) instead of read→merge→whole-file write. A bare
1775
+ // readJsonFile()→{} on a transient read failure here used to drop
1776
+ // every other section when the thin object was written back.
1777
+ // Intentional full replace: the modules section schema is only {enabled}
1778
+ // per module; the UI owns the complete map.
1779
+ updateSection('modules', () => sanitized);
1780
+ console.log(' Config saved: modules');
1781
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1782
+ res.end(JSON.stringify({ ok: true, modules: sanitized }));
1783
+ return;
1784
+ }
1785
+
1786
+ // -- B2 Security capabilities (homeAccess) ---------------------------
1787
+ // Stored as a top-level `capabilities` section inside mixdog-config.json.
1788
+ // Missing keys default to `false` so out-of-the-box installs stay
1789
+ // cwd-only; flipping a toggle takes effect on the next tool call
1790
+ // (capability is re-read per invocation in builtin.mjs/patch.mjs).
1791
+ if (req.method === 'GET' && path === '/capabilities') {
1792
+ const raw = readSection('capabilities');
1793
+ const out = { homeAccess: !!(raw && typeof raw === 'object' && raw.homeAccess === true) };
1794
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1795
+ res.end(JSON.stringify(out));
1796
+ return;
1797
+ }
1798
+
1799
+ if (req.method === 'POST' && path === '/capabilities') {
1800
+ if (!isAllowedOrigin(req)) {
1801
+ res.writeHead(403, { 'Content-Type': 'application/json' });
1802
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
1803
+ return;
1804
+ }
1805
+ const data = await readBody(req);
1806
+ const sanitized = { homeAccess: !!(data && typeof data === 'object' && data.homeAccess === true) };
1807
+ // Serialize through updateSection (file lock + atomic RMW + backup
1808
+ // restore); see /modules POST above for why the read→merge→whole-file
1809
+ // write was unsafe.
1810
+ // Intentional full replace: capabilities is a flat toggle map (homeAccess);
1811
+ // no unmanaged sidecar fields today.
1812
+ updateSection('capabilities', () => sanitized);
1813
+ console.log(' Config saved: capabilities');
1814
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1815
+ res.end(JSON.stringify({ ok: true, capabilities: sanitized }));
1816
+ return;
1817
+ }
1818
+
1819
+ // -- Schedules CRUD --
1820
+ const SCHEDULES_DIR = join(DATA_DIR, 'schedules');
1821
+
1822
+ if (req.method === 'GET' && path === '/schedules') {
1823
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1824
+ res.end(JSON.stringify(listSchedules()));
1825
+ return;
1826
+ }
1827
+
1828
+ if (req.method === 'POST' && path === '/schedules') {
1829
+ if (!isAllowedOrigin(req)) {
1830
+ res.writeHead(403, { 'Content-Type': 'application/json' });
1831
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
1832
+ return;
1833
+ }
1834
+ const sc = await readBody(req);
1835
+ const name = sanitizeName(sc.name);
1836
+ if (!name) { res.writeHead(400); res.end('name required or invalid'); return; }
1837
+ if (!sc.time) {
1838
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1839
+ res.end(JSON.stringify({ ok: false, error: 'time required' }));
1840
+ return;
1841
+ }
1842
+ const isCronLike = sc.time.trim().split(/\s+/).length >= 5;
1843
+ const isHHMM = /^([01]?\d|2[0-3]):[0-5]\d$/.test(sc.time);
1844
+ const isLegacy = /^(every\d+m|hourly|daily)$/.test(sc.time);
1845
+ if (isCronLike) {
1846
+ try { validateCronExpression(sc.time); } catch (e) {
1847
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1848
+ res.end(JSON.stringify({ ok: false, error: e.message }));
1849
+ return;
1850
+ }
1851
+ } else if (!isHHMM && !isLegacy) {
1852
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1853
+ res.end(JSON.stringify({ ok: false, error: 'invalid time format: must be HH:MM, cron expression, or every<N>m|hourly|daily' }));
1854
+ return;
1855
+ }
1856
+ if (isHHMM) {
1857
+ // The scheduler registers cron expressions only — convert on save so
1858
+ // UI-created entries actually fire instead of being skipped at reload.
1859
+ const [h, m] = sc.time.split(':');
1860
+ // Encode the legacy `days` field into the day-of-week slot.
1861
+ const dow = sc.days === 'weekday' ? '1-5' : (sc.days === 'weekend' ? '0,6' : '*');
1862
+ sc.time = `${Number(m)} ${Number(h)} * * ${dow}`;
1863
+ } else if (isLegacy) {
1864
+ const everyM = sc.time.match(/^every(\d+)m$/);
1865
+ if (everyM) {
1866
+ const n = Math.min(Math.max(Number(everyM[1]), 1), 59);
1867
+ sc.time = `*/${n} * * * *`;
1868
+ } else if (sc.time === 'hourly') {
1869
+ sc.time = '0 * * * *';
1870
+ } else {
1871
+ sc.time = '0 0 * * *';
1872
+ }
1873
+ }
1874
+ const dir = join(SCHEDULES_DIR, name);
1875
+ mkdirSync(dir, { recursive: true });
1876
+ const prompt = sc.prompt || '';
1877
+ delete sc.prompt;
1878
+ delete sc.name;
1879
+ const configPath = join(dir, 'config.json');
1880
+ const merged = mergeEndpointConfig(readJsonFile(configPath), sc);
1881
+ writeFileSync(configPath, JSON.stringify(merged, null, 2));
1882
+ writeFileSync(join(dir, 'instructions.md'), prompt);
1883
+ console.log(' Schedule saved:', name);
1884
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1885
+ res.end(JSON.stringify({ ok: true }));
1886
+ return;
1887
+ }
1888
+
1889
+ if (req.method === 'DELETE' && path === '/schedules') {
1890
+ if (!isAllowedOrigin(req)) {
1891
+ res.writeHead(403, { 'Content-Type': 'application/json' });
1892
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
1893
+ return;
1894
+ }
1895
+ const name = sanitizeName(url.searchParams.get('name'));
1896
+ if (!name) { res.writeHead(400); res.end('name required or invalid'); return; }
1897
+ const dir = join(SCHEDULES_DIR, name);
1898
+ if (existsSync(dir)) { rmSync(dir, { recursive: true }); console.log(' Schedule deleted:', name); }
1899
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1900
+ res.end(JSON.stringify({ ok: true }));
1901
+ return;
1902
+ }
1903
+
1904
+ if (req.method === 'GET' && path.startsWith('/schedules/file/')) {
1905
+ if (!isAllowedOrigin(req)) { res.writeHead(403, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' })); return; }
1906
+ const name = sanitizeName(decodeURIComponent(path.slice('/schedules/file/'.length)));
1907
+ if (!name) { res.writeHead(400); res.end('invalid schedule name'); return; }
1908
+ const filePath = join(SCHEDULES_DIR, name, 'instructions.md');
1909
+ if (!existsSync(filePath)) { mkdirSync(join(SCHEDULES_DIR, name), { recursive: true }); writeFileSync(filePath, '', 'utf8'); }
1910
+ if (isWin) { spawn('cmd', ['/c', 'start', '""', filePath.replace(/[&^"<>|]/g, '^$&')], { detached: true, stdio: 'ignore', windowsHide: true, windowsVerbatimArguments: false }).unref(); }
1911
+ else { spawn('open', [filePath], { detached: true, stdio: 'ignore' }).unref(); }
1912
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1913
+ res.end(JSON.stringify({ ok: true }));
1914
+ return;
1915
+ }
1916
+
1917
+ // -- Webhooks CRUD --
1918
+ const WEBHOOKS_DIR = join(DATA_DIR, 'webhooks');
1919
+
1920
+ if (req.method === 'GET' && path === '/webhooks') {
1921
+ const result = [];
1922
+ if (existsSync(WEBHOOKS_DIR)) {
1923
+ for (const name of readdirSync(WEBHOOKS_DIR, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name)) {
1924
+ const cfg = readJsonFile(join(WEBHOOKS_DIR, name, 'config.json')) || {};
1925
+ let instructions = '';
1926
+ try { instructions = readFileSync(join(WEBHOOKS_DIR, name, 'instructions.md'), 'utf8'); } catch {}
1927
+ result.push({ name, ...cfg, instructions });
1928
+ }
1929
+ }
1930
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1931
+ res.end(JSON.stringify(result));
1932
+ return;
1933
+ }
1934
+
1935
+ if (req.method === 'POST' && path === '/webhooks') {
1936
+ if (!isAllowedOrigin(req)) {
1937
+ res.writeHead(403, { 'Content-Type': 'application/json' });
1938
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
1939
+ return;
1940
+ }
1941
+ const wh = await readBody(req);
1942
+ const name = sanitizeName(wh.name);
1943
+ if (!name) { res.writeHead(400); res.end('name required or invalid'); return; }
1944
+ const dir = join(WEBHOOKS_DIR, name);
1945
+ mkdirSync(dir, { recursive: true });
1946
+ const instructions = wh.instructions || '';
1947
+ delete wh.instructions;
1948
+ delete wh.name;
1949
+ // Invariant: webhook delegate dispatch is bound to the internal
1950
+ // `webhook-handler` hidden role. The UI no longer exposes a role
1951
+ // picker, and any role value posted by a third-party client is
1952
+ // overwritten here so dispatch always lands on the plugin-managed
1953
+ // hidden role rather than a user-workflow role.
1954
+ const configPath = join(dir, 'config.json');
1955
+ const merged = mergeWebhookEndpointConfig(readJsonFile(configPath), wh);
1956
+ writeFileSync(configPath, JSON.stringify(merged, null, 2));
1957
+ writeFileSync(join(dir, 'instructions.md'), instructions);
1958
+ console.log(' Webhook saved:', name);
1959
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1960
+ res.end(JSON.stringify({ ok: true }));
1961
+ return;
1962
+ }
1963
+
1964
+ if (req.method === 'DELETE' && path === '/webhooks') {
1965
+ if (!isAllowedOrigin(req)) {
1966
+ res.writeHead(403, { 'Content-Type': 'application/json' });
1967
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
1968
+ return;
1969
+ }
1970
+ const name = sanitizeName(url.searchParams.get('name'));
1971
+ if (!name) { res.writeHead(400); res.end('name required or invalid'); return; }
1972
+ const dir = join(WEBHOOKS_DIR, name);
1973
+ if (existsSync(dir)) { rmSync(dir, { recursive: true }); console.log(' Webhook deleted:', name); }
1974
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1975
+ res.end(JSON.stringify({ ok: true }));
1976
+ return;
1977
+ }
1978
+
1979
+ if (req.method === 'GET' && path.startsWith('/webhooks/file/')) {
1980
+ if (!isAllowedOrigin(req)) { res.writeHead(403, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' })); return; }
1981
+ const name = sanitizeName(decodeURIComponent(path.slice('/webhooks/file/'.length)));
1982
+ if (!name) { res.writeHead(400); res.end('invalid webhook name'); return; }
1983
+ const filePath = join(WEBHOOKS_DIR, name, 'instructions.md');
1984
+ if (!existsSync(filePath)) { mkdirSync(join(WEBHOOKS_DIR, name), { recursive: true }); writeFileSync(filePath, '', 'utf8'); }
1985
+ if (isWin) { spawn('cmd', ['/c', 'start', '""', filePath.replace(/[&^"<>|]/g, '^$&')], { detached: true, stdio: 'ignore', windowsHide: true, windowsVerbatimArguments: false }).unref(); }
1986
+ else { spawn('open', [filePath], { detached: true, stdio: 'ignore' }).unref(); }
1987
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1988
+ res.end(JSON.stringify({ ok: true }));
1989
+ return;
1990
+ }
1991
+
1992
+ // -- Delivery log --
1993
+ // Each endpoint keeps an append-only JSONL under its folder. Lists are
1994
+ // latest-wins merged by id, filtered by ?name= / ?status=, sorted ts desc.
1995
+ if (req.method === 'GET' && path === '/webhooks/deliveries') {
1996
+ const name = url.searchParams.get('name') || null;
1997
+ const status = url.searchParams.get('status') || null;
1998
+ const limitRaw = parseInt(url.searchParams.get('limit') || '100', 10);
1999
+ const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, 500) : 100;
2000
+ try {
2001
+ const mod = await import('../src/channels/lib/webhook.mjs');
2002
+ const list = mod.listAllDeliveries({ endpoint: name, status, limit });
2003
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2004
+ res.end(JSON.stringify(list));
2005
+ } catch (err) {
2006
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2007
+ res.end(JSON.stringify({ error: String(err?.message || err) }));
2008
+ }
2009
+ return;
2010
+ }
2011
+
2012
+ // Retry: payload is only preserved as a 512-char preview, so a silent
2013
+ // replay would be misleading. Return 400 and ask the sender to redeliver.
2014
+ if (req.method === 'POST' && path.startsWith('/webhooks/deliveries/') && path.endsWith('/retry')) {
2015
+ if (!isAllowedOrigin(req)) {
2016
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2017
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2018
+ return;
2019
+ }
2020
+ const id = decodeURIComponent(path.slice('/webhooks/deliveries/'.length, -'/retry'.length));
2021
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2022
+ res.end(JSON.stringify({
2023
+ ok: false,
2024
+ id,
2025
+ error: 'payload not retained — use the upstream Redeliver action (GitHub webhooks UI → Recent Deliveries → Redeliver)',
2026
+ }));
2027
+ return;
2028
+ }
2029
+
2030
+ if (req.method === 'GET' && path === '/cli-check') {
2031
+ let whisperInstalled = false;
2032
+ let voiceMeta = null;
2033
+ let binaryReady = false;
2034
+ let modelReady = false;
2035
+ let ffmpegReady = false;
2036
+ try {
2037
+ const { resolveVoiceRuntime } = await import('../src/channels/lib/voice-runtime-fetcher.mjs');
2038
+ const runtime = resolveVoiceRuntime(DATA_DIR);
2039
+ const whisperCmd = runtime?.whisperCmd || '';
2040
+ const modelPath = runtime?.modelPath || '';
2041
+ const ffmpegPath = runtime?.ffmpegPath || '';
2042
+ binaryReady = !!runtime?.binary;
2043
+ modelReady = !!runtime?.model;
2044
+ ffmpegReady = !!runtime?.ffmpeg;
2045
+ if (whisperCmd || modelPath || ffmpegPath || runtime?.kind) {
2046
+ voiceMeta = {
2047
+ kind: runtime?.kind || '',
2048
+ label: runtime?.label || '',
2049
+ commandName: whisperCmd ? basename(whisperCmd) : '',
2050
+ commandPath: whisperCmd || '',
2051
+ modelName: modelPath ? basename(modelPath) : '',
2052
+ modelPath: modelPath || '',
2053
+ ffmpegName: ffmpegPath ? basename(ffmpegPath) : '',
2054
+ ffmpegPath: ffmpegPath || '',
2055
+ };
2056
+ }
2057
+ } catch {}
2058
+ whisperInstalled = binaryReady && modelReady && ffmpegReady;
2059
+ const ngrok = await checkCli('ngrok');
2060
+ const cliPayload = {
2061
+ whisper: { installed: whisperInstalled, binary: binaryReady, model: modelReady, ffmpeg: ffmpegReady },
2062
+ ngrok,
2063
+ };
2064
+ if (voiceMeta) cliPayload.voice = voiceMeta;
2065
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2066
+ res.end(JSON.stringify(cliPayload));
2067
+ return;
2068
+ }
2069
+
2070
+ // ============================================================
2071
+ // AGENT MODULE ROUTES
2072
+ // ============================================================
2073
+
2074
+ if (req.method === 'GET' && path === '/agent/config') {
2075
+ const config = readAgentConfig();
2076
+ const auth = await detectAuth(config);
2077
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2078
+ res.end(JSON.stringify({ config, auth }));
2079
+ return;
2080
+ }
2081
+
2082
+ if (req.method === 'POST' && path === '/agent/config') {
2083
+ if (!isAllowedOrigin(req)) {
2084
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2085
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2086
+ return;
2087
+ }
2088
+ const data = await readBody(req);
2089
+ const secrets = {};
2090
+ try {
2091
+ // RMW inside the config lock (see /config) so concurrent agent saves
2092
+ // serialize through updateSection instead of racing a read→merge→write.
2093
+ let merged;
2094
+ updateSection('agent', (current) => {
2095
+ merged = mergeAgentConfig(current, data, secrets);
2096
+ return merged;
2097
+ });
2098
+ if (data?.providers && typeof data.providers === 'object') dropRuntimeModelCaches();
2099
+ console.log(` Config saved: agent secrets=${JSON.stringify(secrets)}`);
2100
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2101
+ res.end(JSON.stringify({ ok: true, secrets, secretStatus: fullSecretStatus() }));
2102
+ } catch (e) {
2103
+ process.stderr.write('[setup] /agent/config failed: ' + (e?.stack || e?.message || String(e)) + '\n');
2104
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2105
+ res.end(JSON.stringify({ ok: false, error: e?.message || String(e), secrets }));
2106
+ }
2107
+ return;
2108
+ }
2109
+
2110
+ // Grok CLI OAuth login ("Grok Build"). An existing `grok` CLI login under
2111
+ // ~/.grok/auth.json is auto-detected; this route is for signing in directly
2112
+ // from Setup when no CLI login exists. Blocking: waits (up to 5 min) for the
2113
+ // loopback callback on 127.0.0.1:56121, then persists to the own token store.
2114
+ if (req.method === 'POST' && path === '/agent/grok-oauth/login') {
2115
+ if (!isAllowedOrigin(req)) {
2116
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2117
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2118
+ return;
2119
+ }
2120
+ try {
2121
+ const tokens = await loginGrokOAuth();
2122
+ if (!tokens?.access_token) {
2123
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2124
+ res.end(JSON.stringify({ ok: false, error: 'login cancelled or timed out' }));
2125
+ return;
2126
+ }
2127
+ dropRuntimeModelCaches();
2128
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2129
+ res.end(JSON.stringify({ ok: true }));
2130
+ } catch (e) {
2131
+ process.stderr.write('[setup] /agent/grok-oauth/login failed: ' + (e?.stack || e?.message || String(e)) + '\n');
2132
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2133
+ res.end(JSON.stringify({ ok: false, error: e?.message || String(e) }));
2134
+ }
2135
+ return;
2136
+ }
2137
+
2138
+ if (req.method === 'GET' && path === '/agent/presets') {
2139
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2140
+ res.end(JSON.stringify({ presets: readAgentPresets() }));
2141
+ return;
2142
+ }
2143
+
2144
+ if (req.method === 'POST' && path === '/agent/presets') {
2145
+ if (!isAllowedOrigin(req)) {
2146
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2147
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2148
+ return;
2149
+ }
2150
+ const data = await readBody(req);
2151
+ let preset;
2152
+ try { preset = normalizePreset(data); }
2153
+ catch (err) {
2154
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2155
+ res.end(JSON.stringify({ ok: false, error: err.message }));
2156
+ return;
2157
+ }
2158
+ const list = readAgentPresets();
2159
+ const idx = list.findIndex(p => p.id === preset.id);
2160
+ if (idx >= 0) list[idx] = preset; else list.push(preset);
2161
+ writeAgentPresets(list);
2162
+ console.log(` Agent preset saved: ${preset.id}`);
2163
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2164
+ res.end(JSON.stringify({ ok: true, preset }));
2165
+ return;
2166
+ }
2167
+
2168
+ if (req.method === 'DELETE' && path === '/agent/presets') {
2169
+ const id = url.searchParams.get('id');
2170
+ if (!id) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'id required' })); return; }
2171
+ const list = readAgentPresets().filter(p => p.id !== id);
2172
+ writeAgentPresets(list);
2173
+ console.log(` Agent preset deleted: ${id}`);
2174
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2175
+ res.end(JSON.stringify({ ok: true }));
2176
+ return;
2177
+ }
2178
+
2179
+ // -- Agent maintenance presets --
2180
+ if (req.method === 'GET' && path === '/agent/maintenance') {
2181
+ const cfg = readAgentConfig();
2182
+ const rawMaint = cfg.maintenance || {};
2183
+ // Strip legacy keys that no longer belong in maintenance
2184
+ // (classification/recap were retired with the cycle1 split;
2185
+ // scheduler/webhook keep their model per-entry).
2186
+ // Persist back when the stored config carried any of them so the Setup
2187
+ // panel and the runtime resolver stop having to dual-match name vs id.
2188
+ const allowedKeys = new Set([...Object.keys(DEFAULT_MAINTENANCE), ...MAINTENANCE_SLOTS]);
2189
+ const cleanMaint = {};
2190
+ let changed = false;
2191
+ for (const [k, v] of Object.entries(rawMaint)) {
2192
+ if (allowedKeys.has(k)) cleanMaint[k] = v;
2193
+ else changed = true;
2194
+ }
2195
+ if (changed) {
2196
+ cfg.maintenance = cleanMaint;
2197
+ try { writeAgentConfig(cfg); }
2198
+ catch (e) { process.stderr.write(`[setup] maintenance legacy-key cleanup write failed: ${e.message}\n`); }
2199
+ }
2200
+ const merged = { ...DEFAULT_MAINTENANCE, ...cleanMaint };
2201
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2202
+ res.end(JSON.stringify({ maintenance: merged, defaults: { ...DEFAULT_MAINTENANCE } }));
2203
+ return;
2204
+ }
2205
+
2206
+ if (req.method === 'POST' && path === '/agent/maintenance') {
2207
+ if (!isAllowedOrigin(req)) {
2208
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2209
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2210
+ return;
2211
+ }
2212
+ const data = await readBody(req);
2213
+ const cfg = readAgentConfig();
2214
+ const validIds = new Set([
2215
+ ...(cfg.presets || []).map(p => p.id),
2216
+ ...DEFAULT_PRESETS.map(p => p.id),
2217
+ ]);
2218
+ const allowedKeys = new Set([...Object.keys(DEFAULT_MAINTENANCE), ...MAINTENANCE_SLOTS]);
2219
+ const unknownKeys = Object.keys(data).filter(k => !allowedKeys.has(k));
2220
+ if (unknownKeys.length) {
2221
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2222
+ res.end(JSON.stringify({ ok: false, error: `Unknown maintenance task(s): ${unknownKeys.join(', ')} (per-entry model required for scheduler/webhook)` }));
2223
+ return;
2224
+ }
2225
+ const invalid = Object.entries(data)
2226
+ .filter(([k, v]) => v && !validIds.has(v))
2227
+ .map(([k, v]) => `${k}: ${v}`);
2228
+ if (invalid.length) {
2229
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2230
+ res.end(JSON.stringify({ ok: false, error: `Unknown preset(s): ${invalid.join(', ')}` }));
2231
+ return;
2232
+ }
2233
+ const nextMaint = { ...(cfg.maintenance || {}) };
2234
+ for (const [k, v] of Object.entries(data)) {
2235
+ if (v == null || v === '') delete nextMaint[k]; // inherit → remove override
2236
+ else nextMaint[k] = v;
2237
+ }
2238
+ cfg.maintenance = nextMaint;
2239
+ writeAgentConfig(cfg);
2240
+ console.log(' Maintenance presets saved');
2241
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2242
+ res.end(JSON.stringify({ ok: true }));
2243
+ return;
2244
+ }
2245
+
2246
+ if (req.method === 'GET' && path === '/agent/models') {
2247
+ const provider = url.searchParams.get('provider');
2248
+ if (!provider) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'provider required' })); return; }
2249
+ const cfg = readAgentConfig();
2250
+ const models = await listProviderModels(provider, cfg);
2251
+ if (provider === 'openai-oauth' && (!Array.isArray(models) || models.length === 0) && hasOpenAIOAuthCredentials()) {
2252
+ const detail = getOpenAIOAuthModelCatalogError();
2253
+ const error = detail
2254
+ ? `OpenAI OAuth model catalog unavailable: ${detail}`
2255
+ : 'OpenAI OAuth model catalog unavailable. Run codex login, then reopen this setup page.';
2256
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2257
+ res.end(JSON.stringify({ ok: false, provider, models: [], error }));
2258
+ return;
2259
+ }
2260
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2261
+ res.end(JSON.stringify({ ok: true, provider, models }));
2262
+ return;
2263
+ }
2264
+
2265
+ if (req.method === 'POST' && path === '/agent/validate') {
2266
+ if (!isAllowedOrigin(req)) {
2267
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2268
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2269
+ return;
2270
+ }
2271
+ const data = await readBody(req);
2272
+ const validation = {};
2273
+ const checks = [];
2274
+ for (const [id, key] of Object.entries(data.keys || {})) {
2275
+ if (key) checks.push(validateAgentKey(id, key).then(r => { validation[id] = r; }));
2276
+ }
2277
+ await Promise.all(checks);
2278
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2279
+ res.end(JSON.stringify({ ok: true, validation }));
2280
+ return;
2281
+ }
2282
+
2283
+ // ============================================================
2284
+ // MEMORY MODULE ROUTES
2285
+ // ============================================================
2286
+
2287
+ if (req.method === 'GET' && path === '/memory/config') {
2288
+ const config = readMemoryConfig();
2289
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2290
+ res.end(JSON.stringify(config));
2291
+ return;
2292
+ }
2293
+
2294
+ if (req.method === 'POST' && path === '/memory/config') {
2295
+ if (!isAllowedOrigin(req)) {
2296
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2297
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2298
+ return;
2299
+ }
2300
+ const data = await readBody(req);
2301
+ const secrets = {};
2302
+ try {
2303
+ // RMW inside the config lock (see /config) so concurrent memory saves
2304
+ // serialize through updateSection instead of racing a read→merge→write.
2305
+ let merged;
2306
+ updateSection('memory', (current) => {
2307
+ merged = mergeMemoryConfig(current, data, secrets);
2308
+ return merged;
2309
+ });
2310
+ console.log(` Config saved: memory secrets=${JSON.stringify(secrets)}`);
2311
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2312
+ res.end(JSON.stringify({ ok: true, secrets, secretStatus: fullSecretStatus() }));
2313
+ } catch (e) {
2314
+ process.stderr.write('[setup] /memory/config failed: ' + (e?.stack || e?.message || String(e)) + '\n');
2315
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2316
+ res.end(JSON.stringify({ ok: false, error: e?.message || String(e), secrets }));
2317
+ }
2318
+ return;
2319
+ }
2320
+
2321
+ if (req.method === 'GET' && path === '/memory/auth') {
2322
+ const cfg = readMemoryConfig();
2323
+ const result = await detectAuth(cfg);
2324
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2325
+ res.end(JSON.stringify(result));
2326
+ return;
2327
+ }
2328
+
2329
+ if (req.method === 'GET' && path === '/memory/presets') {
2330
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2331
+ res.end(JSON.stringify({ presets: readMemoryPresets() }));
2332
+ return;
2333
+ }
2334
+
2335
+ if (req.method === 'POST' && path === '/memory/presets') {
2336
+ if (!isAllowedOrigin(req)) {
2337
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2338
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2339
+ return;
2340
+ }
2341
+ const data = await readBody(req);
2342
+ let preset;
2343
+ try { preset = normalizePreset(data); }
2344
+ catch (err) {
2345
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2346
+ res.end(JSON.stringify({ ok: false, error: err.message }));
2347
+ return;
2348
+ }
2349
+ const list = readMemoryPresets();
2350
+ const idx = list.findIndex(p => p.id === preset.id);
2351
+ if (idx >= 0) list[idx] = preset; else list.push(preset);
2352
+ writeMemoryPresets(list);
2353
+ console.log(` Memory preset saved: ${preset.id}`);
2354
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2355
+ res.end(JSON.stringify({ ok: true, preset }));
2356
+ return;
2357
+ }
2358
+
2359
+ if (req.method === 'PUT' && path === '/memory/presets') {
2360
+ const data = await readBody(req);
2361
+ if (!Array.isArray(data.presets)) {
2362
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2363
+ res.end(JSON.stringify({ ok: false, error: 'presets array required' }));
2364
+ return;
2365
+ }
2366
+ const normalized = data.presets.map(p => normalizePreset(p));
2367
+ writeMemoryPresets(normalized);
2368
+ console.log(` Memory presets reordered: ${normalized.length} items`);
2369
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2370
+ res.end(JSON.stringify({ ok: true }));
2371
+ return;
2372
+ }
2373
+
2374
+ if (req.method === 'DELETE' && path === '/memory/presets') {
2375
+ const id = url.searchParams.get('id');
2376
+ if (!id) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'id required' })); return; }
2377
+ const list = readMemoryPresets().filter(p => p.id !== id);
2378
+ writeMemoryPresets(list);
2379
+ console.log(` Memory preset deleted: ${id}`);
2380
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2381
+ res.end(JSON.stringify({ ok: true }));
2382
+ return;
2383
+ }
2384
+
2385
+ if (req.method === 'GET' && path === '/memory/models') {
2386
+ const provider = url.searchParams.get('provider');
2387
+ if (!provider) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'provider required' })); return; }
2388
+ const cfg = readMemoryConfig();
2389
+ const models = await listProviderModels(provider, cfg);
2390
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2391
+ res.end(JSON.stringify({ ok: true, provider, models }));
2392
+ return;
2393
+ }
2394
+ const MEMORY_FILE_WHITELIST = ['user.md', 'bot.md'];
2395
+ const HISTORY_DIR = join(DATA_DIR, 'history');
2396
+
2397
+ if (req.method === 'GET' && path === '/memory/files') {
2398
+ const result = {};
2399
+ for (const name of MEMORY_FILE_WHITELIST) {
2400
+ const filePath = join(HISTORY_DIR, name);
2401
+ try { result[name] = readFileSync(filePath, 'utf8'); }
2402
+ catch { result[name] = ''; }
2403
+ }
2404
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2405
+ res.end(JSON.stringify(result));
2406
+ return;
2407
+ }
2408
+
2409
+ if (req.method === 'POST' && path === '/memory/files') {
2410
+ const data = await readBody(req);
2411
+ const keys = Object.keys(data);
2412
+ for (const key of keys) {
2413
+ if (!MEMORY_FILE_WHITELIST.includes(key)) {
2414
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2415
+ res.end(JSON.stringify({ ok: false, error: `disallowed file name: ${key}` }));
2416
+ return;
2417
+ }
2418
+ }
2419
+ mkdirSync(HISTORY_DIR, { recursive: true });
2420
+ for (const name of MEMORY_FILE_WHITELIST) {
2421
+ if (!(name in data)) continue;
2422
+ const filePath = join(HISTORY_DIR, name);
2423
+ const tmp = filePath + '.tmp';
2424
+ writeFileSync(tmp, String(data[name]), 'utf8');
2425
+ renameSync(tmp, filePath);
2426
+ }
2427
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2428
+ res.end(JSON.stringify({ ok: true }));
2429
+ return;
2430
+ }
2431
+
2432
+ {
2433
+ const fileNameMatch = path.match(/^\/memory\/file\/([^/]+)$/);
2434
+ if (req.method === 'GET' && fileNameMatch) {
2435
+ const name = fileNameMatch[1];
2436
+ if (!MEMORY_FILE_WHITELIST.includes(name)) {
2437
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2438
+ res.end(JSON.stringify({ ok: false, error: `disallowed file name: ${name}` }));
2439
+ return;
2440
+ }
2441
+ const filePath = join(HISTORY_DIR, name);
2442
+ if (!existsSync(filePath)) {
2443
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2444
+ res.end(JSON.stringify({ ok: false, error: 'not found' }));
2445
+ return;
2446
+ }
2447
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
2448
+ res.end(readFileSync(filePath, 'utf8'));
2449
+ return;
2450
+ }
2451
+ }
2452
+
2453
+ if (req.method === 'GET' && path === '/api/memory/entries/active') {
2454
+ try {
2455
+ const r = await memoryServiceCall('GET', '/admin/entries/active', null, 30000);
2456
+ res.writeHead(r.statusCode, { 'Content-Type': 'application/json' });
2457
+ res.end(JSON.stringify(r.body));
2458
+ } catch (e) {
2459
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2460
+ res.end(JSON.stringify({ ok: false, error: e.message }));
2461
+ }
2462
+ return;
2463
+ }
2464
+
2465
+ if (req.method === 'GET' && path === '/api/memory/core') {
2466
+ try {
2467
+ const r = await memoryServiceCall('GET', '/admin/core/entries', null, 30000);
2468
+ res.writeHead(r.statusCode, { 'Content-Type': 'application/json' });
2469
+ res.end(JSON.stringify(r.body));
2470
+ } catch (e) {
2471
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2472
+ res.end(JSON.stringify({ ok: false, error: e.message }));
2473
+ }
2474
+ return;
2475
+ }
2476
+
2477
+ if (req.method === 'POST' && path === '/api/memory/core') {
2478
+ if (!isAllowedOrigin(req)) {
2479
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2480
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2481
+ return;
2482
+ }
2483
+ const data = await readBody(req);
2484
+ try {
2485
+ const r = await memoryServiceCall('POST', '/admin/core/entries', {
2486
+ element: data.element,
2487
+ summary: data.summary,
2488
+ category: data.category,
2489
+ project_id: data.project_id,
2490
+ }, 30000);
2491
+ if (r.body?.ok) console.log(` Core memory saved: ${r.body.item?.id ?? '?'}`);
2492
+ res.writeHead(r.statusCode, { 'Content-Type': 'application/json' });
2493
+ res.end(JSON.stringify(r.body));
2494
+ } catch (e) {
2495
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2496
+ res.end(JSON.stringify({ ok: false, error: e.message }));
2497
+ }
2498
+ return;
2499
+ }
2500
+
2501
+ {
2502
+ const coreDeleteMatch = req.method === 'POST' && path.match(/^\/api\/memory\/core\/(\d+)\/delete$/);
2503
+ if (coreDeleteMatch && !isAllowedOrigin(req)) {
2504
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2505
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2506
+ return;
2507
+ }
2508
+ if (coreDeleteMatch) {
2509
+ const id = Number(coreDeleteMatch[1]);
2510
+ try {
2511
+ const r = await memoryServiceCall('POST', '/admin/core/entries/delete', { id }, 30000);
2512
+ console.log(` Core memory #${id} deleted`);
2513
+ res.writeHead(r.statusCode, { 'Content-Type': 'application/json' });
2514
+ res.end(JSON.stringify(r.body));
2515
+ } catch (e) {
2516
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2517
+ res.end(JSON.stringify({ ok: false, error: e.message }));
2518
+ }
2519
+ return;
2520
+ }
2521
+ }
2522
+
2523
+ {
2524
+ const statusMatch = req.method === 'POST' && path.match(/^\/api\/memory\/entries\/(\d+)\/status$/);
2525
+ if (statusMatch && !isAllowedOrigin(req)) {
2526
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2527
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2528
+ return;
2529
+ }
2530
+ if (statusMatch) {
2531
+ const id = Number(statusMatch[1]);
2532
+ const data = await readBody(req);
2533
+ const VALID = ['pending', 'active', 'archived'];
2534
+ const status = String(data.status ?? '').trim().toLowerCase();
2535
+ if (!Number.isInteger(id) || id <= 0 || !VALID.includes(status)) {
2536
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2537
+ res.end(JSON.stringify({ ok: false, error: 'valid id and status required' }));
2538
+ return;
2539
+ }
2540
+ try {
2541
+ const r = await memoryServiceCall('POST', '/admin/entries/status', { id, status }, 30000);
2542
+ console.log(` Entry #${id} → ${status} (changes=${r.body?.changes ?? '?'})`);
2543
+ res.writeHead(r.statusCode, { 'Content-Type': 'application/json' });
2544
+ res.end(JSON.stringify(r.body));
2545
+ } catch (e) {
2546
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2547
+ res.end(JSON.stringify({ ok: false, error: e.message }));
2548
+ }
2549
+ return;
2550
+ }
2551
+ }
2552
+
2553
+ if (req.method === 'POST' && path === '/api/memory/entries') {
2554
+ if (!isAllowedOrigin(req)) {
2555
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2556
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2557
+ return;
2558
+ }
2559
+ const data = await readBody(req);
2560
+ try {
2561
+ const r = await memoryServiceCall('POST', '/admin/entries/add', {
2562
+ element: data.element,
2563
+ summary: data.summary,
2564
+ category: data.category,
2565
+ }, 30000);
2566
+ if (r.body?.ok) console.log(` Remembered entry #${r.body.id}: ${r.body.text || ''}`);
2567
+ res.writeHead(r.statusCode, { 'Content-Type': 'application/json' });
2568
+ res.end(JSON.stringify(r.body));
2569
+ } catch (e) {
2570
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2571
+ res.end(JSON.stringify({ ok: false, error: e.message }));
2572
+ }
2573
+ return;
2574
+ }
2575
+
2576
+ if (req.method === 'POST' && path === '/memory/backfill') {
2577
+ if (!isAllowedOrigin(req)) {
2578
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2579
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2580
+ return;
2581
+ }
2582
+ const data = await readBody(req);
2583
+ const requestedWindow = data.window || '7d';
2584
+ try {
2585
+ console.log(`[backfill] start window=${requestedWindow}`);
2586
+ // Backfill iterates transcripts and runs cycle1/cycle2 — long, no fixed
2587
+ // upper bound. Pass a generous timeout (1h) and let memory-service's
2588
+ // _cycle1InFlight guard serialise overlapping requests.
2589
+ const r = await memoryServiceCall('POST', '/admin/backfill', {
2590
+ window: requestedWindow,
2591
+ scope: 'all',
2592
+ }, 3_600_000);
2593
+ console.log(`[backfill] ${r.body?.text || JSON.stringify(r.body)}`);
2594
+ res.writeHead(r.statusCode, { 'Content-Type': 'application/json' });
2595
+ res.end(JSON.stringify(r.body));
2596
+ } catch (err) {
2597
+ console.error(`[backfill] failed: ${err.message}`);
2598
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2599
+ res.end(JSON.stringify({ ok: false, error: err.message }));
2600
+ }
2601
+ return;
2602
+ }
2603
+
2604
+ if (req.method === 'POST' && path === '/memory/delete') {
2605
+ if (!isAllowedOrigin(req)) {
2606
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2607
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2608
+ return;
2609
+ }
2610
+ const data = await readBody(req);
2611
+ try {
2612
+ const r = await memoryServiceCall('POST', '/admin/purge', {
2613
+ confirm: data?.confirm,
2614
+ }, 60000);
2615
+ res.writeHead(r.statusCode, { 'Content-Type': 'application/json' });
2616
+ res.end(JSON.stringify(r.body));
2617
+ } catch (err) {
2618
+ console.error(`[memory delete] failed: ${err.message}`);
2619
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2620
+ res.end(JSON.stringify({ ok: false, error: err.message }));
2621
+ }
2622
+ return;
2623
+ }
2624
+
2625
+ if (req.method === 'POST' && path === '/memory/validate') {
2626
+ if (!isAllowedOrigin(req)) {
2627
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2628
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2629
+ return;
2630
+ }
2631
+ const data = await readBody(req);
2632
+ const validation = {};
2633
+ const checks = [];
2634
+ for (const [id, key] of Object.entries(data.keys || {})) {
2635
+ if (key) checks.push(validateAgentKey(id, key).then(r => { validation[id] = r; }));
2636
+ }
2637
+ await Promise.all(checks);
2638
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2639
+ res.end(JSON.stringify({ ok: true, validation }));
2640
+ return;
2641
+ }
2642
+
2643
+ // ============================================================
2644
+ // SEARCH MODULE ROUTES
2645
+ // ============================================================
2646
+
2647
+ if (req.method === 'GET' && path === '/search/config') {
2648
+ const config = readSearchConfig();
2649
+ if (config.rawSearch && config.rawSearch.credentials) {
2650
+ for (const [id, cred] of Object.entries(config.rawSearch.credentials)) {
2651
+ const secret = getSearchApiKey(id);
2652
+ if (secret) {
2653
+ config.rawSearch.credentials[id] = { ...(cred || {}), apiKey: secret };
2654
+ }
2655
+ }
2656
+ }
2657
+ config.availableProviders = computeAvailableProviders();
2658
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2659
+ res.end(JSON.stringify(config));
2660
+ return;
2661
+ }
2662
+
2663
+ if (req.method === 'POST' && path === '/search/config') {
2664
+ if (!isAllowedOrigin(req)) {
2665
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2666
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2667
+ return;
2668
+ }
2669
+ const data = await readBody(req);
2670
+ const secrets = {};
2671
+ try {
2672
+ // RMW inside the config lock (see /config) so concurrent search saves
2673
+ // serialize through updateSection instead of racing a read→merge→write.
2674
+ let merged;
2675
+ updateSection('search', (current) => {
2676
+ merged = mergeSearchConfig(current, data, secrets);
2677
+ return merged;
2678
+ });
2679
+ console.log(` Config saved: search secrets=${JSON.stringify(secrets)}`);
2680
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2681
+ res.end(JSON.stringify({ ok: true, secrets, secretStatus: fullSecretStatus() }));
2682
+ } catch (e) {
2683
+ process.stderr.write('[setup] /search/config failed: ' + (e?.stack || e?.message || String(e)) + '\n');
2684
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2685
+ res.end(JSON.stringify({ ok: false, error: e?.message || String(e), secrets }));
2686
+ }
2687
+ return;
2688
+ }
2689
+
2690
+ if (req.method === 'POST' && path === '/search/validate') {
2691
+ if (!isAllowedOrigin(req)) {
2692
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2693
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2694
+ return;
2695
+ }
2696
+ const data = await readBody(req);
2697
+ const validation = {};
2698
+ const checks = [];
2699
+ for (const [id, val] of Object.entries(data.searchProviders || {})) {
2700
+ const key = typeof val === 'object' ? val.key : val;
2701
+ if (key) checks.push(validateSearchKey(id, key).then(r => { validation[id] = r; }));
2702
+ }
2703
+ for (const [id, val] of Object.entries(data.aiProviders || {})) {
2704
+ if (val && val !== 'cli') checks.push(validateSearchKey(id, val).then(r => { validation[id] = r; }));
2705
+ }
2706
+ await Promise.all(checks);
2707
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2708
+ res.end(JSON.stringify({ ok: true, validation }));
2709
+ return;
2710
+ }
2711
+
2712
+ if (req.method === 'GET' && path === '/search/cli-check') {
2713
+ // Previously three serial `execSync(`${cmd} --version`)` calls — each
2714
+ // wrapped by Node in cmd.exe which flashed a conhost window even with
2715
+ // windowsHide:true, and each blocking the server thread for up to 5s
2716
+ // (15s worst case if all three CLIs are missing). Switch to non-shell
2717
+ // existence-only checks via `where.exe` / `which`, in parallel: no
2718
+ // cmd.exe wrapper → no flash, and the request returns in one round-trip.
2719
+ const check = (cmd) => new Promise(resolve => {
2720
+ const tool = isWin ? 'where.exe' : 'which';
2721
+ const child = spawn(tool, [cmd], {
2722
+ windowsHide: true,
2723
+ stdio: 'ignore',
2724
+ shell: false,
2725
+ });
2726
+ child.once('error', () => resolve(false));
2727
+ child.once('close', code => resolve(code === 0));
2728
+ });
2729
+ const [codex, claude, gemini] = await Promise.all([
2730
+ check('codex'), check('claude'), check('gemini'),
2731
+ ]);
2732
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2733
+ res.end(JSON.stringify({ codex, claude, gemini }));
2734
+ return;
2735
+ }
2736
+
2737
+ // ============================================================
2738
+ // CHANNELS MODULE ROUTES (continued)
2739
+ // ============================================================
2740
+
2741
+ if (req.method === 'POST' && path === '/install') {
2742
+ if (!isAllowedOrigin(req)) {
2743
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2744
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2745
+ return;
2746
+ }
2747
+ const data = await readBody(req);
2748
+ const tool = data.tool;
2749
+ if (!tool || !['ngrok'].includes(tool)) {
2750
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2751
+ res.end(JSON.stringify({ ok: false, error: 'Invalid tool' }));
2752
+ return;
2753
+ }
2754
+ try {
2755
+ const { stdout } = await new Promise((resolve, reject) => {
2756
+ exec('npm install -g ngrok', { timeout: 120000, windowsHide: true }, (err, stdout, stderr) => {
2757
+ if (err) reject(err);
2758
+ else resolve({ stdout, stderr });
2759
+ });
2760
+ });
2761
+ console.log(` Installed ${tool}`);
2762
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2763
+ res.end(JSON.stringify({ ok: true, tool, output: stdout.trim() }));
2764
+ } catch (e) {
2765
+ console.log(` Install ${tool} failed: ${e.message}`);
2766
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2767
+ res.end(JSON.stringify({ ok: false, tool, error: e.message }));
2768
+ }
2769
+ return;
2770
+ }
2771
+
2772
+ // POST /install/voice-runtime — single-shot voice install: binary + model.
2773
+ // Sequentially fetches the platform-matched whisper.cpp runtime and the
2774
+ // large-v3-turbo model from the managed manifest. Both are idempotent: if
2775
+ // the cached binary exists and the model's sha256 matches, the call returns
2776
+ // without re-downloading. The endpoint completes only after both are ready.
2777
+ if (req.method === 'POST' && path === '/install/voice-runtime') {
2778
+ if (!isAllowedOrigin(req)) {
2779
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2780
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2781
+ return;
2782
+ }
2783
+ res.writeHead(200, { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-cache' });
2784
+ const send = (obj) => { try { res.write(JSON.stringify(obj) + '\n'); } catch {} };
2785
+ try {
2786
+ const { ensureWhisperRuntime, ensureWhisperModel, ensureFfmpegRuntime } = await import(new URL('../src/channels/lib/voice-runtime-fetcher.mjs', import.meta.url).href);
2787
+ const onProgress = (p) => send({ type: 'progress', ...p });
2788
+ const runtime = await ensureWhisperRuntime(DATA_DIR, onProgress);
2789
+ const model = await ensureWhisperModel(DATA_DIR, onProgress);
2790
+ const ffmpeg = await ensureFfmpegRuntime(DATA_DIR, onProgress);
2791
+ send({ type: 'done', ok: true, runtime, model, ffmpeg });
2792
+ } catch (e) {
2793
+ send({ type: 'error', ok: false, error: e?.message || String(e) });
2794
+ } finally {
2795
+ res.end();
2796
+ }
2797
+ return;
2798
+ }
2799
+
2800
+ if (req.method === 'GET' && path === '/general/config') {
2801
+ const config = readConfig();
2802
+ const pi = (config && typeof config.promptInjection === 'object' && config.promptInjection) || {};
2803
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2804
+ res.end(JSON.stringify({
2805
+ promptInjection: {
2806
+ mode: pi.mode === 'hook' ? 'hook' : 'claude_md',
2807
+ targetPath: typeof pi.targetPath === 'string' && pi.targetPath ? pi.targetPath : '~/.claude/CLAUDE.md',
2808
+ },
2809
+ }));
2810
+ return;
2811
+ }
2812
+
2813
+ if (req.method === 'POST' && path === '/general/save') {
2814
+ if (!isAllowedOrigin(req)) {
2815
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2816
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2817
+ return;
2818
+ }
2819
+ const data = await readBody(req);
2820
+ const existing = readConfig();
2821
+ const next = { ...existing };
2822
+ const prev = (existing && typeof existing.promptInjection === 'object' && existing.promptInjection) || {};
2823
+ const merged = { ...prev };
2824
+ if (data && (data.mode === 'hook' || data.mode === 'claude_md')) {
2825
+ merged.mode = data.mode;
2826
+ }
2827
+ if (data && typeof data.targetPath === 'string' && data.targetPath.trim()) {
2828
+ merged.targetPath = data.targetPath.trim();
2829
+ }
2830
+ if (!merged.mode) merged.mode = 'claude_md';
2831
+ if (!merged.targetPath) merged.targetPath = '~/.claude/CLAUDE.md';
2832
+ next.promptInjection = merged;
2833
+ writeConfig(next);
2834
+ console.log(' Config saved: general/promptInjection');
2835
+ // Update CLAUDE.md managed block when mode is claude_md
2836
+ let claudeMdResult = null;
2837
+ if (merged.mode === 'claude_md') {
2838
+ claudeMdResult = { ok: false, error: 'generateClaudeMdBlock not available' };
2839
+ }
2840
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2841
+ res.end(JSON.stringify({ ok: true, promptInjection: merged, claudeMd: claudeMdResult }));
2842
+ return;
2843
+ }
2844
+
2845
+ // ============================================================
2846
+ // MD LIBRARY ROUTES — Project MD + per-role MD (Common MD moved to
2847
+ // plugin rules/agent.md and is no longer user-editable).
2848
+ // ============================================================
2849
+
2850
+ if (req.method === 'GET' && path === '/md/project') {
2851
+ const indexPath = join(getPluginData(), 'project-md-index.json');
2852
+ let registry = { paths: [] };
2853
+ try { registry = JSON.parse(readFileSync(indexPath, 'utf8')); } catch {}
2854
+ const items = [];
2855
+ for (const cwd of registry.paths || []) {
2856
+ let content = '';
2857
+ try { content = readFileSync(join(cwd, 'PROJECT.md'), 'utf8'); } catch {}
2858
+ items.push({ path: cwd, content });
2859
+ }
2860
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2861
+ res.end(JSON.stringify({ items }));
2862
+ return;
2863
+ }
2864
+
2865
+ if (req.method === 'POST' && path === '/md/project') {
2866
+ if (!isAllowedOrigin(req)) {
2867
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2868
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2869
+ return;
2870
+ }
2871
+ const body = await readBody(req);
2872
+ const cwd = String(body?.path || '').trim();
2873
+ const content = String(body?.content ?? '');
2874
+ if (!cwd) {
2875
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2876
+ res.end(JSON.stringify({ ok: false, error: 'path required' }));
2877
+ return;
2878
+ }
2879
+ try {
2880
+ mkdirSync(cwd, { recursive: true });
2881
+ const _pTmp = join(cwd, 'PROJECT.md.tmp');
2882
+ writeFileSync(_pTmp, content, 'utf8');
2883
+ renameSync(_pTmp, join(cwd, 'PROJECT.md'));
2884
+ } catch (err) {
2885
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2886
+ res.end(JSON.stringify({ ok: false, error: `Cannot write PROJECT.md: ${err.message}` }));
2887
+ return;
2888
+ }
2889
+ // Update registry
2890
+ const indexPath = join(getPluginData(), 'project-md-index.json');
2891
+ let registry = { paths: [] };
2892
+ try { registry = JSON.parse(readFileSync(indexPath, 'utf8')); } catch {}
2893
+ if (!registry.paths.includes(cwd)) registry.paths.push(cwd);
2894
+ mkdirSync(dirname(indexPath), { recursive: true });
2895
+ // Intentional full replace of project-md-index.json (paths registry only).
2896
+ try {
2897
+ const _regTmp = indexPath + '.tmp';
2898
+ writeFileSync(_regTmp, JSON.stringify(registry, null, 2), 'utf8');
2899
+ renameSync(_regTmp, indexPath);
2900
+ } catch (err) {
2901
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2902
+ res.end(JSON.stringify({ ok: false, error: `Cannot write registry: ${err.message}` }));
2903
+ return;
2904
+ }
2905
+ console.log(` Config saved: project MD (${cwd})`);
2906
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2907
+ res.end(JSON.stringify({ ok: true }));
2908
+ return;
2909
+ }
2910
+
2911
+ if (req.method === 'DELETE' && path === '/md/project') {
2912
+ const qs = new URL(req.url, 'http://x').searchParams;
2913
+ const cwd = String(qs.get('path') || '').trim();
2914
+ if (!cwd) {
2915
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2916
+ res.end(JSON.stringify({ ok: false, error: 'path required' }));
2917
+ return;
2918
+ }
2919
+ const indexPath = join(getPluginData(), 'project-md-index.json');
2920
+ let registry = { paths: [] };
2921
+ try { registry = JSON.parse(readFileSync(indexPath, 'utf8')); } catch {}
2922
+ registry.paths = (registry.paths || []).filter(p => p !== cwd);
2923
+ mkdirSync(dirname(indexPath), { recursive: true });
2924
+ // Intentional full replace of project-md-index.json (paths registry only).
2925
+ try {
2926
+ const _regTmp = indexPath + '.tmp';
2927
+ writeFileSync(_regTmp, JSON.stringify(registry, null, 2), 'utf8');
2928
+ renameSync(_regTmp, indexPath);
2929
+ } catch (err) {
2930
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2931
+ res.end(JSON.stringify({ ok: false, error: `Cannot write registry: ${err.message}` }));
2932
+ return;
2933
+ }
2934
+ console.log(` Config removed from registry: ${cwd} (PROJECT.md file kept)`);
2935
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2936
+ res.end(JSON.stringify({ ok: true }));
2937
+ return;
2938
+ }
2939
+
2940
+ // ROLE MD ROUTES (Phase B §4) — UI-managed agent role files.
2941
+ // Each role lives at <data>/roles/<name>.md with frontmatter
2942
+ // (name, description, permission) + optional body. Permission is one of
2943
+ // "read" | "read-write" | "mcp".
2944
+
2945
+ if (req.method === 'GET' && path === '/md/role') {
2946
+ const rolesDir = join(getPluginData(), 'roles');
2947
+ const items = [];
2948
+ try {
2949
+ mkdirSync(rolesDir, { recursive: true });
2950
+ const files = (await import('fs')).readdirSync(rolesDir).filter(f => f.endsWith('.md'));
2951
+ for (const f of files) {
2952
+ const name = f.replace(/\.md$/, '');
2953
+ let raw = '';
2954
+ try { raw = readFileSync(join(rolesDir, f), 'utf8'); } catch {}
2955
+ const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n*/);
2956
+ const fm = fmMatch ? fmMatch[1] : '';
2957
+ const body = fmMatch ? raw.slice(fmMatch[0].length).trim() : raw.trim();
2958
+ const description = (fm.match(/^description:\s*["']?(.+?)["']?\s*$/m)?.[1] || '').trim();
2959
+ const permission = (fm.match(/^permission:\s*["']?(.+?)["']?\s*$/m)?.[1] || '').trim().toLowerCase();
2960
+ items.push({ name, description, permission, body });
2961
+ }
2962
+ } catch {}
2963
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2964
+ res.end(JSON.stringify({ items }));
2965
+ return;
2966
+ }
2967
+
2968
+ if (req.method === 'POST' && path === '/md/role') {
2969
+ if (!isAllowedOrigin(req)) {
2970
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2971
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2972
+ return;
2973
+ }
2974
+ const body = await readBody(req);
2975
+ const name = String(body?.name || '').trim().toLowerCase().replace(/[^a-z0-9_-]/g, '');
2976
+ const description = String(body?.description ?? '').trim();
2977
+ const permission = String(body?.permission ?? '').trim().toLowerCase();
2978
+ const note = String(body?.body ?? '').trim();
2979
+ if (!name) {
2980
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2981
+ res.end(JSON.stringify({ ok: false, error: 'name required' }));
2982
+ return;
2983
+ }
2984
+ if (permission && permission !== 'read' && permission !== 'read-write' && permission !== 'mcp') {
2985
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2986
+ res.end(JSON.stringify({ ok: false, error: 'permission must be "read", "read-write", or "mcp"' }));
2987
+ return;
2988
+ }
2989
+ const fmLines = [`name: ${name}`];
2990
+ if (description) fmLines.push(`description: ${description.replace(/\n/g, ' ')}`);
2991
+ if (permission) fmLines.push(`permission: ${permission}`);
2992
+ const content = `---\n${fmLines.join('\n')}\n---\n${note ? `\n${note}\n` : ''}`;
2993
+ const p = join(getPluginData(), 'roles', `${name}.md`);
2994
+ // Intentional full replace: POST /md/role owns the entire role markdown file.
2995
+ try {
2996
+ mkdirSync(dirname(p), { recursive: true });
2997
+ const _rTmp = p + '.tmp';
2998
+ writeFileSync(_rTmp, content, 'utf8');
2999
+ renameSync(_rTmp, p);
3000
+ } catch (err) {
3001
+ res.writeHead(500, { 'Content-Type': 'application/json' });
3002
+ res.end(JSON.stringify({ ok: false, error: 'Cannot write role: ' + err.message }));
3003
+ return;
3004
+ }
3005
+ console.log(` Config saved: role MD (${name})`);
3006
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3007
+ res.end(JSON.stringify({ ok: true }));
3008
+ return;
3009
+ }
3010
+
3011
+ if (req.method === 'DELETE' && path === '/md/role') {
3012
+ const qs = new URL(req.url, 'http://x').searchParams;
3013
+ const name = String(qs.get('name') || '').trim().toLowerCase().replace(/[^a-z0-9_-]/g, '');
3014
+ if (!name) {
3015
+ res.writeHead(400, { 'Content-Type': 'application/json' });
3016
+ res.end(JSON.stringify({ ok: false, error: 'name required' }));
3017
+ return;
3018
+ }
3019
+ const p = join(getPluginData(), 'roles', `${name}.md`);
3020
+ try { (await import('fs')).unlinkSync(p); } catch (err) {
3021
+ if (err.code !== 'ENOENT') {
3022
+ res.writeHead(500, { 'Content-Type': 'application/json' });
3023
+ res.end(JSON.stringify({ ok: false, error: 'Cannot delete role: ' + err.message }));
3024
+ return;
3025
+ }
3026
+ }
3027
+ console.log(` Config removed: role MD (${name})`);
3028
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3029
+ res.end(JSON.stringify({ ok: true }));
3030
+ return;
3031
+ }
3032
+
3033
+ // ============================================================
3034
+ // WORKFLOW MODULE ROUTES
3035
+ // ============================================================
3036
+
3037
+ if (req.method === 'GET' && path === '/workflow/load') {
3038
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3039
+ res.end(JSON.stringify(readUserWorkflow()));
3040
+ return;
3041
+ }
3042
+
3043
+ if (req.method === 'POST' && path === '/workflow/save') {
3044
+ if (!isAllowedOrigin(req)) {
3045
+ res.writeHead(403, { 'Content-Type': 'application/json' });
3046
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
3047
+ return;
3048
+ }
3049
+ const data = await readBody(req);
3050
+ writeUserWorkflow(data);
3051
+ console.log(' Config saved: user-workflow');
3052
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3053
+ res.end(JSON.stringify({ ok: true }));
3054
+ return;
3055
+ }
3056
+
3057
+ if (req.method === 'GET' && path === '/workflow/md') {
3058
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
3059
+ res.end(readUserWorkflowMd());
3060
+ return;
3061
+ }
3062
+
3063
+ if (req.method === 'POST' && path === '/workflow/md') {
3064
+ if (!isAllowedOrigin(req)) {
3065
+ res.writeHead(403, { 'Content-Type': 'application/json' });
3066
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
3067
+ return;
3068
+ }
3069
+ let body = '';
3070
+ await new Promise((resolve, reject) => {
3071
+ req.on('data', c => { body += c; });
3072
+ req.on('end', resolve);
3073
+ req.on('error', reject);
3074
+ });
3075
+ writeUserWorkflowMd(body);
3076
+ console.log(' Config saved: user-workflow.md');
3077
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3078
+ res.end(JSON.stringify({ ok: true }));
3079
+ return;
3080
+ }
3081
+
3082
+ if (req.method === 'GET' && path === '/workflow/file') {
3083
+ if (!existsSync(USER_WORKFLOW_MD_PATH)) {
3084
+ if (!shouldSeedMissingUserData(DATA_DIR, 'user-workflow.md')) {
3085
+ res.writeHead(409, { 'Content-Type': 'application/json' });
3086
+ res.end(JSON.stringify({ ok: false, error: 'user-workflow.md missing; restore from backup or intentionally reset user data' }));
3087
+ return;
3088
+ }
3089
+ mkdirSync(dirname(USER_WORKFLOW_MD_PATH), { recursive: true });
3090
+ writeFileSync(USER_WORKFLOW_MD_PATH, DEFAULT_USER_WORKFLOW_MD, 'utf8');
3091
+ markUserDataInitialized(DATA_DIR);
3092
+ }
3093
+ if (isWin) { spawn('cmd', ['/c', 'start', '', USER_WORKFLOW_MD_PATH], { detached: true, stdio: 'ignore', windowsHide: true }).unref(); }
3094
+ else { spawn('open', [USER_WORKFLOW_MD_PATH], { detached: true, stdio: 'ignore' }).unref(); }
3095
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3096
+ res.end(JSON.stringify({ ok: true }));
3097
+ return;
3098
+ }
3099
+
3100
+ if (path === '/close') {
3101
+ windowOpen = false;
3102
+ res.writeHead(200);
3103
+ res.end();
3104
+ console.log(' Window closed');
3105
+ return;
3106
+ }
3107
+
3108
+ debugSetup(`[req-trace] ${req.method} ${path}`);
3109
+ if (path === '/open') {
3110
+ debugSetup(`[open-debug] /open request received`);
3111
+ const result = await openAppWindow();
3112
+ debugSetup(`[open-debug] /open result=${JSON.stringify(result)}`);
3113
+ if (!result.ok) {
3114
+ windowOpen = false;
3115
+ res.writeHead(500, { 'Content-Type': 'application/json' });
3116
+ res.end(JSON.stringify(result));
3117
+ return;
3118
+ }
3119
+
3120
+ windowOpen = true;
3121
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3122
+ res.end(JSON.stringify(result));
3123
+ return;
3124
+ }
3125
+
3126
+ if (req.method === 'GET' && path === '/generation') {
3127
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3128
+ res.end(JSON.stringify({ generation: openGeneration }));
3129
+ return;
3130
+ }
3131
+
3132
+ res.writeHead(404);
3133
+ res.end('Not found');
3134
+ }
3135
+
3136
+ // Fire-and-forget warm-up of the model catalogs the Config UI will request.
3137
+ // Collects every provider referenced by the current agent config's presets,
3138
+ // then drives each through listProviderModels — the exact path /agent/models
3139
+ // serves — so the 24h runtime cache is populated by the time the UI asks.
3140
+ // Errors per provider are logged and swallowed; this never throws.
3141
+ function prefetchModelCatalogs() {
3142
+ // Config reads can rethrow non-ENOENT I/O failures (EACCES/EIO) through
3143
+ // readJsonFile; this runs unguarded inside the listen callback, so contain
3144
+ // them here — a failed prefetch must never take the server down.
3145
+ let cfg;
3146
+ const providers = new Set();
3147
+ try {
3148
+ cfg = readAgentConfig();
3149
+ for (const preset of readAgentPresets()) {
3150
+ const id = normalizeAgentProviderId(preset?.provider);
3151
+ if (id) providers.add(id);
3152
+ }
3153
+ } catch (err) {
3154
+ console.error(`[setup] model catalog prefetch skipped: ${err?.message || err}`);
3155
+ return;
3156
+ }
3157
+ for (const id of providers) {
3158
+ listProviderModels(id, cfg).catch(err => {
3159
+ console.error(`[setup] model catalog prefetch failed for ${id}: ${err?.message || err}`);
3160
+ });
3161
+ }
3162
+ }
3163
+
3164
+ server.listen(PORT, '127.0.0.1', () => { // localhost-only — config UI holds secrets
3165
+ console.log(`\n MIXDOG CONFIG`);
3166
+ console.log(` http://localhost:${PORT}\n`);
3167
+ // Warm model catalogs in the background so the Config UI's provider
3168
+ // dropdowns fill without a cold network round-trip. Boot drops the runtime
3169
+ // caches (dropRuntimeModelCaches above), so the first /agent/models request
3170
+ // would otherwise pay full fetch latency while the user waits on a disabled
3171
+ // dropdown. Fire-and-forget through the same listProviderModels path the UI
3172
+ // uses; errors are logged and never thrown.
3173
+ prefetchModelCatalogs();
3174
+ if (process.env.MIXDOG_SETUP_OPEN_ON_START === '1') {
3175
+ openGeneration++;
3176
+ windowOpen = true;
3177
+ openAppWindow().then(result => {
3178
+ if (!result?.ok) {
3179
+ windowOpen = false;
3180
+ console.error(`[setup] openAppWindow failed: ${result?.error || JSON.stringify(result?.attempts)}`);
3181
+ }
3182
+ }).catch(err => {
3183
+ windowOpen = false;
3184
+ console.error(`[setup] openAppWindow threw: ${err?.message || err}`);
3185
+ });
3186
+ }
3187
+ });
3188
+
3189
+ // Parent-PID watchdog: setup-server is launched detached/unref'd (see
3190
+ // setup/launch.mjs), so losing Claude Code does not reap it. Poll the
3191
+ // launcher's parent PID (the Claude Code CLI) and exit when it dies. This is
3192
+ // the detached-process equivalent of the run-mcp.mjs stdin-close pattern
3193
+ // applied to memory/channels workers in v0.6.0.
3194
+ (() => {
3195
+ const parentPid = parseInt(process.env.MIXDOG_SETUP_PARENT_PID || '', 10);
3196
+ if (!Number.isFinite(parentPid) || parentPid <= 0) return;
3197
+ const tick = () => {
3198
+ try {
3199
+ process.kill(parentPid, 0);
3200
+ } catch {
3201
+ process.exit(0);
3202
+ }
3203
+ };
3204
+ const timer = setInterval(tick, 5000);
3205
+ if (typeof timer.unref === 'function') timer.unref();
3206
+ })();