crewswarm 0.9.2 → 0.9.4

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 (228) hide show
  1. package/README.md +22 -9
  2. package/apps/dashboard/dist/assets/chat-core-uXb_C0GM.js +1 -0
  3. package/apps/dashboard/dist/assets/chat-core-uXb_C0GM.js.br +0 -0
  4. package/apps/dashboard/dist/assets/cli-process-CNZ_UBCt.js +1 -0
  5. package/apps/dashboard/dist/assets/cli-process-CNZ_UBCt.js.br +0 -0
  6. package/apps/dashboard/dist/assets/index-BeVllEj_.js +2 -0
  7. package/apps/dashboard/dist/assets/index-BeVllEj_.js.br +0 -0
  8. package/apps/dashboard/dist/assets/{index-CF0aJRtC.css → index-D-sRshvg.css} +1 -1
  9. package/apps/dashboard/dist/assets/index-D-sRshvg.css.br +0 -0
  10. package/apps/dashboard/dist/assets/tab-benchmarks-tab-BHjKCPm3.js.br +0 -0
  11. package/apps/dashboard/dist/assets/tab-models-tab-dNRgsTOO.js +1 -0
  12. package/apps/dashboard/dist/assets/tab-models-tab-dNRgsTOO.js.br +0 -0
  13. package/apps/dashboard/dist/assets/{tab-pm-loop-tab-Bfd449B4.js → tab-pm-loop-tab-DiAPTJXu.js} +1 -1
  14. package/apps/dashboard/dist/assets/tab-pm-loop-tab-DiAPTJXu.js.br +0 -0
  15. package/apps/dashboard/dist/assets/{tab-projects-tab-DhNWnlzt.js → tab-projects-tab-SFH4E--a.js} +1 -1
  16. package/apps/dashboard/dist/assets/tab-projects-tab-SFH4E--a.js.br +0 -0
  17. package/apps/dashboard/dist/assets/tab-settings-tab-CuvH_Fj_.js +1 -0
  18. package/apps/dashboard/dist/assets/tab-settings-tab-CuvH_Fj_.js.br +0 -0
  19. package/apps/dashboard/dist/assets/tab-skills-tab-DR7PJ7NB.js +1 -0
  20. package/apps/dashboard/dist/assets/tab-skills-tab-DR7PJ7NB.js.br +0 -0
  21. package/apps/dashboard/dist/assets/tab-testing-tab-CezZOZcJ.js +1 -0
  22. package/apps/dashboard/dist/assets/tab-testing-tab-CezZOZcJ.js.br +0 -0
  23. package/apps/dashboard/dist/index.html +135 -15
  24. package/apps/dashboard/dist/index.html.br +0 -0
  25. package/apps/dashboard/dist/index.html.gz +0 -0
  26. package/apps/vibe/README.md +2 -2
  27. package/apps/vibe/package.json +1 -1
  28. package/apps/vibe/server.mjs +101 -56
  29. package/crew-lead.mjs +34 -4
  30. package/lib/bridges/cli-executor.mjs +1 -1
  31. package/lib/bridges/gateway-ws.mjs +4 -0
  32. package/lib/browser/passthrough-stderr.js +1 -0
  33. package/lib/chat/project-messages.mjs +3 -5
  34. package/lib/cli-process-tracker.mjs +3 -2
  35. package/lib/contacts/identity-linker.mjs +1 -0
  36. package/lib/crew-judge/judge.mjs +19 -18
  37. package/lib/crew-lead/agent-manager.mjs +1 -1
  38. package/lib/crew-lead/background.mjs +14 -1
  39. package/lib/crew-lead/chat-handler.mjs +38 -1
  40. package/lib/crew-lead/http-server.mjs +106 -57
  41. package/lib/crew-lead/llm-caller.mjs +24 -8
  42. package/lib/crew-lead/prompts.mjs +14 -1
  43. package/lib/crew-lead/tools.mjs +3 -2
  44. package/lib/crew-lead/wave-dispatcher.mjs +19 -5
  45. package/lib/crew-lead/ws-router.mjs +219 -27
  46. package/lib/engines/crew-cli.mjs +1 -1
  47. package/lib/engines/engine-registry.mjs +14 -3
  48. package/lib/engines/rt-envelope.mjs +1 -0
  49. package/lib/engines/runners.mjs +28 -4
  50. package/lib/gemini-cli-passthrough-noise.mjs +1 -1
  51. package/lib/integrations/code-search.mjs +4 -3
  52. package/lib/memory/shared-adapter.mjs +23 -10
  53. package/lib/pipeline/manager.mjs +2 -1
  54. package/lib/runtime/config.mjs +1 -1
  55. package/lib/runtime/paths.mjs +12 -8
  56. package/lib/runtime/spending.mjs +2 -1
  57. package/package.json +42 -14
  58. package/scripts/capture-build-flow.mjs +118 -0
  59. package/scripts/coverage-report.mjs +209 -0
  60. package/scripts/coverage-summary.mjs +47 -0
  61. package/scripts/dashboard-validation.mjs +76 -0
  62. package/scripts/dashboard.mjs +1667 -551
  63. package/scripts/generate-openapi.mjs +683 -277
  64. package/scripts/live-bridge-matrix.mjs +79 -0
  65. package/scripts/live-cli-matrix.mjs +166 -0
  66. package/scripts/live-crewchat-check.mjs +42 -0
  67. package/scripts/live-engine-matrix.mjs +50 -0
  68. package/scripts/live-provider-failover-matrix.mjs +107 -0
  69. package/scripts/live-provider-matrix.mjs +228 -0
  70. package/scripts/restart-all-from-repo.sh +4 -4
  71. package/scripts/restart-service.sh +12 -9
  72. package/scripts/smoke-dispatch.mjs +4 -1
  73. package/scripts/test-blast-radius.mjs +204 -0
  74. package/scripts/test-report-summary.mjs +88 -0
  75. package/scripts/test-reporter.mjs +651 -0
  76. package/scripts/test-rerun.mjs +136 -0
  77. package/scripts/tmux-bridge +130 -0
  78. package/apps/dashboard/dist/assets/chat-core-Cx4sTxDd.js +0 -1
  79. package/apps/dashboard/dist/assets/chat-core-Cx4sTxDd.js.br +0 -0
  80. package/apps/dashboard/dist/assets/cli-process-COMRNPqr.js +0 -1
  81. package/apps/dashboard/dist/assets/cli-process-COMRNPqr.js.br +0 -0
  82. package/apps/dashboard/dist/assets/index-CF0aJRtC.css.br +0 -0
  83. package/apps/dashboard/dist/assets/index-DnClJ1ee.js +0 -2
  84. package/apps/dashboard/dist/assets/index-DnClJ1ee.js.br +0 -0
  85. package/apps/dashboard/dist/assets/tab-models-tab-BLEjmd19.js +0 -1
  86. package/apps/dashboard/dist/assets/tab-models-tab-BLEjmd19.js.br +0 -0
  87. package/apps/dashboard/dist/assets/tab-pm-loop-tab-Bfd449B4.js.br +0 -0
  88. package/apps/dashboard/dist/assets/tab-projects-tab-DhNWnlzt.js.br +0 -0
  89. package/apps/dashboard/dist/assets/tab-settings-tab-Bn4nXtDe.js +0 -1
  90. package/apps/dashboard/dist/assets/tab-settings-tab-Bn4nXtDe.js.br +0 -0
  91. package/apps/dashboard/dist/assets/tab-skills-tab-BpY0uZHW.js +0 -1
  92. package/apps/dashboard/dist/assets/tab-skills-tab-BpY0uZHW.js.br +0 -0
  93. package/apps/dashboard/index.html +0 -6529
  94. package/apps/dashboard/package.json +0 -15
  95. package/apps/dashboard/src/app.js +0 -2828
  96. package/apps/dashboard/src/app.js.br +0 -0
  97. package/apps/dashboard/src/app.js.gz +0 -0
  98. package/apps/dashboard/src/chat/chat-actions.js +0 -1847
  99. package/apps/dashboard/src/chat/chat-actions.js.br +0 -0
  100. package/apps/dashboard/src/chat/unified-messages.js +0 -327
  101. package/apps/dashboard/src/chat/unified-messages.js.br +0 -0
  102. package/apps/dashboard/src/cli-process.js +0 -208
  103. package/apps/dashboard/src/cli-process.js.br +0 -0
  104. package/apps/dashboard/src/cli-process.js.gz +0 -0
  105. package/apps/dashboard/src/components/active-tasks-panel.js +0 -175
  106. package/apps/dashboard/src/components/active-tasks-panel.js.br +0 -0
  107. package/apps/dashboard/src/core/api.js +0 -18
  108. package/apps/dashboard/src/core/api.js.br +0 -0
  109. package/apps/dashboard/src/core/dom.js +0 -228
  110. package/apps/dashboard/src/core/dom.js.br +0 -0
  111. package/apps/dashboard/src/core/state.js +0 -91
  112. package/apps/dashboard/src/core/state.js.br +0 -0
  113. package/apps/dashboard/src/core/task-manager.js +0 -134
  114. package/apps/dashboard/src/core/task-manager.js.br +0 -0
  115. package/apps/dashboard/src/orchestration-status.js +0 -127
  116. package/apps/dashboard/src/orchestration-status.js.br +0 -0
  117. package/apps/dashboard/src/setup-wizard.js +0 -562
  118. package/apps/dashboard/src/setup-wizard.js.br +0 -0
  119. package/apps/dashboard/src/styles.css +0 -2085
  120. package/apps/dashboard/src/styles.css.br +0 -0
  121. package/apps/dashboard/src/styles.css.gz +0 -0
  122. package/apps/dashboard/src/tabs/agents-tab.js +0 -2237
  123. package/apps/dashboard/src/tabs/agents-tab.js.br +0 -0
  124. package/apps/dashboard/src/tabs/benchmarks-tab.js +0 -229
  125. package/apps/dashboard/src/tabs/benchmarks-tab.js.br +0 -0
  126. package/apps/dashboard/src/tabs/comms-tab.js +0 -955
  127. package/apps/dashboard/src/tabs/comms-tab.js.br +0 -0
  128. package/apps/dashboard/src/tabs/contacts-tab.js +0 -654
  129. package/apps/dashboard/src/tabs/contacts-tab.js.br +0 -0
  130. package/apps/dashboard/src/tabs/engines-tab.js +0 -175
  131. package/apps/dashboard/src/tabs/engines-tab.js.br +0 -0
  132. package/apps/dashboard/src/tabs/memory-tab.js +0 -182
  133. package/apps/dashboard/src/tabs/memory-tab.js.br +0 -0
  134. package/apps/dashboard/src/tabs/models-tab.js +0 -450
  135. package/apps/dashboard/src/tabs/models-tab.js.br +0 -0
  136. package/apps/dashboard/src/tabs/pm-loop-tab.js +0 -185
  137. package/apps/dashboard/src/tabs/pm-loop-tab.js.br +0 -0
  138. package/apps/dashboard/src/tabs/projects-tab.js +0 -663
  139. package/apps/dashboard/src/tabs/projects-tab.js.br +0 -0
  140. package/apps/dashboard/src/tabs/projects-tab.js.gz +0 -0
  141. package/apps/dashboard/src/tabs/prompts-tab.js +0 -160
  142. package/apps/dashboard/src/tabs/prompts-tab.js.br +0 -0
  143. package/apps/dashboard/src/tabs/services-tab.js +0 -202
  144. package/apps/dashboard/src/tabs/services-tab.js.br +0 -0
  145. package/apps/dashboard/src/tabs/settings-tab.js +0 -861
  146. package/apps/dashboard/src/tabs/settings-tab.js.br +0 -0
  147. package/apps/dashboard/src/tabs/skills-tab.js +0 -284
  148. package/apps/dashboard/src/tabs/skills-tab.js.br +0 -0
  149. package/apps/dashboard/src/tabs/spending-tab.js +0 -173
  150. package/apps/dashboard/src/tabs/spending-tab.js.br +0 -0
  151. package/apps/dashboard/src/tabs/swarm-chat-tab.js +0 -660
  152. package/apps/dashboard/src/tabs/swarm-chat-tab.js.br +0 -0
  153. package/apps/dashboard/src/tabs/swarm-tab.js +0 -538
  154. package/apps/dashboard/src/tabs/swarm-tab.js.br +0 -0
  155. package/apps/dashboard/src/tabs/usage-tab.js +0 -390
  156. package/apps/dashboard/src/tabs/usage-tab.js.br +0 -0
  157. package/apps/dashboard/src/tabs/waves-tab.js +0 -238
  158. package/apps/dashboard/src/tabs/waves-tab.js.br +0 -0
  159. package/apps/dashboard/src/tabs/workflows-tab.js +0 -747
  160. package/apps/dashboard/src/tabs/workflows-tab.js.br +0 -0
  161. package/apps/vibe/.crew/agent-memory/pipeline.json +0 -304
  162. package/apps/vibe/.crew/cost.json +0 -17
  163. package/apps/vibe/.crew/json-parse-metrics.jsonl +0 -27
  164. package/apps/vibe/.crew/pipeline-metrics.jsonl +0 -27
  165. package/apps/vibe/.crew/pipeline-runs/pipeline-0f90c392-2425-4ae5-850c-bd9d17b1d690.jsonl +0 -5
  166. package/apps/vibe/.crew/pipeline-runs/pipeline-1c269dd9-a63f-4fba-af81-5cf08048ef06.jsonl +0 -5
  167. package/apps/vibe/.crew/pipeline-runs/pipeline-288a7765-da24-4a22-89bc-1f3cc9b0562c.jsonl +0 -5
  168. package/apps/vibe/.crew/pipeline-runs/pipeline-2c78fd22-a657-4bd1-bc49-0679fb384409.jsonl +0 -5
  169. package/apps/vibe/.crew/pipeline-runs/pipeline-3da23550-22ed-4904-9a0a-8e79c1f3024c.jsonl +0 -5
  170. package/apps/vibe/.crew/pipeline-runs/pipeline-3e6fe08d-3264-404a-8df3-aab7efef10e7.jsonl +0 -5
  171. package/apps/vibe/.crew/pipeline-runs/pipeline-42eec610-57fe-4e09-9e7e-b315038495c2.jsonl +0 -5
  172. package/apps/vibe/.crew/pipeline-runs/pipeline-4438eb4c-ae13-42b1-90e2-b043d8983be8.jsonl +0 -5
  173. package/apps/vibe/.crew/pipeline-runs/pipeline-4740a9f5-86e7-44b6-a394-de433e291727.jsonl +0 -5
  174. package/apps/vibe/.crew/pipeline-runs/pipeline-49e1da6a-957e-48fd-9220-415019e4f8e2.jsonl +0 -5
  175. package/apps/vibe/.crew/pipeline-runs/pipeline-4c9251db-be68-427b-a3fc-a264f2b5778d.jsonl +0 -5
  176. package/apps/vibe/.crew/pipeline-runs/pipeline-6413fa33-a802-4b57-a8c0-a9056ad67842.jsonl +0 -5
  177. package/apps/vibe/.crew/pipeline-runs/pipeline-65e29a57-664d-4196-8109-017e364f182e.jsonl +0 -5
  178. package/apps/vibe/.crew/pipeline-runs/pipeline-6aa04bc5-9593-4b1f-b58d-3bf2978cb602.jsonl +0 -5
  179. package/apps/vibe/.crew/pipeline-runs/pipeline-6e1cba53-9b70-457e-99e0-59199149dd21.jsonl +0 -5
  180. package/apps/vibe/.crew/pipeline-runs/pipeline-749f41cc-4dac-4204-be64-873a6080a0d2.jsonl +0 -5
  181. package/apps/vibe/.crew/pipeline-runs/pipeline-74d68121-e181-4864-bd9a-c3211341dfaf.jsonl +0 -5
  182. package/apps/vibe/.crew/pipeline-runs/pipeline-8509bc24-142d-4e07-b44a-a50bf99d1103.jsonl +0 -5
  183. package/apps/vibe/.crew/pipeline-runs/pipeline-960339c6-07ca-43ce-9900-f6e1702b39b9.jsonl +0 -5
  184. package/apps/vibe/.crew/pipeline-runs/pipeline-9bef2dd2-6122-42e5-b3d9-19f4d80f9e40.jsonl +0 -5
  185. package/apps/vibe/.crew/pipeline-runs/pipeline-9c6480a9-7031-4146-b241-825b9a2d1de1.jsonl +0 -5
  186. package/apps/vibe/.crew/pipeline-runs/pipeline-9fd42426-8492-4157-9d5f-e1537c060489.jsonl +0 -2
  187. package/apps/vibe/.crew/pipeline-runs/pipeline-ad6d40a3-2f5e-46a9-a345-47caaccc51aa.jsonl +0 -5
  188. package/apps/vibe/.crew/pipeline-runs/pipeline-bc606133-8d5b-4535-8d85-f1a29cdaa981.jsonl +0 -5
  189. package/apps/vibe/.crew/pipeline-runs/pipeline-c1418f4e-b773-4ca1-84a3-216acf36e2f2.jsonl +0 -5
  190. package/apps/vibe/.crew/pipeline-runs/pipeline-c1a13ccd-634a-4d01-a4a7-1177b8a752ff.jsonl +0 -5
  191. package/apps/vibe/.crew/pipeline-runs/pipeline-c7d27b42-249e-4bd4-8f26-6aa998110b8a.jsonl +0 -5
  192. package/apps/vibe/.crew/pipeline-runs/pipeline-cca2e9b9-4a34-4d25-a311-5c793fa7e91e.jsonl +0 -5
  193. package/apps/vibe/.crew/sandbox.json +0 -7
  194. package/apps/vibe/.crew/session.json +0 -330
  195. package/apps/vibe/.crew/training-data.jsonl +0 -0
  196. package/apps/vibe/.github/workflows/studio-quality.yml +0 -37
  197. package/apps/vibe/.studio-data/project-messages/chuck-norris.jsonl +0 -18
  198. package/apps/vibe/.studio-data/project-messages/general.jsonl +0 -81
  199. package/apps/vibe/.studio-data/project-messages/studio-local.jsonl +0 -18
  200. package/apps/vibe/ARCHITECTURE.md +0 -3393
  201. package/apps/vibe/QUICK-REFERENCE.md +0 -211
  202. package/apps/vibe/ROADMAP.md +0 -41
  203. package/apps/vibe/STUDIO-SETUP-COMPLETE.md +0 -35
  204. package/apps/vibe/VISUAL-GUIDE.md +0 -378
  205. package/apps/vibe/capture-demo.mjs +0 -160
  206. package/apps/vibe/capture-full-demo.mjs +0 -255
  207. package/apps/vibe/capture-quickstart.mjs +0 -256
  208. package/apps/vibe/capture-vibe-assets.mjs +0 -71
  209. package/apps/vibe/capture-vibe-video.mjs +0 -260
  210. package/apps/vibe/check-buttons.js +0 -41
  211. package/apps/vibe/diagnose.html +0 -106
  212. package/apps/vibe/fix-buttons.js +0 -103
  213. package/apps/vibe/index.html +0 -3404
  214. package/apps/vibe/package-lock.json +0 -920
  215. package/apps/vibe/scripts/studio-pty-host.py +0 -117
  216. package/apps/vibe/src/main.js +0 -2940
  217. package/apps/vibe/src/register-all-languages.js +0 -98
  218. package/apps/vibe/start-studio.sh +0 -11
  219. package/apps/vibe/test/accessibility-tests.js +0 -77
  220. package/apps/vibe/test/browser-performance-audit.mjs +0 -205
  221. package/apps/vibe/test/performance-tests.js +0 -120
  222. package/apps/vibe/test/security-tests.js +0 -213
  223. package/apps/vibe/tests/e2e.local.mjs +0 -54
  224. package/apps/vibe/tests/server.smoke.mjs +0 -106
  225. package/apps/vibe/update_website.mjs +0 -74
  226. package/apps/vibe/vite.config.js +0 -19
  227. package/lib/crew-lead/chat-handler.mjs.bak +0 -1274
  228. package/lib/engines/rt-envelope.mjs.backup-current +0 -870
@@ -30,9 +30,25 @@ import {
30
30
  } from "../lib/agents/tool-instructions.mjs";
31
31
  import {
32
32
  StartBuildSchema,
33
+ EnhancePromptSchema,
33
34
  StartPMLoopSchema,
34
35
  ServiceActionSchema,
35
36
  ImportSkillSchema,
37
+ AgentConfigCreateSchema,
38
+ AgentConfigDeleteSchema,
39
+ AgentResetSessionSchema,
40
+ ProviderAddSchema,
41
+ ProviderSaveSchema,
42
+ ProviderTestSchema,
43
+ ProviderBuiltinTestSchema,
44
+ ContinuousBuildSchema,
45
+ ReplayDLQSchema,
46
+ DeleteProjectSchema,
47
+ UpdateProjectSchema,
48
+ RoadmapWriteSchema,
49
+ RoadmapRetryFailedSchema,
50
+ ContactDeleteSchema,
51
+ ContactSendSchema,
36
52
  validate,
37
53
  } from "./dashboard-validation.mjs";
38
54
  import { execCrewLeadTools } from "../lib/crew-lead/tools.mjs";
@@ -108,6 +124,103 @@ if (!lockResult.ok) {
108
124
  process.exit(1);
109
125
  }
110
126
 
127
+ // ── OAuth token cache with TTL refresh ──────────────────────────────────────
128
+ const _oauthTokenCache = {};
129
+ const _oauthTokenTimestamp = {}; // providerId → Date.now() when cached
130
+ const OAUTH_TOKEN_TTL_MS = 25 * 60 * 1000; // 25 minutes (tokens typically expire in 30-60 min)
131
+
132
+ // Capture execFileSync at module load for sync token refresh
133
+ const { execFileSync: _oauthExecFileSync } = await import("node:child_process");
134
+
135
+ function refreshAnthropicOAuthToken() {
136
+ try {
137
+ for (const acct of [os.userInfo().username, "jeffhobbs", "unknown"]) {
138
+ try {
139
+ const raw = _oauthExecFileSync("security", [
140
+ "find-generic-password", "-s", "Claude Code-credentials", "-a", acct, "-w"
141
+ ], { encoding: "utf8", timeout: 5000 }).trim();
142
+ const parsed = JSON.parse(raw);
143
+ if (parsed?.claudeAiOauth?.accessToken) {
144
+ _oauthTokenCache["anthropic-oauth"] = parsed.claudeAiOauth.accessToken;
145
+ _oauthTokenTimestamp["anthropic-oauth"] = Date.now();
146
+ return;
147
+ }
148
+ } catch { /* try next account */ }
149
+ }
150
+ } catch { /* keychain not accessible */ }
151
+ }
152
+
153
+ // Initial load at startup
154
+ try {
155
+ for (const acct of [os.userInfo().username, "jeffhobbs", "unknown"]) {
156
+ try {
157
+ const raw = _oauthExecFileSync("security", [
158
+ "find-generic-password", "-s", "Claude Code-credentials", "-a", acct, "-w"
159
+ ], { encoding: "utf8", timeout: 5000 }).trim();
160
+ const parsed = JSON.parse(raw);
161
+ if (parsed?.claudeAiOauth?.accessToken) {
162
+ _oauthTokenCache["anthropic-oauth"] = parsed.claudeAiOauth.accessToken;
163
+ _oauthTokenTimestamp["anthropic-oauth"] = Date.now();
164
+ break;
165
+ }
166
+ } catch { /* try next account */ }
167
+ }
168
+ } catch { /* keychain not accessible at startup (launchd) */ }
169
+
170
+ // OpenAI/Codex OAuth — read from disk (no keychain needed)
171
+ async function readOpenAIOAuthToken() {
172
+ const tokenPaths = [
173
+ path.join(os.homedir(), ".codex", "auth.json"),
174
+ path.join(os.homedir(), ".local", "share", "opencode", "auth.json"),
175
+ path.join(os.homedir(), ".codex-auth", "auth.json"),
176
+ ];
177
+ for (const p of tokenPaths) {
178
+ try {
179
+ const raw = await fs.promises.readFile(p, "utf8");
180
+ const data = JSON.parse(raw);
181
+ // Codex CLI chatgpt mode: { tokens: { access_token, refresh_token } }
182
+ if (data.tokens?.access_token) return data.tokens.access_token;
183
+ // openai-codex nested entry
184
+ const entry = data["openai-codex"] || data["codex"] || data["openai"];
185
+ if (entry?.access) return entry.access;
186
+ // flat
187
+ if (data.access_token) return data.access_token;
188
+ if (data.token) return data.token;
189
+ } catch { /* try next */ }
190
+ }
191
+ return null;
192
+ }
193
+
194
+ async function refreshOpenAIOAuthToken() {
195
+ try {
196
+ const t = await readOpenAIOAuthToken();
197
+ if (t) {
198
+ _oauthTokenCache["openai-oauth"] = t;
199
+ _oauthTokenTimestamp["openai-oauth"] = Date.now();
200
+ }
201
+ } catch { /* ignore */ }
202
+ }
203
+
204
+ // Pre-load OpenAI token at startup
205
+ try {
206
+ const t = await readOpenAIOAuthToken();
207
+ if (t) {
208
+ _oauthTokenCache["openai-oauth"] = t;
209
+ _oauthTokenTimestamp["openai-oauth"] = Date.now();
210
+ }
211
+ } catch { /* ignore */ }
212
+
213
+ function getOAuthTokenCached(providerId) {
214
+ const cachedAt = _oauthTokenTimestamp[providerId] || 0;
215
+ const age = Date.now() - cachedAt;
216
+ // Refresh if token is stale (older than TTL)
217
+ if (age > OAUTH_TOKEN_TTL_MS) {
218
+ if (providerId === "anthropic-oauth") refreshAnthropicOAuthToken();
219
+ else if (providerId === "openai-oauth") refreshOpenAIOAuthToken(); // async, best-effort
220
+ }
221
+ return _oauthTokenCache[providerId] || null;
222
+ }
223
+
111
224
  const opencodeBase = process.env.OPENCODE_URL || "http://127.0.0.1:4096";
112
225
 
113
226
  function resolveCommandPath(bin, extraPaths = []) {
@@ -134,6 +247,7 @@ function resolveCommandPath(bin, extraPaths = []) {
134
247
  const out = execSync(`command -v "${candidate.replace(/"/g, '\\"')}"`, {
135
248
  stdio: ["ignore", "pipe", "ignore"],
136
249
  shell: "/bin/zsh",
250
+ timeout: 1500,
137
251
  env: {
138
252
  ...process.env,
139
253
  PATH: [
@@ -960,10 +1074,236 @@ Keep the same intent; make it specific enough for a PM to break into small tasks
960
1074
  return content;
961
1075
  }
962
1076
 
1077
+ function getRtAuthToken() {
1078
+ try {
1079
+ return readSwarmConfigSafe()?.rt?.authToken || "";
1080
+ } catch {
1081
+ return "";
1082
+ }
1083
+ }
1084
+
1085
+ function resolvePlannerEngine(preferredEngine = null, preferredModel = null) {
1086
+ if (preferredEngine) {
1087
+ return {
1088
+ engine: preferredEngine,
1089
+ model: preferredModel || null,
1090
+ permissionMode: null,
1091
+ sandbox: preferredEngine === "codex" ? "read-only" : null,
1092
+ source: "request",
1093
+ };
1094
+ }
1095
+
1096
+ const cfg = readSwarmConfigSafe();
1097
+ const agents = Array.isArray(cfg?.agents) ? cfg.agents : [];
1098
+ const pm = agents.find((agent) => agent?.id === "crew-pm") || {};
1099
+
1100
+ if (pm.useClaudeCode) {
1101
+ return {
1102
+ engine: "claude",
1103
+ model: pm.claudeCodeModel || null,
1104
+ // Claude's plan mode can exit 0 with no streamed text for this planner path.
1105
+ // Use the normal direct lane here; the build-planner prompt already forbids edits.
1106
+ permissionMode: null,
1107
+ sandbox: null,
1108
+ source: "crew-pm",
1109
+ };
1110
+ }
1111
+ if (pm.useCodex) {
1112
+ return {
1113
+ engine: "codex",
1114
+ model: pm.codexModel || null,
1115
+ permissionMode: null,
1116
+ sandbox: "read-only",
1117
+ source: "crew-pm",
1118
+ };
1119
+ }
1120
+ if (pm.useCursorCli) {
1121
+ return {
1122
+ engine: "cursor",
1123
+ model: pm.cursorCliModel || null,
1124
+ permissionMode: null,
1125
+ sandbox: null,
1126
+ source: "crew-pm",
1127
+ };
1128
+ }
1129
+ if (pm.useGeminiCli) {
1130
+ return {
1131
+ engine: "gemini",
1132
+ model: pm.geminiCliModel || null,
1133
+ permissionMode: null,
1134
+ sandbox: null,
1135
+ source: "crew-pm",
1136
+ };
1137
+ }
1138
+ if (pm.useCrewCLI) {
1139
+ return {
1140
+ engine: "crew-cli",
1141
+ model: pm.crewCliModel || null,
1142
+ permissionMode: null,
1143
+ sandbox: null,
1144
+ source: "crew-pm",
1145
+ };
1146
+ }
1147
+ if (pm.useOpenCode) {
1148
+ return {
1149
+ engine: "opencode",
1150
+ model: pm.opencodeModel || null,
1151
+ permissionMode: null,
1152
+ sandbox: null,
1153
+ source: "crew-pm",
1154
+ };
1155
+ }
1156
+
1157
+ const fallbacks = [
1158
+ commandExists(process.env.CLAUDE_CODE_BIN || "claude") && { engine: "claude", model: process.env.CREWSWARM_CLAUDE_CODE_MODEL || null, permissionMode: null, sandbox: null, source: "fallback" },
1159
+ commandExists(process.env.CODEX_CLI_BIN || "codex") && { engine: "codex", model: process.env.CREWSWARM_CODEX_MODEL || null, permissionMode: null, sandbox: "read-only", source: "fallback" },
1160
+ commandExists(process.env.CURSOR_CLI_BIN || path.join(os.homedir(), ".local", "bin", "agent"), [path.join(os.homedir(), ".local", "bin", "agent")]) && { engine: "cursor", model: process.env.CREWSWARM_CURSOR_MODEL || process.env.CURSOR_DEFAULT_MODEL || null, permissionMode: null, sandbox: null, source: "fallback" },
1161
+ commandExists(process.env.GEMINI_CLI_BIN || "gemini") && { engine: "gemini", model: process.env.CREWSWARM_GEMINI_CLI_MODEL || null, permissionMode: null, sandbox: null, source: "fallback" },
1162
+ commandExists(process.env.CREWSWARM_OPENCODE_BIN || "opencode") && { engine: "opencode", model: process.env.CREWSWARM_OPENCODE_MODEL || null, permissionMode: null, sandbox: null, source: "fallback" },
1163
+ ].filter(Boolean);
1164
+
1165
+ return fallbacks[0] || null;
1166
+ }
1167
+
1168
+ function buildRequirementPlanningPrompt(userText, projectDir = null) {
1169
+ return [
1170
+ "You are the planning stage for CrewSwarm's Build tab.",
1171
+ "Transform the user's rough build idea into a concrete build brief that crew-pm can execute.",
1172
+ "If repository context is relevant, inspect the workspace before answering. Do not edit files.",
1173
+ "",
1174
+ "Output format:",
1175
+ "## Build Brief",
1176
+ "A 1-2 paragraph concrete requirement with explicit scope and deliverables.",
1177
+ "",
1178
+ "## Acceptance Criteria",
1179
+ "- 3 to 7 flat bullets",
1180
+ "",
1181
+ "## Constraints / Assumptions",
1182
+ "- Flat bullets only when needed",
1183
+ "",
1184
+ "Rules:",
1185
+ "- Preserve the user's intent; do not invent a different product.",
1186
+ "- Make it specific enough for PM decomposition and agent dispatch.",
1187
+ "- Mention likely subsystems or files only if you have evidence from the repo.",
1188
+ "- Keep it concise and actionable.",
1189
+ "- Do not include implementation code, shell commands, or extra commentary.",
1190
+ projectDir ? `- Current project directory: ${projectDir}` : "",
1191
+ "",
1192
+ "User idea:",
1193
+ userText.trim(),
1194
+ ].filter(Boolean).join("\n");
1195
+ }
1196
+
1197
+ async function collectClaudePlannerTextDirect({ message, projectDir, model = null }) {
1198
+ const claudeBin = resolveCommandPath(process.env.CLAUDE_CODE_BIN || "claude", [
1199
+ path.join(os.homedir(), ".local", "bin", "claude"),
1200
+ "/usr/local/bin/claude",
1201
+ "/opt/homebrew/bin/claude",
1202
+ ]) || (process.env.CLAUDE_CODE_BIN || "claude");
1203
+ const { execFile } = await import("node:child_process");
1204
+ const { promisify } = await import("node:util");
1205
+ const execFileAsync = promisify(execFile);
1206
+ const args = ["-p"];
1207
+ if (projectDir) args.push("--add-dir", projectDir);
1208
+ if (model) args.push("--model", model);
1209
+ // Match the fixed engine-passthrough Claude invocation:
1210
+ // skip user MCP startup and terminate option parsing before the prompt.
1211
+ args.push(
1212
+ "--strict-mcp-config",
1213
+ "--mcp-config",
1214
+ path.join(os.homedir(), ".crewswarm", "config", "empty-mcp.json"),
1215
+ "--",
1216
+ message,
1217
+ );
1218
+ const { stdout, stderr } = await execFileAsync(claudeBin, args, {
1219
+ cwd: projectDir || process.cwd(),
1220
+ env: process.env,
1221
+ timeout: Number(process.env.CREWSWARM_PLANNER_TIMEOUT_MS || 300000),
1222
+ maxBuffer: 2 * 1024 * 1024,
1223
+ });
1224
+ const trimmed = String(stdout || "").trim();
1225
+ if (trimmed) return trimmed;
1226
+ const stderrText = String(stderr || "").trim();
1227
+ if (stderrText) throw new Error(stderrText);
1228
+ throw new Error("planner produced no output");
1229
+ }
1230
+
1231
+ async function collectPassthroughText({
1232
+ engine,
1233
+ message,
1234
+ projectDir,
1235
+ model = null,
1236
+ permissionMode = null,
1237
+ sandbox = null,
1238
+ forceL2 = false,
1239
+ }) {
1240
+ if (engine === "claude") {
1241
+ return collectClaudePlannerTextDirect({ message, projectDir, model });
1242
+ }
1243
+ const token = getRtAuthToken();
1244
+ const crewLeadPort = process.env.CREW_LEAD_PORT || "5010";
1245
+ let upstream;
1246
+ try {
1247
+ upstream = await fetch(`http://127.0.0.1:${crewLeadPort}/api/engine-passthrough`, {
1248
+ method: "POST",
1249
+ headers: {
1250
+ "content-type": "application/json",
1251
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
1252
+ "x-passthrough-continue": "false",
1253
+ },
1254
+ body: JSON.stringify({
1255
+ engine,
1256
+ message,
1257
+ projectDir: projectDir || process.cwd(),
1258
+ sessionId: "build-planner",
1259
+ ...(model ? { model } : {}),
1260
+ ...(permissionMode ? { permissionMode } : {}),
1261
+ ...(sandbox ? { sandbox } : {}),
1262
+ ...(forceL2 ? { forceL2: true } : {}),
1263
+ }),
1264
+ signal: AbortSignal.timeout(300_000),
1265
+ });
1266
+ } catch (fetchErr) {
1267
+ throw new Error(`planner fetch failed for ${engine}: ${fetchErr.message}`);
1268
+ }
1269
+
1270
+ if (!upstream.ok) {
1271
+ throw new Error(`planner upstream ${upstream.status}`);
1272
+ }
1273
+
1274
+ let rawSSE;
1275
+ try {
1276
+ rawSSE = await upstream.text();
1277
+ } catch (readErr) {
1278
+ throw new Error(`planner SSE read failed for ${engine}: ${readErr.message}`);
1279
+ }
1280
+ let text = "";
1281
+ let stderr = "";
1282
+ let exitCode = 0;
1283
+
1284
+ for (const line of rawSSE.split("\n")) {
1285
+ if (!line.startsWith("data: ")) continue;
1286
+ try {
1287
+ const ev = JSON.parse(line.slice(6));
1288
+ if (ev.type === "chunk" && ev.text) text += ev.text;
1289
+ else if (ev.type === "stderr" && ev.text) stderr += ev.text;
1290
+ else if (ev.type === "done") exitCode = ev.exitCode ?? 0;
1291
+ } catch {}
1292
+ }
1293
+
1294
+ const trimmed = text.trim();
1295
+ if (!trimmed && stderr.trim()) {
1296
+ throw new Error(stderr.trim());
1297
+ }
1298
+ if (!trimmed) {
1299
+ throw new Error(`planner produced no output${exitCode ? ` (exit ${exitCode})` : ""}`);
1300
+ }
1301
+ return trimmed;
1302
+ }
1303
+
963
1304
  async function getPhasedProgress(limit = 80) {
964
1305
  const { readFile } = await import("node:fs/promises");
965
- const { existsSync } = await import("node:fs");
966
- if (!existsSync(phasedDispatchLog)) return [];
1306
+ if (!(await fs.promises.access(phasedDispatchLog).then(() => true, () => false))) return [];
967
1307
  try {
968
1308
  const content = await readFile(phasedDispatchLog, "utf8");
969
1309
  const lines = content.trim().split("\n").filter(Boolean).slice(-limit);
@@ -1116,8 +1456,41 @@ function serveStatic(req, res, filePath) {
1116
1456
  }
1117
1457
  }
1118
1458
 
1459
+ /**
1460
+ * Make an HTTP request to crew-lead using http.request with agent:false.
1461
+ * This bypasses Node 25's undici connection pool used by fetch(), which gets
1462
+ * blocked when Chrome SSE EventSource connections saturate the pool.
1463
+ */
1464
+ function crewLeadRequest(path, { method = "GET", body = null, timeout = 10000, port } = {}) {
1465
+ const crewPort = port || process.env.CREW_LEAD_PORT || "5010";
1466
+ return new Promise((resolve, reject) => {
1467
+ const options = {
1468
+ hostname: "127.0.0.1",
1469
+ port: parseInt(crewPort, 10),
1470
+ path,
1471
+ method,
1472
+ timeout,
1473
+ agent: false,
1474
+ headers: { "content-type": "application/json", connection: "close" },
1475
+ };
1476
+ const req = http.request(options, (res) => {
1477
+ let d = "";
1478
+ res.on("data", (chunk) => (d += chunk));
1479
+ res.on("end", () => {
1480
+ try { resolve({ ok: res.statusCode < 400, status: res.statusCode, data: JSON.parse(d) }); }
1481
+ catch { resolve({ ok: res.statusCode < 400, status: res.statusCode, data: d }); }
1482
+ });
1483
+ });
1484
+ req.on("error", (err) => reject(err));
1485
+ req.on("timeout", () => { req.destroy(); reject(new Error("crew-lead request timeout")); });
1486
+ if (body) req.write(typeof body === "string" ? body : JSON.stringify(body));
1487
+ req.end();
1488
+ });
1489
+ }
1490
+
1119
1491
  const server = http.createServer(async (req, res) => {
1120
1492
  const url = new URL(req.url || "/", `http://localhost:${listenPort}`);
1493
+ const exists = (p) => fs.promises.access(p).then(() => true, () => false);
1121
1494
 
1122
1495
  // CORS: echo local dev Origins so http://localhost:3333 (Vibe) works with http://127.0.0.1:4319
1123
1496
  // (browsers treat hostnames as distinct origins). Non-local callers still get *.
@@ -1173,7 +1546,7 @@ const server = http.createServer(async (req, res) => {
1173
1546
  if (url.pathname === "/crew-chat.html") {
1174
1547
  const chatFile = path.join(CREWSWARM_DIR, "crew-chat.html");
1175
1548
  try {
1176
- const chatHtml = fs.readFileSync(chatFile, "utf8");
1549
+ const chatHtml = await fs.promises.readFile(chatFile, "utf8");
1177
1550
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
1178
1551
  res.end(chatHtml);
1179
1552
  } catch {
@@ -1185,7 +1558,7 @@ const server = http.createServer(async (req, res) => {
1185
1558
  if (url.pathname === "/signup" || url.pathname === "/signup.html") {
1186
1559
  const signupFile = path.join(FRONTEND_SRC, "public", "signup.html");
1187
1560
  try {
1188
- const signupHtml = fs.readFileSync(signupFile, "utf8");
1561
+ const signupHtml = await fs.promises.readFile(signupFile, "utf8");
1189
1562
  res.writeHead(200, {
1190
1563
  "content-type": "text/html; charset=utf-8",
1191
1564
  "cache-control": "no-store, no-cache, must-revalidate",
@@ -1198,6 +1571,333 @@ const server = http.createServer(async (req, res) => {
1198
1571
  }
1199
1572
  return;
1200
1573
  }
1574
+ // ── Test results API ─────────────────────────────────────────────────────
1575
+ if (url.pathname === "/api/tests/summary" && req.method === "GET") {
1576
+ const resultsDir = path.join(CREWSWARM_DIR, "test-results");
1577
+ const runsDir = path.join(resultsDir, "runs");
1578
+ try {
1579
+ const allRuns = (await fs.promises.readdir(runsDir)).filter(d => /^\d{4}-/.test(d)).sort().reverse();
1580
+ // Collect the last run per suite type (unit, integration, e2e, all)
1581
+ // by inspecting run.json test_command
1582
+ async function detectSuite(runDir) {
1583
+ try {
1584
+ const r = JSON.parse(await fs.promises.readFile(path.join(runDir, "run.json"), "utf8"));
1585
+ const cmd = r.test_command || "";
1586
+ if (cmd.includes("test/e2e/") || cmd.includes("test:e2e")) return "e2e";
1587
+ if (cmd.includes("test/integration/")) return "integration";
1588
+ if (cmd.includes("test/unit/")) {
1589
+ // If it has ONLY unit tests, it's a unit run; if mixed, it's "all"
1590
+ if (cmd.includes("test/integration/") || cmd.includes("test/e2e/")) return "all";
1591
+ return "unit";
1592
+ }
1593
+ // Check file count heuristic: >100 files = probably "all"
1594
+ const fileCount = (cmd.match(/\.test\.mjs/g) || []).length;
1595
+ if (fileCount > 100) return "all";
1596
+ if (fileCount > 15) return "unit";
1597
+ return "unknown";
1598
+ } catch { return "unknown"; }
1599
+ }
1600
+ async function readRunData(runId) {
1601
+ const runDir = path.join(runsDir, runId);
1602
+ const summaryFile = path.join(runDir, "summary.json");
1603
+ let data;
1604
+ if (await exists(summaryFile)) {
1605
+ data = JSON.parse(await fs.promises.readFile(summaryFile, "utf8"));
1606
+ } else {
1607
+ // Fall back to .last-run.json if this is the matching run
1608
+ const lastRunFile = path.join(resultsDir, ".last-run.json");
1609
+ try {
1610
+ const lr = JSON.parse(await fs.promises.readFile(lastRunFile, "utf8"));
1611
+ if (lr.runId === runId) { data = lr; }
1612
+ } catch {}
1613
+ }
1614
+ if (!data) {
1615
+ // Fall back to counting test artifact dirs + failure.json presence
1616
+ let timestamp = null;
1617
+ try { const r = JSON.parse(await fs.promises.readFile(path.join(runDir, "run.json"), "utf8")); timestamp = r.timestamp; } catch {}
1618
+ const ents = await fs.promises.readdir(runDir);
1619
+ const testDirs = ents.filter(e => !e.endsWith(".json") && !e.startsWith("."));
1620
+ let failed = 0;
1621
+ for (const td of testDirs) { if (await exists(path.join(runDir, td, "failure.json"))) failed++; }
1622
+ data = { runId, timestamp, passed: testDirs.length - failed, failed, skipped: 0, total: testDirs.length, duration_ms: 0 };
1623
+ }
1624
+ data.runId = runId;
1625
+ data.suite = await detectSuite(runDir);
1626
+ // Read failures and skips if available
1627
+ const failFile = path.join(runDir, "failures.json");
1628
+ const skipFile = path.join(runDir, "skips.json");
1629
+ let failures = [];
1630
+ let skips = [];
1631
+ try { failures = JSON.parse(await fs.promises.readFile(failFile, "utf8")); } catch {}
1632
+ try { skips = JSON.parse(await fs.promises.readFile(skipFile, "utf8")); } catch {}
1633
+ data.failures = failures.map(f => ({
1634
+ name: f.name || f.testId || "",
1635
+ file: (f.file_fingerprint?.relative_file || f.file || "").replace(CREWSWARM_DIR + "/", ""),
1636
+ error: typeof f.error === "string" ? f.error : (f.error?.message || f.error?.failureType || JSON.stringify(f.error || "").slice(0, 300)),
1637
+ classification: f.classification || "unknown",
1638
+ rerun_command: f.rerun_command || f.selector?.command || "",
1639
+ }));
1640
+ data.skips = skips.map(s => ({
1641
+ name: s.name || s.testId || "",
1642
+ file: (s.file_fingerprint?.relative_file || s.file || "").replace(CREWSWARM_DIR + "/", ""),
1643
+ }));
1644
+ return data;
1645
+ }
1646
+ // Find latest run for each suite type
1647
+ const suiteRuns = {};
1648
+ let latestOverall = null;
1649
+ for (const runId of allRuns) {
1650
+ if (Object.keys(suiteRuns).length >= 4 && latestOverall) break;
1651
+ try {
1652
+ const runDir = path.join(runsDir, runId);
1653
+ const suite = await detectSuite(runDir);
1654
+ if (!latestOverall) latestOverall = runId;
1655
+ if (!suiteRuns[suite]) suiteRuns[suite] = runId;
1656
+ } catch {}
1657
+ }
1658
+ // Build per-suite summaries
1659
+ const suites = {};
1660
+ for (const [suite, runId] of Object.entries(suiteRuns)) {
1661
+ try { suites[suite] = await readRunData(runId); } catch { /* skip broken runs */ }
1662
+ }
1663
+ // Count test files on disk for reference
1664
+ const testFileDir = path.join(CREWSWARM_DIR, "test");
1665
+ const testsE2eDir = path.join(CREWSWARM_DIR, "tests", "e2e");
1666
+ const crewCliTestDir = path.join(CREWSWARM_DIR, "crew-cli", "tests");
1667
+ const crewCliTestDir2 = path.join(CREWSWARM_DIR, "crew-cli", "test");
1668
+ let unitFiles = 0, intFiles = 0, e2eFiles = 0, playwrightFiles = 0, crewCliFiles = 0, rootFiles = 0;
1669
+ try { unitFiles = (await fs.promises.readdir(path.join(testFileDir, "unit"))).filter(f => f.endsWith(".test.mjs")).length; } catch {}
1670
+ try { intFiles = (await fs.promises.readdir(path.join(testFileDir, "integration"))).filter(f => f.endsWith(".test.mjs")).length; } catch {}
1671
+ try { e2eFiles = (await fs.promises.readdir(path.join(testFileDir, "e2e"))).filter(f => f.endsWith(".test.mjs")).length; } catch {}
1672
+ try { playwrightFiles = (await fs.promises.readdir(testsE2eDir)).filter(f => f.endsWith(".spec.js")).length; } catch {}
1673
+ try { rootFiles = (await fs.promises.readdir(testFileDir)).filter(f => f.match(/\.test\./)).length; } catch {}
1674
+ // crew-cli: tests/unit/*.test.js + tests/*.test.js + test/*.test.ts
1675
+ try {
1676
+ const unitDir = path.join(crewCliTestDir, "unit");
1677
+ crewCliFiles += (await fs.promises.readdir(unitDir)).filter(f => f.match(/\.test\./)).length;
1678
+ } catch {}
1679
+ try { crewCliFiles += (await fs.promises.readdir(crewCliTestDir)).filter(f => f.match(/\.test\./)).length; } catch {}
1680
+ try { crewCliFiles += (await fs.promises.readdir(crewCliTestDir2)).filter(f => f.match(/\.test\./)).length; } catch {}
1681
+ // Remove "unknown" suite if it has no data
1682
+ if (suites.unknown && (!suites.unknown.total || suites.unknown.total === 0)) delete suites.unknown;
1683
+ let latest = null;
1684
+ try { if (latestOverall) latest = await readRunData(latestOverall); } catch {}
1685
+ // Count actual test()/it() calls in source files (ground truth)
1686
+ async function countTestCalls(dir, pattern) {
1687
+ let count = 0;
1688
+ try {
1689
+ const files = (await fs.promises.readdir(dir)).filter(f => f.match(pattern));
1690
+ for (const f of files) {
1691
+ const src = await fs.promises.readFile(path.join(dir, f), "utf8");
1692
+ const matches = src.match(/^\s*(test|it)\s*\(/gm);
1693
+ if (matches) count += matches.length;
1694
+ }
1695
+ } catch {}
1696
+ return count;
1697
+ }
1698
+ async function countTestCallsRecursive(dir) {
1699
+ let count = 0;
1700
+ try {
1701
+ for (const ent of await fs.promises.readdir(dir, { withFileTypes: true })) {
1702
+ if (ent.isDirectory()) { count += countTestCallsRecursive(path.join(dir, ent.name)); continue; }
1703
+ if (!ent.name.match(/\.test\./)) continue;
1704
+ const src = await fs.promises.readFile(path.join(dir, ent.name), "utf8");
1705
+ const matches = src.match(/^\s*(test|it)\s*\(/gm);
1706
+ if (matches) count += matches.length;
1707
+ }
1708
+ } catch {}
1709
+ return count;
1710
+ }
1711
+ const testCounts = {
1712
+ unit: countTestCalls(path.join(testFileDir, "unit"), /\.test\.mjs$/),
1713
+ integration: countTestCalls(path.join(testFileDir, "integration"), /\.test\.mjs$/),
1714
+ e2e: countTestCalls(path.join(testFileDir, "e2e"), /\.test\.mjs$/),
1715
+ playwright: countTestCalls(testsE2eDir, /\.spec\.js$/),
1716
+ "crew-cli": countTestCallsRecursive(crewCliTestDir) + countTestCallsRecursive(crewCliTestDir2),
1717
+ root: countTestCalls(testFileDir, /\.test\./),
1718
+ };
1719
+ res.writeHead(200, { "content-type": "application/json" });
1720
+ res.end(JSON.stringify({
1721
+ latest,
1722
+ suites,
1723
+ fileCounts: { unit: unitFiles, integration: intFiles, e2e: e2eFiles, playwright: playwrightFiles, "crew-cli": crewCliFiles, root: rootFiles, total: unitFiles + intFiles + e2eFiles + playwrightFiles + crewCliFiles + rootFiles },
1724
+ testCounts,
1725
+ }));
1726
+ } catch (e) {
1727
+ res.writeHead(200, { "content-type": "application/json" });
1728
+ res.end(JSON.stringify({ latest: null, suites: {}, fileCounts: {}, error: e.message }));
1729
+ }
1730
+ return;
1731
+ }
1732
+ if (url.pathname === "/api/tests/history" && req.method === "GET") {
1733
+ const resultsDir = path.join(CREWSWARM_DIR, "test-results");
1734
+ const runsDir = path.join(resultsDir, "runs");
1735
+ try {
1736
+ const dirs = (await fs.promises.readdir(runsDir)).filter(d => /^\d{4}-/.test(d)).sort().reverse().slice(0, 40);
1737
+ const history = [];
1738
+ for (const d of dirs) {
1739
+ const runDir = path.join(runsDir, d);
1740
+ try {
1741
+ let entry = null;
1742
+ // Prefer summary.json (has all data)
1743
+ const summaryFile = path.join(runDir, "summary.json");
1744
+ if (await exists(summaryFile)) {
1745
+ const s = JSON.parse(await fs.promises.readFile(summaryFile, "utf8"));
1746
+ entry = { runId: d, timestamp: s.timestamp, status: s.status || (s.failed > 0 ? "failed" : "passed"), passed: s.passed || 0, failed: s.failed || 0, skipped: s.skipped || 0, total: s.total || 0, duration_ms: s.duration_ms || 0 };
1747
+ } else {
1748
+ const runMeta = JSON.parse(await fs.promises.readFile(path.join(runDir, "run.json"), "utf8"));
1749
+ const ents = await fs.promises.readdir(runDir);
1750
+ const testDirs = ents.filter(e => !e.endsWith(".json") && !e.startsWith("."));
1751
+ let failed = 0;
1752
+ for (const td of testDirs) { if (await exists(path.join(runDir, td, "failure.json"))) failed++; }
1753
+ entry = { runId: d, timestamp: runMeta.timestamp, status: failed > 0 ? "failed" : "passed", passed: testDirs.length - failed, failed, skipped: 0, total: testDirs.length, duration_ms: 0 };
1754
+ }
1755
+ // Detect suite from test_command
1756
+ try {
1757
+ const r = JSON.parse(await fs.promises.readFile(path.join(runDir, "run.json"), "utf8"));
1758
+ const cmd = r.test_command || "";
1759
+ if (cmd.includes("test/e2e/")) entry.suite = "e2e";
1760
+ else if (cmd.includes("test/integration/")) entry.suite = "integration";
1761
+ else if (cmd.includes("test/unit/") && !cmd.includes("test/integration/")) entry.suite = "unit";
1762
+ else { const fc = (cmd.match(/\.test\.mjs/g) || []).length; entry.suite = fc > 100 ? "all" : fc > 15 ? "unit" : "unknown"; }
1763
+ } catch { entry.suite = "unknown"; }
1764
+ history.push(entry);
1765
+ } catch { /* skip */ }
1766
+ }
1767
+ res.writeHead(200, { "content-type": "application/json" });
1768
+ res.end(JSON.stringify({ history }));
1769
+ } catch (e) {
1770
+ res.writeHead(200, { "content-type": "application/json" });
1771
+ res.end(JSON.stringify({ history: [], error: e.message }));
1772
+ }
1773
+ return;
1774
+ }
1775
+ if (url.pathname === "/api/tests/run-detail" && req.method === "GET") {
1776
+ const resultsDir = path.join(CREWSWARM_DIR, "test-results");
1777
+ const runsDir = path.join(resultsDir, "runs");
1778
+ const runId = url.searchParams.get("runId");
1779
+ if (!runId) { res.writeHead(400, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "Missing runId" })); return; }
1780
+ try {
1781
+ const runDir = path.join(runsDir, runId);
1782
+ // Get timestamp from run.json
1783
+ let timestamp = null;
1784
+ try { const r = JSON.parse(await fs.promises.readFile(path.join(runDir, "run.json"), "utf8")); timestamp = r.timestamp; } catch {}
1785
+ // Try summary.json for totals
1786
+ let passed = 0, failed = 0, skipped = 0, total = 0, duration_ms = 0;
1787
+ const summaryFile = path.join(runDir, "summary.json");
1788
+ if (await exists(summaryFile)) {
1789
+ const s = JSON.parse(await fs.promises.readFile(summaryFile, "utf8"));
1790
+ passed = s.passed || 0; failed = s.failed || 0; skipped = s.skipped || 0; total = s.total || 0; duration_ms = s.duration_ms || 0;
1791
+ if (!timestamp) timestamp = s.timestamp;
1792
+ }
1793
+ // If no summary, count from test artifact dirs
1794
+ if (total === 0) {
1795
+ const ents = await fs.promises.readdir(runDir);
1796
+ const testDirs = ents.filter(e => !e.endsWith(".json") && !e.startsWith("."));
1797
+ for (const td of testDirs) {
1798
+ total++;
1799
+ if (await exists(path.join(runDir, td, "failure.json"))) failed++;
1800
+ else passed++;
1801
+ }
1802
+ }
1803
+ // Read failures.json and skips.json
1804
+ let failures = [], skips = [];
1805
+ try { failures = JSON.parse(await fs.promises.readFile(path.join(runDir, "failures.json"), "utf8")); } catch {}
1806
+ try { skips = JSON.parse(await fs.promises.readFile(path.join(runDir, "skips.json"), "utf8")); } catch {}
1807
+ res.writeHead(200, { "content-type": "application/json" });
1808
+ res.end(JSON.stringify({
1809
+ runId, timestamp, passed, failed, skipped, total, duration_ms,
1810
+ failures: failures.map(f => {
1811
+ let err = f.error;
1812
+ if (typeof err === "object" && err !== null) err = err.message || err.failureType || JSON.stringify(err).slice(0, 300);
1813
+ return {
1814
+ name: f.name || f.testId || "", file: (f.file_fingerprint?.relative_file || f.file || "").replace(CREWSWARM_DIR + "/", ""),
1815
+ error: String(err || ""),
1816
+ rerun_command: f.rerun_command || f.selector?.command || "",
1817
+ };
1818
+ }),
1819
+ skips: skips.map(s => ({
1820
+ name: s.name || s.testId || "", file: (s.file_fingerprint?.relative_file || s.file || "").replace(CREWSWARM_DIR + "/", ""),
1821
+ })),
1822
+ }));
1823
+ } catch (e) {
1824
+ res.writeHead(200, { "content-type": "application/json" });
1825
+ res.end(JSON.stringify({ error: e.message }));
1826
+ }
1827
+ return;
1828
+ }
1829
+ if (url.pathname === "/api/tests/run" && req.method === "POST") {
1830
+ let body = "";
1831
+ req.on("data", (c) => (body += c));
1832
+ req.on("end", async () => {
1833
+ let suite = "test:unit";
1834
+ try { const parsed = JSON.parse(body); suite = parsed.suite || suite; } catch { /* default */ }
1835
+ const allowed = ["test:unit", "test:integration", "test:e2e", "test:all", "test", "test:e2e:vibe"];
1836
+ if (!allowed.includes(suite)) {
1837
+ res.writeHead(400, { "content-type": "application/json" });
1838
+ res.end(JSON.stringify({ error: "Invalid suite: " + suite }));
1839
+ return;
1840
+ }
1841
+ const { spawn } = await import("node:child_process");
1842
+ const progressFile = path.join(CREWSWARM_DIR, "test-results", ".test-progress.json");
1843
+ const outputFile = path.join(CREWSWARM_DIR, "test-results", ".test-output.log");
1844
+ // Write initial progress
1845
+ await fs.promises.writeFile(progressFile, JSON.stringify({ suite, running: true, pid: 0, started: Date.now(), passed: 0, failed: 0, skipped: 0, files_done: 0, current_file: "" }));
1846
+ const outFd = fs.openSync(outputFile, "w");
1847
+ const child = spawn("npm", ["run", suite], { cwd: CREWSWARM_DIR, stdio: ["ignore", outFd, outFd], detached: true });
1848
+ child.unref();
1849
+ fs.closeSync(outFd);
1850
+ // Update progress by tailing the output file
1851
+ const progressInterval = setInterval(() => {
1852
+ try {
1853
+ const out = fs.readFileSync(outputFile, "utf8");
1854
+ const lines = out.split("\n");
1855
+ let passed = 0, failed = 0, skipped = 0, files_done = 0, current_file = "";
1856
+ for (const line of lines) {
1857
+ if (line.startsWith(" ✓ ")) passed++;
1858
+ else if (line.startsWith(" ✗ ")) failed++;
1859
+ else if (line.includes("(skipped)")) skipped++;
1860
+ else if (line.endsWith(".test.mjs") || line.endsWith(".test.ts") || line.endsWith(".test.js")) {
1861
+ files_done++;
1862
+ current_file = line.trim();
1863
+ }
1864
+ }
1865
+ fs.writeFileSync(progressFile, JSON.stringify({ suite, running: true, pid: child.pid, started: JSON.parse(fs.readFileSync(progressFile, "utf8")).started, passed, failed, skipped, files_done, current_file }));
1866
+ } catch { /* file may not exist yet */ }
1867
+ }, 2000);
1868
+ child.on("exit", (code) => {
1869
+ clearInterval(progressInterval);
1870
+ try {
1871
+ const out = fs.readFileSync(outputFile, "utf8");
1872
+ const lines = out.split("\n");
1873
+ let passed = 0, failed = 0, skipped = 0, files_done = 0;
1874
+ for (const line of lines) {
1875
+ if (line.startsWith(" ✓ ")) passed++;
1876
+ else if (line.startsWith(" ✗ ")) failed++;
1877
+ else if (line.includes("(skipped)")) skipped++;
1878
+ else if (line.endsWith(".test.mjs") || line.endsWith(".test.ts") || line.endsWith(".test.js")) files_done++;
1879
+ }
1880
+ fs.writeFileSync(progressFile, JSON.stringify({ suite, running: false, pid: child.pid, started: JSON.parse(fs.readFileSync(progressFile, "utf8")).started, finished: Date.now(), exitCode: code, passed, failed, skipped, files_done, current_file: "" }));
1881
+ } catch {}
1882
+ });
1883
+ res.writeHead(200, { "content-type": "application/json" });
1884
+ res.end(JSON.stringify({ started: true, suite, pid: child.pid }));
1885
+ });
1886
+ return;
1887
+ }
1888
+ if (url.pathname === "/api/tests/progress" && req.method === "GET") {
1889
+ const progressFile = path.join(CREWSWARM_DIR, "test-results", ".test-progress.json");
1890
+ try {
1891
+ const data = JSON.parse(await fs.promises.readFile(progressFile, "utf8"));
1892
+ res.writeHead(200, { "content-type": "application/json" });
1893
+ res.end(JSON.stringify(data));
1894
+ } catch {
1895
+ res.writeHead(200, { "content-type": "application/json" });
1896
+ res.end(JSON.stringify({ running: false }));
1897
+ }
1898
+ return;
1899
+ }
1900
+
1201
1901
  // ── First-run detection ──────────────────────────────────────────────────
1202
1902
  if (url.pathname === "/api/first-run-status" && req.method === "GET") {
1203
1903
  const cfg = readSwarmConfigSafe();
@@ -1221,16 +1921,11 @@ const server = http.createServer(async (req, res) => {
1221
1921
 
1222
1922
  const hasApiKeys = configuredProviders.length > 0;
1223
1923
 
1224
- // Check crew-lead health
1924
+ // Check crew-lead health (uses http.request, not fetch — avoids undici pool saturation from SSE)
1225
1925
  let crewLeadUp = false;
1226
1926
  try {
1227
- const controller = new AbortController();
1228
- const timer = setTimeout(() => controller.abort(), 2000);
1229
- const resp = await fetch("http://127.0.0.1:5010/health", {
1230
- signal: controller.signal,
1231
- });
1232
- clearTimeout(timer);
1233
- crewLeadUp = resp.ok;
1927
+ const { ok } = await crewLeadRequest("/health", { timeout: 2000 });
1928
+ crewLeadUp = ok;
1234
1929
  } catch {
1235
1930
  crewLeadUp = false;
1236
1931
  }
@@ -1280,11 +1975,11 @@ const server = http.createServer(async (req, res) => {
1280
1975
  const engine = String(url.searchParams.get("engine") || "opencode").trim();
1281
1976
  const limit = Math.min(Number(url.searchParams.get("limit") || "20"), 50);
1282
1977
  const projectId = String(url.searchParams.get("projectId") || "").trim();
1283
- const projectDir = (() => {
1978
+ const projectDir = await (async () => {
1284
1979
  if (!projectId || projectId === "general") return "";
1285
1980
  try {
1286
1981
  const registryFile = path.join(CFG_DIR, "projects.json");
1287
- const projects = JSON.parse(fs.readFileSync(registryFile, "utf8"));
1982
+ const projects = JSON.parse(await fs.promises.readFile(registryFile, "utf8"));
1288
1983
  return String(projects?.[projectId]?.outputDir || "").trim();
1289
1984
  } catch {
1290
1985
  return "";
@@ -1333,19 +2028,18 @@ const server = http.createServer(async (req, res) => {
1333
2028
  const limit = Math.min(Number(url.searchParams.get("limit") || "20"), 50);
1334
2029
  const sessionsBase = path.join(os.homedir(), ".codex", "sessions");
1335
2030
  const sessions = [];
1336
- if (fs.existsSync(sessionsBase)) {
1337
- const years = fs.readdirSync(sessionsBase).filter(d => /^\d{4}$/.test(d));
2031
+ if (await exists(sessionsBase)) {
2032
+ const years = (await fs.promises.readdir(sessionsBase)).filter(d => /^\d{4}$/.test(d));
1338
2033
  for (const year of years.sort().reverse()) {
1339
2034
  const yearDir = path.join(sessionsBase, year);
1340
- const months = fs.readdirSync(yearDir).filter(d => /^\d{2}$/.test(d));
2035
+ const months = (await fs.promises.readdir(yearDir)).filter(d => /^\d{2}$/.test(d));
1341
2036
  for (const month of months.sort().reverse()) {
1342
2037
  const monthDir = path.join(yearDir, month);
1343
- const days = fs.readdirSync(monthDir).filter(d => /^\d{2}$/.test(d));
2038
+ const days = (await fs.promises.readdir(monthDir)).filter(d => /^\d{2}$/.test(d));
1344
2039
  for (const day of days.sort().reverse()) {
1345
2040
  const dayDir = path.join(yearDir, month, day);
1346
- const files = fs.readdirSync(dayDir)
1347
- .filter(f => f.endsWith(".jsonl"))
1348
- .map(f => ({ f, mt: fs.statSync(path.join(dayDir, f)).mtimeMs }))
2041
+ const rawFiles = (await fs.promises.readdir(dayDir)).filter(f => f.endsWith(".jsonl"));
2042
+ const files = (await Promise.all(rawFiles.map(async f => ({ f, mt: (await fs.promises.stat(path.join(dayDir, f))).mtimeMs }))))
1349
2043
  .sort((a, b) => b.mt - a.mt);
1350
2044
  for (const { f } of files) {
1351
2045
  if (sessions.length >= limit) break;
@@ -1353,7 +2047,7 @@ const server = http.createServer(async (req, res) => {
1353
2047
  const filePath = path.join(dayDir, f);
1354
2048
 
1355
2049
  // Skip files larger than 10MB to avoid memory issues
1356
- const stats = fs.statSync(filePath);
2050
+ const stats = await fs.promises.stat(filePath);
1357
2051
  if (stats.size > 10 * 1024 * 1024) {
1358
2052
  sessions.push({
1359
2053
  id: sessionId,
@@ -1365,7 +2059,7 @@ const server = http.createServer(async (req, res) => {
1365
2059
  }
1366
2060
 
1367
2061
  const messages = [];
1368
- const allLines = fs.readFileSync(filePath, "utf8").trim().split("\n");
2062
+ const allLines = (await fs.promises.readFile(filePath, "utf8")).trim().split("\n");
1369
2063
  const lines = allLines.slice(-100); // Last 100 lines only
1370
2064
  let firstUserMsg = "";
1371
2065
  for (const line of lines) {
@@ -1405,22 +2099,21 @@ const server = http.createServer(async (req, res) => {
1405
2099
  const qDir = url.searchParams.get("dir") || process.cwd();
1406
2100
  const dirKey = qDir.replace(/\//g, "-");
1407
2101
  const projectsBase = path.join(os.homedir(), ".claude", "projects");
1408
- const candidates = fs.existsSync(projectsBase)
1409
- ? fs.readdirSync(projectsBase).filter(d => d === dirKey || d.endsWith(dirKey.split("-").slice(-2).join("-")))
2102
+ const candidates = await exists(projectsBase)
2103
+ ? (await fs.promises.readdir(projectsBase)).filter(d => d === dirKey || d.endsWith(dirKey.split("-").slice(-2).join("-")))
1410
2104
  : [];
1411
2105
  const sessions = [];
1412
2106
  for (const cand of candidates) {
1413
2107
  const sessDir = path.join(projectsBase, cand);
1414
- const files = fs.readdirSync(sessDir)
1415
- .filter(f => f.endsWith(".jsonl"))
1416
- .map(f => ({ f, mt: fs.statSync(path.join(sessDir, f)).mtimeMs }))
2108
+ const rawSessFiles = (await fs.promises.readdir(sessDir)).filter(f => f.endsWith(".jsonl"));
2109
+ const files = (await Promise.all(rawSessFiles.map(async f => ({ f, mt: (await fs.promises.stat(path.join(sessDir, f))).mtimeMs }))))
1417
2110
  .sort((a, b) => b.mt - a.mt)
1418
2111
  .slice(0, limit)
1419
2112
  .map(x => x.f);
1420
2113
  for (const file of files) {
1421
2114
  const sessionId = file.replace(".jsonl", "");
1422
2115
  const messages = [];
1423
- const lines = fs.readFileSync(path.join(sessDir, file), "utf8").trim().split("\n");
2116
+ const lines = (await fs.promises.readFile(path.join(sessDir, file), "utf8")).trim().split("\n");
1424
2117
  for (const line of lines) {
1425
2118
  try {
1426
2119
  const d = JSON.parse(line);
@@ -1444,13 +2137,13 @@ const server = http.createServer(async (req, res) => {
1444
2137
  const limit = Math.min(Number(url.searchParams.get("limit") || "20"), 50);
1445
2138
  const historyBase = path.join(os.homedir(), ".gemini", "history");
1446
2139
  const sessions = [];
1447
- if (fs.existsSync(historyBase)) {
1448
- const projects = fs.readdirSync(historyBase);
2140
+ if (await exists(historyBase)) {
2141
+ const projects = await fs.promises.readdir(historyBase);
1449
2142
  for (const proj of projects) {
1450
2143
  const sessionFile = path.join(historyBase, proj, "session.jsonl");
1451
- if (fs.existsSync(sessionFile)) {
2144
+ if (await exists(sessionFile)) {
1452
2145
  const messages = [];
1453
- const lines = fs.readFileSync(sessionFile, "utf8").trim().split("\n");
2146
+ const lines = (await fs.promises.readFile(sessionFile, "utf8")).trim().split("\n");
1454
2147
  let firstUserMsg = "";
1455
2148
  for (const line of lines) {
1456
2149
  try {
@@ -1464,7 +2157,7 @@ const server = http.createServer(async (req, res) => {
1464
2157
  } catch { }
1465
2158
  }
1466
2159
  if (messages.length) {
1467
- const stat = fs.statSync(sessionFile);
2160
+ const stat = await fs.promises.stat(sessionFile);
1468
2161
  sessions.push({
1469
2162
  id: proj,
1470
2163
  title: firstUserMsg || proj,
@@ -1486,33 +2179,32 @@ const server = http.createServer(async (req, res) => {
1486
2179
  const limit = Math.min(Number(url.searchParams.get("limit") || "20"), 50);
1487
2180
  const sessionsBase = path.join(process.cwd(), ".crew", "sessions");
1488
2181
  const sessions = [];
1489
- if (fs.existsSync(sessionsBase)) {
1490
- const engines = fs.readdirSync(sessionsBase);
2182
+ if (await exists(sessionsBase)) {
2183
+ const engines = await fs.promises.readdir(sessionsBase);
1491
2184
  for (const engine of engines) {
1492
2185
  const engineDir = path.join(sessionsBase, engine);
1493
- const projects = fs.readdirSync(engineDir);
2186
+ const projects = await fs.promises.readdir(engineDir);
1494
2187
  for (const project of projects) {
1495
2188
  const projectSessionsDir = path.join(engineDir, project, "sessions");
1496
- if (!fs.existsSync(projectSessionsDir)) continue;
1497
- const years = fs.readdirSync(projectSessionsDir).filter(d => /^\d{4}$/.test(d));
2189
+ if (!await exists(projectSessionsDir)) continue;
2190
+ const years = (await fs.promises.readdir(projectSessionsDir)).filter(d => /^\d{4}$/.test(d));
1498
2191
  for (const year of years.sort().reverse()) {
1499
2192
  const yearDir = path.join(projectSessionsDir, year);
1500
- const months = fs.readdirSync(yearDir).filter(d => /^\d{2}$/.test(d));
2193
+ const months = (await fs.promises.readdir(yearDir)).filter(d => /^\d{2}$/.test(d));
1501
2194
  for (const month of months.sort().reverse()) {
1502
2195
  const monthDir = path.join(yearDir, month);
1503
- const days = fs.readdirSync(monthDir).filter(d => /^\d{2}$/.test(d));
2196
+ const days = (await fs.promises.readdir(monthDir)).filter(d => /^\d{2}$/.test(d));
1504
2197
  for (const day of days.sort().reverse()) {
1505
2198
  const dayDir = path.join(yearDir, month, day);
1506
- const files = fs.readdirSync(dayDir)
1507
- .filter(f => f.endsWith(".jsonl"))
1508
- .map(f => ({ f, mt: fs.statSync(path.join(dayDir, f)).mtimeMs }))
2199
+ const rawDayFiles = (await fs.promises.readdir(dayDir)).filter(f => f.endsWith(".jsonl"));
2200
+ const files = (await Promise.all(rawDayFiles.map(async f => ({ f, mt: (await fs.promises.stat(path.join(dayDir, f))).mtimeMs }))))
1509
2201
  .sort((a, b) => b.mt - a.mt);
1510
2202
  for (const { f } of files) {
1511
2203
  if (sessions.length >= limit) break;
1512
2204
  const sessionId = f.replace(".jsonl", "");
1513
2205
  const messages = [];
1514
2206
  // Limit to last 100 lines to avoid huge session files
1515
- const allLines = fs.readFileSync(path.join(dayDir, f), "utf8").trim().split("\n");
2207
+ const allLines = (await fs.promises.readFile(path.join(dayDir, f), "utf8")).trim().split("\n");
1516
2208
  const lines = allLines.slice(-100);
1517
2209
  let firstUserMsg = "";
1518
2210
  for (const line of lines) {
@@ -1588,8 +2280,8 @@ const server = http.createServer(async (req, res) => {
1588
2280
  "passthrough-sessions.json",
1589
2281
  );
1590
2282
  let sessions = {};
1591
- if (fs.existsSync(sessionFile)) {
1592
- sessions = JSON.parse(fs.readFileSync(sessionFile, "utf8"));
2283
+ if (await exists(sessionFile)) {
2284
+ sessions = JSON.parse(await fs.promises.readFile(sessionFile, "utf8"));
1593
2285
  }
1594
2286
  res.writeHead(200, { "content-type": "application/json" });
1595
2287
  res.end(JSON.stringify({ sessions }));
@@ -1613,11 +2305,11 @@ const server = http.createServer(async (req, res) => {
1613
2305
  "passthrough-sessions.json",
1614
2306
  );
1615
2307
  let sessions = {};
1616
- if (fs.existsSync(sessionFile)) {
1617
- sessions = JSON.parse(fs.readFileSync(sessionFile, "utf8"));
2308
+ if (await exists(sessionFile)) {
2309
+ sessions = JSON.parse(await fs.promises.readFile(sessionFile, "utf8"));
1618
2310
  }
1619
2311
  delete sessions[key];
1620
- fs.writeFileSync(
2312
+ await fs.promises.writeFile(
1621
2313
  sessionFile,
1622
2314
  JSON.stringify(sessions, null, 2),
1623
2315
  "utf8",
@@ -1641,7 +2333,7 @@ const server = http.createServer(async (req, res) => {
1641
2333
  );
1642
2334
  let usage = { calls: 0, prompt: 0, completion: 0, byModel: {} };
1643
2335
  try {
1644
- usage = JSON.parse(fs.readFileSync(usageFile, "utf8"));
2336
+ usage = JSON.parse(await fs.promises.readFile(usageFile, "utf8"));
1645
2337
  } catch { }
1646
2338
  res.writeHead(200, { "content-type": "application/json" });
1647
2339
  res.end(JSON.stringify(usage));
@@ -1738,13 +2430,12 @@ const server = http.createServer(async (req, res) => {
1738
2430
  const histDir = path.join(os.homedir(), ".crewswarm", "chat-history");
1739
2431
  const sessions = [];
1740
2432
  try {
1741
- const files = fs
1742
- .readdirSync(histDir)
2433
+ const files = (await fs.promises.readdir(histDir))
1743
2434
  .filter((f) => f.startsWith("telegram-") && f.endsWith(".jsonl"));
1744
2435
  for (const file of files) {
1745
2436
  const chatId = file.replace(/^telegram-/, "").replace(/\.jsonl$/, "");
1746
- const lines = fs
1747
- .readFileSync(path.join(histDir, file), "utf8")
2437
+ const lines = (await fs.promises
2438
+ .readFile(path.join(histDir, file), "utf8"))
1748
2439
  .split("\n")
1749
2440
  .filter(Boolean);
1750
2441
  const msgs = lines
@@ -1770,7 +2461,7 @@ const server = http.createServer(async (req, res) => {
1770
2461
  return;
1771
2462
  }
1772
2463
 
1773
- if ((url.pathname === "/health" || url.pathname === "/api/health") && req.method === "GET") {
2464
+ if (url.pathname === "/health" && req.method === "GET") {
1774
2465
  res.writeHead(200, { "content-type": "application/json" });
1775
2466
  res.end(JSON.stringify({ ok: true, uptime: Math.round(process.uptime()) }));
1776
2467
  return;
@@ -1849,7 +2540,7 @@ const server = http.createServer(async (req, res) => {
1849
2540
  // Credential keys are never exposed here
1850
2541
  let cfgEnv = {};
1851
2542
  try {
1852
- cfgEnv = JSON.parse(fs.readFileSync(CFG_FILE, "utf8")).env || {};
2543
+ cfgEnv = JSON.parse(await fs.promises.readFile(CFG_FILE, "utf8")).env || {};
1853
2544
  } catch { }
1854
2545
  const result = {};
1855
2546
  for (const v of vars) {
@@ -1875,9 +2566,9 @@ const server = http.createServer(async (req, res) => {
1875
2566
  }
1876
2567
  const cfgPath = path.join(os.homedir(), ".crewswarm", "crewswarm.json");
1877
2568
  try {
1878
- const raw = (() => {
2569
+ const raw = await (async () => {
1879
2570
  try {
1880
- return fs.readFileSync(cfgPath, "utf8");
2571
+ return await fs.promises.readFile(cfgPath, "utf8");
1881
2572
  } catch {
1882
2573
  return "{}";
1883
2574
  }
@@ -1908,7 +2599,7 @@ const server = http.createServer(async (req, res) => {
1908
2599
  return;
1909
2600
  }
1910
2601
 
1911
- // ── Auth Token (for Studio) ───────────────────────────────────────────────────
2602
+ // ── Auth Token (for Vibe) ─────────────────────────────────────────────────────
1912
2603
  if (url.pathname === "/api/auth/token") {
1913
2604
  res.writeHead(200, {
1914
2605
  "content-type": "application/json",
@@ -2070,7 +2761,7 @@ const server = http.createServer(async (req, res) => {
2070
2761
 
2071
2762
  try {
2072
2763
  // Load agent config
2073
- const csSwarm = JSON.parse(fs.readFileSync(CFG_FILE, "utf8"));
2764
+ const csSwarm = JSON.parse(await fs.promises.readFile(CFG_FILE, "utf8"));
2074
2765
  const agentCfg = csSwarm.agents?.find((a) => a.id === agentId);
2075
2766
  if (!agentCfg?.model) {
2076
2767
  res.writeHead(404, { "content-type": "application/json" });
@@ -2103,7 +2794,7 @@ const server = http.createServer(async (req, res) => {
2103
2794
  let systemPrompt = `You are ${agentId}.`;
2104
2795
  try {
2105
2796
  const agentPrompts = JSON.parse(
2106
- fs.readFileSync(agentPromptsPath, "utf8"),
2797
+ await fs.promises.readFile(agentPromptsPath, "utf8"),
2107
2798
  );
2108
2799
  const bareId = agentId.replace(/^crew-/, "");
2109
2800
  systemPrompt =
@@ -2295,14 +2986,14 @@ const server = http.createServer(async (req, res) => {
2295
2986
  );
2296
2987
 
2297
2988
  try {
2298
- if (!fs.existsSync(historyPath)) {
2989
+ if (!await exists(historyPath)) {
2299
2990
  res.writeHead(200, { "content-type": "application/json" });
2300
2991
  res.end(JSON.stringify({ history: [] }));
2301
2992
  return;
2302
2993
  }
2303
2994
 
2304
- const lines = fs
2305
- .readFileSync(historyPath, "utf8")
2995
+ const lines = (await fs.promises
2996
+ .readFile(historyPath, "utf8"))
2306
2997
  .split("\n")
2307
2998
  .filter(Boolean);
2308
2999
  const history = lines.map((line) => JSON.parse(line));
@@ -2335,24 +3026,76 @@ const server = http.createServer(async (req, res) => {
2335
3026
  if (url.pathname === "/api/enhance-prompt" && req.method === "POST") {
2336
3027
  let body = "";
2337
3028
  for await (const chunk of req) body += chunk;
2338
- const { text } = JSON.parse(body || "{}");
2339
- if (!text || typeof text !== "string") {
3029
+ let parsed;
3030
+ try { parsed = JSON.parse(body || "{}"); } catch {
2340
3031
  res.writeHead(400, { "content-type": "application/json" });
2341
- res.end(JSON.stringify({ error: "missing text" }));
3032
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
2342
3033
  return;
2343
3034
  }
3035
+ const vr = validate(EnhancePromptSchema, parsed);
3036
+ if (!vr.ok) {
3037
+ res.writeHead(400, { "content-type": "application/json" });
3038
+ res.end(JSON.stringify({ ok: false, error: vr.error }));
3039
+ return;
3040
+ }
3041
+ const { text, projectId, engine: requestedEngine, model: requestedModel } = vr.data;
2344
3042
  try {
2345
- const enhanced = await enhancePromptWithGroq(text);
3043
+ // Default to cwd for planner context — engines need repo access to produce aware briefs.
3044
+ // Claude/Cursor/Codex use --add-dir for safe read access; crew-cli uses --project.
3045
+ // Fall back to temp dir only if no project context is available.
3046
+ let projectDir = process.cwd();
3047
+ if (projectId) {
3048
+ const regPath = path.join(CFG_DIR, "projects.json");
3049
+ if (await exists(regPath)) {
3050
+ const reg = JSON.parse(await fs.promises.readFile(regPath, "utf8") || "{}");
3051
+ const proj = reg[projectId];
3052
+ if (proj?.outputDir) projectDir = proj.outputDir;
3053
+ }
3054
+ }
3055
+ await fs.promises.mkdir(projectDir, { recursive: true });
3056
+
3057
+ const planner = resolvePlannerEngine(requestedEngine, requestedModel);
3058
+ if (!planner) throw new Error("No planning engine is configured or installed");
3059
+
3060
+ const planned = await collectPassthroughText({
3061
+ engine: planner.engine,
3062
+ message: buildRequirementPlanningPrompt(text, projectDir),
3063
+ projectDir,
3064
+ model: planner.model,
3065
+ permissionMode: planner.permissionMode,
3066
+ sandbox: planner.sandbox,
3067
+ forceL2: planner.engine === "crew-cli",
3068
+ });
3069
+
2346
3070
  res.writeHead(200, { "content-type": "application/json" });
2347
- res.end(JSON.stringify({ enhanced }));
3071
+ res.end(JSON.stringify({
3072
+ enhanced: planned,
3073
+ engine: planner.engine,
3074
+ model: planner.model,
3075
+ mode: planner.permissionMode || planner.sandbox || "prompt",
3076
+ source: planner.source,
3077
+ }));
2348
3078
  } catch (err) {
2349
- res.writeHead(200, { "content-type": "application/json" });
2350
- res.end(
2351
- JSON.stringify({
2352
- error: err?.message || String(err),
2353
- enhanced: null,
2354
- }),
2355
- );
3079
+ try {
3080
+ const enhanced = await enhancePromptWithGroq(text);
3081
+ res.writeHead(200, { "content-type": "application/json" });
3082
+ res.end(JSON.stringify({
3083
+ enhanced,
3084
+ engine: "groq",
3085
+ model: "llama-3.3-70b-versatile",
3086
+ mode: "fallback-rewrite",
3087
+ source: "fallback",
3088
+ warning: err?.message || String(err),
3089
+ }));
3090
+ } catch (fallbackErr) {
3091
+ res.writeHead(200, { "content-type": "application/json" });
3092
+ res.end(
3093
+ JSON.stringify({
3094
+ error: fallbackErr?.message || err?.message || String(fallbackErr || err),
3095
+ enhanced: null,
3096
+ }),
3097
+ );
3098
+ }
2356
3099
  }
2357
3100
  return;
2358
3101
  }
@@ -2375,13 +3118,12 @@ const server = http.createServer(async (req, res) => {
2375
3118
  // Resolve project output dir if projectId provided
2376
3119
  let projectEnv = {};
2377
3120
  if (projectId) {
2378
- const { existsSync: ex } = await import("node:fs");
2379
3121
  const { readFile: rf } = await import("node:fs/promises");
2380
3122
  const regPath = path.join(
2381
3123
  CFG_DIR,
2382
3124
  "projects.json",
2383
3125
  );
2384
- if (ex(regPath)) {
3126
+ if (await exists(regPath)) {
2385
3127
  const reg = JSON.parse(await rf(regPath, "utf8").catch(() => "{}"));
2386
3128
  const proj = reg[projectId];
2387
3129
  if (proj) {
@@ -2397,8 +3139,7 @@ const server = http.createServer(async (req, res) => {
2397
3139
  }
2398
3140
  }
2399
3141
  const { spawn } = await import("node:child_process");
2400
- const { existsSync } = await import("node:fs");
2401
- if (!existsSync(phasedOrchestrator))
3142
+ if (!(await exists(phasedOrchestrator)))
2402
3143
  throw new Error(
2403
3144
  "phased-orchestrator.mjs not found at " + phasedOrchestrator,
2404
3145
  );
@@ -2442,10 +3183,10 @@ const server = http.createServer(async (req, res) => {
2442
3183
  projectId ? "phased-" + projectId + ".pid" : "phased-orchestrator.pid",
2443
3184
  );
2444
3185
  try {
2445
- const pidStr = fs.readFileSync(pidFile, "utf8").trim();
3186
+ const pidStr = (await fs.promises.readFile(pidFile, "utf8")).trim();
2446
3187
  const pid = parseInt(pidStr, 10);
2447
3188
  if (pid) process.kill(pid, "SIGTERM");
2448
- fs.unlinkSync(pidFile);
3189
+ await fs.promises.unlink(pidFile);
2449
3190
  res.writeHead(200, { "content-type": "application/json" });
2450
3191
  res.end(JSON.stringify({ ok: true, pid }));
2451
3192
  } catch (e) {
@@ -2457,18 +3198,27 @@ const server = http.createServer(async (req, res) => {
2457
3198
  if (url.pathname === "/api/continuous-build" && req.method === "POST") {
2458
3199
  let body = "";
2459
3200
  for await (const chunk of req) body += chunk;
2460
- const { requirement, projectId } = JSON.parse(body || "{}");
2461
- if (!requirement || typeof requirement !== "string")
2462
- throw new Error("missing requirement");
3201
+ let parsed;
3202
+ try { parsed = JSON.parse(body || "{}"); } catch {
3203
+ res.writeHead(400, { "content-type": "application/json" });
3204
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
3205
+ return;
3206
+ }
3207
+ const vr = validate(ContinuousBuildSchema, parsed);
3208
+ if (!vr.ok) {
3209
+ res.writeHead(400, { "content-type": "application/json" });
3210
+ res.end(JSON.stringify({ ok: false, error: vr.error }));
3211
+ return;
3212
+ }
3213
+ const { requirement, projectId } = vr.data;
2463
3214
  let projectEnv = {};
2464
3215
  if (projectId) {
2465
- const { existsSync: ex } = await import("node:fs");
2466
3216
  const { readFile: rf } = await import("node:fs/promises");
2467
3217
  const regPath = path.join(
2468
3218
  CFG_DIR,
2469
3219
  "projects.json",
2470
3220
  );
2471
- if (ex(regPath)) {
3221
+ if (await exists(regPath)) {
2472
3222
  const reg = JSON.parse(await rf(regPath, "utf8").catch(() => "{}"));
2473
3223
  const proj = reg[projectId];
2474
3224
  if (proj) {
@@ -2484,8 +3234,7 @@ const server = http.createServer(async (req, res) => {
2484
3234
  }
2485
3235
  }
2486
3236
  const { spawn } = await import("node:child_process");
2487
- const { existsSync } = await import("node:fs");
2488
- if (!existsSync(continuousBuild))
3237
+ if (!(await exists(continuousBuild)))
2489
3238
  throw new Error("continuous-build.mjs not found at " + continuousBuild);
2490
3239
  const proc = spawn(PREFERRED_NODE_BIN, [continuousBuild, requirement], {
2491
3240
  cwd: CREWSWARM_DIR,
@@ -2533,10 +3282,10 @@ const server = http.createServer(async (req, res) => {
2533
3282
  projectId ? "continuous-" + projectId + ".pid" : "continuous-build.pid",
2534
3283
  );
2535
3284
  try {
2536
- const pidStr = fs.readFileSync(pidFile, "utf8").trim();
3285
+ const pidStr = (await fs.promises.readFile(pidFile, "utf8")).trim();
2537
3286
  const pid = parseInt(pidStr, 10);
2538
3287
  if (pid) process.kill(pid, "SIGTERM");
2539
- fs.unlinkSync(pidFile);
3288
+ await fs.promises.unlink(pidFile);
2540
3289
  res.writeHead(200, { "content-type": "application/json" });
2541
3290
  res.end(JSON.stringify({ ok: true, pid }));
2542
3291
  } catch (e) {
@@ -2546,7 +3295,6 @@ const server = http.createServer(async (req, res) => {
2546
3295
  return;
2547
3296
  }
2548
3297
  if (url.pathname === "/api/continuous-build/log" && req.method === "GET") {
2549
- const { existsSync } = await import("node:fs");
2550
3298
  const { readFile } = await import("node:fs/promises");
2551
3299
  const logPath = path.join(
2552
3300
  CREWSWARM_DIR,
@@ -2554,7 +3302,7 @@ const server = http.createServer(async (req, res) => {
2554
3302
  "continuous-build.jsonl",
2555
3303
  );
2556
3304
  let lines = [];
2557
- if (existsSync(logPath)) {
3305
+ if (await exists(logPath)) {
2558
3306
  const raw = await readFile(logPath, "utf8").catch(() => "");
2559
3307
  lines = raw
2560
3308
  .trim()
@@ -2651,7 +3399,6 @@ const server = http.createServer(async (req, res) => {
2651
3399
 
2652
3400
  // ── Waves Configuration APIs ──────────────────────────────────────────
2653
3401
  if (url.pathname === "/api/waves/config" && req.method === "GET") {
2654
- const { existsSync, readFileSync } = await import("node:fs");
2655
3402
  const { readFile: rf } = await import("node:fs/promises");
2656
3403
  const wavesConfigPath = path.join(
2657
3404
  CREWSWARM_DIR,
@@ -2660,7 +3407,7 @@ const server = http.createServer(async (req, res) => {
2660
3407
  "waves-config.json",
2661
3408
  );
2662
3409
 
2663
- if (!existsSync(wavesConfigPath)) {
3410
+ if (!(await exists(wavesConfigPath))) {
2664
3411
  res.writeHead(404, { "Content-Type": "application/json" });
2665
3412
  res.end(JSON.stringify({ ok: false, error: "Waves config not found" }));
2666
3413
  return;
@@ -2675,7 +3422,7 @@ const server = http.createServer(async (req, res) => {
2675
3422
  raw = await rf(wavesConfigPath, "utf8");
2676
3423
  } catch {
2677
3424
  try {
2678
- raw = readFileSync(wavesConfigPath, "utf8");
3425
+ raw = await fs.promises.readFile(wavesConfigPath, "utf8");
2679
3426
  } catch {
2680
3427
  raw = JSON.stringify(getEmbeddedWavesConfig());
2681
3428
  }
@@ -2729,8 +3476,7 @@ const server = http.createServer(async (req, res) => {
2729
3476
 
2730
3477
  try {
2731
3478
  // Check if we have a backup
2732
- const { existsSync } = await import("node:fs");
2733
- if (existsSync(wavesConfigBackup)) {
3479
+ if (await exists(wavesConfigBackup)) {
2734
3480
  const defaultConfig = await rf(wavesConfigBackup, "utf8");
2735
3481
  await wf(wavesConfigPath, defaultConfig, "utf8");
2736
3482
  } else {
@@ -2881,7 +3627,7 @@ const server = http.createServer(async (req, res) => {
2881
3627
  if (!isValidWorkflowName(name))
2882
3628
  throw new Error("Invalid workflow name");
2883
3629
  const fp = getWorkflowFile(name);
2884
- if (fs.existsSync(fp)) fs.unlinkSync(fp);
3630
+ if (await exists(fp)) await fs.promises.unlink(fp);
2885
3631
  workflowRuntime.runs.delete(name);
2886
3632
  res.writeHead(200, { "Content-Type": "application/json" });
2887
3633
  res.end(JSON.stringify({ ok: true }));
@@ -2961,11 +3707,10 @@ const server = http.createServer(async (req, res) => {
2961
3707
 
2962
3708
  // ── Project management APIs ───────────────────────────────────────────
2963
3709
  if (url.pathname === "/api/projects" && req.method === "GET") {
2964
- const { existsSync } = await import("node:fs");
2965
3710
  const { readFile: rf } = await import("node:fs/promises");
2966
3711
  const registryFile = path.join(CFG_DIR, "projects.json");
2967
3712
  let projects = {};
2968
- if (existsSync(registryFile)) {
3713
+ if (await exists(registryFile)) {
2969
3714
  projects = JSON.parse(await rf(registryFile, "utf8").catch(() => "{}"));
2970
3715
  }
2971
3716
  // Enrich each project with live roadmap stats and running status.
@@ -2979,7 +3724,7 @@ const server = http.createServer(async (req, res) => {
2979
3724
  failed = 0,
2980
3725
  pending = 0,
2981
3726
  total = 0;
2982
- if (existsSync(project.roadmapFile)) {
3727
+ if (await exists(project.roadmapFile)) {
2983
3728
  const rm = await rf(project.roadmapFile, "utf8").catch(() => "");
2984
3729
  const lines = rm.split("\n").filter((l) => /^- \[/.test(l));
2985
3730
  total = lines.length;
@@ -2989,7 +3734,7 @@ const server = http.createServer(async (req, res) => {
2989
3734
  }
2990
3735
  let running = false;
2991
3736
  const pidPath = path.join(logsDir2, `pm-loop-${id}.pid`);
2992
- if (existsSync(pidPath)) {
3737
+ if (await exists(pidPath)) {
2993
3738
  try {
2994
3739
  const pidStr = await rf(pidPath, "utf8").catch(() => "");
2995
3740
  const pid = parseInt(pidStr.trim(), 10);
@@ -3056,12 +3801,11 @@ const server = http.createServer(async (req, res) => {
3056
3801
  body || "{}",
3057
3802
  );
3058
3803
  if (!name || !outputDir) throw new Error("name and outputDir required");
3059
- const { existsSync, mkdirSync } = await import("node:fs");
3060
3804
  const { readFile: rf, writeFile: wf } = await import("node:fs/promises");
3061
3805
  // Create output dir and ROADMAP.md if they don't exist
3062
- if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
3806
+ if (!(await exists(outputDir))) await fs.promises.mkdir(outputDir, { recursive: true });
3063
3807
  const roadmapFile = path.join(outputDir, "ROADMAP.md");
3064
- if (!existsSync(roadmapFile)) {
3808
+ if (!(await exists(roadmapFile))) {
3065
3809
  await wf(
3066
3810
  roadmapFile,
3067
3811
  `# ${name} — Living Roadmap\n\n> Managed by pm-loop.mjs. Add \`- [ ] items\` here at any time.\n\n---\n\n## Phase 0 — Getting Started\n\n- [ ] Create the initial project structure and entry point\n`,
@@ -3073,7 +3817,7 @@ const server = http.createServer(async (req, res) => {
3073
3817
  .replace(/^-|-$/g, "");
3074
3818
  const registryFile = path.join(CFG_DIR, "projects.json");
3075
3819
  let projects = {};
3076
- if (existsSync(registryFile))
3820
+ if (await exists(registryFile))
3077
3821
  projects = JSON.parse(await rf(registryFile, "utf8").catch(() => "{}"));
3078
3822
  projects[id] = {
3079
3823
  id,
@@ -3097,13 +3841,23 @@ const server = http.createServer(async (req, res) => {
3097
3841
  if (url.pathname === "/api/projects/delete" && req.method === "POST") {
3098
3842
  let body = "";
3099
3843
  for await (const chunk of req) body += chunk;
3100
- const { projectId } = JSON.parse(body || "{}");
3101
- if (!projectId) throw new Error("projectId required");
3844
+ let parsed;
3845
+ try { parsed = JSON.parse(body || "{}"); } catch {
3846
+ res.writeHead(400, { "content-type": "application/json" });
3847
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
3848
+ return;
3849
+ }
3850
+ const vr = validate(DeleteProjectSchema, parsed);
3851
+ if (!vr.ok) {
3852
+ res.writeHead(400, { "content-type": "application/json" });
3853
+ res.end(JSON.stringify({ ok: false, error: vr.error }));
3854
+ return;
3855
+ }
3856
+ const { projectId } = vr.data;
3102
3857
  const registryFile = path.join(CFG_DIR, "projects.json");
3103
- const { existsSync, rmSync, writeFileSync, unlinkSync } = await import("node:fs");
3104
3858
  const { readFile: rf, writeFile: wf } = await import("node:fs/promises");
3105
3859
  let projects = {};
3106
- if (existsSync(registryFile))
3860
+ if (await exists(registryFile))
3107
3861
  projects = JSON.parse(await rf(registryFile, "utf8").catch(() => "{}"));
3108
3862
  if (!projects[projectId])
3109
3863
  throw new Error("Project not found: " + projectId);
@@ -3112,7 +3866,7 @@ const server = http.createServer(async (req, res) => {
3112
3866
  const stopPath = path.join(logsDir, `pm-loop-${projectId}.stop`);
3113
3867
  const logPath = path.join(logsDir, `pm-loop-${projectId}.jsonl`);
3114
3868
  let stoppedPmLoop = false;
3115
- if (existsSync(pidPath)) {
3869
+ if (await exists(pidPath)) {
3116
3870
  const pidStr = await rf(pidPath, "utf8").catch(() => "");
3117
3871
  const pid = parseInt(pidStr.trim(), 10);
3118
3872
  if (pid) {
@@ -3123,18 +3877,18 @@ const server = http.createServer(async (req, res) => {
3123
3877
  }
3124
3878
  }
3125
3879
  try {
3126
- writeFileSync(stopPath, new Date().toISOString());
3880
+ await fs.promises.writeFile(stopPath, new Date().toISOString());
3127
3881
  } catch { }
3128
3882
  for (const cleanupPath of [pidPath, stopPath, logPath]) {
3129
- if (!existsSync(cleanupPath)) continue;
3883
+ if (!(await exists(cleanupPath))) continue;
3130
3884
  try {
3131
- unlinkSync(cleanupPath);
3885
+ await fs.promises.unlink(cleanupPath);
3132
3886
  } catch { }
3133
3887
  }
3134
3888
  const projectMessageDir = path.join(CFG_DIR, "project-messages", projectId);
3135
- if (existsSync(projectMessageDir)) {
3889
+ if (await exists(projectMessageDir)) {
3136
3890
  try {
3137
- rmSync(projectMessageDir, { recursive: true, force: true });
3891
+ await fs.promises.rm(projectMessageDir, { recursive: true, force: true });
3138
3892
  } catch { }
3139
3893
  }
3140
3894
  delete projects[projectId];
@@ -3146,14 +3900,23 @@ const server = http.createServer(async (req, res) => {
3146
3900
  if (url.pathname === "/api/projects/update" && req.method === "POST") {
3147
3901
  let body = "";
3148
3902
  for await (const chunk of req) body += chunk;
3149
- const { projectId, autoAdvance, name, description, outputDir } =
3150
- JSON.parse(body || "{}");
3151
- if (!projectId) throw new Error("projectId required");
3903
+ let parsed;
3904
+ try { parsed = JSON.parse(body || "{}"); } catch {
3905
+ res.writeHead(400, { "content-type": "application/json" });
3906
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
3907
+ return;
3908
+ }
3909
+ const vr = validate(UpdateProjectSchema, parsed);
3910
+ if (!vr.ok) {
3911
+ res.writeHead(400, { "content-type": "application/json" });
3912
+ res.end(JSON.stringify({ ok: false, error: vr.error }));
3913
+ return;
3914
+ }
3915
+ const { projectId, autoAdvance, name, description, outputDir } = vr.data;
3152
3916
  const registryFile = path.join(CFG_DIR, "projects.json");
3153
- const { existsSync } = await import("node:fs");
3154
3917
  const { readFile: rf, writeFile: wf } = await import("node:fs/promises");
3155
3918
  let projects = {};
3156
- if (existsSync(registryFile))
3919
+ if (await exists(registryFile))
3157
3920
  projects = JSON.parse(await rf(registryFile, "utf8").catch(() => "{}"));
3158
3921
  if (!projects[projectId])
3159
3922
  throw new Error("Project not found: " + projectId);
@@ -3168,7 +3931,6 @@ const server = http.createServer(async (req, res) => {
3168
3931
  return;
3169
3932
  }
3170
3933
  if (url.pathname === "/api/pm-loop/status" && req.method === "GET") {
3171
- const { existsSync } = await import("node:fs");
3172
3934
  const { readFile: rf } = await import("node:fs/promises");
3173
3935
  const statusProjectId = url.searchParams.get("projectId") || "";
3174
3936
  const suffix = statusProjectId ? `-${statusProjectId}` : "";
@@ -3179,7 +3941,7 @@ const server = http.createServer(async (req, res) => {
3179
3941
  );
3180
3942
  let running = false,
3181
3943
  pid = null;
3182
- if (existsSync(pidPath)) {
3944
+ if (await exists(pidPath)) {
3183
3945
  const pidStr = await rf(pidPath, "utf8").catch(() => "");
3184
3946
  pid = parseInt(pidStr.trim(), 10);
3185
3947
  if (pid) {
@@ -3213,9 +3975,8 @@ const server = http.createServer(async (req, res) => {
3213
3975
  }
3214
3976
  const { dryRun, projectId, pmOptions = {} } = vr.data;
3215
3977
  const { spawn } = await import("node:child_process");
3216
- const { existsSync, mkdirSync, unlinkSync } = await import("node:fs");
3217
3978
  const { readFile: rf } = await import("node:fs/promises");
3218
- if (!existsSync(pmLoop))
3979
+ if (!(await exists(pmLoop)))
3219
3980
  throw new Error("pm-loop.mjs not found at " + pmLoop);
3220
3981
  // Resolve project config if projectId provided
3221
3982
  let projectDir = null,
@@ -3223,7 +3984,7 @@ const server = http.createServer(async (req, res) => {
3223
3984
  projectFeaturesDoc = null;
3224
3985
  if (projectId) {
3225
3986
  const registryFile = path.join(CFG_DIR, "projects.json");
3226
- if (existsSync(registryFile)) {
3987
+ if (await exists(registryFile)) {
3227
3988
  const reg = JSON.parse(
3228
3989
  await rf(registryFile, "utf8").catch(() => "{}"),
3229
3990
  );
@@ -3247,7 +4008,7 @@ const server = http.createServer(async (req, res) => {
3247
4008
  "orchestrator-logs",
3248
4009
  `pm-loop${pidSuffix}.stop`,
3249
4010
  );
3250
- if (existsSync(pidFile)) {
4011
+ if (await exists(pidFile)) {
3251
4012
  const pidStr = await rf(pidFile, "utf8").catch(() => "");
3252
4013
  const existingPid = parseInt(pidStr.trim(), 10);
3253
4014
  if (existingPid) {
@@ -3269,13 +4030,13 @@ const server = http.createServer(async (req, res) => {
3269
4030
  }
3270
4031
  }
3271
4032
  // Clear any stale stop file
3272
- if (existsSync(stopFilePath)) {
4033
+ if (await exists(stopFilePath)) {
3273
4034
  try {
3274
- unlinkSync(stopFilePath);
4035
+ await fs.promises.unlink(stopFilePath);
3275
4036
  } catch { }
3276
4037
  }
3277
4038
  const logsDir = path.join(CREWSWARM_DIR, "orchestrator-logs");
3278
- if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
4039
+ if (!(await exists(logsDir))) await fs.promises.mkdir(logsDir, { recursive: true });
3279
4040
  // Load RT token so pm-loop and its child gateway-bridge --send can authenticate with the RT daemon
3280
4041
  let rtToken = process.env.CREWSWARM_RT_AUTH_TOKEN || "";
3281
4042
  if (!rtToken) {
@@ -3367,13 +4128,12 @@ const server = http.createServer(async (req, res) => {
3367
4128
  let body = "";
3368
4129
  for await (const chunk of req) body += chunk;
3369
4130
  const { projectId } = JSON.parse(body || "{}");
3370
- const { writeFileSync, mkdirSync, existsSync } = await import("node:fs");
3371
4131
  const logsDir = path.join(CREWSWARM_DIR, "orchestrator-logs");
3372
- if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
4132
+ if (!(await exists(logsDir))) await fs.promises.mkdir(logsDir, { recursive: true });
3373
4133
  // Write project-specific stop file if projectId provided
3374
4134
  const suffix = projectId ? `-${projectId}` : "";
3375
4135
  const stopFilePath = path.join(logsDir, `pm-loop${suffix}.stop`);
3376
- writeFileSync(stopFilePath, new Date().toISOString());
4136
+ await fs.promises.writeFile(stopFilePath, new Date().toISOString());
3377
4137
  res.writeHead(200, { "content-type": "application/json" });
3378
4138
  res.end(
3379
4139
  JSON.stringify({
@@ -3384,10 +4144,9 @@ const server = http.createServer(async (req, res) => {
3384
4144
  return;
3385
4145
  }
3386
4146
  if (url.pathname === "/api/pm-loop/log" && req.method === "GET") {
3387
- const { existsSync } = await import("node:fs");
3388
4147
  const { readFile } = await import("node:fs/promises");
3389
4148
  let lines = [];
3390
- if (existsSync(pmLogFile)) {
4149
+ if (await exists(pmLogFile)) {
3391
4150
  const raw = await readFile(pmLogFile, "utf8").catch(() => "");
3392
4151
  lines = raw
3393
4152
  .trim()
@@ -3408,10 +4167,9 @@ const server = http.createServer(async (req, res) => {
3408
4167
  return;
3409
4168
  }
3410
4169
  if (url.pathname === "/api/pm-loop/roadmap" && req.method === "GET") {
3411
- const { existsSync } = await import("node:fs");
3412
4170
  const { readFile } = await import("node:fs/promises");
3413
4171
  let content = "(ROADMAP.md not found — create website/ROADMAP.md)";
3414
- if (existsSync(roadmapFile)) {
4172
+ if (await exists(roadmapFile)) {
3415
4173
  content = await readFile(roadmapFile, "utf8").catch(
3416
4174
  () => "(unreadable)",
3417
4175
  );
@@ -3423,10 +4181,23 @@ const server = http.createServer(async (req, res) => {
3423
4181
  if (url.pathname === "/api/dlq/replay" && req.method === "POST") {
3424
4182
  let body = "";
3425
4183
  for await (const chunk of req) body += chunk;
3426
- const { key } = JSON.parse(body);
3427
- if (!key) throw new Error("missing key");
3428
- const { execSync } = await import("node:child_process");
3429
- execSync(`"${ctlPath}" dlq-replay "${key}"`, {
4184
+ let parsed;
4185
+ try { parsed = JSON.parse(body); } catch {
4186
+ res.writeHead(400, { "content-type": "application/json" });
4187
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
4188
+ return;
4189
+ }
4190
+ const vr = validate(ReplayDLQSchema, parsed);
4191
+ if (!vr.ok) {
4192
+ res.writeHead(400, { "content-type": "application/json" });
4193
+ res.end(JSON.stringify({ ok: false, error: vr.error }));
4194
+ return;
4195
+ }
4196
+ const { key } = vr.data;
4197
+ const { execSync: _execSync } = await import("node:child_process");
4198
+ const { promisify: _promisify } = await import("node:util");
4199
+ const execAsync = _promisify(_execSync);
4200
+ await execAsync(`"${ctlPath}" dlq-replay "${key}"`, {
3430
4201
  encoding: "utf8",
3431
4202
  timeout: 10000,
3432
4203
  });
@@ -3451,7 +4222,9 @@ const server = http.createServer(async (req, res) => {
3451
4222
  // ── Settings: Preset (from setup wizard) ────────────────────────────────
3452
4223
  // ── Engine detection for first-run wizard ─────────────────────────────
3453
4224
  if (url.pathname === "/api/first-run-engines" && req.method === "GET") {
3454
- const { execSync } = await import("node:child_process");
4225
+ const { exec: _execFRE } = await import("node:child_process");
4226
+ const { promisify: _promisifyFRE } = await import("node:util");
4227
+ const _execAsyncFRE = _promisifyFRE(_execFRE);
3455
4228
  // Expand PATH to include common install locations (launchd has restricted PATH)
3456
4229
  const extraPaths = [
3457
4230
  `${os.homedir()}/.local/bin`,
@@ -3477,13 +4250,14 @@ const server = http.createServer(async (req, res) => {
3477
4250
  ];
3478
4251
  for (const { id, bin } of checks) {
3479
4252
  // Check common paths directly (launchd PATH is restricted)
3480
- if (searchDirs.some(d => fs.existsSync(path.join(d, bin)))) {
4253
+ const foundInSearchDirs = await Promise.any(searchDirs.map(d => fs.promises.access(path.join(d, bin)).then(() => true))).catch(() => false);
4254
+ if (foundInSearchDirs) {
3481
4255
  engines[id] = true;
3482
4256
  continue;
3483
4257
  }
3484
4258
  // Fallback: try login shell
3485
4259
  try {
3486
- execSync(`/bin/zsh -lc 'command -v ${bin}'`, { stdio: "pipe", timeout: 5000 });
4260
+ await _execAsyncFRE(`/bin/zsh -lc 'command -v ${bin}'`, { timeout: 5000 });
3487
4261
  engines[id] = true;
3488
4262
  } catch {
3489
4263
  engines[id] = false;
@@ -3499,7 +4273,7 @@ const server = http.createServer(async (req, res) => {
3499
4273
  let token = "";
3500
4274
  try {
3501
4275
  token =
3502
- JSON.parse(fs.readFileSync(csConfigPath, "utf8"))?.rt?.authToken ||
4276
+ JSON.parse(await fs.promises.readFile(csConfigPath, "utf8"))?.rt?.authToken ||
3503
4277
  "";
3504
4278
  } catch { }
3505
4279
  if (!token) token = process.env.CREWSWARM_RT_AUTH_TOKEN || "";
@@ -3513,13 +4287,13 @@ const server = http.createServer(async (req, res) => {
3513
4287
  const { token } = JSON.parse(body);
3514
4288
  const csDir = path.join(os.homedir(), ".crewswarm");
3515
4289
  const csConfigPath = path.join(csDir, "crewswarm.json");
3516
- fs.mkdirSync(csDir, { recursive: true });
4290
+ await fs.promises.mkdir(csDir, { recursive: true });
3517
4291
  let cfg = {};
3518
4292
  try {
3519
- cfg = JSON.parse(fs.readFileSync(csConfigPath, "utf8"));
4293
+ cfg = JSON.parse(await fs.promises.readFile(csConfigPath, "utf8"));
3520
4294
  } catch { }
3521
4295
  cfg.rt = { ...(cfg.rt || {}), authToken: token };
3522
- fs.writeFileSync(csConfigPath, JSON.stringify(cfg, null, 2));
4296
+ await fs.promises.writeFile(csConfigPath, JSON.stringify(cfg, null, 2));
3523
4297
  res.writeHead(200, { "content-type": "application/json" });
3524
4298
  res.end(JSON.stringify({ ok: true }));
3525
4299
  return;
@@ -3529,15 +4303,20 @@ const server = http.createServer(async (req, res) => {
3529
4303
  const cfgPath = path.join(os.homedir(), ".crewswarm", "crewswarm.json");
3530
4304
  let locked = false;
3531
4305
  try {
3532
- const { execSync } = require("child_process");
4306
+ const { exec: _lockExec } = await import("node:child_process");
4307
+ const { promisify: _lockPromisify } = await import("node:util");
4308
+ const _lockExecAsync = _lockPromisify(_lockExec);
3533
4309
  // Use stat command which is more reliable than ls
3534
- const output = execSync(`stat -f "%Sf" "${cfgPath}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
3535
- locked = output.trim().includes('uchg');
4310
+ const { stdout: lockOut } = await _lockExecAsync(`stat -f "%Sf" "${cfgPath}"`, { encoding: 'utf8' });
4311
+ locked = lockOut.trim().includes('uchg');
3536
4312
  } catch (e) {
3537
4313
  // If stat fails, try ls as fallback
3538
4314
  try {
3539
- const output2 = execSync(`ls -lO "${cfgPath}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
3540
- locked = output2.includes('uchg');
4315
+ const { exec: _lockExec2 } = await import("node:child_process");
4316
+ const { promisify: _lockPromisify2 } = await import("node:util");
4317
+ const _lockExecAsync2 = _lockPromisify2(_lockExec2);
4318
+ const { stdout: lockOut2 } = await _lockExecAsync2(`ls -lO "${cfgPath}"`, { encoding: 'utf8' });
4319
+ locked = lockOut2.includes('uchg');
3541
4320
  } catch { }
3542
4321
  }
3543
4322
  res.writeHead(200, { "content-type": "application/json" });
@@ -3547,7 +4326,9 @@ const server = http.createServer(async (req, res) => {
3547
4326
  if (url.pathname === "/api/config/lock" && req.method === "POST") {
3548
4327
  const cfgPath = path.join(os.homedir(), ".crewswarm", "crewswarm.json");
3549
4328
  try {
3550
- execSync(`chflags uchg "${cfgPath}"`);
4329
+ const { exec: _chflagsExec } = await import("node:child_process");
4330
+ const { promisify: _chflagsPromisify } = await import("node:util");
4331
+ await _chflagsPromisify(_chflagsExec)(`chflags uchg "${cfgPath}"`);
3551
4332
  res.writeHead(200, { "content-type": "application/json" });
3552
4333
  res.end(JSON.stringify({ ok: true, locked: true }));
3553
4334
  } catch (e) {
@@ -3559,7 +4340,9 @@ const server = http.createServer(async (req, res) => {
3559
4340
  if (url.pathname === "/api/config/unlock" && req.method === "POST") {
3560
4341
  const cfgPath = path.join(os.homedir(), ".crewswarm", "crewswarm.json");
3561
4342
  try {
3562
- execSync(`chflags nouchg "${cfgPath}"`);
4343
+ const { exec: _chflagsExec2 } = await import("node:child_process");
4344
+ const { promisify: _chflagsPromisify2 } = await import("node:util");
4345
+ await _chflagsPromisify2(_chflagsExec2)(`chflags nouchg "${cfgPath}"`);
3563
4346
  res.writeHead(200, { "content-type": "application/json" });
3564
4347
  res.end(JSON.stringify({ ok: true, locked: false }));
3565
4348
  } catch (e) {
@@ -3583,7 +4366,7 @@ const server = http.createServer(async (req, res) => {
3583
4366
  "groq/moonshotai/kimi-k2-instruct-0905";
3584
4367
  let crewLeadModel = process.env.CREWSWARM_CREW_LEAD_MODEL || "";
3585
4368
  try {
3586
- const c = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
4369
+ const c = JSON.parse(await fs.promises.readFile(cfgPath, "utf8"));
3587
4370
  if (c.opencodeProject) dir = c.opencodeProject;
3588
4371
  if (c.opencodeFallbackModel) fallbackModel = c.opencodeFallbackModel;
3589
4372
  if (c.opencodeModel) opencodeModel = c.opencodeModel;
@@ -3614,10 +4397,10 @@ const server = http.createServer(async (req, res) => {
3614
4397
  }
3615
4398
  const cfgDir = path.join(os.homedir(), ".crewswarm");
3616
4399
  const cfgPath = path.join(cfgDir, "crewswarm.json");
3617
- fs.mkdirSync(cfgDir, { recursive: true });
4400
+ await fs.promises.mkdir(cfgDir, { recursive: true });
3618
4401
  let cfg = {};
3619
4402
  try {
3620
- cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
4403
+ cfg = JSON.parse(await fs.promises.readFile(cfgPath, "utf8"));
3621
4404
  } catch { }
3622
4405
  if (dir !== undefined) {
3623
4406
  if (dir) cfg.opencodeProject = dir;
@@ -3684,38 +4467,38 @@ const server = http.createServer(async (req, res) => {
3684
4467
  const csConfig = path.join(csDir, "crewswarm.json");
3685
4468
  const csSwarmConfig = path.join(csDir, "crewswarm.json");
3686
4469
  const ocConfig = path.join(os.homedir(), ".openclaw", "openclaw.json");
3687
- function readCSConfig() {
4470
+ async function readCSConfig() {
3688
4471
  try {
3689
- return JSON.parse(fs.readFileSync(csConfig, "utf8"));
4472
+ return JSON.parse(await fs.promises.readFile(csConfig, "utf8"));
3690
4473
  } catch {
3691
4474
  return {};
3692
4475
  }
3693
4476
  }
3694
- function readCSSwarmConfig() {
4477
+ async function readCSSwarmConfig() {
3695
4478
  try {
3696
- return JSON.parse(fs.readFileSync(csSwarmConfig, "utf8"));
4479
+ return JSON.parse(await fs.promises.readFile(csSwarmConfig, "utf8"));
3697
4480
  } catch {
3698
4481
  return {};
3699
4482
  }
3700
4483
  }
3701
- function writeCSSwarmConfig(c) {
3702
- fs.mkdirSync(csDir, { recursive: true });
3703
- fs.writeFileSync(csSwarmConfig, JSON.stringify(c, null, 2));
4484
+ async function writeCSSwarmConfig(c) {
4485
+ await fs.promises.mkdir(csDir, { recursive: true });
4486
+ await fs.promises.writeFile(csSwarmConfig, JSON.stringify(c, null, 2));
3704
4487
  }
3705
- function readOCConfig() {
4488
+ async function readOCConfig() {
3706
4489
  try {
3707
- return JSON.parse(fs.readFileSync(ocConfig, "utf8"));
4490
+ return JSON.parse(await fs.promises.readFile(ocConfig, "utf8"));
3708
4491
  } catch {
3709
4492
  return null;
3710
4493
  }
3711
4494
  }
3712
- function writeOCConfig(c) {
3713
- fs.writeFileSync(ocConfig, JSON.stringify(c, null, 4));
4495
+ async function writeOCConfig(c) {
4496
+ await fs.promises.writeFile(ocConfig, JSON.stringify(c, null, 4));
3714
4497
  }
3715
- function getBuiltinKey(id) {
3716
- const sw = readCSSwarmConfig();
3717
- const cs = readCSConfig();
3718
- const oc = readOCConfig();
4498
+ async function getBuiltinKey(id) {
4499
+ const sw = await readCSSwarmConfig();
4500
+ const cs = await readCSConfig();
4501
+ const oc = await readOCConfig();
3719
4502
  return (
3720
4503
  sw?.providers?.[id]?.apiKey ||
3721
4504
  sw?.env?.[id.toUpperCase() + "_API_KEY"] ||
@@ -3726,10 +4509,257 @@ const server = http.createServer(async (req, res) => {
3726
4509
  );
3727
4510
  }
3728
4511
 
4512
+ // ── OAuth / subscription provider status + model config ──────────────────
4513
+ if (url.pathname === "/api/oauth/status" && req.method === "GET") {
4514
+ const providers = {};
4515
+ // Use startup cache first; fall back to live keychain only if cache missed
4516
+ providers["anthropic-oauth"] = !!getOAuthTokenCached("anthropic-oauth");
4517
+ if (!providers["anthropic-oauth"]) {
4518
+ try {
4519
+ const { execFileSync } = await import("node:child_process");
4520
+ const { userInfo } = await import("node:os");
4521
+ for (const acct of [userInfo().username, "jeffhobbs", "unknown"]) {
4522
+ try {
4523
+ const raw = execFileSync("security", [
4524
+ "find-generic-password", "-s", "Claude Code-credentials", "-a", acct, "-w"
4525
+ ], { encoding: "utf8", timeout: 5000 }).trim();
4526
+ const parsed = JSON.parse(raw);
4527
+ if (parsed?.claudeAiOauth?.accessToken) {
4528
+ _oauthTokenCache["anthropic-oauth"] = parsed.claudeAiOauth.accessToken;
4529
+ providers["anthropic-oauth"] = true;
4530
+ break;
4531
+ }
4532
+ } catch { /* try next */ }
4533
+ }
4534
+ } catch { providers["anthropic-oauth"] = false; }
4535
+ }
4536
+ providers["openai-oauth"] = !!getOAuthTokenCached("openai-oauth");
4537
+ if (!providers["openai-oauth"]) {
4538
+ try {
4539
+ const t = await readOpenAIOAuthToken();
4540
+ if (t) { _oauthTokenCache["openai-oauth"] = t; providers["openai-oauth"] = true; }
4541
+ } catch { providers["openai-oauth"] = false; }
4542
+ }
4543
+ res.writeHead(200, { "content-type": "application/json" });
4544
+ res.end(JSON.stringify({ ok: true, providers }));
4545
+ return;
4546
+ }
4547
+ if (url.pathname === "/api/oauth/test" && req.method === "POST") {
4548
+ let body = "";
4549
+ for await (const chunk of req) body += chunk;
4550
+ const { providerId, model } = JSON.parse(body);
4551
+ try {
4552
+ let token = getOAuthTokenCached(providerId);
4553
+ if (!token) {
4554
+ if (providerId === "anthropic-oauth") {
4555
+ const { execFileSync } = await import("node:child_process");
4556
+ const { userInfo } = await import("node:os");
4557
+ for (const acct of [userInfo().username, "jeffhobbs", "unknown"]) {
4558
+ try {
4559
+ const raw = execFileSync("security", [
4560
+ "find-generic-password", "-s", "Claude Code-credentials", "-a", acct, "-w"
4561
+ ], { encoding: "utf8", timeout: 5000 }).trim();
4562
+ const t = JSON.parse(raw)?.claudeAiOauth?.accessToken;
4563
+ if (t) { token = t; _oauthTokenCache[providerId] = t; break; }
4564
+ } catch { /* try next */ }
4565
+ }
4566
+ } else if (providerId === "openai-oauth") {
4567
+ token = await readOpenAIOAuthToken();
4568
+ if (token) _oauthTokenCache[providerId] = token;
4569
+ }
4570
+ }
4571
+ if (!token) {
4572
+ res.writeHead(200, { "content-type": "application/json" });
4573
+ res.end(JSON.stringify({ ok: false, error: "No OAuth token found — run login command first" }));
4574
+ return;
4575
+ }
4576
+ // Quick test call
4577
+ if (providerId === "anthropic-oauth") {
4578
+ const { computeVersionSuffix, buildBillingBlock, signBody } = await import("../crew-cli/dist/engine.mjs").catch(() => ({}));
4579
+ let responseText, testModel = model || "claude-sonnet-4-6";
4580
+ {
4581
+ const xxhash = (await import("xxhash-wasm")).default;
4582
+ const wasm = await xxhash();
4583
+ const CCH_SEED = BigInt("0x6E52736AC806831E");
4584
+ const VERSION = "2.1.87", SALT = "59cf53e54c78";
4585
+ const msg = "reply with exactly: OK";
4586
+ const chars = [4,7,20].map(i => i < msg.length ? msg[i] : "0").join("");
4587
+ const suffix = crypto.subtle
4588
+ ? await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${SALT}${chars}${VERSION}`))
4589
+ .then(b => Array.from(new Uint8Array(b)).map(x=>x.toString(16).padStart(2,"0")).join("").slice(0,3))
4590
+ : createHash("sha256").update(`${SALT}${chars}${VERSION}`).digest("hex").slice(0,3);
4591
+ const billingText = `x-anthropic-billing-header: cc_version=${VERSION}.${suffix}; cc_entrypoint=cli; cch=00000;`;
4592
+ const supportsThinking = !testModel.includes("haiku");
4593
+ const bodyObj = {
4594
+ model: testModel, max_tokens: 50,
4595
+ ...(supportsThinking ? { thinking: { type: "adaptive" } } : {}),
4596
+ metadata: { user_id: "user_crewswarm_test" },
4597
+ system: [{ type: "text", text: billingText }],
4598
+ messages: [{ role: "user", content: msg }],
4599
+ };
4600
+ const { system, messages, ...rest } = bodyObj;
4601
+ const bodyStr = JSON.stringify({ ...rest, system, messages });
4602
+ const cch = Number(wasm.h64(bodyStr, CCH_SEED) & BigInt(0xfffff)).toString(16).padStart(5,"0");
4603
+ const signed = bodyStr.replace("cch=00000", `cch=${cch}`);
4604
+ const BETAS = "claude-code-20250219,oauth-2025-04-20,adaptive-thinking-2026-01-28,research-preview-2026-02-01,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27";
4605
+ const r = await fetch("https://api.anthropic.com/v1/messages?beta=true", {
4606
+ method: "POST",
4607
+ headers: {
4608
+ Authorization: `Bearer ${token}`, "content-type": "application/json",
4609
+ "anthropic-version": "2023-06-01", "anthropic-beta": BETAS,
4610
+ "anthropic-dangerous-direct-browser-access": "true",
4611
+ "x-app": "cli", "user-agent": "claude-cli/2.1.87 (external, cli)",
4612
+ "x-claude-code-session-id": crypto.randomUUID(),
4613
+ },
4614
+ body: signed,
4615
+ signal: AbortSignal.timeout(15000),
4616
+ });
4617
+ const data = await r.json();
4618
+ if (!r.ok) throw new Error(data?.error?.message || r.statusText);
4619
+ responseText = data?.content?.find(b => b.type === "text")?.text?.trim() || "OK";
4620
+ }
4621
+ res.writeHead(200, { "content-type": "application/json" });
4622
+ res.end(JSON.stringify({ ok: true, response: responseText, model: testModel }));
4623
+ } else if (providerId === "openai-oauth") {
4624
+ const testModel = model || "gpt-5.4";
4625
+ // Codex CLI backend requires streaming (SSE)
4626
+ const r = await fetch("https://chatgpt.com/backend-api/codex/responses", {
4627
+ method: "POST",
4628
+ headers: {
4629
+ Authorization: `Bearer ${token}`,
4630
+ "Content-Type": "application/json",
4631
+ "User-Agent": "openai-codex-cli/0.1.0",
4632
+ },
4633
+ body: JSON.stringify({
4634
+ model: testModel,
4635
+ instructions: "Reply with exactly: OK",
4636
+ input: [{ role: "user", content: "ping" }],
4637
+ stream: true,
4638
+ store: false,
4639
+ }),
4640
+ signal: AbortSignal.timeout(20000),
4641
+ });
4642
+ if (!r.ok) {
4643
+ const errData = await r.json().catch(() => ({}));
4644
+ throw new Error(errData?.detail || errData?.error?.message || r.statusText);
4645
+ }
4646
+ // Consume SSE stream and collect output_text delta chunks
4647
+ let responseText = "";
4648
+ const decoder = new TextDecoder();
4649
+ for await (const chunk of r.body) {
4650
+ const text = decoder.decode(chunk, { stream: true });
4651
+ for (const line of text.split("\n")) {
4652
+ if (!line.startsWith("data: ")) continue;
4653
+ const raw = line.slice(6).trim();
4654
+ if (raw === "[DONE]") break;
4655
+ try {
4656
+ const evt = JSON.parse(raw);
4657
+ // response.output_text.delta event
4658
+ if (evt.type === "response.output_text.delta") responseText += evt.delta || "";
4659
+ // completed event with full output
4660
+ if (evt.type === "response.completed") {
4661
+ const items = evt.response?.output?.flatMap(o => o.content || []) || [];
4662
+ const full = items.find(c => c.type === "output_text")?.text;
4663
+ if (full) responseText = full;
4664
+ }
4665
+ } catch { /* skip malformed */ }
4666
+ }
4667
+ }
4668
+ res.writeHead(200, { "content-type": "application/json" });
4669
+ res.end(JSON.stringify({ ok: true, response: responseText.trim() || "OK", model: testModel }));
4670
+ } else {
4671
+ res.writeHead(200, { "content-type": "application/json" });
4672
+ res.end(JSON.stringify({ ok: false, error: "Unknown provider" }));
4673
+ }
4674
+ } catch(e) {
4675
+ res.writeHead(200, { "content-type": "application/json" });
4676
+ res.end(JSON.stringify({ ok: false, error: e.message }));
4677
+ }
4678
+ return;
4679
+ }
4680
+ if (url.pathname === "/api/oauth/models" && req.method === "GET") {
4681
+ const { providerId } = Object.fromEntries(url.searchParams);
4682
+ try {
4683
+ let token = getOAuthTokenCached(providerId);
4684
+ if (!token && providerId === "anthropic-oauth") {
4685
+ const { execFileSync } = await import("node:child_process");
4686
+ const { userInfo } = await import("node:os");
4687
+ for (const acct of [userInfo().username, "jeffhobbs", "unknown"]) {
4688
+ try {
4689
+ const raw = execFileSync("security", [
4690
+ "find-generic-password", "-s", "Claude Code-credentials", "-a", acct, "-w"
4691
+ ], { encoding: "utf8", timeout: 5000 }).trim();
4692
+ const t = JSON.parse(raw)?.claudeAiOauth?.accessToken;
4693
+ if (t) { token = t; _oauthTokenCache[providerId] = t; break; }
4694
+ } catch { /* try next */ }
4695
+ }
4696
+ }
4697
+ if (providerId === "anthropic-oauth") {
4698
+ if (!token) throw new Error("No Anthropic OAuth token");
4699
+ const r = await fetch("https://api.anthropic.com/v1/models", {
4700
+ headers: {
4701
+ Authorization: `Bearer ${token}`,
4702
+ "anthropic-version": "2023-06-01",
4703
+ "anthropic-beta": "claude-code-20250219,oauth-2025-04-20",
4704
+ "x-app": "cli",
4705
+ },
4706
+ signal: AbortSignal.timeout(10000),
4707
+ });
4708
+ const data = await r.json();
4709
+ const models = (data?.data || []).map(m => ({ id: m.id, name: m.display_name || m.id }));
4710
+ res.writeHead(200, { "content-type": "application/json" });
4711
+ res.end(JSON.stringify({ ok: true, models }));
4712
+ } else if (providerId === "openai-oauth") {
4713
+ // Codex subscription doesn't expose a /models list endpoint —
4714
+ // return the known codex-accessible models directly
4715
+ const models = [
4716
+ { id: "gpt-5.4", name: "GPT-5.4 · Latest · Recommended" },
4717
+ { id: "gpt-5.4-mini", name: "GPT-5.4 Mini · Smaller frontier" },
4718
+ { id: "gpt-5.3-codex", name: "GPT-5.3 Codex · Codex-optimized" },
4719
+ { id: "gpt-5.2-codex", name: "GPT-5.2 Codex · Frontier agentic" },
4720
+ { id: "gpt-5.2", name: "GPT-5.2 · Professional / long-running agents" },
4721
+ { id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max · Deep & fast reasoning" },
4722
+ { id: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini · Fast & cheap" },
4723
+ { id: "gpt-4o", name: "GPT-4o · Fast & capable" },
4724
+ ];
4725
+ res.writeHead(200, { "content-type": "application/json" });
4726
+ res.end(JSON.stringify({ ok: true, models }));
4727
+ } else {
4728
+ throw new Error("Unknown providerId");
4729
+ }
4730
+ } catch(e) {
4731
+ res.writeHead(200, { "content-type": "application/json" });
4732
+ res.end(JSON.stringify({ ok: false, error: e.message }));
4733
+ }
4734
+ return;
4735
+ }
4736
+ if (url.pathname === "/api/oauth/model" && req.method === "GET") {
4737
+ const cfg = readSwarmConfigSafe() || {};
4738
+ const models = {
4739
+ claudeOauthModel: cfg.claudeOauthModel || "claude-sonnet-4-6",
4740
+ openaiOauthModel: cfg.openaiOauthModel || "gpt-5.4",
4741
+ };
4742
+ res.writeHead(200, { "content-type": "application/json" });
4743
+ res.end(JSON.stringify({ ok: true, models }));
4744
+ return;
4745
+ }
4746
+ if (url.pathname === "/api/oauth/model" && req.method === "POST") {
4747
+ let body = "";
4748
+ for await (const chunk of req) body += chunk;
4749
+ const data = JSON.parse(body);
4750
+ const cfg = readSwarmConfigSafe() || {};
4751
+ if (data.claudeOauthModel) cfg.claudeOauthModel = data.claudeOauthModel;
4752
+ if (data.openaiOauthModel) cfg.openaiOauthModel = data.openaiOauthModel;
4753
+ await safeWriteConfig(cfg);
4754
+ res.writeHead(200, { "content-type": "application/json" });
4755
+ res.end(JSON.stringify({ ok: true }));
4756
+ return;
4757
+ }
4758
+
3729
4759
  if (url.pathname === "/api/providers/builtin" && req.method === "GET") {
3730
4760
  const keys = {};
3731
4761
  for (const id of Object.keys(BUILTIN_URLS)) {
3732
- keys[id] = getBuiltinKey(id) ? "SET" : "";
4762
+ keys[id] = await getBuiltinKey(id) ? "SET" : "";
3733
4763
  }
3734
4764
  res.writeHead(200, { "content-type": "application/json" });
3735
4765
  res.end(JSON.stringify({ ok: true, keys }));
@@ -3746,16 +4776,16 @@ const server = http.createServer(async (req, res) => {
3746
4776
  if (providerId === "openai-local" && !(apiKey && apiKey.trim()))
3747
4777
  apiKey = "key";
3748
4778
  // Write to ~/.crewswarm/crewswarm.json
3749
- const cfg = readCSSwarmConfig();
4779
+ const cfg = await readCSSwarmConfig();
3750
4780
  if (!cfg.providers) cfg.providers = {};
3751
4781
  cfg.providers[providerId] = {
3752
4782
  ...(cfg.providers[providerId] || {}),
3753
4783
  apiKey,
3754
4784
  baseUrl: BUILTIN_URLS[providerId],
3755
4785
  };
3756
- writeCSSwarmConfig(cfg);
4786
+ await writeCSSwarmConfig(cfg);
3757
4787
  // Sync to ~/.openclaw/openclaw.json if it exists (legacy compat)
3758
- const oc = readOCConfig();
4788
+ const oc = await readOCConfig();
3759
4789
  if (oc) {
3760
4790
  if (!oc.models) oc.models = {};
3761
4791
  if (!oc.models.providers) oc.models.providers = {};
@@ -3767,7 +4797,7 @@ const server = http.createServer(async (req, res) => {
3767
4797
  };
3768
4798
  }
3769
4799
  oc.models.providers[providerId].apiKey = apiKey;
3770
- writeOCConfig(oc);
4800
+ await writeOCConfig(oc);
3771
4801
  }
3772
4802
  res.writeHead(200, { "content-type": "application/json" });
3773
4803
  res.end(JSON.stringify({ ok: true }));
@@ -3779,8 +4809,20 @@ const server = http.createServer(async (req, res) => {
3779
4809
  ) {
3780
4810
  let body = "";
3781
4811
  for await (const chunk of req) body += chunk;
3782
- const { providerId } = JSON.parse(body);
3783
- const apiKey = getBuiltinKey(providerId);
4812
+ let parsed;
4813
+ try { parsed = JSON.parse(body); } catch {
4814
+ res.writeHead(400, { "content-type": "application/json" });
4815
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
4816
+ return;
4817
+ }
4818
+ const vr = validate(ProviderBuiltinTestSchema, parsed);
4819
+ if (!vr.ok) {
4820
+ res.writeHead(400, { "content-type": "application/json" });
4821
+ res.end(JSON.stringify({ ok: false, error: vr.error }));
4822
+ return;
4823
+ }
4824
+ const { providerId } = vr.data;
4825
+ const apiKey = await getBuiltinKey(providerId);
3784
4826
  const baseUrl = BUILTIN_URLS[providerId] || "";
3785
4827
  if (providerId === "ollama") {
3786
4828
  try {
@@ -3875,20 +4917,7 @@ const server = http.createServer(async (req, res) => {
3875
4917
  return b;
3876
4918
  })()
3877
4919
  : null;
3878
- const token = (() => {
3879
- try {
3880
- return (
3881
- JSON.parse(
3882
- fs.readFileSync(
3883
- path.join(os.homedir(), ".crewswarm", "crewswarm.json"),
3884
- "utf8",
3885
- ),
3886
- )?.rt?.authToken || ""
3887
- );
3888
- } catch {
3889
- return "";
3890
- }
3891
- })();
4920
+ const token = resolveCrewLeadAuthToken();
3892
4921
  const r = await fetch(
3893
4922
  "http://127.0.0.1:5010/api/settings/bg-consciousness",
3894
4923
  {
@@ -3925,20 +4954,7 @@ const server = http.createServer(async (req, res) => {
3925
4954
  return b;
3926
4955
  })()
3927
4956
  : null;
3928
- const token = (() => {
3929
- try {
3930
- return (
3931
- JSON.parse(
3932
- fs.readFileSync(
3933
- path.join(os.homedir(), ".crewswarm", "crewswarm.json"),
3934
- "utf8",
3935
- ),
3936
- )?.rt?.authToken || ""
3937
- );
3938
- } catch {
3939
- return "";
3940
- }
3941
- })();
4957
+ const token = resolveCrewLeadAuthToken();
3942
4958
  const r = await fetch(
3943
4959
  "http://127.0.0.1:5010/api/settings/cursor-waves",
3944
4960
  {
@@ -3975,20 +4991,7 @@ const server = http.createServer(async (req, res) => {
3975
4991
  return b;
3976
4992
  })()
3977
4993
  : null;
3978
- const token = (() => {
3979
- try {
3980
- return (
3981
- JSON.parse(
3982
- fs.readFileSync(
3983
- path.join(os.homedir(), ".crewswarm", "crewswarm.json"),
3984
- "utf8",
3985
- ),
3986
- )?.rt?.authToken || ""
3987
- );
3988
- } catch {
3989
- return "";
3990
- }
3991
- })();
4994
+ const token = resolveCrewLeadAuthToken();
3992
4995
  const r = await fetch(
3993
4996
  "http://127.0.0.1:5010/api/settings/claude-code",
3994
4997
  {
@@ -4014,6 +5017,27 @@ const server = http.createServer(async (req, res) => {
4014
5017
  }
4015
5018
  return;
4016
5019
  }
5020
+ // ── Proxy /api/settings/tmux-bridge → crew-lead:5010 ─────────────────────
5021
+ if (url.pathname === "/api/settings/tmux-bridge") {
5022
+ try {
5023
+ const rawBody =
5024
+ req.method === "POST"
5025
+ ? await (async () => { let b = ""; for await (const c of req) b += c; return b; })()
5026
+ : null;
5027
+ const token = resolveCrewLeadAuthToken();
5028
+ const { data } = await crewLeadRequest("/api/settings/tmux-bridge", {
5029
+ method: req.method,
5030
+ body: rawBody || null,
5031
+ timeout: 8000,
5032
+ });
5033
+ res.writeHead(200, { "content-type": "application/json" });
5034
+ res.end(typeof data === "string" ? data : JSON.stringify(data));
5035
+ } catch (e) {
5036
+ res.writeHead(200, { "content-type": "application/json" });
5037
+ res.end(JSON.stringify({ ok: false, error: "crew-lead unreachable: " + e.message }));
5038
+ }
5039
+ return;
5040
+ }
4017
5041
  // ── Codex CLI executor toggle ──────────────────────────────────────────────
4018
5042
  if (url.pathname === "/api/settings/codex") {
4019
5043
  const { readFile, writeFile } = await import("node:fs/promises");
@@ -4099,7 +5123,7 @@ const server = http.createServer(async (req, res) => {
4099
5123
  process.env.CREWSWARM_CREW_CLI_ENABLED === "1";
4100
5124
  const installed =
4101
5125
  commandExists("crew") ||
4102
- fs.existsSync(path.join(CREWSWARM_DIR, "crew-cli", "dist", "index.js"));
5126
+ await exists(path.join(CREWSWARM_DIR, "crew-cli", "dist", "index.js"));
4103
5127
  res.writeHead(200, { "content-type": "application/json" });
4104
5128
  res.end(JSON.stringify({ enabled, installed }));
4105
5129
  } catch {
@@ -4169,9 +5193,9 @@ const server = http.createServer(async (req, res) => {
4169
5193
  return;
4170
5194
  }
4171
5195
  try {
4172
- const raw = (() => {
5196
+ const raw = await (async () => {
4173
5197
  try {
4174
- return fs.readFileSync(CFG_FILE, "utf8");
5198
+ return await fs.promises.readFile(CFG_FILE, "utf8");
4175
5199
  } catch {
4176
5200
  return "{}";
4177
5201
  }
@@ -4223,7 +5247,7 @@ const server = http.createServer(async (req, res) => {
4223
5247
  process.env.CREWSWARM_OPENCODE_ENABLED === "1";
4224
5248
  const installed =
4225
5249
  commandExists(process.env.CREWSWARM_OPENCODE_BIN || "opencode") ||
4226
- fs.existsSync(path.join(os.homedir(), ".opencode", "bin", "opencode"));
5250
+ await exists(path.join(os.homedir(), ".opencode", "bin", "opencode"));
4227
5251
  res.writeHead(200, { "content-type": "application/json" });
4228
5252
  res.end(JSON.stringify({ enabled, installed }));
4229
5253
  } catch {
@@ -4373,7 +5397,7 @@ const server = http.createServer(async (req, res) => {
4373
5397
  const cfgPath = CFG_FILE;
4374
5398
  if (req.method === "GET") {
4375
5399
  try {
4376
- const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
5400
+ const cfg = JSON.parse(await fs.promises.readFile(cfgPath, "utf8"));
4377
5401
  const roles = cfg.roleToolDefaults || {};
4378
5402
  res.writeHead(200, { "content-type": "application/json" });
4379
5403
  res.end(JSON.stringify({ roles }));
@@ -4388,7 +5412,7 @@ const server = http.createServer(async (req, res) => {
4388
5412
  let body = "";
4389
5413
  for await (const chunk of req) body += chunk;
4390
5414
  const { roles } = JSON.parse(body || "{}");
4391
- const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
5415
+ const cfg = JSON.parse(await fs.promises.readFile(cfgPath, "utf8"));
4392
5416
  cfg.roleToolDefaults = roles || {};
4393
5417
  const writeErr = await safeWriteConfig(cfg);
4394
5418
  if (writeErr) {
@@ -4410,7 +5434,7 @@ const server = http.createServer(async (req, res) => {
4410
5434
  const cfgPath = CFG_FILE;
4411
5435
  if (req.method === "GET") {
4412
5436
  try {
4413
- const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
5437
+ const cfg = JSON.parse(await fs.promises.readFile(cfgPath, "utf8"));
4414
5438
  const caps = cfg.globalSpendingCaps || {};
4415
5439
  res.writeHead(200, { "content-type": "application/json" });
4416
5440
  res.end(
@@ -4432,7 +5456,7 @@ const server = http.createServer(async (req, res) => {
4432
5456
  const { dailyTokenLimit, dailyCostLimitUSD } = JSON.parse(
4433
5457
  body || "{}",
4434
5458
  );
4435
- const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
5459
+ const cfg = JSON.parse(await fs.promises.readFile(cfgPath, "utf8"));
4436
5460
  cfg.globalSpendingCaps = {
4437
5461
  dailyTokenLimit: dailyTokenLimit ?? undefined,
4438
5462
  dailyCostLimitUSD: dailyCostLimitUSD ?? undefined,
@@ -4462,8 +5486,8 @@ const server = http.createServer(async (req, res) => {
4462
5486
  const rulesPath = path.join(CFG_DIR, "global-rules.md");
4463
5487
  if (req.method === "GET") {
4464
5488
  try {
4465
- const content = fs.existsSync(rulesPath)
4466
- ? fs.readFileSync(rulesPath, "utf8")
5489
+ const content = await exists(rulesPath)
5490
+ ? await fs.promises.readFile(rulesPath, "utf8")
4467
5491
  : "";
4468
5492
  res.writeHead(200, { "content-type": "application/json" });
4469
5493
  res.end(JSON.stringify({ content }));
@@ -4478,7 +5502,7 @@ const server = http.createServer(async (req, res) => {
4478
5502
  let body = "";
4479
5503
  for await (const chunk of req) body += chunk;
4480
5504
  const { content } = JSON.parse(body || "{}");
4481
- fs.writeFileSync(rulesPath, content || "", "utf8");
5505
+ await fs.promises.writeFile(rulesPath, content || "", "utf8");
4482
5506
  res.writeHead(200, { "content-type": "application/json" });
4483
5507
  res.end(JSON.stringify({ ok: true }));
4484
5508
  } catch (e) {
@@ -4528,20 +5552,7 @@ const server = http.createServer(async (req, res) => {
4528
5552
  for await (const c of req) b += c;
4529
5553
  return b;
4530
5554
  })();
4531
- const token = (() => {
4532
- try {
4533
- return (
4534
- JSON.parse(
4535
- fs.readFileSync(
4536
- path.join(os.homedir(), ".crewswarm", "crewswarm.json"),
4537
- "utf8",
4538
- ),
4539
- )?.rt?.authToken || ""
4540
- );
4541
- } catch {
4542
- return "";
4543
- }
4544
- })();
5555
+ const token = resolveCrewLeadAuthToken();
4545
5556
  const upstream = await fetch(
4546
5557
  "http://127.0.0.1:5010/api/engine-passthrough",
4547
5558
  {
@@ -4606,20 +5617,7 @@ const server = http.createServer(async (req, res) => {
4606
5617
  return b;
4607
5618
  })()
4608
5619
  : null;
4609
- const token = (() => {
4610
- try {
4611
- return (
4612
- JSON.parse(
4613
- fs.readFileSync(
4614
- path.join(os.homedir(), ".crewswarm", "crewswarm.json"),
4615
- "utf8",
4616
- ),
4617
- )?.rt?.authToken || ""
4618
- );
4619
- } catch {
4620
- return "";
4621
- }
4622
- })();
5620
+ const token = resolveCrewLeadAuthToken();
4623
5621
  const r = await fetch(
4624
5622
  "http://127.0.0.1:5010/api/settings/global-fallback",
4625
5623
  {
@@ -4657,7 +5655,7 @@ const server = http.createServer(async (req, res) => {
4657
5655
  );
4658
5656
  const deviceJsonAlt = path.join(os.homedir(), ".openclaw", "device.json");
4659
5657
  const installed =
4660
- fs.existsSync(deviceJson) || fs.existsSync(deviceJsonAlt);
5658
+ await exists(deviceJson) || await exists(deviceJsonAlt);
4661
5659
  res.writeHead(200, { "content-type": "application/json" });
4662
5660
  res.end(JSON.stringify({ ok: true, installed }));
4663
5661
  return;
@@ -4667,10 +5665,8 @@ const server = http.createServer(async (req, res) => {
4667
5665
  try {
4668
5666
  let online = false;
4669
5667
  try {
4670
- const health = await fetch("http://127.0.0.1:5010/health", {
4671
- signal: AbortSignal.timeout(1500),
4672
- });
4673
- online = health.ok;
5668
+ const { ok } = await crewLeadRequest("/health", { timeout: 1500 });
5669
+ online = ok;
4674
5670
  } catch { }
4675
5671
  if (!online) {
4676
5672
  const { execSync: es } = await import("node:child_process");
@@ -4690,11 +5686,11 @@ const server = http.createServer(async (req, res) => {
4690
5686
  return;
4691
5687
  }
4692
5688
 
4693
- // ── Models API (list available models for CrewChat dropdown) ─────────────
5689
+ // ── Models API (list available models for crewchat dropdown) ─────────────
4694
5690
  if (url.pathname === "/api/models" && req.method === "GET") {
4695
5691
  try {
4696
5692
  const csSwarm = JSON.parse(
4697
- fs.readFileSync(
5693
+ await fs.promises.readFile(
4698
5694
  path.join(os.homedir(), ".crewswarm", "crewswarm.json"),
4699
5695
  "utf8",
4700
5696
  ),
@@ -4780,7 +5776,7 @@ const server = http.createServer(async (req, res) => {
4780
5776
  return;
4781
5777
  }
4782
5778
 
4783
- // ── CLI Chat API (CrewChat CLI mode passthrough) ──────────────────────
5779
+ // ── CLI Chat API (crewchat CLI mode passthrough) ──────────────────────
4784
5780
  if (url.pathname === "/api/cli/chat" && req.method === "POST") {
4785
5781
  let body = "";
4786
5782
  for await (const chunk of req) body += chunk;
@@ -4802,8 +5798,8 @@ const server = http.createServer(async (req, res) => {
4802
5798
  if (projectId) {
4803
5799
  try {
4804
5800
  const registryFile = path.join(CFG_DIR, "projects.json");
4805
- const projects = fs.existsSync(registryFile)
4806
- ? JSON.parse(fs.readFileSync(registryFile, "utf8"))
5801
+ const projects = await exists(registryFile)
5802
+ ? JSON.parse(await fs.promises.readFile(registryFile, "utf8"))
4807
5803
  : {};
4808
5804
  projectDir = projects?.[projectId]?.outputDir || null;
4809
5805
  } catch { }
@@ -4904,7 +5900,7 @@ const server = http.createServer(async (req, res) => {
4904
5900
 
4905
5901
  // Load agent config
4906
5902
  const csSwarm = JSON.parse(
4907
- fs.readFileSync(
5903
+ await fs.promises.readFile(
4908
5904
  path.join(os.homedir(), ".crewswarm", "crewswarm.json"),
4909
5905
  "utf8",
4910
5906
  ),
@@ -4944,7 +5940,7 @@ const server = http.createServer(async (req, res) => {
4944
5940
  );
4945
5941
  let systemPrompt = `You are ${agentId}.`;
4946
5942
  try {
4947
- const prompts = JSON.parse(fs.readFileSync(promptPath, "utf8"));
5943
+ const prompts = JSON.parse(await fs.promises.readFile(promptPath, "utf8"));
4948
5944
  const bareId = agentId.replace(/^crew-/, "");
4949
5945
  systemPrompt = prompts[agentId] || prompts[bareId] || systemPrompt;
4950
5946
  } catch { }
@@ -5020,7 +6016,7 @@ const server = http.createServer(async (req, res) => {
5020
6016
  return;
5021
6017
  }
5022
6018
 
5023
- // ── Chat Agent API (CrewChat direct agent chat) ──────────────────────────
6019
+ // ── Chat Agent API (crewchat direct agent chat) ──────────────────────────
5024
6020
  if (url.pathname === "/api/chat-agent" && req.method === "POST") {
5025
6021
  let body = "";
5026
6022
  for await (const chunk of req) body += chunk;
@@ -5068,7 +6064,7 @@ const server = http.createServer(async (req, res) => {
5068
6064
  return;
5069
6065
  }
5070
6066
 
5071
- // ── Dispatch API (CrewChat agent direct mode) ─────────────────────────────
6067
+ // ── Dispatch API (crewchat agent direct mode) ─────────────────────────────
5072
6068
  if (url.pathname === "/api/dispatch" && req.method === "POST") {
5073
6069
  let body = "";
5074
6070
  for await (const chunk of req) body += chunk;
@@ -5139,7 +6135,7 @@ const server = http.createServer(async (req, res) => {
5139
6135
  }
5140
6136
 
5141
6137
  if (url.pathname === "/api/transcribe-audio" && req.method === "POST") {
5142
- // Expects multipart/form-data with audio file (CrewChat: m4a, Dashboard: webm)
6138
+ // Expects multipart/form-data with audio file (crewchat: m4a, Dashboard: webm)
5143
6139
  // Per Groq docs: https://console.groq.com/docs/speech-to-text — file, model required
5144
6140
  const sendJson = (status, body) => {
5145
6141
  if (res.headersSent) return;
@@ -5149,7 +6145,7 @@ const server = http.createServer(async (req, res) => {
5149
6145
  try {
5150
6146
  const busboy = await import("busboy");
5151
6147
  const chunks = [];
5152
- let mimeType = "audio/m4a"; // CrewChat default
6148
+ let mimeType = "audio/m4a"; // crewchat default
5153
6149
  let resolved = false;
5154
6150
  const resolveOnce = () => {
5155
6151
  if (resolved) return;
@@ -5235,8 +6231,8 @@ const server = http.createServer(async (req, res) => {
5235
6231
  if (!resolvedProjectDir && projectId) {
5236
6232
  try {
5237
6233
  const registryFile = path.join(CFG_DIR, "projects.json");
5238
- const projects = fs.existsSync(registryFile)
5239
- ? JSON.parse(fs.readFileSync(registryFile, "utf8"))
6234
+ const projects = await exists(registryFile)
6235
+ ? JSON.parse(await fs.promises.readFile(registryFile, "utf8"))
5240
6236
  : {};
5241
6237
  resolvedProjectDir = projects?.[projectId]?.outputDir || null;
5242
6238
  } catch { }
@@ -5269,7 +6265,10 @@ const server = http.createServer(async (req, res) => {
5269
6265
  `http://127.0.0.1:${listenPort}/api/engine-passthrough`,
5270
6266
  {
5271
6267
  method: "POST",
5272
- headers: { "content-type": "application/json" },
6268
+ headers: {
6269
+ "content-type": "application/json",
6270
+ ...(clAuthToken ? { authorization: `Bearer ${clAuthToken}` } : {}),
6271
+ },
5273
6272
  body: JSON.stringify(payload),
5274
6273
  signal: AbortSignal.timeout(240000),
5275
6274
  },
@@ -5371,7 +6370,7 @@ const server = http.createServer(async (req, res) => {
5371
6370
  }
5372
6371
 
5373
6372
  if (wantAgentSSE && upstream.body) {
5374
- // SSE streaming path (Vibe, Studio)
6373
+ // SSE streaming path (Dashboard or Vibe)
5375
6374
  res.writeHead(200, {
5376
6375
  "content-type": "text/event-stream",
5377
6376
  "cache-control": "no-cache",
@@ -5471,7 +6470,7 @@ const server = http.createServer(async (req, res) => {
5471
6470
  }
5472
6471
 
5473
6472
  if (wantSSE && upstream.body) {
5474
- // SSE streaming path (Vibe, Studio)
6473
+ // SSE streaming path (Dashboard or Vibe)
5475
6474
  res.writeHead(200, {
5476
6475
  "content-type": "text/event-stream",
5477
6476
  "cache-control": "no-cache",
@@ -5820,9 +6819,19 @@ const server = http.createServer(async (req, res) => {
5820
6819
  const { readFile, writeFile } = await import("node:fs/promises");
5821
6820
  let body = "";
5822
6821
  for await (const chunk of req) body += chunk;
5823
- const { providerId, apiKey } = JSON.parse(body);
5824
- if (!providerId || !apiKey)
5825
- throw new Error("providerId and apiKey required");
6822
+ let parsed;
6823
+ try { parsed = JSON.parse(body); } catch {
6824
+ res.writeHead(400, { "content-type": "application/json" });
6825
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
6826
+ return;
6827
+ }
6828
+ const vr = validate(ProviderSaveSchema, parsed);
6829
+ if (!vr.ok) {
6830
+ res.writeHead(400, { "content-type": "application/json" });
6831
+ res.end(JSON.stringify({ ok: false, error: vr.error }));
6832
+ return;
6833
+ }
6834
+ const { providerId, apiKey } = vr.data;
5826
6835
  const cfgPath = CFG_FILE;
5827
6836
  const cfg = JSON.parse(await readFile(cfgPath, "utf8"));
5828
6837
  const fromModels = cfg?.models?.providers?.[providerId];
@@ -5870,8 +6879,19 @@ const server = http.createServer(async (req, res) => {
5870
6879
  const { readFile, writeFile } = await import("node:fs/promises");
5871
6880
  let body = "";
5872
6881
  for await (const chunk of req) body += chunk;
5873
- const { id, baseUrl, apiKey, api } = JSON.parse(body);
5874
- if (!id || !baseUrl) throw new Error("id and baseUrl required");
6882
+ let parsed;
6883
+ try { parsed = JSON.parse(body); } catch {
6884
+ res.writeHead(400, { "content-type": "application/json" });
6885
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
6886
+ return;
6887
+ }
6888
+ const vr = validate(ProviderAddSchema, parsed);
6889
+ if (!vr.ok) {
6890
+ res.writeHead(400, { "content-type": "application/json" });
6891
+ res.end(JSON.stringify({ ok: false, error: vr.error }));
6892
+ return;
6893
+ }
6894
+ const { id, baseUrl, apiKey, api } = vr.data;
5875
6895
  const cfgPath = CFG_FILE;
5876
6896
  const cfg = JSON.parse(await readFile(cfgPath, "utf8"));
5877
6897
  if (!cfg.models) cfg.models = {};
@@ -6191,7 +7211,19 @@ const server = http.createServer(async (req, res) => {
6191
7211
  if (url.pathname === "/api/providers/test" && req.method === "POST") {
6192
7212
  let body = "";
6193
7213
  for await (const chunk of req) body += chunk;
6194
- const { providerId } = JSON.parse(body);
7214
+ let parsed;
7215
+ try { parsed = JSON.parse(body); } catch {
7216
+ res.writeHead(400, { "content-type": "application/json" });
7217
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
7218
+ return;
7219
+ }
7220
+ const vr = validate(ProviderTestSchema, parsed);
7221
+ if (!vr.ok) {
7222
+ res.writeHead(400, { "content-type": "application/json" });
7223
+ res.end(JSON.stringify({ ok: false, error: vr.error }));
7224
+ return;
7225
+ }
7226
+ const { providerId } = vr.data;
6195
7227
  const { readFile } = await import("node:fs/promises");
6196
7228
  const cfgPath = CFG_FILE;
6197
7229
  const cfg = JSON.parse(await readFile(cfgPath, "utf8").catch(() => "{}"));
@@ -6618,7 +7650,7 @@ ORDER BY day DESC, cost DESC;`;
6618
7650
  try {
6619
7651
  const { execFile } = await import("node:child_process");
6620
7652
  const ocBin = path.join(os.homedir(), ".opencode", "bin", "opencode");
6621
- const bin = fs.existsSync(ocBin) ? ocBin : "opencode";
7653
+ const bin = await exists(ocBin) ? ocBin : "opencode";
6622
7654
  models = await new Promise((resolve, reject) => {
6623
7655
  const child = execFile(
6624
7656
  bin,
@@ -6644,7 +7676,7 @@ ORDER BY day DESC, cost DESC;`;
6644
7676
  "opencode",
6645
7677
  "auth.json",
6646
7678
  );
6647
- const auth = JSON.parse(fs.readFileSync(authPath, "utf8"));
7679
+ const auth = JSON.parse(await fs.promises.readFile(authPath, "utf8"));
6648
7680
  const providers = Object.keys(auth || {}).map((k) => k.toLowerCase());
6649
7681
  const knownModels = {
6650
7682
  openai: [
@@ -6908,6 +7940,16 @@ ORDER BY day DESC, cost DESC;`;
6908
7940
  for (const m of defaultModels) {
6909
7941
  if (!allModels.includes(m)) allModels.push(m);
6910
7942
  }
7943
+ // Inject OAuth subscription models so they appear in agent primary/fallback dropdowns
7944
+ const CLAUDE_OAUTH_MODELS = [
7945
+ 'claude-haiku-4-5-20251001',
7946
+ 'claude-sonnet-4-6',
7947
+ 'claude-opus-4-6',
7948
+ ];
7949
+ const OPENAI_OAUTH_MODELS = ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.2-codex', 'gpt-5.2', 'gpt-5.1-codex-max', 'gpt-5.1-codex-mini', 'gpt-4o'];
7950
+ for (const m of [...CLAUDE_OAUTH_MODELS, ...OPENAI_OAUTH_MODELS]) {
7951
+ if (!allModels.includes(m)) allModels.push(m);
7952
+ }
6911
7953
  const roleToolDefaults = cfg.roleToolDefaults || {};
6912
7954
  res.writeHead(200, { "content-type": "application/json" });
6913
7955
  res.end(
@@ -6985,7 +8027,7 @@ ORDER BY day DESC, cost DESC;`;
6985
8027
  await import("../lib/memory/shared-adapter.mjs");
6986
8028
  const brainPath = path.join(CREWSWARM_DIR, "memory", "brain.md");
6987
8029
 
6988
- if (!fs.existsSync(brainPath)) {
8030
+ if (!await exists(brainPath)) {
6989
8031
  res.statusCode = 404;
6990
8032
  res.end(JSON.stringify({ ok: false, error: "brain.md not found" }));
6991
8033
  return;
@@ -7204,6 +8246,18 @@ ORDER BY day DESC, cost DESC;`;
7204
8246
  const { readFile, writeFile } = await import("node:fs/promises");
7205
8247
  let body = "";
7206
8248
  for await (const chunk of req) body += chunk;
8249
+ let parsed;
8250
+ try { parsed = JSON.parse(body); } catch {
8251
+ res.writeHead(400, { "content-type": "application/json" });
8252
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
8253
+ return;
8254
+ }
8255
+ const vr = validate(AgentConfigCreateSchema, parsed);
8256
+ if (!vr.ok) {
8257
+ res.writeHead(400, { "content-type": "application/json" });
8258
+ res.end(JSON.stringify({ ok: false, error: vr.error }));
8259
+ return;
8260
+ }
7207
8261
  const {
7208
8262
  id,
7209
8263
  model,
@@ -7212,7 +8266,7 @@ ORDER BY day DESC, cost DESC;`;
7212
8266
  theme,
7213
8267
  systemPrompt,
7214
8268
  alsoAllow: reqAlsoAllow,
7215
- } = JSON.parse(body);
8269
+ } = vr.data;
7216
8270
  const rawId = String(id || "");
7217
8271
  const normalizedId =
7218
8272
  rawId && !rawId.startsWith("crew-")
@@ -7302,8 +8356,19 @@ ORDER BY day DESC, cost DESC;`;
7302
8356
  const { readFile, writeFile } = await import("node:fs/promises");
7303
8357
  let body = "";
7304
8358
  for await (const chunk of req) body += chunk;
7305
- const { agentId } = JSON.parse(body);
7306
- if (!agentId) throw new Error("agentId required");
8359
+ let parsed;
8360
+ try { parsed = JSON.parse(body); } catch {
8361
+ res.writeHead(400, { "content-type": "application/json" });
8362
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
8363
+ return;
8364
+ }
8365
+ const vr = validate(AgentConfigDeleteSchema, parsed);
8366
+ if (!vr.ok) {
8367
+ res.writeHead(400, { "content-type": "application/json" });
8368
+ res.end(JSON.stringify({ ok: false, error: vr.error }));
8369
+ return;
8370
+ }
8371
+ const { agentId } = vr.data;
7307
8372
  const cfgPath = CFG_FILE;
7308
8373
  const promptsPath = path.join(CFG_DIR, "agent-prompts.json");
7309
8374
  const cfg = JSON.parse(await readFile(cfgPath, "utf8"));
@@ -7351,12 +8416,19 @@ ORDER BY day DESC, cost DESC;`;
7351
8416
  ) {
7352
8417
  let body = "";
7353
8418
  for await (const chunk of req) body += chunk;
7354
- const { agentId } = JSON.parse(body || "{}");
7355
- if (!agentId) {
7356
- res.writeHead(400);
7357
- res.end(JSON.stringify({ error: "agentId required" }));
8419
+ let parsed;
8420
+ try { parsed = JSON.parse(body || "{}"); } catch {
8421
+ res.writeHead(400, { "content-type": "application/json" });
8422
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
8423
+ return;
8424
+ }
8425
+ const vr = validate(AgentResetSessionSchema, parsed);
8426
+ if (!vr.ok) {
8427
+ res.writeHead(400, { "content-type": "application/json" });
8428
+ res.end(JSON.stringify({ ok: false, error: vr.error }));
7358
8429
  return;
7359
8430
  }
8431
+ const { agentId } = vr.data;
7360
8432
  const { execFile } = await import("node:child_process");
7361
8433
  const bridgePath = path.join(CREWSWARM_DIR, "gateway-bridge.mjs");
7362
8434
  // 1. Reset the agent session via gateway-bridge --reset-session
@@ -7389,9 +8461,8 @@ ORDER BY day DESC, cost DESC;`;
7389
8461
 
7390
8462
  if (url.pathname === "/api/crew/start" && req.method === "POST") {
7391
8463
  const { spawn: spawnProc } = await import("node:child_process");
7392
- const { existsSync: eS } = await import("node:fs");
7393
8464
  const crewScript = path.join(CREWSWARM_DIR, "scripts", "start-crew.mjs");
7394
- if (!eS(crewScript))
8465
+ if (!(await exists(crewScript)))
7395
8466
  throw new Error(
7396
8467
  "start-crew.mjs not found — is the dashboard running from the crewswarm repo?",
7397
8468
  );
@@ -7434,9 +8505,19 @@ ORDER BY day DESC, cost DESC;`;
7434
8505
  const { writeFile } = await import("node:fs/promises");
7435
8506
  let body = "";
7436
8507
  for await (const chunk of req) body += chunk;
7437
- const { roadmapFile, content } = JSON.parse(body);
7438
- if (!roadmapFile || content === undefined)
7439
- throw new Error("roadmapFile and content required");
8508
+ let parsed;
8509
+ try { parsed = JSON.parse(body); } catch {
8510
+ res.writeHead(400, { "content-type": "application/json" });
8511
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
8512
+ return;
8513
+ }
8514
+ const vr = validate(RoadmapWriteSchema, parsed);
8515
+ if (!vr.ok) {
8516
+ res.writeHead(400, { "content-type": "application/json" });
8517
+ res.end(JSON.stringify({ ok: false, error: vr.error }));
8518
+ return;
8519
+ }
8520
+ const { roadmapFile, content } = vr.data;
7440
8521
  await writeFile(roadmapFile, content, "utf8");
7441
8522
  res.writeHead(200, { "content-type": "application/json" });
7442
8523
  res.end(JSON.stringify({ ok: true }));
@@ -7448,8 +8529,19 @@ ORDER BY day DESC, cost DESC;`;
7448
8529
  const { readFile, writeFile } = await import("node:fs/promises");
7449
8530
  let body = "";
7450
8531
  for await (const chunk of req) body += chunk;
7451
- const { roadmapFile } = JSON.parse(body);
7452
- if (!roadmapFile) throw new Error("roadmapFile required");
8532
+ let parsed;
8533
+ try { parsed = JSON.parse(body); } catch {
8534
+ res.writeHead(400, { "content-type": "application/json" });
8535
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
8536
+ return;
8537
+ }
8538
+ const vr = validate(RoadmapRetryFailedSchema, parsed);
8539
+ if (!vr.ok) {
8540
+ res.writeHead(400, { "content-type": "application/json" });
8541
+ res.end(JSON.stringify({ ok: false, error: vr.error }));
8542
+ return;
8543
+ }
8544
+ const { roadmapFile } = vr.data;
7453
8545
  const content = await readFile(roadmapFile, "utf8");
7454
8546
  // Strip [!] markers back to [ ] and remove failure timestamps
7455
8547
  const reset = content
@@ -7492,13 +8584,13 @@ ORDER BY day DESC, cost DESC;`;
7492
8584
  "agent-prompts.json",
7493
8585
  );
7494
8586
  try {
7495
- const promptsRaw = JSON.parse(fs.readFileSync(promptsFile, "utf8"));
8587
+ const promptsRaw = JSON.parse(await fs.promises.readFile(promptsFile, "utf8"));
7496
8588
  const { applySharedChatPromptOverlay } = await import(
7497
8589
  "../lib/chat/shared-chat-prompt-overlay.mjs"
7498
8590
  );
7499
8591
  let agents = [];
7500
8592
  try {
7501
- const cfg = JSON.parse(fs.readFileSync(CFG_FILE, "utf8"));
8593
+ const cfg = JSON.parse(await fs.promises.readFile(CFG_FILE, "utf8"));
7502
8594
  const list = Array.isArray(cfg.agents)
7503
8595
  ? cfg.agents
7504
8596
  : Array.isArray(cfg.agents?.list)
@@ -7561,13 +8653,13 @@ ORDER BY day DESC, cost DESC;`;
7561
8653
  try {
7562
8654
  let prompts = {};
7563
8655
  try {
7564
- prompts = JSON.parse(fs.readFileSync(promptsFile, "utf8"));
8656
+ prompts = JSON.parse(await fs.promises.readFile(promptsFile, "utf8"));
7565
8657
  } catch { }
7566
8658
 
7567
8659
  const keySet = new Set(Object.keys(prompts));
7568
8660
  let agentIds = [];
7569
8661
  try {
7570
- const cfg = JSON.parse(fs.readFileSync(CFG_FILE, "utf8"));
8662
+ const cfg = JSON.parse(await fs.promises.readFile(CFG_FILE, "utf8"));
7571
8663
  const list = Array.isArray(cfg.agents)
7572
8664
  ? cfg.agents
7573
8665
  : Array.isArray(cfg.agents?.list)
@@ -7586,7 +8678,7 @@ ORDER BY day DESC, cost DESC;`;
7586
8678
  delete prompts[canonicalAgent.slice(5)];
7587
8679
  }
7588
8680
 
7589
- fs.writeFileSync(promptsFile, JSON.stringify(prompts, null, 2), "utf8");
8681
+ await fs.promises.writeFile(promptsFile, JSON.stringify(prompts, null, 2), "utf8");
7590
8682
  console.log(
7591
8683
  `[dashboard] Prompt updated for ${canonicalAgent} (${prompt.length} chars)`,
7592
8684
  );
@@ -7624,11 +8716,11 @@ ORDER BY day DESC, cost DESC;`;
7624
8716
  ]);
7625
8717
  const MAX_FILES = 500;
7626
8718
  const results = [];
7627
- function walk(dir, depth) {
8719
+ async function walk(dir, depth) {
7628
8720
  if (depth > 5) return;
7629
8721
  let entries;
7630
8722
  try {
7631
- entries = fs.readdirSync(dir, { withFileTypes: true });
8723
+ entries = await fs.promises.readdir(dir, { withFileTypes: true });
7632
8724
  } catch {
7633
8725
  return;
7634
8726
  }
@@ -7636,12 +8728,12 @@ ORDER BY day DESC, cost DESC;`;
7636
8728
  if (e.name.startsWith(".") || e.name === "node_modules") continue;
7637
8729
  const full = path.join(dir, e.name);
7638
8730
  if (e.isDirectory()) {
7639
- walk(full, depth + 1);
8731
+ await walk(full, depth + 1);
7640
8732
  } else if (e.isFile()) {
7641
8733
  const ext = path.extname(e.name).toLowerCase();
7642
8734
  if (!ALLOWED_EXT.has(ext)) continue;
7643
8735
  try {
7644
- const st = fs.statSync(full);
8736
+ const st = await fs.promises.stat(full);
7645
8737
  results.push({ path: full, size: st.size, mtime: st.mtimeMs });
7646
8738
  } catch {
7647
8739
  /* skip */
@@ -7650,7 +8742,7 @@ ORDER BY day DESC, cost DESC;`;
7650
8742
  }
7651
8743
  }
7652
8744
  }
7653
- walk(scanDir, 0);
8745
+ await walk(scanDir, 0);
7654
8746
  results.sort((a, b) => b.mtime - a.mtime);
7655
8747
  res.writeHead(200, {
7656
8748
  "content-type": "application/json",
@@ -7669,7 +8761,7 @@ ORDER BY day DESC, cost DESC;`;
7669
8761
  return;
7670
8762
  }
7671
8763
  try {
7672
- const raw = fs.readFileSync(filePath, "utf8");
8764
+ const raw = await fs.promises.readFile(filePath, "utf8");
7673
8765
  const lines = raw.split("\n");
7674
8766
  const content =
7675
8767
  lines.length > 300
@@ -7710,17 +8802,17 @@ ORDER BY day DESC, cost DESC;`;
7710
8802
  "telegram-messages.jsonl",
7711
8803
  );
7712
8804
 
7713
- function loadTgConfig() {
8805
+ async function loadTgConfig() {
7714
8806
  try {
7715
- return JSON.parse(fs.readFileSync(TG_CONFIG_PATH, "utf8"));
8807
+ return JSON.parse(await fs.promises.readFile(TG_CONFIG_PATH, "utf8"));
7716
8808
  } catch {
7717
8809
  return {};
7718
8810
  }
7719
8811
  }
7720
8812
 
7721
- function isTgRunning() {
8813
+ async function isTgRunning() {
7722
8814
  try {
7723
- const pid = parseInt(fs.readFileSync(TG_PID_PATH, "utf8").trim(), 10);
8815
+ const pid = parseInt((await fs.promises.readFile(TG_PID_PATH, "utf8")).trim(), 10);
7724
8816
  if (!pid) return false;
7725
8817
  process.kill(pid, 0);
7726
8818
  return true;
@@ -7730,15 +8822,15 @@ ORDER BY day DESC, cost DESC;`;
7730
8822
  }
7731
8823
 
7732
8824
  if (url.pathname === "/api/telegram/status") {
7733
- const running = isTgRunning();
7734
- const cfg = loadTgConfig();
8825
+ const running = await isTgRunning();
8826
+ const cfg = await loadTgConfig();
7735
8827
  res.writeHead(200, { "content-type": "application/json" });
7736
8828
  res.end(JSON.stringify({ running, botName: cfg.botName || "" }));
7737
8829
  return;
7738
8830
  }
7739
8831
 
7740
8832
  if (url.pathname === "/api/telegram/config" && req.method === "GET") {
7741
- const cfg = loadTgConfig();
8833
+ const cfg = await loadTgConfig();
7742
8834
  res.writeHead(200, { "content-type": "application/json" });
7743
8835
  res.end(
7744
8836
  JSON.stringify({
@@ -7757,9 +8849,9 @@ ORDER BY day DESC, cost DESC;`;
7757
8849
  let raw = "";
7758
8850
  for await (const chunk of req) raw += chunk;
7759
8851
  const body = JSON.parse(raw || "{}");
7760
- const existing = loadTgConfig();
8852
+ const existing = await loadTgConfig();
7761
8853
  const updated = { ...existing, ...body };
7762
- fs.writeFileSync(TG_CONFIG_PATH, JSON.stringify(updated, null, 2));
8854
+ await fs.promises.writeFile(TG_CONFIG_PATH, JSON.stringify(updated, null, 2));
7763
8855
  res.writeHead(200, { "content-type": "application/json" });
7764
8856
  res.end(JSON.stringify({ ok: true }));
7765
8857
  return;
@@ -7770,19 +8862,19 @@ ORDER BY day DESC, cost DESC;`;
7770
8862
  for await (const chunk of req) raw += chunk;
7771
8863
  const body = JSON.parse(raw || "{}");
7772
8864
  if (body.token) {
7773
- const existing = loadTgConfig();
7774
- fs.writeFileSync(
8865
+ const existing = await loadTgConfig();
8866
+ await fs.promises.writeFile(
7775
8867
  TG_CONFIG_PATH,
7776
8868
  JSON.stringify({ ...existing, ...body }, null, 2),
7777
8869
  );
7778
8870
  }
7779
- const cfg = loadTgConfig();
8871
+ const cfg = await loadTgConfig();
7780
8872
  if (!cfg.token) {
7781
8873
  res.writeHead(400, { "content-type": "application/json" });
7782
8874
  res.end(JSON.stringify({ error: "No token configured" }));
7783
8875
  return;
7784
8876
  }
7785
- if (isTgRunning()) {
8877
+ if (await isTgRunning()) {
7786
8878
  res.writeHead(200, { "content-type": "application/json" });
7787
8879
  res.end(JSON.stringify({ ok: true, message: "Already running" }));
7788
8880
  return;
@@ -7807,7 +8899,7 @@ ORDER BY day DESC, cost DESC;`;
7807
8899
 
7808
8900
  if (url.pathname === "/api/telegram/stop" && req.method === "POST") {
7809
8901
  try {
7810
- const pid = parseInt(fs.readFileSync(TG_PID_PATH, "utf8").trim(), 10);
8902
+ const pid = parseInt((await fs.promises.readFile(TG_PID_PATH, "utf8")).trim(), 10);
7811
8903
  if (pid) process.kill(pid, "SIGTERM");
7812
8904
  } catch { }
7813
8905
  res.writeHead(200, { "content-type": "application/json" });
@@ -7817,7 +8909,7 @@ ORDER BY day DESC, cost DESC;`;
7817
8909
 
7818
8910
  if (url.pathname === "/api/telegram/messages") {
7819
8911
  try {
7820
- const raw = fs.readFileSync(TG_MSG_PATH, "utf8");
8912
+ const raw = await fs.promises.readFile(TG_MSG_PATH, "utf8");
7821
8913
  const msgs = raw
7822
8914
  .trim()
7823
8915
  .split("\n")
@@ -7847,7 +8939,7 @@ ORDER BY day DESC, cost DESC;`;
7847
8939
  "logs",
7848
8940
  "telegram-bridge.jsonl",
7849
8941
  );
7850
- const raw = fs.readFileSync(TG_LOG_PATH, "utf8");
8942
+ const raw = await fs.promises.readFile(TG_LOG_PATH, "utf8");
7851
8943
  const lines = raw.trim().split("\n").filter(Boolean);
7852
8944
  const topics = new Map(); // chatId:threadId -> {chatId, threadId, lastSeen, text}
7853
8945
 
@@ -7899,16 +8991,16 @@ ORDER BY day DESC, cost DESC;`;
7899
8991
  );
7900
8992
  const WA_AUTH_DIR = path.join(os.homedir(), ".crewswarm", "whatsapp-auth");
7901
8993
 
7902
- function loadWaCfg() {
8994
+ async function loadWaCfg() {
7903
8995
  try {
7904
- return JSON.parse(fs.readFileSync(WA_CONFIG_PATH, "utf8"));
8996
+ return JSON.parse(await fs.promises.readFile(WA_CONFIG_PATH, "utf8"));
7905
8997
  } catch {
7906
8998
  return {};
7907
8999
  }
7908
9000
  }
7909
- function isWaRunning() {
9001
+ async function isWaRunning() {
7910
9002
  try {
7911
- const pid = parseInt(fs.readFileSync(WA_PID_PATH, "utf8").trim(), 10);
9003
+ const pid = parseInt((await fs.promises.readFile(WA_PID_PATH, "utf8")).trim(), 10);
7912
9004
  if (!pid) return false;
7913
9005
  process.kill(pid, 0);
7914
9006
  return true;
@@ -7927,16 +9019,16 @@ ORDER BY day DESC, cost DESC;`;
7927
9019
  }
7928
9020
 
7929
9021
  if (url.pathname === "/api/whatsapp/status") {
7930
- const running = isWaRunning();
7931
- const authSaved = fs.existsSync(path.join(WA_AUTH_DIR, "creds.json"));
7932
- const cfg = loadWaCfg();
9022
+ const running = await isWaRunning();
9023
+ const authSaved = await exists(path.join(WA_AUTH_DIR, "creds.json"));
9024
+ const cfg = await loadWaCfg();
7933
9025
  res.writeHead(200, { "content-type": "application/json" });
7934
9026
  res.end(JSON.stringify({ running, authSaved, number: cfg.number || "" }));
7935
9027
  return;
7936
9028
  }
7937
9029
 
7938
9030
  if (url.pathname === "/api/whatsapp/config" && req.method === "GET") {
7939
- const cfg = loadWaCfg();
9031
+ const cfg = await loadWaCfg();
7940
9032
  res.writeHead(200, { "content-type": "application/json" });
7941
9033
  res.end(
7942
9034
  JSON.stringify({
@@ -7953,8 +9045,8 @@ ORDER BY day DESC, cost DESC;`;
7953
9045
  let raw = "";
7954
9046
  for await (const chunk of req) raw += chunk;
7955
9047
  const body = JSON.parse(raw || "{}");
7956
- const existing = loadWaCfg();
7957
- fs.writeFileSync(
9048
+ const existing = await loadWaCfg();
9049
+ await fs.promises.writeFile(
7958
9050
  WA_CONFIG_PATH,
7959
9051
  JSON.stringify({ ...existing, ...body }, null, 2),
7960
9052
  );
@@ -7965,13 +9057,13 @@ ORDER BY day DESC, cost DESC;`;
7965
9057
  ".crewswarm",
7966
9058
  "crewswarm.json",
7967
9059
  );
7968
- const swarm = JSON.parse(fs.readFileSync(swarmPath, "utf8"));
9060
+ const swarm = JSON.parse(await fs.promises.readFile(swarmPath, "utf8"));
7969
9061
  swarm.env = swarm.env || {};
7970
9062
  if (body.allowedNumbers !== undefined) {
7971
9063
  swarm.env.WA_ALLOWED_NUMBERS = (body.allowedNumbers || []).join(",");
7972
9064
  }
7973
9065
  if (body.targetAgent) swarm.env.WA_TARGET_AGENT = body.targetAgent;
7974
- fs.writeFileSync(swarmPath, JSON.stringify(swarm, null, 2));
9066
+ await fs.promises.writeFile(swarmPath, JSON.stringify(swarm, null, 2));
7975
9067
  } catch { }
7976
9068
  res.writeHead(200, { "content-type": "application/json" });
7977
9069
  res.end(JSON.stringify({ ok: true }));
@@ -7979,24 +9071,21 @@ ORDER BY day DESC, cost DESC;`;
7979
9071
  }
7980
9072
 
7981
9073
  if (url.pathname === "/api/whatsapp/start" && req.method === "POST") {
7982
- if (isWaRunning()) {
9074
+ if (await isWaRunning()) {
7983
9075
  res.writeHead(200, { "content-type": "application/json" });
7984
9076
  res.end(JSON.stringify({ ok: true, message: "Already running" }));
7985
9077
  return;
7986
9078
  }
7987
- const cfg = loadWaCfg();
7988
- const swarm = (() => {
7989
- try {
7990
- return JSON.parse(
7991
- fs.readFileSync(
7992
- path.join(os.homedir(), ".crewswarm", "crewswarm.json"),
7993
- "utf8",
7994
- ),
7995
- );
7996
- } catch {
7997
- return {};
7998
- }
7999
- })();
9079
+ const cfg = await loadWaCfg();
9080
+ let swarm = {};
9081
+ try {
9082
+ swarm = JSON.parse(
9083
+ await fs.promises.readFile(
9084
+ path.join(os.homedir(), ".crewswarm", "crewswarm.json"),
9085
+ "utf8",
9086
+ ),
9087
+ );
9088
+ } catch { }
8000
9089
  const waEnv = swarm.env || {};
8001
9090
  const { spawn: spawnBridge } = await import("node:child_process");
8002
9091
  const bridgePath = path.join(CREWSWARM_DIR, "whatsapp-bridge.mjs");
@@ -8018,7 +9107,7 @@ ORDER BY day DESC, cost DESC;`;
8018
9107
  });
8019
9108
  proc.unref();
8020
9109
  await new Promise((r) => setTimeout(r, 1500));
8021
- const running = isWaRunning();
9110
+ const running = await isWaRunning();
8022
9111
  res.writeHead(running ? 200 : 500, { "content-type": "application/json" });
8023
9112
  res.end(
8024
9113
  JSON.stringify({
@@ -8034,7 +9123,7 @@ ORDER BY day DESC, cost DESC;`;
8034
9123
 
8035
9124
  if (url.pathname === "/api/whatsapp/stop" && req.method === "POST") {
8036
9125
  try {
8037
- const pid = parseInt(fs.readFileSync(WA_PID_PATH, "utf8").trim(), 10);
9126
+ const pid = parseInt((await fs.promises.readFile(WA_PID_PATH, "utf8")).trim(), 10);
8038
9127
  if (pid) process.kill(pid, "SIGTERM");
8039
9128
  } catch { }
8040
9129
  res.writeHead(200, { "content-type": "application/json" });
@@ -8044,7 +9133,7 @@ ORDER BY day DESC, cost DESC;`;
8044
9133
 
8045
9134
  if (url.pathname === "/api/whatsapp/messages") {
8046
9135
  try {
8047
- const raw = fs.readFileSync(WA_MSG_PATH, "utf8");
9136
+ const raw = await fs.promises.readFile(WA_MSG_PATH, "utf8");
8048
9137
  const msgs = raw
8049
9138
  .trim()
8050
9139
  .split("\n")
@@ -8117,11 +9206,22 @@ ORDER BY day DESC, cost DESC;`;
8117
9206
  }
8118
9207
 
8119
9208
  if (url.pathname === "/api/contacts/delete" && req.method === "POST") {
9209
+ let raw = "";
9210
+ for await (const chunk of req) raw += chunk;
9211
+ let parsed;
9212
+ try { parsed = JSON.parse(raw || "{}"); } catch {
9213
+ res.writeHead(400, { "content-type": "application/json" });
9214
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
9215
+ return;
9216
+ }
9217
+ const vr = validate(ContactDeleteSchema, parsed);
9218
+ if (!vr.ok) {
9219
+ res.writeHead(400, { "content-type": "application/json" });
9220
+ res.end(JSON.stringify({ ok: false, error: vr.error }));
9221
+ return;
9222
+ }
9223
+ const { contactId } = vr.data;
8120
9224
  try {
8121
- let raw = "";
8122
- for await (const chunk of req) raw += chunk;
8123
- const { contactId } = JSON.parse(raw || "{}");
8124
- if (!contactId) throw new Error("contactId required");
8125
9225
  const { deleteContact } = await import("../lib/contacts/index.mjs");
8126
9226
  deleteContact(contactId);
8127
9227
  res.writeHead(200, { "content-type": "application/json" });
@@ -8134,12 +9234,22 @@ ORDER BY day DESC, cost DESC;`;
8134
9234
  }
8135
9235
 
8136
9236
  if (url.pathname === "/api/contacts/send" && req.method === "POST") {
9237
+ let raw = "";
9238
+ for await (const chunk of req) raw += chunk;
9239
+ let parsedSend;
9240
+ try { parsedSend = JSON.parse(raw || "{}"); } catch {
9241
+ res.writeHead(400, { "content-type": "application/json" });
9242
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
9243
+ return;
9244
+ }
9245
+ const vrSend = validate(ContactSendSchema, parsedSend);
9246
+ if (!vrSend.ok) {
9247
+ res.writeHead(400, { "content-type": "application/json" });
9248
+ res.end(JSON.stringify({ ok: false, error: vrSend.error }));
9249
+ return;
9250
+ }
9251
+ const { contactId, platform, message } = vrSend.data;
8137
9252
  try {
8138
- let raw = "";
8139
- for await (const chunk of req) raw += chunk;
8140
- const { contactId, platform, message } = JSON.parse(raw || "{}");
8141
- if (!contactId || !message)
8142
- throw new Error("contactId and message required");
8143
9253
 
8144
9254
  const { getContact } = await import("../lib/contacts/index.mjs");
8145
9255
  const contact = getContact(contactId);
@@ -8155,12 +9265,19 @@ ORDER BY day DESC, cost DESC;`;
8155
9265
  : platformLinks.whatsapp;
8156
9266
  if (waJid) {
8157
9267
  // Call WhatsApp bridge's sendMessage function
8158
- const waUrl = `http://127.0.0.1:${process.env.WA_HTTP_PORT || 3000}/send`;
8159
- await fetch(waUrl, {
9268
+ const waUrl = `http://127.0.0.1:${process.env.WA_HTTP_PORT || 5015}/send`;
9269
+ const waRes = await fetch(waUrl, {
8160
9270
  method: "POST",
8161
9271
  headers: { "content-type": "application/json" },
8162
- body: JSON.stringify({ jid: waJid, message }),
9272
+ body: JSON.stringify({ jid: waJid, text: message }),
8163
9273
  });
9274
+ const waData = await waRes.json().catch(() => ({}));
9275
+ if (!waRes.ok || !waData.ok) {
9276
+ throw new Error(
9277
+ waData.error ||
9278
+ `WhatsApp send failed (${waRes.status})`,
9279
+ );
9280
+ }
8164
9281
  }
8165
9282
  }
8166
9283
 
@@ -8176,10 +9293,10 @@ ORDER BY day DESC, cost DESC;`;
8176
9293
  ".crewswarm",
8177
9294
  "telegram-bridge.json",
8178
9295
  );
8179
- const tgCfg = JSON.parse(fs.readFileSync(TG_CONFIG_PATH, "utf8"));
9296
+ const tgCfg = JSON.parse(await fs.promises.readFile(TG_CONFIG_PATH, "utf8"));
8180
9297
  const botToken = tgCfg.token;
8181
9298
  if (botToken) {
8182
- await fetch(
9299
+ const tgRes = await fetch(
8183
9300
  `https://api.telegram.org/bot${botToken}/sendMessage`,
8184
9301
  {
8185
9302
  method: "POST",
@@ -8187,6 +9304,14 @@ ORDER BY day DESC, cost DESC;`;
8187
9304
  body: JSON.stringify({ chat_id: tgChatId, text: message }),
8188
9305
  },
8189
9306
  );
9307
+ const tgData = await tgRes.json().catch(() => ({}));
9308
+ if (!tgRes.ok || !tgData.ok) {
9309
+ throw new Error(
9310
+ tgData.description ||
9311
+ tgData.error ||
9312
+ `Telegram send failed (${tgRes.status})`,
9313
+ );
9314
+ }
8190
9315
  }
8191
9316
  }
8192
9317
  }
@@ -8204,9 +9329,49 @@ ORDER BY day DESC, cost DESC;`;
8204
9329
  if (url.pathname === "/api/services/status") {
8205
9330
  let services;
8206
9331
  try {
8207
- const { execSync } = await import("node:child_process");
9332
+ const { exec: execCb } = await import("node:child_process");
8208
9333
  const net = await import("node:net");
8209
9334
 
9335
+ // Single ps snapshot — avoids spawning dozens of pgrep child processes
9336
+ const psSnapshot = await new Promise((resolve) => {
9337
+ execCb("ps ax -o pid=,command=", { encoding: "utf8", timeout: 2000 }, (err, stdout) => {
9338
+ resolve(err ? "" : stdout || "");
9339
+ });
9340
+ });
9341
+
9342
+ function findPid(pattern) {
9343
+ const re = new RegExp(pattern);
9344
+ for (const line of psSnapshot.split("\n")) {
9345
+ const trimmed = line.trim();
9346
+ if (!trimmed) continue;
9347
+ const spaceIdx = trimmed.indexOf(" ");
9348
+ if (spaceIdx < 0) continue;
9349
+ const pid = parseInt(trimmed.slice(0, spaceIdx), 10);
9350
+ const cmd = trimmed.slice(spaceIdx + 1);
9351
+ if (re.test(cmd)) return pid;
9352
+ }
9353
+ return null;
9354
+ }
9355
+
9356
+ function findAllPids(pattern) {
9357
+ const re = new RegExp(pattern);
9358
+ const pids = [];
9359
+ for (const line of psSnapshot.split("\n")) {
9360
+ const trimmed = line.trim();
9361
+ if (!trimmed) continue;
9362
+ const spaceIdx = trimmed.indexOf(" ");
9363
+ if (spaceIdx < 0) continue;
9364
+ const pid = parseInt(trimmed.slice(0, spaceIdx), 10);
9365
+ const cmd = trimmed.slice(spaceIdx + 1);
9366
+ if (re.test(cmd)) pids.push(pid);
9367
+ }
9368
+ return pids;
9369
+ }
9370
+
9371
+ function countProcs(pattern) {
9372
+ return findAllPids(pattern).length;
9373
+ }
9374
+
8210
9375
  function portListening(port, timeoutMs = 2000) {
8211
9376
  return new Promise((resolve) => {
8212
9377
  const sock = new net.default.Socket();
@@ -8240,9 +9405,9 @@ ORDER BY day DESC, cost DESC;`;
8240
9405
  }
8241
9406
  }
8242
9407
 
8243
- function pidRunning(pidFile) {
9408
+ async function pidRunning(pidFile) {
8244
9409
  try {
8245
- const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
9410
+ const pid = parseInt((await fs.promises.readFile(pidFile, "utf8")).trim(), 10);
8246
9411
  if (!pid) return null;
8247
9412
  process.kill(pid, 0);
8248
9413
  return pid;
@@ -8251,85 +9416,41 @@ ORDER BY day DESC, cost DESC;`;
8251
9416
  }
8252
9417
  }
8253
9418
 
8254
- function countProcs(pattern) {
8255
- try {
8256
- const out = execSync(`pgrep -f "${pattern}" | wc -l`, {
8257
- encoding: "utf8",
8258
- timeout: 300,
8259
- stdio: ["pipe", "pipe", "pipe"],
8260
- }).trim();
8261
- return parseInt(out, 10) || 0;
8262
- } catch {
8263
- return 0;
8264
- }
8265
- }
8266
-
8267
- function getPid(pattern) {
8268
- try {
8269
- const out = execSync(`pgrep -f "${pattern}"`, {
8270
- encoding: "utf8",
8271
- timeout: 300,
8272
- stdio: ["pipe", "pipe", "pipe"],
8273
- }).trim();
8274
- const pids = out
8275
- .split("\n")
8276
- .filter(Boolean)
8277
- .map((p) => parseInt(p, 10));
8278
- return pids.length > 0 ? pids[0] : null;
8279
- } catch {
8280
- return null;
8281
- }
8282
- }
8283
-
8284
- function getAllPids(pattern) {
8285
- try {
8286
- const out = execSync(`pgrep -f "${pattern}"`, {
8287
- encoding: "utf8",
8288
- timeout: 300,
8289
- stdio: ["pipe", "pipe", "pipe"],
8290
- }).trim();
8291
- return out
8292
- .split("\n")
8293
- .filter(Boolean)
8294
- .map((p) => parseInt(p, 10));
8295
- } catch {
8296
- return [];
9419
+ async function commandExistsFast(bin, extraPaths = []) {
9420
+ const candidate = String(bin || "").trim();
9421
+ if (!candidate) return false;
9422
+ if (candidate.includes("/")) {
9423
+ return await exists(candidate);
8297
9424
  }
8298
- }
8299
-
8300
- function procStartTime(pid) {
8301
- try {
8302
- const out = execSync(`ps -p ${pid} -o lstart=`, {
8303
- encoding: "utf8",
8304
- timeout: 300,
8305
- stdio: ["pipe", "pipe", "pipe"],
8306
- }).trim();
8307
- return out ? new Date(out).getTime() : null;
8308
- } catch {
8309
- return null;
9425
+ const checks = [
9426
+ ...extraPaths,
9427
+ path.join("/usr/local/bin", candidate),
9428
+ path.join("/opt/homebrew/bin", candidate),
9429
+ path.join(os.homedir(), ".local", "bin", candidate),
9430
+ path.join(os.homedir(), "bin", candidate),
9431
+ ];
9432
+ for (const item of checks) {
9433
+ if (item && await exists(item)) return true;
8310
9434
  }
9435
+ return false;
8311
9436
  }
8312
9437
 
8313
9438
  const crewLeadPort = Number(process.env.CREW_LEAD_PORT || 5010);
8314
- const tgPid = pidRunning(
9439
+ const tgPid = await pidRunning(
8315
9440
  path.join(os.homedir(), ".crewswarm", "logs", "telegram-bridge.pid"),
8316
9441
  );
8317
9442
  const waPid =
8318
- pidRunning(
9443
+ (await pidRunning(
8319
9444
  path.join(os.homedir(), ".crewswarm", "logs", "whatsapp-bridge.pid"),
8320
- ) || getPid("whatsapp-bridge.mjs");
9445
+ )) || findPid("whatsapp-bridge\\.mjs");
9446
+
9447
+ // Fire network checks in parallel — all process lookups are instant (ps snapshot)
8321
9448
  const rtStatusPromise = fetch("http://127.0.0.1:18889/status", {
8322
- signal: AbortSignal.timeout(2000),
9449
+ signal: AbortSignal.timeout(5000),
8323
9450
  });
8324
9451
  const mcpHealthPromise = httpOk("http://127.0.0.1:5020/health", 3000);
8325
9452
  const [
8326
- rtUp,
8327
- crewLeadUp,
8328
- gwUp,
8329
- ocPortUp,
8330
- dashUp,
8331
- studioUp,
8332
- watchUp,
9453
+ rtUp, crewLeadUp, gwUp, ocPortUp, dashUp, studioUp, watchUp,
8333
9454
  ] = await Promise.all([
8334
9455
  portListening(18889),
8335
9456
  portListening(crewLeadPort),
@@ -8339,37 +9460,38 @@ ORDER BY day DESC, cost DESC;`;
8339
9460
  portListening(3333),
8340
9461
  portListening(3334),
8341
9462
  ]);
8342
- const rtPid = getPid("opencrew-rt-daemon");
8343
- const crewLeadPid = getPid("crew-lead.mjs");
8344
- const gwPid = getPid("openclaw-gateway");
9463
+
9464
+ // All PID lookups are instant — pure JS regex against ps snapshot
9465
+ const rtPid = findPid("opencrew-rt-daemon");
9466
+ const crewLeadPid = findPid("crew-lead\\.mjs");
9467
+ const gwPid = findPid("openclaw-gateway");
9468
+ const ocPid = findPid("\\.opencode serve") || findPid("opencode serve") || findPid("bin/\\.opencode");
9469
+ const mcpPid = findPid("mcp-server\\.mjs");
9470
+ const studioPid = findPid("apps/vibe/server\\.mjs") || findPid("npm.*studio:start");
9471
+ const watchPid = findPid("watch-server\\.mjs");
9472
+
9473
+ // commandExists — fs.existsSync only, no shell spawn
9474
+ const codexInstalled = await commandExistsFast(process.env.CODEX_CLI_BIN || "codex");
9475
+ const claudeInstalled = await commandExistsFast(process.env.CLAUDE_CODE_BIN || "claude");
9476
+ const cursorInstalled = (await commandExistsFast(
9477
+ process.env.CURSOR_CLI_BIN || path.join(os.homedir(), ".local", "bin", "agent"),
9478
+ [path.join(os.homedir(), ".local", "bin", "agent")],
9479
+ )) || (await commandExistsFast("agent", [path.join(os.homedir(), ".local", "bin", "agent")]));
9480
+ const geminiInstalled = await commandExistsFast(process.env.GEMINI_CLI_BIN || "gemini");
9481
+ const crewCliInstalled = (await commandExistsFast("crew", [
9482
+ path.join(CREWSWARM_DIR, "crew-cli", "dist", "index.js"),
9483
+ ])) || (await exists(path.join(CREWSWARM_DIR, "crew-cli", "dist", "index.js")));
8345
9484
  const oclawPaired =
8346
- fs.existsSync(
9485
+ (await exists(
8347
9486
  path.join(os.homedir(), ".openclaw", "devices", "paired.json"),
8348
- ) ||
8349
- fs.existsSync(path.join(os.homedir(), ".openclaw", "device.json"));
8350
- const ocPid =
8351
- getPid("\\.opencode serve") ||
8352
- getPid("opencode serve") ||
8353
- getPid("bin/.opencode") ||
8354
- getPid("/.opencode");
9487
+ )) ||
9488
+ (await exists(path.join(os.homedir(), ".openclaw", "device.json")));
8355
9489
  const ocUp = ocPortUp || ocPid !== null;
8356
- const codexInstalled = commandExists(process.env.CODEX_CLI_BIN || "codex");
8357
- const claudeInstalled = commandExists(process.env.CLAUDE_CODE_BIN || "claude");
8358
- const cursorInstalled = commandExists(
8359
- process.env.CURSOR_CLI_BIN ||
8360
- path.join(os.homedir(), ".local", "bin", "agent"),
8361
- [path.join(os.homedir(), ".local", "bin", "agent")],
8362
- ) || commandExists("agent", [path.join(os.homedir(), ".local", "bin", "agent")]);
8363
- const geminiInstalled = commandExists(process.env.GEMINI_CLI_BIN || "gemini");
8364
- const crewCliInstalled =
8365
- commandExists("crew", [
8366
- path.join(CREWSWARM_DIR, "crew-cli", "dist", "index.js"),
8367
- ]) ||
8368
- fs.existsSync(path.join(CREWSWARM_DIR, "crew-cli", "dist", "index.js"));
9490
+
8369
9491
  let swarmCfg = {};
8370
9492
  try {
8371
9493
  swarmCfg = JSON.parse(
8372
- fs.readFileSync(
9494
+ await fs.promises.readFile(
8373
9495
  path.join(os.homedir(), ".crewswarm", "crewswarm.json"),
8374
9496
  "utf8",
8375
9497
  ),
@@ -8391,10 +9513,6 @@ ORDER BY day DESC, cost DESC;`;
8391
9513
  cfgEnv.CREWSWARM_OPENCODE_ENABLED === "1" ||
8392
9514
  process.env.CREWSWARM_OPENCODE_ENABLED === "on" ||
8393
9515
  process.env.CREWSWARM_OPENCODE_ENABLED === "1";
8394
- const mcpPid = getPid("mcp-server.mjs");
8395
- const studioPid =
8396
- getPid("apps/vibe/server.mjs") || getPid("npm.*studio:start");
8397
- const watchPid = getPid("watch-server.mjs");
8398
9516
 
8399
9517
  // Agent count: ask RT bus which agents are actually connected (most reliable source)
8400
9518
  let agentsOnline = 0;
@@ -8408,11 +9526,11 @@ ORDER BY day DESC, cost DESC;`;
8408
9526
  (a) => String(a).toLowerCase() !== "crew-lead",
8409
9527
  );
8410
9528
  agentsOnline = rtAgentList.length;
8411
- agentPids = getAllPids("gateway-bridge.mjs --rt-daemon");
9529
+ agentPids = findAllPids("gateway-bridge\\.mjs --rt-daemon");
8412
9530
  } catch {
8413
- // RT not reachable — fall back to pgrep for count, config for names
8414
- agentsOnline = countProcs("gateway-bridge.mjs --rt-daemon");
8415
- agentPids = getAllPids("gateway-bridge.mjs --rt-daemon");
9531
+ // RT not reachable — fall back to ps snapshot for count, config for names
9532
+ agentsOnline = countProcs("gateway-bridge\\.mjs --rt-daemon");
9533
+ agentPids = findAllPids("gateway-bridge\\.mjs --rt-daemon");
8416
9534
  try {
8417
9535
  rtAgentList = (swarmCfg.agents || [])
8418
9536
  .map((a) => a.id)
@@ -8428,7 +9546,7 @@ ORDER BY day DESC, cost DESC;`;
8428
9546
  } catch { }
8429
9547
  if (agentsTotal === 0) agentsTotal = 14;
8430
9548
  agentsTotal = Math.max(agentsTotal, agentsOnline);
8431
- const pmCount = countProcs("pm-loop.mjs");
9549
+ const pmCount = countProcs("pm-loop\\.mjs");
8432
9550
 
8433
9551
  services = [
8434
9552
  {
@@ -8458,7 +9576,7 @@ ORDER BY day DESC, cost DESC;`;
8458
9576
  {
8459
9577
  id: "crew-lead",
8460
9578
  label: "crew-lead",
8461
- description: "Chat commander — dashboard chat, CrewChat, Telegram",
9579
+ description: "Chat commander — dashboard chat, crewchat, Telegram",
8462
9580
  port: crewLeadPort,
8463
9581
  running: crewLeadUp,
8464
9582
  canRestart: true,
@@ -8590,7 +9708,7 @@ ORDER BY day DESC, cost DESC;`;
8590
9708
  label: "Vibe UI",
8591
9709
  description: studioUp
8592
9710
  ? "Monaco editor + agent chat — Cursor-like IDE for crewswarm"
8593
- : "Run: npm run studio:start (port 3333)",
9711
+ : "Run: npm run vibe:start (port 3333)",
8594
9712
  port: 3333,
8595
9713
  running: studioUp,
8596
9714
  canRestart: true,
@@ -8602,7 +9720,7 @@ ORDER BY day DESC, cost DESC;`;
8602
9720
  label: "Vibe Watch Server",
8603
9721
  description: watchUp
8604
9722
  ? "CLI → Vibe live reload WebSocket relay (port 3334)"
8605
- : "Run: npm run studio:watch — enables live file reload in Vibe",
9723
+ : "Run: npm run vibe:watch — enables live file reload in Vibe",
8606
9724
  port: 3334,
8607
9725
  running: watchUp,
8608
9726
  canRestart: true,
@@ -8637,12 +9755,8 @@ ORDER BY day DESC, cost DESC;`;
8637
9755
  canRestart: false,
8638
9756
  pid: (() => {
8639
9757
  try {
8640
- const pids = execSync("pgrep -f 'pm-loop.mjs'", {
8641
- encoding: "utf8",
8642
- timeout: 300,
8643
- stdio: ["pipe", "pipe", "pipe"],
8644
- }).trim().split("\n").filter(Boolean).map(p => parseInt(p, 10));
8645
- return pids.length === 1 ? pids[0] : pids;
9758
+ const pids = findAllPids("pm-loop\\.mjs");
9759
+ return pids.length === 1 ? pids[0] : (pids.length > 0 ? pids : null);
8646
9760
  } catch {
8647
9761
  return null;
8648
9762
  }
@@ -8854,17 +9968,15 @@ ORDER BY day DESC, cost DESC;`;
8854
9968
  } else if (id === "telegram") {
8855
9969
  try {
8856
9970
  const pid = parseInt(
8857
- fs
8858
- .readFileSync(
8859
- path.join(
8860
- os.homedir(),
8861
- ".crewswarm",
8862
- "logs",
8863
- "telegram-bridge.pid",
8864
- ),
8865
- "utf8",
8866
- )
8867
- .trim(),
9971
+ (await fs.promises.readFile(
9972
+ path.join(
9973
+ os.homedir(),
9974
+ ".crewswarm",
9975
+ "logs",
9976
+ "telegram-bridge.pid",
9977
+ ),
9978
+ "utf8",
9979
+ )).trim(),
8868
9980
  10,
8869
9981
  );
8870
9982
  if (pid) process.kill(pid, "SIGTERM");
@@ -8872,17 +9984,15 @@ ORDER BY day DESC, cost DESC;`;
8872
9984
  } else if (id === "whatsapp") {
8873
9985
  try {
8874
9986
  const pid = parseInt(
8875
- fs
8876
- .readFileSync(
8877
- path.join(
8878
- os.homedir(),
8879
- ".crewswarm",
8880
- "logs",
8881
- "whatsapp-bridge.pid",
8882
- ),
8883
- "utf8",
8884
- )
8885
- .trim(),
9987
+ (await fs.promises.readFile(
9988
+ path.join(
9989
+ os.homedir(),
9990
+ ".crewswarm",
9991
+ "logs",
9992
+ "whatsapp-bridge.pid",
9993
+ ),
9994
+ "utf8",
9995
+ )).trim(),
8886
9996
  10,
8887
9997
  );
8888
9998
  if (pid) process.kill(pid, "SIGTERM");
@@ -8898,8 +10008,8 @@ ORDER BY day DESC, cost DESC;`;
8898
10008
  let killed = false;
8899
10009
 
8900
10010
  try {
8901
- if (fs.existsSync(pidFile)) {
8902
- const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
10011
+ if (await exists(pidFile)) {
10012
+ const pid = parseInt((await fs.promises.readFile(pidFile, "utf8")).trim(), 10);
8903
10013
  if (pid && !isNaN(pid)) {
8904
10014
  try {
8905
10015
  process.kill(pid, 0); // Check if process exists
@@ -8911,7 +10021,7 @@ ORDER BY day DESC, cost DESC;`;
8911
10021
  );
8912
10022
  } catch (e) {
8913
10023
  // Process doesn't exist, clean up stale PID file
8914
- fs.writeFileSync(pidFile, "");
10024
+ await fs.promises.writeFile(pidFile, "");
8915
10025
  }
8916
10026
  }
8917
10027
  }
@@ -9153,20 +10263,7 @@ ORDER BY day DESC, cost DESC;`;
9153
10263
  .filter(Boolean)
9154
10264
  .join("\n");
9155
10265
 
9156
- const token = (() => {
9157
- try {
9158
- return (
9159
- JSON.parse(
9160
- fs.readFileSync(
9161
- path.join(os.homedir(), ".crewswarm", "crewswarm.json"),
9162
- "utf8",
9163
- ),
9164
- )?.rt?.authToken || ""
9165
- );
9166
- } catch {
9167
- return "";
9168
- }
9169
- })();
10266
+ const token = resolveCrewLeadAuthToken();
9170
10267
  try {
9171
10268
  const upstream = await fetch(
9172
10269
  "http://127.0.0.1:5010/api/engine-passthrough",
@@ -9282,13 +10379,11 @@ ORDER BY day DESC, cost DESC;`;
9282
10379
  const userDir = path.join(os.homedir(), ".crewswarm", "engines");
9283
10380
  const enginesMap = {};
9284
10381
  for (const dir of [bundledDir, userDir]) {
9285
- if (!fs.existsSync(dir)) continue;
9286
- for (const f of fs
9287
- .readdirSync(dir)
9288
- .filter((f) => f.endsWith(".json"))) {
10382
+ if (!await exists(dir)) continue;
10383
+ for (const f of (await fs.promises.readdir(dir)).filter((f) => f.endsWith(".json"))) {
9289
10384
  try {
9290
10385
  const eng = JSON.parse(
9291
- fs.readFileSync(path.join(dir, f), "utf8"),
10386
+ await fs.promises.readFile(path.join(dir, f), "utf8"),
9292
10387
  );
9293
10388
  if (eng.id)
9294
10389
  enginesMap[eng.id] = {
@@ -9302,11 +10397,11 @@ ORDER BY day DESC, cost DESC;`;
9302
10397
  const configPath = path.join(os.homedir(), ".crewswarm", "crewswarm.json");
9303
10398
  let envVars = {};
9304
10399
  try {
9305
- const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
10400
+ const cfg = JSON.parse(await fs.promises.readFile(configPath, "utf8"));
9306
10401
  envVars = cfg.env || {};
9307
10402
  } catch { }
9308
10403
 
9309
- const engines = Object.values(enginesMap).map((eng) => {
10404
+ const engines = await Promise.all(Object.values(enginesMap).map(async (eng) => {
9310
10405
  let installed = false;
9311
10406
  try {
9312
10407
  const bin = eng.bin || eng.id;
@@ -9315,7 +10410,7 @@ ORDER BY day DESC, cost DESC;`;
9315
10410
  } catch {
9316
10411
  if (eng.binAlternate) {
9317
10412
  const alt = eng.binAlternate.replace(/^~/, os.homedir());
9318
- installed = fs.existsSync(alt);
10413
+ installed = await exists(alt);
9319
10414
  }
9320
10415
  }
9321
10416
  const missingEnv = eng.requiresAuth
@@ -9336,7 +10431,7 @@ ORDER BY day DESC, cost DESC;`;
9336
10431
  ready: installed && missingEnv.length === 0,
9337
10432
  enabled,
9338
10433
  };
9339
- });
10434
+ }));
9340
10435
  res.writeHead(200, { "content-type": "application/json" });
9341
10436
  res.end(JSON.stringify({ engines }));
9342
10437
  } catch (err) {
@@ -9363,10 +10458,10 @@ ORDER BY day DESC, cost DESC;`;
9363
10458
  if (!eng.id || !eng.label)
9364
10459
  throw new Error("Engine descriptor must have id and label");
9365
10460
  const engDir = path.join(os.homedir(), ".crewswarm", "engines");
9366
- if (!fs.existsSync(engDir)) fs.mkdirSync(engDir, { recursive: true });
10461
+ if (!await exists(engDir)) await fs.promises.mkdir(engDir, { recursive: true });
9367
10462
  const outPath = path.join(engDir, `${eng.id}.json`);
9368
10463
  if (!outPath.startsWith(engDir)) throw new Error("Invalid engine id");
9369
- fs.writeFileSync(outPath, JSON.stringify(eng, null, 2), "utf8");
10464
+ await fs.promises.writeFile(outPath, JSON.stringify(eng, null, 2), "utf8");
9370
10465
  res.writeHead(200, { "content-type": "application/json" });
9371
10466
  res.end(JSON.stringify({ ok: true, id: eng.id, label: eng.label }));
9372
10467
  } catch (err) {
@@ -9389,8 +10484,8 @@ ORDER BY day DESC, cost DESC;`;
9389
10484
  let engineDef = null;
9390
10485
  for (const dir of [bundledDir, userDir]) {
9391
10486
  const p = path.join(dir, `${engineId}.json`);
9392
- if (fs.existsSync(p)) {
9393
- engineDef = JSON.parse(fs.readFileSync(p, "utf8"));
10487
+ if (await exists(p)) {
10488
+ engineDef = JSON.parse(await fs.promises.readFile(p, "utf8"));
9394
10489
  break;
9395
10490
  }
9396
10491
  }
@@ -9401,12 +10496,12 @@ ORDER BY day DESC, cost DESC;`;
9401
10496
 
9402
10497
  const envVarName = engineDef.envToggle;
9403
10498
  const configPath = path.join(os.homedir(), ".crewswarm", "crewswarm.json");
9404
- const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
10499
+ const cfg = JSON.parse(await fs.promises.readFile(configPath, "utf8"));
9405
10500
 
9406
10501
  if (!cfg.env) cfg.env = {};
9407
10502
  cfg.env[envVarName] = enabled ? "1" : "off";
9408
10503
 
9409
- fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2), "utf8");
10504
+ await fs.promises.writeFile(configPath, JSON.stringify(cfg, null, 2), "utf8");
9410
10505
 
9411
10506
  res.writeHead(200, { "content-type": "application/json" });
9412
10507
  res.end(JSON.stringify({ ok: true, engineId, enabled, envVar: envVarName }));
@@ -9427,7 +10522,7 @@ ORDER BY day DESC, cost DESC;`;
9427
10522
  return;
9428
10523
  }
9429
10524
  try {
9430
- fs.unlinkSync(target);
10525
+ await fs.promises.unlink(target);
9431
10526
  } catch { }
9432
10527
  res.writeHead(200, { "content-type": "application/json" });
9433
10528
  res.end(JSON.stringify({ ok: true }));
@@ -9583,13 +10678,13 @@ ORDER BY day DESC, cost DESC;`;
9583
10678
  ".crewswarm",
9584
10679
  "skills",
9585
10680
  );
9586
- if (!fs.existsSync(skillsDir))
9587
- fs.mkdirSync(skillsDir, { recursive: true });
10681
+ if (!await exists(skillsDir))
10682
+ await fs.promises.mkdir(skillsDir, { recursive: true });
9588
10683
  const outPath = path.join(skillsDir, `${skillName}.json`);
9589
10684
  // Final path traversal guard
9590
10685
  if (!outPath.startsWith(skillsDir))
9591
10686
  throw new Error("Invalid skill name");
9592
- fs.writeFileSync(outPath, JSON.stringify(skill, null, 2), "utf8");
10687
+ await fs.promises.writeFile(outPath, JSON.stringify(skill, null, 2), "utf8");
9593
10688
 
9594
10689
  res.writeHead(200, { "content-type": "application/json" });
9595
10690
  res.end(
@@ -9683,14 +10778,35 @@ if (process.argv.includes("--print-html")) {
9683
10778
  }
9684
10779
 
9685
10780
  process.on("uncaughtException", (err) => {
10781
+ const msg = String(err?.message || err || "");
9686
10782
  console.error(
9687
10783
  "[dashboard] uncaughtException:",
9688
- err?.stack || err?.message || err,
10784
+ err?.stack || msg,
9689
10785
  );
9690
10786
 
9691
- // Always exit on uncaught exceptions process is in undefined state
9692
- console.error("[dashboard] FATAL — exiting due to uncaught exception");
9693
- process.exit(1);
10787
+ // Benign errors from engine passthrough / SSE streams keep alive
10788
+ if (
10789
+ msg === "terminated" ||
10790
+ msg === "aborted" ||
10791
+ /client.*disconnect/i.test(msg) ||
10792
+ /socket hang up/i.test(msg) ||
10793
+ /ECONNRESET/i.test(msg) ||
10794
+ /EPIPE/i.test(msg) ||
10795
+ /fetch failed/i.test(msg) ||
10796
+ /UND_ERR/i.test(msg)
10797
+ ) {
10798
+ console.error("[dashboard] Non-fatal uncaughtException — keeping alive");
10799
+ return;
10800
+ }
10801
+
10802
+ // Fatal errors: port conflicts, permissions, OOM — must exit
10803
+ if (/EADDRINUSE|EACCES|out of memory|cannot allocate/i.test(msg)) {
10804
+ console.error("[dashboard] FATAL — exiting due to uncaught exception");
10805
+ process.exit(1);
10806
+ }
10807
+
10808
+ // Default: log but keep alive — engine passthrough errors shouldn't kill the dashboard
10809
+ console.error("[dashboard] Unexpected uncaughtException — keeping alive (not fatal)");
9694
10810
  });
9695
10811
 
9696
10812
  process.on("unhandledRejection", (reason) => {