@mrpatronz/nexusflow 0.2.17 → 0.2.19

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 (320) hide show
  1. package/.agents/skills/ccc/SKILL.md +71 -0
  2. package/.agents/skills/ccc/references/management.md +110 -0
  3. package/.agents/skills/ccc/references/settings.md +126 -0
  4. package/.github/dependabot.yml +52 -52
  5. package/.github/workflows/ci.yml +44 -0
  6. package/.github/workflows/dependabot-merge.yml +53 -53
  7. package/.github/workflows/release-desktop.yml +67 -0
  8. package/.github/workflows/release-npm.yml +65 -0
  9. package/.github/workflows/release-vscode.yml +61 -0
  10. package/.vscode/launch.json +17 -17
  11. package/.vscode/tasks.json +17 -17
  12. package/GETTING_STARTED.md +114 -114
  13. package/README.md +364 -336
  14. package/desktop/build-installer.js +228 -228
  15. package/desktop/build.js +118 -118
  16. package/desktop/installer/nexusflow.iss +35 -35
  17. package/desktop/neutralino.config.json +38 -38
  18. package/desktop/package-lock.json +1060 -1060
  19. package/desktop/package.json +14 -14
  20. package/dist/commands/adapter.d.ts +27 -0
  21. package/dist/commands/adapter.d.ts.map +1 -0
  22. package/dist/commands/adapter.js +318 -0
  23. package/dist/commands/adapter.js.map +1 -0
  24. package/dist/commands/add-repo.d.ts.map +1 -1
  25. package/dist/commands/add-repo.js +0 -1
  26. package/dist/commands/add-repo.js.map +1 -1
  27. package/dist/commands/commands.test.js +2 -2
  28. package/dist/commands/commands.test.js.map +1 -1
  29. package/dist/commands/config.d.ts +22 -0
  30. package/dist/commands/config.d.ts.map +1 -0
  31. package/dist/commands/config.js +50 -0
  32. package/dist/commands/config.js.map +1 -0
  33. package/dist/commands/create.d.ts.map +1 -1
  34. package/dist/commands/create.js +14 -17
  35. package/dist/commands/create.js.map +1 -1
  36. package/dist/commands/doctor.d.ts.map +1 -1
  37. package/dist/commands/doctor.js +11 -0
  38. package/dist/commands/doctor.js.map +1 -1
  39. package/dist/commands/refresh.d.ts +1 -0
  40. package/dist/commands/refresh.d.ts.map +1 -1
  41. package/dist/commands/refresh.js +2 -14
  42. package/dist/commands/refresh.js.map +1 -1
  43. package/dist/commands/sync.d.ts.map +1 -1
  44. package/dist/commands/sync.js +36 -54
  45. package/dist/commands/sync.js.map +1 -1
  46. package/dist/commands/tui.d.ts.map +1 -1
  47. package/dist/commands/tui.js +63 -10
  48. package/dist/commands/tui.js.map +1 -1
  49. package/dist/core/adapters/local-storage.d.ts +14 -0
  50. package/dist/core/adapters/local-storage.d.ts.map +1 -0
  51. package/dist/core/adapters/local-storage.js +58 -0
  52. package/dist/core/adapters/local-storage.js.map +1 -0
  53. package/dist/core/adapters/obsidian-storage.d.ts +28 -0
  54. package/dist/core/adapters/obsidian-storage.d.ts.map +1 -0
  55. package/dist/core/adapters/obsidian-storage.js +135 -0
  56. package/dist/core/adapters/obsidian-storage.js.map +1 -0
  57. package/dist/core/adapters/registry.d.ts +8 -0
  58. package/dist/core/adapters/registry.d.ts.map +1 -0
  59. package/dist/core/adapters/registry.js +37 -0
  60. package/dist/core/adapters/registry.js.map +1 -0
  61. package/dist/core/adapters/storage.test.d.ts +2 -0
  62. package/dist/core/adapters/storage.test.d.ts.map +1 -0
  63. package/dist/core/adapters/storage.test.js +106 -0
  64. package/dist/core/adapters/storage.test.js.map +1 -0
  65. package/dist/core/adapters/vault-storage.d.ts +16 -0
  66. package/dist/core/adapters/vault-storage.d.ts.map +1 -0
  67. package/dist/core/adapters/vault-storage.js +77 -0
  68. package/dist/core/adapters/vault-storage.js.map +1 -0
  69. package/dist/core/config.d.ts.map +1 -1
  70. package/dist/core/config.js +28 -4
  71. package/dist/core/config.js.map +1 -1
  72. package/dist/core/plugins/index.d.ts +14 -0
  73. package/dist/core/plugins/index.d.ts.map +1 -0
  74. package/dist/core/plugins/index.js +2 -0
  75. package/dist/core/plugins/index.js.map +1 -0
  76. package/dist/core/plugins/loader.d.ts +2 -0
  77. package/dist/core/plugins/loader.d.ts.map +1 -0
  78. package/dist/core/plugins/loader.js +34 -0
  79. package/dist/core/plugins/loader.js.map +1 -0
  80. package/dist/core/ports/storage.d.ts +59 -0
  81. package/dist/core/ports/storage.d.ts.map +1 -0
  82. package/dist/core/ports/storage.js +10 -0
  83. package/dist/core/ports/storage.js.map +1 -0
  84. package/dist/core/storage.d.ts +10 -0
  85. package/dist/core/storage.d.ts.map +1 -0
  86. package/dist/core/storage.js +29 -0
  87. package/dist/core/storage.js.map +1 -0
  88. package/dist/core/sync.d.ts +44 -0
  89. package/dist/core/sync.d.ts.map +1 -0
  90. package/dist/core/sync.js +82 -0
  91. package/dist/core/sync.js.map +1 -0
  92. package/dist/core/workspace-state.d.ts +51 -0
  93. package/dist/core/workspace-state.d.ts.map +1 -0
  94. package/dist/core/workspace-state.js +108 -0
  95. package/dist/core/workspace-state.js.map +1 -0
  96. package/dist/core/workspace-state.test.d.ts +2 -0
  97. package/dist/core/workspace-state.test.d.ts.map +1 -0
  98. package/dist/core/workspace-state.test.js +84 -0
  99. package/dist/core/workspace-state.test.js.map +1 -0
  100. package/dist/core/workspace.d.ts.map +1 -1
  101. package/dist/core/workspace.js +110 -16
  102. package/dist/core/workspace.js.map +1 -1
  103. package/dist/generators/base.d.ts.map +1 -1
  104. package/dist/generators/base.js +80 -79
  105. package/dist/generators/base.js.map +1 -1
  106. package/dist/generators/codex.js +11 -11
  107. package/dist/generators/copilot.js +17 -17
  108. package/dist/generators/cursor.js +5 -5
  109. package/dist/generators/diff-context.d.ts +6 -0
  110. package/dist/generators/diff-context.d.ts.map +1 -0
  111. package/dist/generators/diff-context.js +68 -0
  112. package/dist/generators/diff-context.js.map +1 -0
  113. package/dist/generators/index.d.ts +1 -1
  114. package/dist/generators/index.d.ts.map +1 -1
  115. package/dist/generators/index.js +125 -68
  116. package/dist/generators/index.js.map +1 -1
  117. package/dist/generators/map-generator.d.ts.map +1 -1
  118. package/dist/generators/map-generator.js +21 -25
  119. package/dist/generators/map-generator.js.map +1 -1
  120. package/dist/generators/plan-generator.d.ts.map +1 -1
  121. package/dist/generators/plan-generator.js +3 -5
  122. package/dist/generators/plan-generator.js.map +1 -1
  123. package/dist/generators/skills-generator.js +28 -28
  124. package/dist/gui/assets/index-Bdbld6yY.css +1 -0
  125. package/dist/gui/assets/index-D8iJPvnk.js +26 -0
  126. package/dist/gui/icons.svg +24 -24
  127. package/dist/gui/index.html +18 -18
  128. package/dist/index.js +131 -20
  129. package/dist/index.js.map +1 -1
  130. package/dist/mcp/server.d.ts.map +1 -1
  131. package/dist/mcp/server.js +42 -0
  132. package/dist/mcp/server.js.map +1 -1
  133. package/dist/server.d.ts.map +1 -1
  134. package/dist/server.js +140 -56
  135. package/dist/server.js.map +1 -1
  136. package/dist/server.test.js +105 -48
  137. package/dist/server.test.js.map +1 -1
  138. package/dist/types.d.ts +38 -2
  139. package/dist/types.d.ts.map +1 -1
  140. package/dist/utils/local-ai.d.ts.map +1 -1
  141. package/dist/utils/local-ai.js +7 -3
  142. package/dist/utils/local-ai.js.map +1 -1
  143. package/dist/utils/multi-git.d.ts +33 -8
  144. package/dist/utils/multi-git.d.ts.map +1 -1
  145. package/dist/utils/multi-git.js +91 -19
  146. package/dist/utils/multi-git.js.map +1 -1
  147. package/dist/utils/multi-git.test.d.ts +2 -0
  148. package/dist/utils/multi-git.test.d.ts.map +1 -0
  149. package/dist/utils/multi-git.test.js +125 -0
  150. package/dist/utils/multi-git.test.js.map +1 -0
  151. package/dist/utils/update-check.d.ts.map +1 -1
  152. package/dist/utils/update-check.js +0 -28
  153. package/dist/utils/update-check.js.map +1 -1
  154. package/dist/utils/workflow-advisor.d.ts +16 -0
  155. package/dist/utils/workflow-advisor.d.ts.map +1 -0
  156. package/dist/utils/workflow-advisor.js +100 -0
  157. package/dist/utils/workflow-advisor.js.map +1 -0
  158. package/extension/package-lock.json +6044 -2673
  159. package/extension/package.json +117 -108
  160. package/extension/src/extension.ts +653 -631
  161. package/extension/tsconfig.json +21 -21
  162. package/gui/README.md +73 -73
  163. package/gui/e2e/dashboard.spec.ts +61 -0
  164. package/gui/e2e/wizard.spec.ts +346 -346
  165. package/gui/eslint.config.js +26 -26
  166. package/gui/index.html +17 -17
  167. package/gui/package-lock.json +3137 -3079
  168. package/gui/package.json +37 -36
  169. package/gui/playwright.config.ts +41 -41
  170. package/gui/public/icons.svg +24 -24
  171. package/gui/src/App.css +1 -1
  172. package/gui/src/App.tsx +3573 -3407
  173. package/gui/src/app/AppSidebar.tsx +69 -0
  174. package/gui/src/assets/vite.svg +1 -1
  175. package/gui/src/components/AddRepoPicker.tsx +81 -0
  176. package/gui/src/components/ui/Button.tsx +40 -0
  177. package/gui/src/components/ui/Card.tsx +6 -0
  178. package/gui/src/components/ui/EmptyState.tsx +22 -0
  179. package/gui/src/components/ui/Input.tsx +21 -0
  180. package/gui/src/components/ui/Menu.tsx +71 -0
  181. package/gui/src/components/ui/Modal.tsx +39 -0
  182. package/gui/src/components/ui/PageHeader.tsx +21 -0
  183. package/gui/src/components/ui/RepoStatusStrip.tsx +33 -0
  184. package/gui/src/components/ui/Skeleton.tsx +5 -0
  185. package/gui/src/components/ui/StatusPill.tsx +36 -0
  186. package/gui/src/components/ui/Tabs.tsx +40 -0
  187. package/gui/src/components/ui/cn.ts +4 -0
  188. package/gui/src/components/ui/index.ts +16 -0
  189. package/gui/src/features/changes/ChangesViewer.tsx +388 -388
  190. package/gui/src/features/knowledge/KnowledgeBase.tsx +84 -84
  191. package/gui/src/features/onboarding/OnboardingWizard.tsx +230 -230
  192. package/gui/src/features/plan/ImplementationPlan.tsx +32 -32
  193. package/gui/src/features/services/ServiceConsole.tsx +215 -250
  194. package/gui/src/features/sessions/SessionHistory.tsx +195 -195
  195. package/gui/src/index.css +167 -152
  196. package/gui/src/lib/status.ts +24 -0
  197. package/gui/src/main.tsx +94 -94
  198. package/gui/src/pages/DashboardPage.tsx +129 -0
  199. package/gui/src/pages/WorkspacesPage.tsx +352 -0
  200. package/gui/src/types.ts +81 -62
  201. package/gui/tsconfig.app.json +25 -25
  202. package/gui/tsconfig.json +7 -7
  203. package/gui/tsconfig.node.json +24 -24
  204. package/gui/vite.config.ts +12 -12
  205. package/package.json +55 -56
  206. package/resources/workflows/plan-implement-review.md +8 -8
  207. package/resources/workflows/research-verify.md +6 -6
  208. package/resources/workflows/solo-developer.md +3 -3
  209. package/scripts/simulate-workspaces.ts +55 -55
  210. package/skills-lock.json +11 -0
  211. package/src/analyzers/detect-apis.ts +290 -290
  212. package/src/analyzers/detect-deps.ts +315 -315
  213. package/src/analyzers/detect-existing.ts +74 -74
  214. package/src/analyzers/detect-ports.ts +110 -110
  215. package/src/analyzers/index.ts +97 -97
  216. package/src/analyzers/messaging-analyzer.ts +254 -254
  217. package/src/analyzers/readme-summarizer.ts +102 -102
  218. package/src/analyzers/run-analyzer.ts +269 -269
  219. package/src/analyzers/tech-stack.ts +283 -283
  220. package/src/commands/adapter.ts +348 -0
  221. package/src/commands/add-repo.ts +156 -157
  222. package/src/commands/commands.test.ts +156 -155
  223. package/src/commands/commit.ts +131 -131
  224. package/src/commands/config.ts +52 -0
  225. package/src/commands/create.ts +204 -207
  226. package/src/commands/desktop.ts +128 -128
  227. package/src/commands/diff.ts +125 -125
  228. package/src/commands/doctor.ts +370 -357
  229. package/src/commands/handoff.ts +266 -266
  230. package/src/commands/init.ts +134 -134
  231. package/src/commands/list.ts +46 -46
  232. package/src/commands/logs.ts +63 -63
  233. package/src/commands/mcp.ts +94 -92
  234. package/src/commands/open.ts +129 -129
  235. package/src/commands/refresh.ts +137 -147
  236. package/src/commands/remove.ts +98 -98
  237. package/src/commands/start.ts +117 -117
  238. package/src/commands/status.ts +54 -54
  239. package/src/commands/stop.ts +55 -55
  240. package/src/commands/sync.ts +125 -151
  241. package/src/commands/tui.ts +484 -424
  242. package/src/commands/ui.ts +111 -111
  243. package/src/core/adapters/local-storage.ts +66 -0
  244. package/src/core/adapters/obsidian-storage.ts +151 -0
  245. package/src/core/adapters/registry.ts +44 -0
  246. package/src/core/adapters/storage.test.ts +174 -0
  247. package/src/core/adapters/vault-storage.ts +86 -0
  248. package/src/core/config.test.ts +96 -96
  249. package/src/core/config.ts +140 -115
  250. package/src/core/graph.ts +344 -344
  251. package/src/core/plugins/index.ts +17 -0
  252. package/src/core/plugins/loader.ts +36 -0
  253. package/src/core/ports/storage.ts +71 -0
  254. package/src/core/scanner.test.ts +61 -61
  255. package/src/core/scanner.ts +91 -91
  256. package/src/core/storage.ts +37 -0
  257. package/src/core/sync.ts +121 -0
  258. package/src/core/workspace-state.test.ts +108 -0
  259. package/src/core/workspace-state.ts +130 -0
  260. package/src/core/workspace.ts +487 -380
  261. package/src/core/worktree.ts +120 -120
  262. package/src/generators/antigravity.ts +32 -32
  263. package/src/generators/base.ts +243 -240
  264. package/src/generators/claude.ts +34 -34
  265. package/src/generators/codex.ts +48 -48
  266. package/src/generators/copilot.ts +56 -56
  267. package/src/generators/cursor.ts +44 -44
  268. package/src/generators/diff-context.ts +75 -0
  269. package/src/generators/index.ts +322 -250
  270. package/src/generators/map-generator.test.ts +159 -159
  271. package/src/generators/map-generator.ts +539 -542
  272. package/src/generators/plan-generator.ts +483 -481
  273. package/src/generators/skills-generator.ts +259 -259
  274. package/src/index.ts +539 -426
  275. package/src/mcp/server.ts +338 -294
  276. package/src/orchestration/detect.ts +313 -313
  277. package/src/orchestration/index.ts +7 -7
  278. package/src/orchestration/runner.ts +283 -283
  279. package/src/server.test.ts +618 -555
  280. package/src/server.ts +1402 -1308
  281. package/src/types.ts +451 -408
  282. package/src/utils/detect-ai.test.ts +44 -44
  283. package/src/utils/detect-ai.ts +84 -84
  284. package/src/utils/detect-editors.test.ts +48 -48
  285. package/src/utils/detect-editors.ts +57 -57
  286. package/src/utils/git.test.ts +74 -74
  287. package/src/utils/git.ts +117 -117
  288. package/src/utils/local-ai.test.ts +130 -130
  289. package/src/utils/local-ai.ts +116 -111
  290. package/src/utils/multi-git.test.ts +158 -0
  291. package/src/utils/multi-git.ts +405 -313
  292. package/src/utils/prompts.ts +209 -209
  293. package/src/utils/session-finder.ts +483 -483
  294. package/src/utils/system-scanner.test.ts +89 -89
  295. package/src/utils/system-scanner.ts +96 -96
  296. package/src/utils/update-check.ts +286 -309
  297. package/src/utils/workflow-advisor.ts +118 -0
  298. package/src/utils/workflows.test.ts +114 -114
  299. package/src/utils/workflows.ts +178 -178
  300. package/tsconfig.json +19 -19
  301. package/vitest.config.ts +14 -14
  302. package/.github/workflows/release.yml +0 -112
  303. package/dist/commands/pack.d.ts +0 -7
  304. package/dist/commands/pack.d.ts.map +0 -1
  305. package/dist/commands/pack.js +0 -64
  306. package/dist/commands/pack.js.map +0 -1
  307. package/dist/core/packer.d.ts +0 -14
  308. package/dist/core/packer.d.ts.map +0 -1
  309. package/dist/core/packer.js +0 -87
  310. package/dist/core/packer.js.map +0 -1
  311. package/dist/core/packer.test.d.ts +0 -2
  312. package/dist/core/packer.test.d.ts.map +0 -1
  313. package/dist/core/packer.test.js +0 -82
  314. package/dist/core/packer.test.js.map +0 -1
  315. package/dist/gui/assets/index-Ct3qq-X4.js +0 -25
  316. package/dist/gui/assets/index-P_ZrgHXg.css +0 -1
  317. package/gui/src/features/workspace/WorkspaceList.tsx +0 -666
  318. package/src/commands/pack.ts +0 -73
  319. package/src/core/packer.test.ts +0 -99
  320. package/src/core/packer.ts +0 -107
package/src/server.ts CHANGED
@@ -1,1308 +1,1402 @@
1
- /**
2
- * @module server
3
- * Hono local web server for the NexusFlow GUI.
4
- */
5
-
6
- import { Hono } from 'hono';
7
- import { serve } from '@hono/node-server';
8
- import { serveStatic } from '@hono/node-server/serve-static';
9
- import { cors } from 'hono/cors';
10
- import { streamSSE } from 'hono/streaming';
11
- import * as fs from 'node:fs/promises';
12
- import { createWriteStream, existsSync } from 'node:fs';
13
- import * as path from 'node:path';
14
- import { fileURLToPath } from 'node:url';
15
- import { execa } from 'execa';
16
- import * as os from 'node:os';
17
- import { pipeline } from 'node:stream/promises';
18
- import { spawn } from 'node:child_process';
19
-
20
- import { loadConfig, saveConfig, getConfigDir } from './core/config.js';
21
- import { scanForRepos } from './core/scanner.js';
22
- import { createWorkspace, listWorkspaces, loadFeatureConfig, deleteWorkspace, addRepoToWorkspace } from './core/workspace.js';
23
- import { analyzeAllRepos } from './analyzers/index.js';
24
- import { generateContextFiles } from './generators/index.js';
25
- import { packWorkspace } from './core/packer.js';
26
- import { isOllamaModelAvailable, getOpenAiCompatibleUrl, callLocalLlm } from './utils/local-ai.js';
27
- import { detectAIAssistants } from './utils/detect-ai.js';
28
- import { detectEditors } from './utils/detect-editors.js';
29
- import { findSessions, getSessionTranscript } from './utils/session-finder.js';
30
- import { scanSystemSpecs } from './utils/system-scanner.js';
31
- import { getWorkspaceRepos, rebaseRepo, commitAndPush, getRepoStatus } from './utils/multi-git.js';
32
- import {
33
- detectAllServices,
34
- detectOrchestrationTools,
35
- startServices,
36
- stopServices,
37
- loadRunningState,
38
- } from './orchestration/index.js';
39
- import { checkForUpdates, getCurrentVersion, getToolsStatus } from './utils/update-check.js';
40
- import { getWorkflowTemplates, saveWorkflowTemplate, deleteWorkflowTemplate } from './utils/workflows.js';
41
- import type { Feature, RepoInfo, WorkspaceContext } from './types.js';
42
-
43
- // Resolve static files directory
44
- const __filename = fileURLToPath(import.meta.url);
45
- const __dirname = path.dirname(__filename);
46
-
47
- // In development, the static GUI is built to dist/gui
48
- // In production, it will be served from dist/gui as well
49
- const guiPath = path.join(__dirname, 'gui');
50
-
51
- export const app = new Hono();
52
-
53
- // Allowed editor binaries/scripts to prevent command injection
54
- const ALLOWED_EDITORS = new Set(['code', 'code-insiders', 'cursor', 'antigravity', 'agy', 'idea', 'charm', 'webstorm', 'subl', 'nano', 'vim', 'nvim', 'emacs']);
55
-
56
- // Enable CORS for frontend dev server
57
- app.use('/api/*', cors());
58
-
59
- // ─── API Endpoints ────────────────────────────────────────────────────────
60
-
61
- // 1. Get current configuration
62
- app.get('/api/config', async (c) => {
63
- try {
64
- const configPath = path.join(getConfigDir(), 'config.json');
65
- let exists = false;
66
- try {
67
- await fs.access(configPath);
68
- exists = true;
69
- } catch {}
70
-
71
- const config = await loadConfig();
72
- return c.json({ config, exists });
73
- } catch (error) {
74
- const msg = error instanceof Error ? error.message : String(error);
75
- return c.json({ error: msg }, 500);
76
- }
77
- });
78
-
79
- function isSafeLocalEndpoint(urlStr: string): boolean {
80
- try {
81
- const url = new URL(urlStr);
82
- const hostname = url.hostname.toLowerCase();
83
- if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]') {
84
- return true;
85
- }
86
- const ipv4Pattern = /^(?:10|127|192\.168|172\.(?:1[6-9]|2[0-9]|3[01]))\.\d+\.\d+\.\d+$/;
87
- if (ipv4Pattern.test(hostname)) {
88
- return true;
89
- }
90
- return false;
91
- } catch {
92
- return false;
93
- }
94
- }
95
-
96
- // 2. Save configuration
97
- app.post('/api/config', async (c) => {
98
- try {
99
- const newConfig = await c.req.json();
100
- if (newConfig?.localLlm?.enabled && newConfig.localLlm.endpoint) {
101
- if (!isSafeLocalEndpoint(newConfig.localLlm.endpoint)) {
102
- return c.json({ error: 'Local AI endpoint must be localhost, 127.0.0.1, or a private LAN IP.' }, 400);
103
- }
104
- }
105
- await saveConfig(newConfig);
106
- return c.json({ success: true, config: newConfig });
107
- } catch (error) {
108
- const msg = error instanceof Error ? error.message : String(error);
109
- return c.json({ error: msg }, 500);
110
- }
111
- });
112
-
113
- // 3. Scan development directory for repositories
114
- app.get('/api/repos', async (c) => {
115
- try {
116
- const config = await loadConfig();
117
- const repos = await scanForRepos(config.devDir, config.scanDepth);
118
- return c.json(repos);
119
- } catch (error) {
120
- const msg = error instanceof Error ? error.message : String(error);
121
- return c.json({ error: msg }, 500);
122
- }
123
- });
124
-
125
- // 4. List existing workspaces
126
- app.get('/api/workspaces', async (c) => {
127
- try {
128
- const config = await loadConfig();
129
- const workspaces = await listWorkspaces(config.workspacesDir);
130
- return c.json(workspaces);
131
- } catch (error) {
132
- const msg = error instanceof Error ? error.message : String(error);
133
- return c.json({ error: msg }, 500);
134
- }
135
- });
136
-
137
- // 5. Detect available AI assistants
138
- app.get('/api/ai-detect', async (c) => {
139
- try {
140
- const assistants = await detectAIAssistants();
141
- return c.json(assistants);
142
- } catch (error) {
143
- const msg = error instanceof Error ? error.message : String(error);
144
- return c.json({ error: msg }, 500);
145
- }
146
- });
147
-
148
- // 6. Detect available editors
149
- app.get('/api/editor-detect', async (c) => {
150
- try {
151
- const editors = await detectEditors();
152
- return c.json(editors);
153
- } catch (error) {
154
- const msg = error instanceof Error ? error.message : String(error);
155
- return c.json({ error: msg }, 500);
156
- }
157
- });
158
-
159
- // 6.5. Local LLM test & recommendation
160
- app.post('/api/local-llm/test', async (c) => {
161
- try {
162
- const { provider, endpoint, model, shoot } = await c.req.json();
163
- if (!endpoint || !isSafeLocalEndpoint(endpoint)) {
164
- return c.json({ success: false, error: 'Local AI endpoint must be localhost, 127.0.0.1, or a private LAN IP.' }, 400);
165
- }
166
- const cleanEndpoint = endpoint.replace(/\/$/, '');
167
-
168
- if (shoot) {
169
- const responseText = await callLocalLlm(
170
- { enabled: true, provider, endpoint, model },
171
- [{ role: 'user', content: 'Respond with the exact word "OK" and nothing else.' }]
172
- );
173
- const cleanResponse = responseText.trim();
174
- return c.json({
175
- success: true,
176
- modelReady: true,
177
- message: `Inference test succeeded! Response from model: "${cleanResponse}"`
178
- });
179
- }
180
-
181
- if (provider === 'ollama') {
182
- const res = await fetch(`${cleanEndpoint}/api/tags`);
183
- if (!res.ok) throw new Error(`Ollama responded with status ${res.status}`);
184
- const data: any = await res.json();
185
- const models = data?.models || [];
186
- const isModelLoaded = isOllamaModelAvailable(models, model);
187
- return c.json({
188
- success: true,
189
- modelReady: isModelLoaded,
190
- message: isModelLoaded ? 'Connected successfully! Model is ready.' : `Connected successfully, but model "${model}" is not pulled. Run "ollama pull ${model}" to install it.`
191
- });
192
- } else {
193
- const testUrl = getOpenAiCompatibleUrl(cleanEndpoint, '/v1/models');
194
- const res = await fetch(testUrl);
195
- if (!res.ok) throw new Error(`OpenAI-compatible server responded with status ${res.status}`);
196
- return c.json({ success: true, modelReady: true, message: 'Connected successfully to OpenAI-compatible server!' });
197
- }
198
- } catch (error) {
199
- const msg = error instanceof Error ? error.message : String(error);
200
- return c.json({ success: false, error: msg }, 400);
201
- }
202
- });
203
-
204
- app.get('/api/local-llm/recommend', async (c) => {
205
- try {
206
- const specs = await scanSystemSpecs();
207
- return c.json(specs);
208
- } catch (error) {
209
- return c.json({
210
- totalRamGb: 8,
211
- gpuName: 'Unknown/Integrated',
212
- hasHardwareAcceleration: false,
213
- recommendedModel: 'qwen2.5-coder:1.5b',
214
- });
215
- }
216
- });
217
-
218
-
219
- interface JobStep {
220
- id: string;
221
- name: string;
222
- status: 'pending' | 'running' | 'completed' | 'failed';
223
- message: string;
224
- }
225
-
226
- interface CreationJob {
227
- id: string;
228
- status: 'running' | 'completed' | 'failed';
229
- progress: number;
230
- error?: string;
231
- steps: JobStep[];
232
- workspacePath?: string;
233
- feature?: Feature;
234
- listeners: Set<(event: { type: string; data: any }) => void>;
235
- }
236
-
237
- const creationJobs = new Map<string, CreationJob>();
238
-
239
- function updateJobStep(
240
- jobId: string,
241
- stepId: string,
242
- status: 'running' | 'completed' | 'failed',
243
- message: string,
244
- extraData?: any
245
- ) {
246
- const job = creationJobs.get(jobId);
247
- if (!job) return;
248
-
249
- const step = job.steps.find((s) => s.id === stepId);
250
- if (step) {
251
- step.status = status;
252
- step.message = message;
253
- }
254
-
255
- const isLastStep = job.steps[job.steps.length - 1]?.id === stepId;
256
- if (isLastStep && status === 'completed') {
257
- job.status = 'completed';
258
- job.progress = 100;
259
- } else if (status === 'failed') {
260
- job.status = 'failed';
261
- job.error = message;
262
- } else {
263
- const completedCount = job.steps.filter((s) => s.status === 'completed').length;
264
- const runningCount = job.steps.filter((s) => s.status === 'running').length;
265
- job.progress = Math.round(((completedCount + runningCount * 0.5) / job.steps.length) * 100);
266
- }
267
-
268
- const eventPayload = {
269
- type: 'progress',
270
- data: {
271
- id: job.id,
272
- status: job.status,
273
- progress: job.progress,
274
- error: job.error,
275
- steps: job.steps,
276
- workspacePath: job.workspacePath,
277
- feature: job.feature,
278
- ...extraData,
279
- },
280
- };
281
-
282
- for (const listener of job.listeners) {
283
- listener(eventPayload);
284
- }
285
- }
286
-
287
- async function runCreationJob(jobId: string, body: any, config: any) {
288
- try {
289
- const workspacePath = path.join(config.workspacesDir, body.branchName);
290
- const job = creationJobs.get(jobId);
291
- if (job) {
292
- job.workspacePath = workspacePath;
293
- }
294
-
295
- const feature: Feature = {
296
- id: body.branchName,
297
- branchName: body.branchName,
298
- description: body.description,
299
- repos: body.repos.map((r: any) => path.join(workspacePath, r.name)),
300
- originalRepos: body.repos.map((r: any) => r.path),
301
- assistants: body.assistants,
302
- workspacePath,
303
- createdAt: new Date().toISOString(),
304
- resumption: body.resumption,
305
- localLlmEnabled: body.localLlmEnabled,
306
- teamworkInstructions: body.teamworkInstructions,
307
- };
308
- if (job) {
309
- job.feature = feature;
310
- }
311
-
312
- // Step 1: Create worktrees
313
- updateJobStep(jobId, 'worktrees', 'running', 'Creating git worktrees...');
314
- await createWorkspace(feature, body.repos);
315
- updateJobStep(jobId, 'worktrees', 'completed', 'Git worktrees created successfully.');
316
-
317
- // Step 2: Analyze repos
318
- updateJobStep(jobId, 'analysis', 'running', 'Analyzing projects and dependencies...');
319
- const workspaceRepos = body.repos.map((repo: any) => ({
320
- ...repo,
321
- path: path.join(workspacePath, repo.name),
322
- }));
323
- const analysis = await analyzeAllRepos(workspaceRepos);
324
- updateJobStep(jobId, 'analysis', 'completed', 'Project analysis complete.');
325
-
326
- // Step 3: Generate AI context files
327
- updateJobStep(jobId, 'context', 'running', 'Generating AI context files...');
328
- const ctx: WorkspaceContext = {
329
- feature,
330
- repos: workspaceRepos,
331
- analysis,
332
- localLlm: config.localLlm,
333
- };
334
- await generateContextFiles(ctx, body.assistants, workspacePath);
335
- updateJobStep(jobId, 'context', 'completed', 'AI context files generated.');
336
-
337
- // Step 4: Pack codebase context
338
- if (config.packContextXml) {
339
- updateJobStep(jobId, 'pack', 'running', 'Packing codebase context with Repomix...');
340
- const packResult = await packWorkspace(workspacePath);
341
- updateJobStep(jobId, 'pack', 'completed', `Packed codebase context (${packResult.totalFiles} files, ${(packResult.fileSize / 1024).toFixed(2)} KB).`);
342
- }
343
-
344
- } catch (error) {
345
- const msg = error instanceof Error ? error.message : String(error);
346
- const job = creationJobs.get(jobId);
347
- if (job) {
348
- const runningStep = job.steps.find((s) => s.status === 'running');
349
- const failedStepId = runningStep ? runningStep.id : 'worktrees';
350
- updateJobStep(jobId, failedStepId, 'failed', msg);
351
- }
352
- }
353
- }
354
-
355
- // 7. Create workspace
356
- app.post('/api/workspace', async (c) => {
357
- try {
358
- const body = await c.req.json() as {
359
- branchName: string;
360
- description: string;
361
- repos: RepoInfo[];
362
- assistants: any[];
363
- localLlmEnabled?: boolean;
364
- teamworkInstructions?: string;
365
- resumption?: {
366
- testCommand?: string;
367
- mockCommand?: string;
368
- startCommand?: string;
369
- };
370
- };
371
-
372
- const config = await loadConfig();
373
- const jobId = body.branchName;
374
-
375
- if (creationJobs.has(jobId)) {
376
- const existing = creationJobs.get(jobId)!;
377
- if (existing.status === 'running') {
378
- return c.json({ success: true, jobId, message: 'Job already running' });
379
- }
380
- }
381
-
382
- const steps: JobStep[] = [
383
- { id: 'worktrees', name: 'Create Git Worktrees', status: 'pending', message: 'Waiting...' },
384
- { id: 'analysis', name: 'Analyze Repositories', status: 'pending', message: 'Waiting...' },
385
- { id: 'context', name: 'Generate AI Context Files', status: 'pending', message: 'Waiting...' },
386
- ];
387
- if (config.packContextXml) {
388
- steps.push({ id: 'pack', name: 'Pack Codebase Context', status: 'pending', message: 'Waiting...' });
389
- }
390
-
391
- const job: CreationJob = {
392
- id: jobId,
393
- status: 'running',
394
- progress: 0,
395
- steps,
396
- listeners: new Set(),
397
- };
398
- creationJobs.set(jobId, job);
399
-
400
- // Run the job in background
401
- runCreationJob(jobId, body, config);
402
-
403
- return c.json({ success: true, jobId });
404
- } catch (error) {
405
- const msg = error instanceof Error ? error.message : String(error);
406
- return c.json({ error: msg }, 500);
407
- }
408
- });
409
-
410
- // 7.5. Create workspace SSE stream
411
- app.get('/api/workspace/create-stream/:jobId', async (c) => {
412
- const jobId = decodeURIComponent(c.req.param('jobId'));
413
- const job = creationJobs.get(jobId);
414
- if (!job) {
415
- return c.json({ error: 'Job not found' }, 404);
416
- }
417
-
418
- c.header('Content-Type', 'text/event-stream');
419
- c.header('Cache-Control', 'no-cache');
420
- c.header('Connection', 'keep-alive');
421
-
422
- return streamSSE(c, async (stream) => {
423
- // Send initial state
424
- await stream.writeSSE({
425
- event: 'progress',
426
- data: JSON.stringify({
427
- id: job.id,
428
- status: job.status,
429
- progress: job.progress,
430
- error: job.error,
431
- steps: job.steps,
432
- workspacePath: job.workspacePath,
433
- feature: job.feature,
434
- }),
435
- });
436
-
437
- if (job.status === 'completed' || job.status === 'failed') {
438
- return;
439
- }
440
-
441
- const listener = async (event: { type: string; data: any }) => {
442
- try {
443
- await stream.writeSSE({
444
- event: event.type,
445
- data: JSON.stringify(event.data),
446
- });
447
- } catch (err) {
448
- job.listeners.delete(listener);
449
- }
450
- };
451
-
452
- job.listeners.add(listener);
453
-
454
- // Keep connection alive until job finishes
455
- while (job.status === 'running') {
456
- await new Promise((resolve) => setTimeout(resolve, 1000));
457
- }
458
-
459
- job.listeners.delete(listener);
460
- });
461
- });
462
-
463
- // 7.6. Delete workspace
464
- app.delete('/api/workspace/:id', async (c) => {
465
- try {
466
- const id = decodeURIComponent(c.req.param('id'));
467
- const config = await loadConfig();
468
- const workspacePath = path.join(config.workspacesDir, id);
469
-
470
- await deleteWorkspace(workspacePath);
471
- return c.json({ success: true });
472
- } catch (error) {
473
- const msg = error instanceof Error ? error.message : String(error);
474
- return c.json({ error: msg }, 500);
475
- }
476
- });
477
-
478
- // 7.7. Add repo to workspace
479
- app.post('/api/workspace/:id/repo', async (c) => {
480
- try {
481
- const id = decodeURIComponent(c.req.param('id'));
482
- const { repoPath } = await c.req.json() as { repoPath: string };
483
- const config = await loadConfig();
484
- const workspacePath = path.join(config.workspacesDir, id);
485
-
486
- await addRepoToWorkspace(workspacePath, repoPath);
487
- return c.json({ success: true });
488
- } catch (error) {
489
- const msg = error instanceof Error ? error.message : String(error);
490
- return c.json({ error: msg }, 500);
491
- }
492
- });
493
-
494
- // 8. Open workspace in editor
495
- app.post('/api/open-editor', async (c) => {
496
- try {
497
- const { workspacePath, command } = await c.req.json() as {
498
- workspacePath: string;
499
- command: string;
500
- };
501
-
502
- if (!ALLOWED_EDITORS.has(command)) {
503
- return c.json({ error: 'Forbidden editor command' }, 400);
504
- }
505
-
506
- // Validate path exists and is a directory
507
- try {
508
- const stats = await fs.stat(workspacePath);
509
- if (!stats.isDirectory()) {
510
- return c.json({ error: 'Workspace path is not a directory' }, 400);
511
- }
512
- } catch {
513
- return c.json({ error: 'Workspace path does not exist' }, 400);
514
- }
515
-
516
- // Spawn editor process
517
- const isWin = process.platform === 'win32';
518
- const child = execa(command, [workspacePath], {
519
- detached: true,
520
- stdio: 'ignore',
521
- shell: isWin,
522
- cleanup: false,
523
- });
524
- child.unref();
525
- child.catch((err) => {
526
- console.error(`Failed to launch editor ${command} for path ${workspacePath}:`, err);
527
- });
528
-
529
- return c.json({ success: true });
530
- } catch (error) {
531
- const msg = error instanceof Error ? error.message : String(error);
532
- return c.json({ error: msg }, 500);
533
- }
534
- });
535
-
536
- // 9. Get status of running services in all/specific workspace
537
- app.get('/api/workspace/:id/services', async (c) => {
538
- try {
539
- const id = c.req.param('id');
540
- const config = await loadConfig();
541
- const workspacePath = path.join(config.workspacesDir, id);
542
-
543
- // Detect all services
544
- const services = await detectAllServices(workspacePath);
545
- // Detect existing tools
546
- const tools = await detectOrchestrationTools(workspacePath);
547
- // Get currently running services from running state
548
- const runningState = await loadRunningState(workspacePath);
549
-
550
- return c.json({
551
- services,
552
- orchestrationTools: tools,
553
- runningState: runningState?.services || [],
554
- });
555
- } catch (error) {
556
- const msg = error instanceof Error ? error.message : String(error);
557
- return c.json({ error: msg }, 500);
558
- }
559
- });
560
-
561
- // 10. Start services in workspace
562
- app.post('/api/workspace/:id/services/start', async (c) => {
563
- try {
564
- const id = c.req.param('id');
565
- const { services } = await c.req.json() as { services: any[] };
566
- const config = await loadConfig();
567
- const workspacePath = path.join(config.workspacesDir, id);
568
- const logDir = path.join(workspacePath, '.nexusflow-logs');
569
-
570
- await startServices(services, workspacePath, logDir);
571
- return c.json({ success: true });
572
- } catch (error) {
573
- const msg = error instanceof Error ? error.message : String(error);
574
- return c.json({ error: msg }, 500);
575
- }
576
- });
577
-
578
- // 11. Stop services in workspace
579
- app.post('/api/workspace/:id/services/stop', async (c) => {
580
- try {
581
- const id = c.req.param('id');
582
- const config = await loadConfig();
583
- const workspacePath = path.join(config.workspacesDir, id);
584
-
585
- await stopServices(workspacePath);
586
- return c.json({ success: true });
587
- } catch (error) {
588
- const msg = error instanceof Error ? error.message : String(error);
589
- return c.json({ error: msg }, 500);
590
- }
591
- });
592
-
593
- // 12. Get service logs
594
- app.get('/api/workspace/:id/services/logs/:serviceName', async (c) => {
595
- try {
596
- const id = c.req.param('id');
597
- const serviceName = c.req.param('serviceName');
598
- const config = await loadConfig();
599
- const workspacePath = path.join(config.workspacesDir, id);
600
- const logFile = path.join(workspacePath, '.nexusflow-logs', `${serviceName}.log`);
601
-
602
- let content = '';
603
- try {
604
- // Read last 200 lines or 50KB
605
- const stats = await fs.stat(logFile);
606
- const size = stats.size;
607
- const start = Math.max(0, size - 50000);
608
- const fd = await fs.open(logFile, 'r');
609
- const buffer = Buffer.alloc(size - start);
610
- await fd.read(buffer, 0, buffer.length, start);
611
- await fd.close();
612
- content = buffer.toString('utf-8');
613
- } catch {
614
- content = 'No logs available yet.';
615
- }
616
-
617
- return c.json({ logs: content });
618
- } catch (error) {
619
- const msg = error instanceof Error ? error.message : String(error);
620
- return c.json({ error: msg }, 500);
621
- }
622
- });
623
-
624
- // 13. Get git changes in workspace sub-repositories
625
- app.get('/api/workspace/:id/changes', async (c) => {
626
- try {
627
- const id = c.req.param('id');
628
- const config = await loadConfig();
629
- const workspacePath = path.join(config.workspacesDir, id);
630
-
631
- // Load feature config to get repo paths
632
- const feature = await loadFeatureConfig(workspacePath);
633
- if (!feature) {
634
- return c.json({ error: 'Workspace configuration not found.' }, 404);
635
- }
636
-
637
- const results: any[] = [];
638
-
639
- // Check git status in each repo
640
- for (const repoPath of feature.repos) {
641
- const repoName = path.basename(repoPath);
642
- const worktreePath = path.join(workspacePath, repoName);
643
-
644
- try {
645
- const { stdout } = await execa('git', ['status', '--porcelain'], { cwd: worktreePath });
646
- const lines = stdout.split('\n').map((l) => l.trim()).filter(Boolean);
647
-
648
- // Get numstat to determine additions and deletions per file
649
- const numstatMap = new Map<string, { additions: number; deletions: number }>();
650
- try {
651
- const { stdout: numstatRaw } = await execa('git', ['diff', 'HEAD', '--numstat'], {
652
- cwd: worktreePath,
653
- });
654
- const numstatLines = numstatRaw.split('\n').filter(Boolean);
655
- for (const numLine of numstatLines) {
656
- const parts = numLine.trim().split(/\s+/);
657
- if (parts.length >= 3) {
658
- const [add, del, file] = parts;
659
- numstatMap.set(file, {
660
- additions: add === '-' ? 0 : parseInt(add, 10) || 0,
661
- deletions: del === '-' ? 0 : parseInt(del, 10) || 0,
662
- });
663
- }
664
- }
665
- } catch (e) {
666
- // Ignore diff errors
667
- }
668
-
669
- const files = lines.map((line) => {
670
- const status = line.slice(0, 2).trim();
671
- const file = line.slice(2).trim();
672
-
673
- let type = 'modified';
674
- if (status === 'A' || status === '??') type = 'added';
675
- else if (status === 'D') type = 'deleted';
676
-
677
- const stats = numstatMap.get(file) || { additions: 0, deletions: 0 };
678
-
679
- return {
680
- file,
681
- type,
682
- rawStatus: status,
683
- additions: stats.additions,
684
- deletions: stats.deletions,
685
- };
686
- });
687
-
688
- results.push({
689
- repoName,
690
- repoPath: worktreePath,
691
- files,
692
- });
693
- } catch (error) {
694
- results.push({
695
- repoName,
696
- repoPath: worktreePath,
697
- files: [],
698
- error: error instanceof Error ? error.message : String(error),
699
- });
700
- }
701
- }
702
-
703
- return c.json({ changes: results });
704
- } catch (error) {
705
- const msg = error instanceof Error ? error.message : String(error);
706
- return c.json({ error: msg }, 500);
707
- }
708
- });
709
-
710
- // 13_diff. Get git diff for a specific file in workspace sub-repositories
711
- app.get('/api/workspace/:id/changes/diff', async (c) => {
712
- try {
713
- const id = c.req.param('id');
714
- const repoName = c.req.query('repo');
715
- const filePath = c.req.query('file');
716
-
717
- if (!repoName || !filePath) {
718
- return c.json({ error: 'Missing repo or file query parameter.' }, 400);
719
- }
720
-
721
- const config = await loadConfig();
722
- const workspacePath = path.join(config.workspacesDir, id);
723
- const worktreePath = path.join(workspacePath, repoName);
724
-
725
- // Security check: ensure path is within workspace directory
726
- const resolvedPath = path.resolve(worktreePath);
727
- if (!resolvedPath.startsWith(path.resolve(workspacePath))) {
728
- return c.json({ error: 'Invalid repository path.' }, 400);
729
- }
730
-
731
- let diff = '';
732
-
733
- // Check git status for the file to know if it's untracked
734
- try {
735
- const { stdout: statusOut } = await execa('git', ['status', '--porcelain', '--', filePath], { cwd: worktreePath });
736
- const statusLine = statusOut.trim();
737
- const isUntracked = statusLine.startsWith('??');
738
-
739
- if (isUntracked) {
740
- const result = await execa('git', ['diff', '--no-index', '--', '/dev/null', filePath], {
741
- cwd: worktreePath,
742
- reject: false
743
- });
744
- diff = result.stdout || result.stderr || '';
745
- } else {
746
- const result = await execa('git', ['diff', 'HEAD', '--', filePath], {
747
- cwd: worktreePath,
748
- reject: false
749
- });
750
- diff = result.stdout || result.stderr || '';
751
- }
752
- } catch (e) {
753
- const result = await execa('git', ['diff', 'HEAD', '--', filePath], {
754
- cwd: worktreePath,
755
- reject: false
756
- });
757
- diff = result.stdout || result.stderr || '';
758
- }
759
-
760
- return c.json({ diff });
761
- } catch (error) {
762
- const msg = error instanceof Error ? error.message : String(error);
763
- return c.json({ error: msg }, 500);
764
- }
765
- });
766
-
767
- // 13a. Get workspace knowledge (nexusflow-knowledge.md)
768
- app.get('/api/workspace/:id/knowledge', async (c) => {
769
- try {
770
- const id = c.req.param('id');
771
- const config = await loadConfig();
772
- const workspacePath = path.join(config.workspacesDir, id);
773
- const knowledgeFile = path.join(workspacePath, 'nexusflow-knowledge.md');
774
-
775
- let content = '';
776
- try {
777
- content = await fs.readFile(knowledgeFile, 'utf-8');
778
- } catch {
779
- content = '# Workspace Knowledge\n\nNo knowledge file yet.';
780
- }
781
-
782
- return c.json({ content });
783
- } catch (error) {
784
- const msg = error instanceof Error ? error.message : String(error);
785
- return c.json({ error: msg }, 500);
786
- }
787
- });
788
-
789
- // 13b. Update workspace knowledge (nexusflow-knowledge.md)
790
- app.put('/api/workspace/:id/knowledge', async (c) => {
791
- try {
792
- const id = c.req.param('id');
793
- const { content } = await c.req.json() as { content: string };
794
- const config = await loadConfig();
795
- const workspacePath = path.join(config.workspacesDir, id);
796
- const knowledgeFile = path.join(workspacePath, 'nexusflow-knowledge.md');
797
-
798
- await fs.writeFile(knowledgeFile, content, 'utf-8');
799
- return c.json({ success: true });
800
- } catch (error) {
801
- const msg = error instanceof Error ? error.message : String(error);
802
- return c.json({ error: msg }, 500);
803
- }
804
- });
805
-
806
- // 13c. Get workspace plan (nexusflow-plan.md)
807
- app.get('/api/workspace/:id/plan', async (c) => {
808
- try {
809
- const id = c.req.param('id');
810
- const config = await loadConfig();
811
- const workspacePath = path.join(config.workspacesDir, id);
812
- const planFile = path.join(workspacePath, 'nexusflow-plan.md');
813
-
814
- let content = '';
815
- try {
816
- content = await fs.readFile(planFile, 'utf-8');
817
- } catch {
818
- content = '# Workspace Plan\n\nNo implementation plan file yet.';
819
- }
820
-
821
- return c.json({ content });
822
- } catch (error) {
823
- const msg = error instanceof Error ? error.message : String(error);
824
- return c.json({ error: msg }, 500);
825
- }
826
- });
827
-
828
- // 13d. Sync all repositories in workspace
829
- app.post('/api/workspace/:id/sync', async (c) => {
830
- try {
831
- const id = c.req.param('id');
832
- const config = await loadConfig();
833
- const workspacePath = path.join(config.workspacesDir, id);
834
-
835
- const repos = await getWorkspaceRepos(workspacePath);
836
- const results = [];
837
-
838
- for (const repo of repos) {
839
- const result = await rebaseRepo(repo.path, repo.defaultBranch || 'main');
840
- results.push({
841
- repoName: repo.name,
842
- success: result.success,
843
- message: result.message,
844
- conflict: result.conflict,
845
- });
846
- }
847
-
848
- return c.json({ results });
849
- } catch (error) {
850
- const msg = error instanceof Error ? error.message : String(error);
851
- return c.json({ error: msg }, 500);
852
- }
853
- });
854
-
855
- // 13e. Commit changes in all repositories in workspace
856
- app.post('/api/workspace/:id/commit', async (c) => {
857
- try {
858
- const id = c.req.param('id');
859
- const { message } = await c.req.json() as { message: string };
860
- const config = await loadConfig();
861
- const workspacePath = path.join(config.workspacesDir, id);
862
-
863
- const repos = await getWorkspaceRepos(workspacePath);
864
- const results = [];
865
-
866
- for (const repo of repos) {
867
- const status = await getRepoStatus(repo.path);
868
- if (status.hasChanges) {
869
- const result = await commitAndPush(repo.path, message, repo.branchName);
870
- results.push({
871
- repoName: repo.name,
872
- success: result.success,
873
- commitHash: result.commitHash,
874
- filesChanged: result.filesChanged,
875
- message: result.message,
876
- });
877
- }
878
- }
879
-
880
- return c.json({ results });
881
- } catch (error) {
882
- const msg = error instanceof Error ? error.message : String(error);
883
- return c.json({ error: msg }, 500);
884
- }
885
- });
886
-
887
-
888
- // 14. Resume session in workspace (copies CLI resume command and opens editor)
889
- app.post('/api/workspace/:id/resume', async (c) => {
890
- try {
891
- const id = decodeURIComponent(c.req.param('id'));
892
- const config = await loadConfig();
893
- const workspacePath = path.join(config.workspacesDir, id);
894
-
895
- // Load feature config to get repo paths
896
- const feature = await loadFeatureConfig(workspacePath);
897
- if (!feature) {
898
- return c.json({ error: 'Workspace configuration not found.' }, 404);
899
- }
900
-
901
- const body = await c.req.json().catch(() => ({}));
902
- const targetSessionId = body.sessionId;
903
- const targetAssistant = body.assistant;
904
-
905
- // Find the session to resume
906
- let resumeCommand = '';
907
- let selectedAssistant = targetAssistant || feature.assistants[0] || 'antigravity';
908
- let selectedSessionId = targetSessionId;
909
-
910
- if (!selectedSessionId) {
911
- // Find the most recent session matching this workspace
912
- const sessions = await findSessions(workspacePath, feature.repos);
913
- if (sessions.length > 0) {
914
- selectedSessionId = sessions[0].id;
915
- selectedAssistant = sessions[0].assistant;
916
- }
917
- }
918
-
919
- if (selectedSessionId) {
920
- if (selectedAssistant === 'antigravity') {
921
- resumeCommand = `agy --conversation ${selectedSessionId}`;
922
- } else if (selectedAssistant === 'claude') {
923
- resumeCommand = `claude --resume ${selectedSessionId}`;
924
- } else if (selectedAssistant === 'codex') {
925
- resumeCommand = `codex resume ${selectedSessionId}`;
926
- } else if (selectedAssistant === 'copilot') {
927
- resumeCommand = `copilot --resume ${selectedSessionId}`;
928
- }
929
- } else {
930
- // Fallback to start command (since there's no existing session for this workspace)
931
- if (selectedAssistant === 'antigravity') {
932
- resumeCommand = 'agy --continue';
933
- } else if (selectedAssistant === 'claude') {
934
- resumeCommand = 'claude --resume';
935
- } else if (selectedAssistant === 'codex') {
936
- resumeCommand = 'codex resume';
937
- } else if (selectedAssistant === 'copilot') {
938
- resumeCommand = 'copilot --resume';
939
- } else {
940
- resumeCommand = 'agy --continue';
941
- }
942
- }
943
-
944
- // Open in editor if command is provided
945
- if (body.command) {
946
- if (!ALLOWED_EDITORS.has(body.command)) {
947
- return c.json({ error: 'Forbidden editor command' }, 400);
948
- }
949
- const isWin = process.platform === 'win32';
950
- const child = execa(body.command, [workspacePath], {
951
- detached: true,
952
- stdio: 'ignore',
953
- shell: isWin,
954
- cleanup: false,
955
- });
956
- child.unref();
957
- child.catch((err) => {
958
- console.error(`Failed to launch editor ${body.command} for path ${workspacePath}:`, err);
959
- });
960
- }
961
-
962
- return c.json({ success: true, resumeCommand, workspacePath });
963
- } catch (error) {
964
- const msg = error instanceof Error ? error.message : String(error);
965
- return c.json({ error: msg }, 500);
966
- }
967
- });
968
-
969
- // 15. List past AI sessions for a workspace
970
- app.get('/api/workspace/:id/sessions', async (c) => {
971
- try {
972
- const id = decodeURIComponent(c.req.param('id'));
973
- const config = await loadConfig();
974
- const workspacePath = path.join(config.workspacesDir, id);
975
-
976
- const feature = await loadFeatureConfig(workspacePath);
977
- if (!feature) {
978
- return c.json({ error: 'Workspace configuration not found.' }, 404);
979
- }
980
-
981
- const sessions = await findSessions(workspacePath, feature.repos);
982
- return c.json({ sessions });
983
- } catch (error) {
984
- const msg = error instanceof Error ? error.message : String(error);
985
- return c.json({ error: msg }, 500);
986
- }
987
- });
988
-
989
- // 16. Fetch transcript for a specific AI session
990
- app.get('/api/session/:assistant/:sessionId/transcript', async (c) => {
991
- try {
992
- const assistant = c.req.param('assistant');
993
- const sessionId = c.req.param('sessionId');
994
- const messages = await getSessionTranscript(assistant, sessionId);
995
- return c.json({ messages });
996
- } catch (error) {
997
- const msg = error instanceof Error ? error.message : String(error);
998
- return c.json({ error: msg }, 500);
999
- }
1000
- });
1001
-
1002
- // 17. Check for NexusFlow updates
1003
- app.get('/api/update-status', async (c) => {
1004
- try {
1005
- const status = await checkForUpdates(false);
1006
- if (!status) {
1007
- const currentVersion = getCurrentVersion();
1008
- return c.json({ currentVersion, latestVersion: currentVersion, updateAvailable: false });
1009
- }
1010
- return c.json(status);
1011
- } catch (error) {
1012
- const msg = error instanceof Error ? error.message : String(error);
1013
- return c.json({ error: msg }, 500);
1014
- }
1015
- });
1016
-
1017
- // 17.5. Check tools updates status
1018
- app.get('/api/updates/tools', async (c) => {
1019
- try {
1020
- const force = c.req.query('force') === 'true';
1021
- const status = await getToolsStatus(force);
1022
- return c.json(status);
1023
- } catch (error) {
1024
- const msg = error instanceof Error ? error.message : String(error);
1025
- return c.json({ error: msg }, 500);
1026
- }
1027
- });
1028
-
1029
- // 17.6. Install updates for a specific tool
1030
- app.post('/api/updates/install', async (c) => {
1031
- try {
1032
- const { toolId } = await c.req.json() as { toolId: string };
1033
- const tools = [
1034
- { id: 'nexusflow', cmd: 'npm', args: ['install', '-g', '@mrpatronz/nexusflow'] },
1035
- { id: 'repomix', cmd: 'npm', args: ['install', '-g', 'repomix'] },
1036
- { id: 'antigravity', cmd: 'agy', args: ['update'] },
1037
- { id: 'claude', cmd: 'npm', args: ['install', '-g', '@anthropic-ai/claude-code'] },
1038
- ];
1039
-
1040
- const target = tools.find(t => t.id === toolId);
1041
- if (!target) {
1042
- return c.json({ error: 'Tool not found' }, 404);
1043
- }
1044
-
1045
- const result = await execa(target.cmd, target.args, {
1046
- reject: false,
1047
- shell: process.platform === 'win32',
1048
- });
1049
- if (result.exitCode === 0) {
1050
- return c.json({ success: true, output: result.stdout });
1051
- } else {
1052
- return c.json({ error: `Update failed: ${result.stderr || result.stdout}` }, 500);
1053
- }
1054
- } catch (error) {
1055
- const msg = error instanceof Error ? error.message : String(error);
1056
- return c.json({ error: msg }, 500);
1057
- }
1058
- });
1059
-
1060
- let downloadedInstallerPath: string | null = null;
1061
-
1062
- // 17.7. Download matching GitHub Release installer to temporary folder
1063
- app.post('/api/updates/download', async (c) => {
1064
- try {
1065
- const { downloadUrl } = await c.req.json() as { downloadUrl: string };
1066
- if (!downloadUrl) {
1067
- return c.json({ error: 'Download URL is required' }, 400);
1068
- }
1069
-
1070
- const tempDir = os.tmpdir();
1071
- const fileName = 'NexusFlowSetup_Update.exe';
1072
- const targetPath = path.join(tempDir, fileName);
1073
-
1074
- const response = await fetch(downloadUrl);
1075
- if (!response.ok) {
1076
- throw new Error(`HTTP error ${response.status}`);
1077
- }
1078
-
1079
- // Node fetch body stream download
1080
- const fileStream = createWriteStream(targetPath);
1081
- if (!response.body) {
1082
- throw new Error('Response body is null');
1083
- }
1084
-
1085
- // Convert ReadableStream to Node stream
1086
- await pipeline(response.body as any, fileStream);
1087
-
1088
- downloadedInstallerPath = targetPath;
1089
- return c.json({ success: true, path: targetPath });
1090
- } catch (error) {
1091
- const msg = error instanceof Error ? error.message : String(error);
1092
- return c.json({ error: `Download failed: ${msg}` }, 500);
1093
- }
1094
- });
1095
-
1096
- // 17.8. Launch the silent installer detached and exit server
1097
- app.post('/api/updates/apply', async (c) => {
1098
- if (!downloadedInstallerPath || !existsSync(downloadedInstallerPath)) {
1099
- return c.json({ error: 'No downloaded installer found on disk' }, 400);
1100
- }
1101
-
1102
- try {
1103
- const isWin = process.platform === 'win32';
1104
- if (isWin) {
1105
- // Inno Setup silent install flags
1106
- console.log(`Applying update: Spawning detached installer at: ${downloadedInstallerPath}`);
1107
- const child = spawn(downloadedInstallerPath, ['/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART'], {
1108
- detached: true,
1109
- stdio: 'ignore',
1110
- });
1111
- child.unref();
1112
- }
1113
-
1114
- // Gracefully exit server process in 1 second to release file locks
1115
- setTimeout(() => {
1116
- console.log('Update installer successfully spawned. Exiting Hono server process...');
1117
- process.exit(0);
1118
- }, 1000);
1119
-
1120
- return c.json({ success: true, message: 'Installer spawned, app shutting down...' });
1121
- } catch (error) {
1122
- const msg = error instanceof Error ? error.message : String(error);
1123
- return c.json({ error: `Failed to execute update: ${msg}` }, 500);
1124
- }
1125
- });
1126
-
1127
- // 17.9. Get available workflow templates
1128
- app.get('/api/workflows/templates', async (c) => {
1129
- try {
1130
- const templates = await getWorkflowTemplates();
1131
- return c.json({ templates });
1132
- } catch (error) {
1133
- const msg = error instanceof Error ? error.message : String(error);
1134
- return c.json({ error: msg }, 500);
1135
- }
1136
- });
1137
-
1138
- // Save or update custom teamwork template
1139
- app.post('/api/workflows/templates', async (c) => {
1140
- try {
1141
- const { id, name, content } = await c.req.json();
1142
- if (!name || !content) {
1143
- return c.json({ error: 'Name and content are required.' }, 400);
1144
- }
1145
- const template = await saveWorkflowTemplate(name, content, id);
1146
- return c.json({ success: true, template });
1147
- } catch (error) {
1148
- const msg = error instanceof Error ? error.message : String(error);
1149
- return c.json({ error: msg }, 500);
1150
- }
1151
- });
1152
-
1153
- // Delete custom teamwork template
1154
- app.delete('/api/workflows/templates/:id', async (c) => {
1155
- try {
1156
- const id = decodeURIComponent(c.req.param('id'));
1157
- const templates = await getWorkflowTemplates();
1158
- const target = templates.find(t => t.id === id);
1159
- if (!target) {
1160
- return c.json({ error: 'Template not found.' }, 404);
1161
- }
1162
- if (!target.custom) {
1163
- return c.json({ error: 'Cannot delete built-in templates.' }, 403);
1164
- }
1165
- await deleteWorkflowTemplate(id);
1166
- return c.json({ success: true });
1167
- } catch (error) {
1168
- const msg = error instanceof Error ? error.message : String(error);
1169
- return c.json({ error: msg }, 500);
1170
- }
1171
- });
1172
-
1173
- // Analyze teamwork template rules via selected AI coding assistant harness
1174
- app.post('/api/workflows/templates/:id/analyze', async (c) => {
1175
- try {
1176
- const { content, assistant, comment } = await c.req.json();
1177
- if (!content) {
1178
- return c.json({ error: 'Content is required.' }, 400);
1179
- }
1180
-
1181
- const selectedAssistant = assistant || 'antigravity';
1182
- let command = '';
1183
- let args: string[] = [];
1184
-
1185
- let prompt = `You are an expert AI system engineering reviewer. Analyze the following Agent Teamwork Strategy guidelines.
1186
- Evaluate its instructions, identify any ambiguities or contradictions, rate its expected effectiveness for orchestrating subagents, and provide specific recommendations or improvements. Format your analysis in clean Markdown with clear headings (e.g. Overview, Strengths, Weaknesses, Recommendations).
1187
-
1188
- After your analysis, provide a fully rewritten, optimized, and complete version of the strategy guidelines incorporating all your recommendations. This rewritten version must be suitable for production orchestration.
1189
- You MUST prefix the rewritten version with the exact delimiter line:
1190
- === SUGGESTED IMPROVEMENT START ===
1191
- and suffix it with:
1192
- === SUGGESTED IMPROVEMENT END ===`;
1193
-
1194
- if (comment && comment.trim()) {
1195
- prompt += `\n\nIMPORTANT: The user has provided the following specific instruction/comment that you MUST consider and prioritize during your evaluation and when rewriting the guidelines:\n"${comment.trim()}"`;
1196
- }
1197
-
1198
- prompt += `\n\n--- GUIDELINES START ---\n${content}\n--- GUIDELINES END ---`;
1199
-
1200
- const assistants = await detectAIAssistants();
1201
- const target = assistants.find(ai => ai.name === selectedAssistant);
1202
-
1203
- if (!target || !target.detected || !target.command) {
1204
- return c.json({
1205
- error: `AI assistant harness '${selectedAssistant}' is not detected or does not support command-line execution.`
1206
- }, 400);
1207
- }
1208
-
1209
- command = target.command;
1210
- if (command === 'claude') {
1211
- args = ['-p', prompt];
1212
- } else if (command === 'agy') {
1213
- args = [prompt];
1214
- } else {
1215
- args = [prompt];
1216
- }
1217
-
1218
- const result = await execa(command, args, {
1219
- input: '',
1220
- shell: false,
1221
- reject: false
1222
- });
1223
-
1224
- if (result.exitCode !== 0) {
1225
- return c.json({
1226
- error: `AI Assistant harness execution failed (exit code ${result.exitCode}): ${result.stderr || result.stdout || 'Unknown error'}`
1227
- }, 500);
1228
- }
1229
-
1230
- // Clean potential warning lines or stdout prefixes if present
1231
- let cleanText = result.stdout;
1232
- if (cleanText.includes('Warning: no stdin data received')) {
1233
- cleanText = cleanText.replace(/Warning: no stdin data received in \d+s, proceeding without it\. If piping from a slow command, redirect stdin explicitly: < \/dev\/null to skip, or wait longer\.\r?\n?/, '');
1234
- }
1235
-
1236
- let analysis = cleanText.trim();
1237
- let suggestedImprovement = '';
1238
-
1239
- const startDelimiter = '=== SUGGESTED IMPROVEMENT START ===';
1240
- const endDelimiter = '=== SUGGESTED IMPROVEMENT END ===';
1241
-
1242
- const startIdx = cleanText.indexOf(startDelimiter);
1243
- const endIdx = cleanText.indexOf(endDelimiter);
1244
-
1245
- if (startIdx !== -1 && endIdx !== -1) {
1246
- analysis = cleanText.substring(0, startIdx).trim();
1247
- suggestedImprovement = cleanText.substring(startIdx + startDelimiter.length, endIdx).trim();
1248
- }
1249
-
1250
- return c.json({ analysis, suggestedImprovement });
1251
- } catch (error) {
1252
- const msg = error instanceof Error ? error.message : String(error);
1253
- return c.json({ error: msg }, 500);
1254
- }
1255
- });
1256
-
1257
- // 18. Pack workspace codebase and download
1258
- app.get('/api/workspace/:id/pack', async (c) => {
1259
- try {
1260
- const id = decodeURIComponent(c.req.param('id'));
1261
- const config = await loadConfig();
1262
- const workspacePath = path.join(config.workspacesDir, id);
1263
-
1264
- const feature = await loadFeatureConfig(workspacePath);
1265
- if (!feature) {
1266
- return c.json({ error: 'Workspace configuration not found.' }, 404);
1267
- }
1268
-
1269
- const result = await packWorkspace(workspacePath);
1270
- const content = await fs.readFile(result.outputPath, 'utf-8');
1271
-
1272
- c.header('Content-Disposition', `attachment; filename="nexusflow-context-${id.replace(/[\/\\ ]/g, '-')}.xml"`);
1273
- c.header('Content-Type', 'application/xml');
1274
- return c.text(content);
1275
- } catch (error) {
1276
- const msg = error instanceof Error ? error.message : String(error);
1277
- return c.json({ error: msg }, 500);
1278
- }
1279
- });
1280
-
1281
- // Serve index.html explicitly on root endpoint
1282
- app.get('/', async (c) => {
1283
- try {
1284
- const html = await fs.readFile(path.join(guiPath, 'index.html'), 'utf-8');
1285
- return c.html(html);
1286
- } catch (error) {
1287
- return c.text('GUI dashboard built assets not found. Run "npm run build" first.', 404);
1288
- }
1289
- });
1290
-
1291
- // Serve static assets from GUI build folder
1292
- app.use('/*', serveStatic({ root: path.relative(process.cwd(), guiPath) }));
1293
-
1294
- export function startServer(port = 3000): Promise<{ port: number; server: any }> {
1295
- return new Promise((resolve, reject) => {
1296
- const server = serve({ fetch: app.fetch, port }, (info) => {
1297
- resolve({ port: info.port, server });
1298
- }) as import('node:http').Server;
1299
-
1300
- server.on('error', (e: any) => {
1301
- if (e.code === 'EADDRINUSE') {
1302
- resolve(startServer(port + 1));
1303
- } else {
1304
- reject(e);
1305
- }
1306
- });
1307
- });
1308
- }
1
+ /**
2
+ * @module server
3
+ * Hono local web server for the NexusFlow GUI.
4
+ */
5
+
6
+ import { Hono } from 'hono';
7
+ import { serve } from '@hono/node-server';
8
+ import { serveStatic } from '@hono/node-server/serve-static';
9
+ import { cors } from 'hono/cors';
10
+ import { streamSSE } from 'hono/streaming';
11
+ import * as fs from 'node:fs/promises';
12
+ import { createWriteStream, existsSync } from 'node:fs';
13
+ import * as path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { execa } from 'execa';
16
+ import * as os from 'node:os';
17
+ import { pipeline } from 'node:stream/promises';
18
+ import { spawn } from 'node:child_process';
19
+
20
+ import { loadConfig, saveConfig, getConfigDir } from './core/config.js';
21
+ import { listStorageProviders } from './core/adapters/registry.js';
22
+ import { scanForRepos } from './core/scanner.js';
23
+ import { createWorkspace, listWorkspaces, loadFeatureConfig, deleteWorkspace, addRepoToWorkspace } from './core/workspace.js';
24
+ import { loadWorkspaceState } from './core/workspace-state.js';
25
+ import { analyzeAllRepos } from './analyzers/index.js';
26
+ import { generateContextFiles } from './generators/index.js';
27
+ import { isOllamaModelAvailable, getOpenAiCompatibleUrl, callLocalLlm } from './utils/local-ai.js';
28
+ import { detectAIAssistants } from './utils/detect-ai.js';
29
+ import { detectEditors } from './utils/detect-editors.js';
30
+ import { findSessions, getSessionTranscript } from './utils/session-finder.js';
31
+ import { scanSystemSpecs } from './utils/system-scanner.js';
32
+ import { getWorkspaceRepos, commitAndPush, getRepoStatus } from './utils/multi-git.js';
33
+ import { syncWorkspace } from './core/sync.js';
34
+ import {
35
+ detectAllServices,
36
+ detectOrchestrationTools,
37
+ startServices,
38
+ stopServices,
39
+ loadRunningState,
40
+ } from './orchestration/index.js';
41
+ import { checkForUpdates, getCurrentVersion, getToolsStatus } from './utils/update-check.js';
42
+ import { getWorkflowTemplates, saveWorkflowTemplate, deleteWorkflowTemplate } from './utils/workflows.js';
43
+ import type { Feature, RepoInfo, WorkspaceContext, SyncStatus, RepoSyncState } from './types.js';
44
+ import { suggestWorkflow } from './utils/workflow-advisor.js';
45
+
46
+ // Resolve static files directory
47
+ const __filename = fileURLToPath(import.meta.url);
48
+ const __dirname = path.dirname(__filename);
49
+
50
+ // In development, the static GUI is built to dist/gui
51
+ // In production, it will be served from dist/gui as well
52
+ const guiPath = path.join(__dirname, 'gui');
53
+
54
+ export const app = new Hono();
55
+
56
+ // Allowed editor binaries/scripts to prevent command injection
57
+ const ALLOWED_EDITORS = new Set(['code', 'code-insiders', 'cursor', 'antigravity', 'agy', 'idea', 'charm', 'webstorm', 'subl', 'nano', 'vim', 'nvim', 'emacs']);
58
+
59
+ // Enable CORS for frontend dev server
60
+ app.use('/api/*', cors());
61
+
62
+ // ─── API Endpoints ────────────────────────────────────────────────────────
63
+
64
+ // 1. Get current configuration
65
+ app.get('/api/config', async (c) => {
66
+ try {
67
+ const configPath = path.join(getConfigDir(), 'config.json');
68
+ let exists = false;
69
+ try {
70
+ await fs.access(configPath);
71
+ exists = true;
72
+ } catch {}
73
+
74
+ const config = await loadConfig();
75
+ return c.json({ config, exists });
76
+ } catch (error) {
77
+ const msg = error instanceof Error ? error.message : String(error);
78
+ return c.json({ error: msg }, 500);
79
+ }
80
+ });
81
+
82
+ // Get all registered storage adapters
83
+ app.get('/api/adapters', async (c) => {
84
+ try {
85
+ const adapters = listStorageProviders();
86
+ return c.json({ adapters });
87
+ } catch (error) {
88
+ const msg = error instanceof Error ? error.message : String(error);
89
+ return c.json({ error: msg }, 500);
90
+ }
91
+ });
92
+
93
+ function isSafeLocalEndpoint(urlStr: string): boolean {
94
+ try {
95
+ const url = new URL(urlStr);
96
+ const hostname = url.hostname.toLowerCase();
97
+ if (url.protocol === 'https:') {
98
+ return true;
99
+ }
100
+ if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]') {
101
+ return true;
102
+ }
103
+ const ipv4Pattern = /^(?:10|127|192\.168|172\.(?:1[6-9]|2[0-9]|3[01]))\.\d+\.\d+\.\d+$/;
104
+ if (ipv4Pattern.test(hostname)) {
105
+ return true;
106
+ }
107
+ return false;
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+
113
+ // 2. Save configuration
114
+ app.post('/api/config', async (c) => {
115
+ try {
116
+ const newConfig = await c.req.json();
117
+ if (newConfig?.localLlm?.enabled && newConfig.localLlm.endpoint) {
118
+ if (!isSafeLocalEndpoint(newConfig.localLlm.endpoint)) {
119
+ return c.json({ error: 'Local AI endpoint must be HTTPS, localhost, 127.0.0.1, or a private LAN IP.' }, 400);
120
+ }
121
+ }
122
+ await saveConfig(newConfig);
123
+ return c.json({ success: true, config: newConfig });
124
+ } catch (error) {
125
+ const msg = error instanceof Error ? error.message : String(error);
126
+ return c.json({ error: msg }, 500);
127
+ }
128
+ });
129
+
130
+ // 3. Scan development directory for repositories
131
+ app.get('/api/repos', async (c) => {
132
+ try {
133
+ const config = await loadConfig();
134
+ const repos = await scanForRepos(config.devDir, config.scanDepth);
135
+ return c.json(repos);
136
+ } catch (error) {
137
+ const msg = error instanceof Error ? error.message : String(error);
138
+ return c.json({ error: msg }, 500);
139
+ }
140
+ });
141
+
142
+ // 4. List existing workspaces
143
+ app.get('/api/workspaces', async (c) => {
144
+ try {
145
+ const config = await loadConfig();
146
+ const workspaces = await listWorkspaces(config.workspacesDir);
147
+ return c.json(workspaces);
148
+ } catch (error) {
149
+ const msg = error instanceof Error ? error.message : String(error);
150
+ return c.json({ error: msg }, 500);
151
+ }
152
+ });
153
+
154
+ // Severity ranking for picking the worst per-repo sync outcome in a workspace.
155
+ const SYNC_SEVERITY: Record<SyncStatus, number> = {
156
+ conflict: 4,
157
+ 'stash-conflict': 3,
158
+ error: 2,
159
+ rebased: 1,
160
+ 'up-to-date': 0,
161
+ };
162
+
163
+ /** Returns the most severe recorded sync status across a workspace's repos. */
164
+ function worstSyncStatus(states: RepoSyncState[]): SyncStatus | 'unknown' {
165
+ let worst: SyncStatus | 'unknown' = 'unknown';
166
+ let worstSeverity = -1;
167
+ for (const s of states) {
168
+ if (!s.lastSyncStatus) continue;
169
+ const severity = SYNC_SEVERITY[s.lastSyncStatus];
170
+ if (severity > worstSeverity) {
171
+ worstSeverity = severity;
172
+ worst = s.lastSyncStatus;
173
+ }
174
+ }
175
+ return worst;
176
+ }
177
+
178
+ // 4b. Aggregate at-a-glance status for every workspace (for the listing overview).
179
+ // Cheap on purpose: git status + cached running/sync state only — never fetch/rebase.
180
+ app.get('/api/workspaces/status', async (c) => {
181
+ try {
182
+ const config = await loadConfig();
183
+ const workspaces = await listWorkspaces(config.workspacesDir);
184
+
185
+ const entries = await Promise.all(
186
+ workspaces.map(async (ws) => {
187
+ const workspacePath =
188
+ ws.workspacePath || path.join(config.workspacesDir, ws.branchName);
189
+ const status = {
190
+ id: ws.id,
191
+ branchName: ws.branchName,
192
+ changedFiles: 0,
193
+ dirtyRepos: 0,
194
+ runningServices: 0,
195
+ syncStatus: 'unknown' as SyncStatus | 'unknown',
196
+ pendingValidation: false,
197
+ };
198
+
199
+ try {
200
+ // Uncommitted changes across the workspace's repo worktrees.
201
+ for (const repoPath of ws.repos) {
202
+ const worktreePath = path.join(workspacePath, path.basename(repoPath));
203
+ const repoStatus = await getRepoStatus(worktreePath);
204
+ if (repoStatus.hasChanges) {
205
+ status.dirtyRepos += 1;
206
+ status.changedFiles += repoStatus.changedFiles.length;
207
+ }
208
+ }
209
+
210
+ // Running services (cached running-state, PM2-verified — same source as
211
+ // the Services tab; only workspaces that ever started services touch PM2).
212
+ const runningState = await loadRunningState(workspacePath);
213
+ status.runningServices = runningState?.services?.length ?? 0;
214
+
215
+ // Sync state: worst-case classification + any repo pending validation.
216
+ const wsState = await loadWorkspaceState(workspacePath);
217
+ const repoStates = Object.values(wsState.repos);
218
+ status.syncStatus = worstSyncStatus(repoStates);
219
+ status.pendingValidation = repoStates.some((r) => r.pendingValidation);
220
+ } catch {
221
+ // Leave defaults on any per-workspace failure so one bad repo doesn't
222
+ // fail the whole response.
223
+ }
224
+
225
+ return status;
226
+ })
227
+ );
228
+
229
+ // Keyed by branchName to match how the GUI looks up a workspace.
230
+ const byWorkspace: Record<string, (typeof entries)[number]> = {};
231
+ for (const entry of entries) byWorkspace[entry.branchName] = entry;
232
+ return c.json(byWorkspace);
233
+ } catch (error) {
234
+ const msg = error instanceof Error ? error.message : String(error);
235
+ return c.json({ error: msg }, 500);
236
+ }
237
+ });
238
+
239
+ // 5. Detect available AI assistants
240
+ app.get('/api/ai-detect', async (c) => {
241
+ try {
242
+ const assistants = await detectAIAssistants();
243
+ return c.json(assistants);
244
+ } catch (error) {
245
+ const msg = error instanceof Error ? error.message : String(error);
246
+ return c.json({ error: msg }, 500);
247
+ }
248
+ });
249
+
250
+ // 6. Detect available editors
251
+ app.get('/api/editor-detect', async (c) => {
252
+ try {
253
+ const editors = await detectEditors();
254
+ return c.json(editors);
255
+ } catch (error) {
256
+ const msg = error instanceof Error ? error.message : String(error);
257
+ return c.json({ error: msg }, 500);
258
+ }
259
+ });
260
+
261
+ // 6.5. Local LLM test & recommendation
262
+ app.post('/api/local-llm/test', async (c) => {
263
+ try {
264
+ const { provider, endpoint, model, apiKey, shoot } = await c.req.json();
265
+ if (!endpoint || !isSafeLocalEndpoint(endpoint)) {
266
+ return c.json({ success: false, error: 'Local AI endpoint must be HTTPS, localhost, 127.0.0.1, or a private LAN IP.' }, 400);
267
+ }
268
+ const cleanEndpoint = endpoint.replace(/\/$/, '');
269
+
270
+ if (shoot) {
271
+ const responseText = await callLocalLlm(
272
+ { enabled: true, provider, endpoint, model, apiKey },
273
+ [{ role: 'user', content: 'Respond with the exact word "OK" and nothing else.' }]
274
+ );
275
+ const cleanResponse = responseText.trim();
276
+ return c.json({
277
+ success: true,
278
+ modelReady: true,
279
+ message: `Inference test succeeded! Response from model: "${cleanResponse}"`
280
+ });
281
+ }
282
+
283
+ if (provider === 'ollama') {
284
+ const res = await fetch(`${cleanEndpoint}/api/tags`);
285
+ if (!res.ok) throw new Error(`Ollama responded with status ${res.status}`);
286
+ const data: any = await res.json();
287
+ const models = data?.models || [];
288
+ const isModelLoaded = isOllamaModelAvailable(models, model);
289
+ return c.json({
290
+ success: true,
291
+ modelReady: isModelLoaded,
292
+ message: isModelLoaded ? 'Connected successfully! Model is ready.' : `Connected successfully, but model "${model}" is not pulled. Run "ollama pull ${model}" to install it.`
293
+ });
294
+ } else {
295
+ const testUrl = getOpenAiCompatibleUrl(cleanEndpoint, '/v1/models');
296
+ const res = await fetch(testUrl);
297
+ if (!res.ok) throw new Error(`OpenAI-compatible server responded with status ${res.status}`);
298
+ return c.json({ success: true, modelReady: true, message: 'Connected successfully to OpenAI-compatible server!' });
299
+ }
300
+ } catch (error) {
301
+ const msg = error instanceof Error ? error.message : String(error);
302
+ return c.json({ success: false, error: msg }, 400);
303
+ }
304
+ });
305
+
306
+ app.get('/api/local-llm/recommend', async (c) => {
307
+ try {
308
+ const specs = await scanSystemSpecs();
309
+ return c.json(specs);
310
+ } catch (error) {
311
+ return c.json({
312
+ totalRamGb: 8,
313
+ gpuName: 'Unknown/Integrated',
314
+ hasHardwareAcceleration: false,
315
+ recommendedModel: 'qwen2.5-coder:1.5b',
316
+ });
317
+ }
318
+ });
319
+
320
+
321
+ interface JobStep {
322
+ id: string;
323
+ name: string;
324
+ status: 'pending' | 'running' | 'completed' | 'failed';
325
+ message: string;
326
+ }
327
+
328
+ interface CreationJob {
329
+ id: string;
330
+ status: 'running' | 'completed' | 'failed';
331
+ progress: number;
332
+ error?: string;
333
+ steps: JobStep[];
334
+ workspacePath?: string;
335
+ feature?: Feature;
336
+ listeners: Set<(event: { type: string; data: any }) => void>;
337
+ }
338
+
339
+ const creationJobs = new Map<string, CreationJob>();
340
+
341
+ function updateJobStep(
342
+ jobId: string,
343
+ stepId: string,
344
+ status: 'running' | 'completed' | 'failed',
345
+ message: string,
346
+ extraData?: any
347
+ ) {
348
+ const job = creationJobs.get(jobId);
349
+ if (!job) return;
350
+
351
+ const step = job.steps.find((s) => s.id === stepId);
352
+ if (step) {
353
+ step.status = status;
354
+ step.message = message;
355
+ }
356
+
357
+ const isLastStep = job.steps[job.steps.length - 1]?.id === stepId;
358
+ if (isLastStep && status === 'completed') {
359
+ job.status = 'completed';
360
+ job.progress = 100;
361
+ } else if (status === 'failed') {
362
+ job.status = 'failed';
363
+ job.error = message;
364
+ } else {
365
+ const completedCount = job.steps.filter((s) => s.status === 'completed').length;
366
+ const runningCount = job.steps.filter((s) => s.status === 'running').length;
367
+ job.progress = Math.round(((completedCount + runningCount * 0.5) / job.steps.length) * 100);
368
+ }
369
+
370
+ const eventPayload = {
371
+ type: 'progress',
372
+ data: {
373
+ id: job.id,
374
+ status: job.status,
375
+ progress: job.progress,
376
+ error: job.error,
377
+ steps: job.steps,
378
+ workspacePath: job.workspacePath,
379
+ feature: job.feature,
380
+ ...extraData,
381
+ },
382
+ };
383
+
384
+ for (const listener of job.listeners) {
385
+ listener(eventPayload);
386
+ }
387
+ }
388
+
389
+ async function runCreationJob(jobId: string, body: any, config: any) {
390
+ try {
391
+ const workspacePath = path.join(config.workspacesDir, body.branchName);
392
+ const job = creationJobs.get(jobId);
393
+ if (job) {
394
+ job.workspacePath = workspacePath;
395
+ }
396
+
397
+ const feature: Feature = {
398
+ id: body.branchName,
399
+ branchName: body.branchName,
400
+ description: body.description,
401
+ repos: body.repos.map((r: any) => path.join(workspacePath, r.name)),
402
+ originalRepos: body.repos.map((r: any) => r.path),
403
+ assistants: body.assistants,
404
+ workspacePath,
405
+ createdAt: new Date().toISOString(),
406
+ resumption: body.resumption,
407
+ localLlmEnabled: body.localLlmEnabled,
408
+ teamworkInstructions: body.teamworkInstructions,
409
+ };
410
+ if (job) {
411
+ job.feature = feature;
412
+ }
413
+
414
+ // Step 1: Create worktrees
415
+ updateJobStep(jobId, 'worktrees', 'running', 'Creating git worktrees...');
416
+ await createWorkspace(feature, body.repos);
417
+ updateJobStep(jobId, 'worktrees', 'completed', 'Git worktrees created successfully.');
418
+
419
+ // Step 2: Analyze repos
420
+ updateJobStep(jobId, 'analysis', 'running', 'Analyzing projects and dependencies...');
421
+ const workspaceRepos = body.repos.map((repo: any) => ({
422
+ ...repo,
423
+ path: path.join(workspacePath, repo.name),
424
+ }));
425
+ const analysis = await analyzeAllRepos(workspaceRepos);
426
+ updateJobStep(jobId, 'analysis', 'completed', 'Project analysis complete.');
427
+
428
+ // Step 3: Generate AI context files
429
+ updateJobStep(jobId, 'context', 'running', 'Generating AI context files...');
430
+ const ctx: WorkspaceContext = {
431
+ feature,
432
+ repos: workspaceRepos,
433
+ analysis,
434
+ localLlm: config.localLlm,
435
+ };
436
+ await generateContextFiles(ctx, body.assistants, workspacePath);
437
+ updateJobStep(jobId, 'context', 'completed', 'AI context files generated.');
438
+
439
+ // XML context packing removed.
440
+
441
+ } catch (error) {
442
+ const msg = error instanceof Error ? error.message : String(error);
443
+ const job = creationJobs.get(jobId);
444
+ if (job) {
445
+ const runningStep = job.steps.find((s) => s.status === 'running');
446
+ const failedStepId = runningStep ? runningStep.id : 'worktrees';
447
+ updateJobStep(jobId, failedStepId, 'failed', msg);
448
+ }
449
+ }
450
+ }
451
+
452
+ // 7. Create workspace
453
+ app.post('/api/workspace', async (c) => {
454
+ try {
455
+ const body = await c.req.json() as {
456
+ branchName: string;
457
+ description: string;
458
+ repos: RepoInfo[];
459
+ assistants: any[];
460
+ localLlmEnabled?: boolean;
461
+ teamworkInstructions?: string;
462
+ resumption?: {
463
+ testCommand?: string;
464
+ mockCommand?: string;
465
+ startCommand?: string;
466
+ };
467
+ };
468
+
469
+ const config = await loadConfig();
470
+ const jobId = body.branchName;
471
+
472
+ if (creationJobs.has(jobId)) {
473
+ const existing = creationJobs.get(jobId)!;
474
+ if (existing.status === 'running') {
475
+ return c.json({ success: true, jobId, message: 'Job already running' });
476
+ }
477
+ }
478
+
479
+ const steps: JobStep[] = [
480
+ { id: 'worktrees', name: 'Create Git Worktrees', status: 'pending', message: 'Waiting...' },
481
+ { id: 'analysis', name: 'Analyze Repositories', status: 'pending', message: 'Waiting...' },
482
+ { id: 'context', name: 'Generate AI Context Files', status: 'pending', message: 'Waiting...' },
483
+ ];
484
+ // XML context packing removed.
485
+
486
+ const job: CreationJob = {
487
+ id: jobId,
488
+ status: 'running',
489
+ progress: 0,
490
+ steps,
491
+ listeners: new Set(),
492
+ };
493
+ creationJobs.set(jobId, job);
494
+
495
+ // Run the job in background
496
+ runCreationJob(jobId, body, config);
497
+
498
+ return c.json({ success: true, jobId });
499
+ } catch (error) {
500
+ const msg = error instanceof Error ? error.message : String(error);
501
+ return c.json({ error: msg }, 500);
502
+ }
503
+ });
504
+
505
+ // 7.5. Create workspace SSE stream
506
+ app.get('/api/workspace/create-stream/:jobId', async (c) => {
507
+ const jobId = decodeURIComponent(c.req.param('jobId'));
508
+ const job = creationJobs.get(jobId);
509
+ if (!job) {
510
+ return c.json({ error: 'Job not found' }, 404);
511
+ }
512
+
513
+ c.header('Content-Type', 'text/event-stream');
514
+ c.header('Cache-Control', 'no-cache');
515
+ c.header('Connection', 'keep-alive');
516
+
517
+ return streamSSE(c, async (stream) => {
518
+ // Send initial state
519
+ await stream.writeSSE({
520
+ event: 'progress',
521
+ data: JSON.stringify({
522
+ id: job.id,
523
+ status: job.status,
524
+ progress: job.progress,
525
+ error: job.error,
526
+ steps: job.steps,
527
+ workspacePath: job.workspacePath,
528
+ feature: job.feature,
529
+ }),
530
+ });
531
+
532
+ if (job.status === 'completed' || job.status === 'failed') {
533
+ return;
534
+ }
535
+
536
+ const listener = async (event: { type: string; data: any }) => {
537
+ try {
538
+ await stream.writeSSE({
539
+ event: event.type,
540
+ data: JSON.stringify(event.data),
541
+ });
542
+ } catch (err) {
543
+ job.listeners.delete(listener);
544
+ }
545
+ };
546
+
547
+ job.listeners.add(listener);
548
+
549
+ // Keep connection alive until job finishes
550
+ while (job.status === 'running') {
551
+ await new Promise((resolve) => setTimeout(resolve, 1000));
552
+ }
553
+
554
+ job.listeners.delete(listener);
555
+ });
556
+ });
557
+
558
+ // 7.6. Delete workspace
559
+ app.delete('/api/workspace/:id', async (c) => {
560
+ try {
561
+ const id = decodeURIComponent(c.req.param('id'));
562
+ const config = await loadConfig();
563
+ const workspacePath = path.join(config.workspacesDir, id);
564
+
565
+ await deleteWorkspace(workspacePath);
566
+ return c.json({ success: true });
567
+ } catch (error) {
568
+ const msg = error instanceof Error ? error.message : String(error);
569
+ return c.json({ error: msg }, 500);
570
+ }
571
+ });
572
+
573
+ // 7.7. Add repo to workspace
574
+ app.post('/api/workspace/:id/repo', async (c) => {
575
+ try {
576
+ const id = decodeURIComponent(c.req.param('id'));
577
+ const { repoPath } = await c.req.json() as { repoPath: string };
578
+ const config = await loadConfig();
579
+ const workspacePath = path.join(config.workspacesDir, id);
580
+
581
+ await addRepoToWorkspace(workspacePath, repoPath);
582
+ return c.json({ success: true });
583
+ } catch (error) {
584
+ const msg = error instanceof Error ? error.message : String(error);
585
+ return c.json({ error: msg }, 500);
586
+ }
587
+ });
588
+
589
+ // 7.8. Suggest workflow strategy based on feature description & selected repos
590
+ app.post('/api/workspace/suggest-workflow', async (c) => {
591
+ try {
592
+ const { description, repos } = await c.req.json() as {
593
+ description: string;
594
+ repos: RepoInfo[];
595
+ };
596
+
597
+ const config = await loadConfig();
598
+ const suggestion = await suggestWorkflow(description, repos, config.localLlm);
599
+
600
+ return c.json({
601
+ success: true,
602
+ ...suggestion
603
+ });
604
+ } catch (error) {
605
+ const msg = error instanceof Error ? error.message : String(error);
606
+ return c.json({ error: msg }, 500);
607
+ }
608
+ });
609
+
610
+ // 8. Open workspace in editor
611
+ app.post('/api/open-editor', async (c) => {
612
+ try {
613
+ const { workspacePath, command } = await c.req.json() as {
614
+ workspacePath: string;
615
+ command: string;
616
+ };
617
+
618
+ if (!ALLOWED_EDITORS.has(command)) {
619
+ return c.json({ error: 'Forbidden editor command' }, 400);
620
+ }
621
+
622
+ // Validate path exists and is a directory
623
+ try {
624
+ const stats = await fs.stat(workspacePath);
625
+ if (!stats.isDirectory()) {
626
+ return c.json({ error: 'Workspace path is not a directory' }, 400);
627
+ }
628
+ } catch {
629
+ return c.json({ error: 'Workspace path does not exist' }, 400);
630
+ }
631
+
632
+ // Spawn editor process
633
+ const isWin = process.platform === 'win32';
634
+ const child = execa(command, [workspacePath], {
635
+ detached: true,
636
+ stdio: 'ignore',
637
+ shell: isWin,
638
+ cleanup: false,
639
+ });
640
+ child.unref();
641
+ child.catch((err) => {
642
+ console.error(`Failed to launch editor ${command} for path ${workspacePath}:`, err);
643
+ });
644
+
645
+ return c.json({ success: true });
646
+ } catch (error) {
647
+ const msg = error instanceof Error ? error.message : String(error);
648
+ return c.json({ error: msg }, 500);
649
+ }
650
+ });
651
+
652
+ // 9. Get status of running services in all/specific workspace
653
+ app.get('/api/workspace/:id/services', async (c) => {
654
+ try {
655
+ const id = c.req.param('id');
656
+ const config = await loadConfig();
657
+ const workspacePath = path.join(config.workspacesDir, id);
658
+
659
+ // Detect all services
660
+ const services = await detectAllServices(workspacePath);
661
+ // Detect existing tools
662
+ const tools = await detectOrchestrationTools(workspacePath);
663
+ // Get currently running services from running state
664
+ const runningState = await loadRunningState(workspacePath);
665
+
666
+ return c.json({
667
+ services,
668
+ orchestrationTools: tools,
669
+ runningState: runningState?.services || [],
670
+ });
671
+ } catch (error) {
672
+ const msg = error instanceof Error ? error.message : String(error);
673
+ return c.json({ error: msg }, 500);
674
+ }
675
+ });
676
+
677
+ // 10. Start services in workspace
678
+ app.post('/api/workspace/:id/services/start', async (c) => {
679
+ try {
680
+ const id = c.req.param('id');
681
+ const { services } = await c.req.json() as { services: any[] };
682
+ const config = await loadConfig();
683
+ const workspacePath = path.join(config.workspacesDir, id);
684
+ const logDir = path.join(workspacePath, '.nexusflow-logs');
685
+
686
+ await startServices(services, workspacePath, logDir);
687
+ return c.json({ success: true });
688
+ } catch (error) {
689
+ const msg = error instanceof Error ? error.message : String(error);
690
+ return c.json({ error: msg }, 500);
691
+ }
692
+ });
693
+
694
+ // 11. Stop services in workspace
695
+ app.post('/api/workspace/:id/services/stop', async (c) => {
696
+ try {
697
+ const id = c.req.param('id');
698
+ const config = await loadConfig();
699
+ const workspacePath = path.join(config.workspacesDir, id);
700
+
701
+ await stopServices(workspacePath);
702
+ return c.json({ success: true });
703
+ } catch (error) {
704
+ const msg = error instanceof Error ? error.message : String(error);
705
+ return c.json({ error: msg }, 500);
706
+ }
707
+ });
708
+
709
+ // 12. Get service logs
710
+ app.get('/api/workspace/:id/services/logs/:serviceName', async (c) => {
711
+ try {
712
+ const id = c.req.param('id');
713
+ const serviceName = c.req.param('serviceName');
714
+ const config = await loadConfig();
715
+ const workspacePath = path.join(config.workspacesDir, id);
716
+ const logFile = path.join(workspacePath, '.nexusflow-logs', `${serviceName}.log`);
717
+
718
+ let content = '';
719
+ try {
720
+ // Read last 200 lines or 50KB
721
+ const stats = await fs.stat(logFile);
722
+ const size = stats.size;
723
+ const start = Math.max(0, size - 50000);
724
+ const fd = await fs.open(logFile, 'r');
725
+ const buffer = Buffer.alloc(size - start);
726
+ await fd.read(buffer, 0, buffer.length, start);
727
+ await fd.close();
728
+ content = buffer.toString('utf-8');
729
+ } catch {
730
+ content = 'No logs available yet.';
731
+ }
732
+
733
+ return c.json({ logs: content });
734
+ } catch (error) {
735
+ const msg = error instanceof Error ? error.message : String(error);
736
+ return c.json({ error: msg }, 500);
737
+ }
738
+ });
739
+
740
+ // 13. Get git changes in workspace sub-repositories
741
+ app.get('/api/workspace/:id/changes', async (c) => {
742
+ try {
743
+ const id = c.req.param('id');
744
+ const config = await loadConfig();
745
+ const workspacePath = path.join(config.workspacesDir, id);
746
+
747
+ // Load feature config to get repo paths
748
+ const feature = await loadFeatureConfig(workspacePath);
749
+ if (!feature) {
750
+ return c.json({ error: 'Workspace configuration not found.' }, 404);
751
+ }
752
+
753
+ const results: any[] = [];
754
+
755
+ // Check git status in each repo
756
+ for (const repoPath of feature.repos) {
757
+ const repoName = path.basename(repoPath);
758
+ const worktreePath = path.join(workspacePath, repoName);
759
+
760
+ try {
761
+ const { stdout } = await execa('git', ['status', '--porcelain'], { cwd: worktreePath });
762
+ const lines = stdout.split('\n').map((l) => l.trim()).filter(Boolean);
763
+
764
+ // Get numstat to determine additions and deletions per file
765
+ const numstatMap = new Map<string, { additions: number; deletions: number }>();
766
+ try {
767
+ const { stdout: numstatRaw } = await execa('git', ['diff', 'HEAD', '--numstat'], {
768
+ cwd: worktreePath,
769
+ });
770
+ const numstatLines = numstatRaw.split('\n').filter(Boolean);
771
+ for (const numLine of numstatLines) {
772
+ const parts = numLine.trim().split(/\s+/);
773
+ if (parts.length >= 3) {
774
+ const [add, del, file] = parts;
775
+ numstatMap.set(file, {
776
+ additions: add === '-' ? 0 : parseInt(add, 10) || 0,
777
+ deletions: del === '-' ? 0 : parseInt(del, 10) || 0,
778
+ });
779
+ }
780
+ }
781
+ } catch (e) {
782
+ // Ignore diff errors
783
+ }
784
+
785
+ const files = lines.map((line) => {
786
+ const status = line.slice(0, 2).trim();
787
+ const file = line.slice(2).trim();
788
+
789
+ let type = 'modified';
790
+ if (status === 'A' || status === '??') type = 'added';
791
+ else if (status === 'D') type = 'deleted';
792
+
793
+ const stats = numstatMap.get(file) || { additions: 0, deletions: 0 };
794
+
795
+ return {
796
+ file,
797
+ type,
798
+ rawStatus: status,
799
+ additions: stats.additions,
800
+ deletions: stats.deletions,
801
+ };
802
+ });
803
+
804
+ results.push({
805
+ repoName,
806
+ repoPath: worktreePath,
807
+ files,
808
+ });
809
+ } catch (error) {
810
+ results.push({
811
+ repoName,
812
+ repoPath: worktreePath,
813
+ files: [],
814
+ error: error instanceof Error ? error.message : String(error),
815
+ });
816
+ }
817
+ }
818
+
819
+ return c.json({ changes: results });
820
+ } catch (error) {
821
+ const msg = error instanceof Error ? error.message : String(error);
822
+ return c.json({ error: msg }, 500);
823
+ }
824
+ });
825
+
826
+ // 13_diff. Get git diff for a specific file in workspace sub-repositories
827
+ app.get('/api/workspace/:id/changes/diff', async (c) => {
828
+ try {
829
+ const id = c.req.param('id');
830
+ const repoName = c.req.query('repo');
831
+ const filePath = c.req.query('file');
832
+
833
+ if (!repoName || !filePath) {
834
+ return c.json({ error: 'Missing repo or file query parameter.' }, 400);
835
+ }
836
+
837
+ const config = await loadConfig();
838
+ const workspacePath = path.join(config.workspacesDir, id);
839
+ const worktreePath = path.join(workspacePath, repoName);
840
+
841
+ // Security check: ensure path is within workspace directory
842
+ const resolvedPath = path.resolve(worktreePath);
843
+ if (!resolvedPath.startsWith(path.resolve(workspacePath))) {
844
+ return c.json({ error: 'Invalid repository path.' }, 400);
845
+ }
846
+
847
+ let diff = '';
848
+
849
+ // Check git status for the file to know if it's untracked
850
+ try {
851
+ const { stdout: statusOut } = await execa('git', ['status', '--porcelain', '--', filePath], { cwd: worktreePath });
852
+ const statusLine = statusOut.trim();
853
+ const isUntracked = statusLine.startsWith('??');
854
+
855
+ if (isUntracked) {
856
+ const result = await execa('git', ['diff', '--no-index', '--', '/dev/null', filePath], {
857
+ cwd: worktreePath,
858
+ reject: false
859
+ });
860
+ diff = result.stdout || result.stderr || '';
861
+ } else {
862
+ const result = await execa('git', ['diff', 'HEAD', '--', filePath], {
863
+ cwd: worktreePath,
864
+ reject: false
865
+ });
866
+ diff = result.stdout || result.stderr || '';
867
+ }
868
+ } catch (e) {
869
+ const result = await execa('git', ['diff', 'HEAD', '--', filePath], {
870
+ cwd: worktreePath,
871
+ reject: false
872
+ });
873
+ diff = result.stdout || result.stderr || '';
874
+ }
875
+
876
+ return c.json({ diff });
877
+ } catch (error) {
878
+ const msg = error instanceof Error ? error.message : String(error);
879
+ return c.json({ error: msg }, 500);
880
+ }
881
+ });
882
+
883
+ // 13a. Get workspace knowledge (nexusflow-knowledge.md)
884
+ app.get('/api/workspace/:id/knowledge', async (c) => {
885
+ try {
886
+ const id = c.req.param('id');
887
+ const config = await loadConfig();
888
+ const workspacePath = path.join(config.workspacesDir, id);
889
+ const knowledgeFile = path.join(workspacePath, 'nexusflow-knowledge.md');
890
+
891
+ let content = '';
892
+ try {
893
+ content = await fs.readFile(knowledgeFile, 'utf-8');
894
+ } catch {
895
+ content = '# Workspace Knowledge\n\nNo knowledge file yet.';
896
+ }
897
+
898
+ return c.json({ content });
899
+ } catch (error) {
900
+ const msg = error instanceof Error ? error.message : String(error);
901
+ return c.json({ error: msg }, 500);
902
+ }
903
+ });
904
+
905
+ // 13b. Update workspace knowledge (nexusflow-knowledge.md)
906
+ app.put('/api/workspace/:id/knowledge', async (c) => {
907
+ try {
908
+ const id = c.req.param('id');
909
+ const { content } = await c.req.json() as { content: string };
910
+ const config = await loadConfig();
911
+ const workspacePath = path.join(config.workspacesDir, id);
912
+ const knowledgeFile = path.join(workspacePath, 'nexusflow-knowledge.md');
913
+
914
+ await fs.writeFile(knowledgeFile, content, 'utf-8');
915
+ return c.json({ success: true });
916
+ } catch (error) {
917
+ const msg = error instanceof Error ? error.message : String(error);
918
+ return c.json({ error: msg }, 500);
919
+ }
920
+ });
921
+
922
+ // 13c. Get workspace plan (nexusflow-plan.md)
923
+ app.get('/api/workspace/:id/plan', async (c) => {
924
+ try {
925
+ const id = c.req.param('id');
926
+ const config = await loadConfig();
927
+ const workspacePath = path.join(config.workspacesDir, id);
928
+ const planFile = path.join(workspacePath, 'nexusflow-plan.md');
929
+
930
+ let content = '';
931
+ try {
932
+ content = await fs.readFile(planFile, 'utf-8');
933
+ } catch {
934
+ content = '# Workspace Plan\n\nNo implementation plan file yet.';
935
+ }
936
+
937
+ return c.json({ content });
938
+ } catch (error) {
939
+ const msg = error instanceof Error ? error.message : String(error);
940
+ return c.json({ error: msg }, 500);
941
+ }
942
+ });
943
+
944
+ // 13d. Sync all repositories in workspace
945
+ app.post('/api/workspace/:id/sync', async (c) => {
946
+ try {
947
+ const id = c.req.param('id');
948
+ const config = await loadConfig();
949
+ const workspacePath = path.join(config.workspacesDir, id);
950
+
951
+ const report = await syncWorkspace(workspacePath);
952
+ const results = report.repos.map((repo) => ({
953
+ repoName: repo.name,
954
+ success: repo.status !== 'conflict' && repo.status !== 'error',
955
+ status: repo.status,
956
+ message: repo.message,
957
+ conflict: repo.conflict,
958
+ }));
959
+
960
+ return c.json({
961
+ results,
962
+ syncedCount: report.syncedCount,
963
+ conflictCount: report.conflictCount,
964
+ errorCount: report.errorCount,
965
+ });
966
+ } catch (error) {
967
+ const msg = error instanceof Error ? error.message : String(error);
968
+ return c.json({ error: msg }, 500);
969
+ }
970
+ });
971
+
972
+ // 13e. Commit changes in all repositories in workspace
973
+ app.post('/api/workspace/:id/commit', async (c) => {
974
+ try {
975
+ const id = c.req.param('id');
976
+ const { message } = await c.req.json() as { message: string };
977
+ const config = await loadConfig();
978
+ const workspacePath = path.join(config.workspacesDir, id);
979
+
980
+ const repos = await getWorkspaceRepos(workspacePath);
981
+ const results = [];
982
+
983
+ for (const repo of repos) {
984
+ const status = await getRepoStatus(repo.path);
985
+ if (status.hasChanges) {
986
+ const result = await commitAndPush(repo.path, message, repo.branchName);
987
+ results.push({
988
+ repoName: repo.name,
989
+ success: result.success,
990
+ commitHash: result.commitHash,
991
+ filesChanged: result.filesChanged,
992
+ message: result.message,
993
+ });
994
+ }
995
+ }
996
+
997
+ return c.json({ results });
998
+ } catch (error) {
999
+ const msg = error instanceof Error ? error.message : String(error);
1000
+ return c.json({ error: msg }, 500);
1001
+ }
1002
+ });
1003
+
1004
+
1005
+ // 14. Resume session in workspace (copies CLI resume command and opens editor)
1006
+ app.post('/api/workspace/:id/resume', async (c) => {
1007
+ try {
1008
+ const id = decodeURIComponent(c.req.param('id'));
1009
+ const config = await loadConfig();
1010
+ const workspacePath = path.join(config.workspacesDir, id);
1011
+
1012
+ // Load feature config to get repo paths
1013
+ const feature = await loadFeatureConfig(workspacePath);
1014
+ if (!feature) {
1015
+ return c.json({ error: 'Workspace configuration not found.' }, 404);
1016
+ }
1017
+
1018
+ const body = await c.req.json().catch(() => ({}));
1019
+ const targetSessionId = body.sessionId;
1020
+ const targetAssistant = body.assistant;
1021
+
1022
+ // Find the session to resume
1023
+ let resumeCommand = '';
1024
+ let selectedAssistant = targetAssistant || feature.assistants[0] || 'antigravity';
1025
+ let selectedSessionId = targetSessionId;
1026
+
1027
+ if (!selectedSessionId) {
1028
+ // Find the most recent session matching this workspace
1029
+ const sessions = await findSessions(workspacePath, feature.repos);
1030
+ if (sessions.length > 0) {
1031
+ selectedSessionId = sessions[0].id;
1032
+ selectedAssistant = sessions[0].assistant;
1033
+ }
1034
+ }
1035
+
1036
+ if (selectedSessionId) {
1037
+ if (selectedAssistant === 'antigravity') {
1038
+ resumeCommand = `agy --conversation ${selectedSessionId}`;
1039
+ } else if (selectedAssistant === 'claude') {
1040
+ resumeCommand = `claude --resume ${selectedSessionId}`;
1041
+ } else if (selectedAssistant === 'codex') {
1042
+ resumeCommand = `codex resume ${selectedSessionId}`;
1043
+ } else if (selectedAssistant === 'copilot') {
1044
+ resumeCommand = `copilot --resume ${selectedSessionId}`;
1045
+ }
1046
+ } else {
1047
+ // Fallback to start command (since there's no existing session for this workspace)
1048
+ if (selectedAssistant === 'antigravity') {
1049
+ resumeCommand = 'agy --continue';
1050
+ } else if (selectedAssistant === 'claude') {
1051
+ resumeCommand = 'claude --resume';
1052
+ } else if (selectedAssistant === 'codex') {
1053
+ resumeCommand = 'codex resume';
1054
+ } else if (selectedAssistant === 'copilot') {
1055
+ resumeCommand = 'copilot --resume';
1056
+ } else {
1057
+ resumeCommand = 'agy --continue';
1058
+ }
1059
+ }
1060
+
1061
+ // Open in editor if command is provided
1062
+ if (body.command) {
1063
+ if (!ALLOWED_EDITORS.has(body.command)) {
1064
+ return c.json({ error: 'Forbidden editor command' }, 400);
1065
+ }
1066
+ const isWin = process.platform === 'win32';
1067
+ const child = execa(body.command, [workspacePath], {
1068
+ detached: true,
1069
+ stdio: 'ignore',
1070
+ shell: isWin,
1071
+ cleanup: false,
1072
+ });
1073
+ child.unref();
1074
+ child.catch((err) => {
1075
+ console.error(`Failed to launch editor ${body.command} for path ${workspacePath}:`, err);
1076
+ });
1077
+ }
1078
+
1079
+ return c.json({ success: true, resumeCommand, workspacePath });
1080
+ } catch (error) {
1081
+ const msg = error instanceof Error ? error.message : String(error);
1082
+ return c.json({ error: msg }, 500);
1083
+ }
1084
+ });
1085
+
1086
+ // 15. List past AI sessions for a workspace
1087
+ app.get('/api/workspace/:id/sessions', async (c) => {
1088
+ try {
1089
+ const id = decodeURIComponent(c.req.param('id'));
1090
+ const config = await loadConfig();
1091
+ const workspacePath = path.join(config.workspacesDir, id);
1092
+
1093
+ const feature = await loadFeatureConfig(workspacePath);
1094
+ if (!feature) {
1095
+ return c.json({ error: 'Workspace configuration not found.' }, 404);
1096
+ }
1097
+
1098
+ const sessions = await findSessions(workspacePath, feature.repos);
1099
+ return c.json({ sessions });
1100
+ } catch (error) {
1101
+ const msg = error instanceof Error ? error.message : String(error);
1102
+ return c.json({ error: msg }, 500);
1103
+ }
1104
+ });
1105
+
1106
+ // 16. Fetch transcript for a specific AI session
1107
+ app.get('/api/session/:assistant/:sessionId/transcript', async (c) => {
1108
+ try {
1109
+ const assistant = c.req.param('assistant');
1110
+ const sessionId = c.req.param('sessionId');
1111
+ const messages = await getSessionTranscript(assistant, sessionId);
1112
+ return c.json({ messages });
1113
+ } catch (error) {
1114
+ const msg = error instanceof Error ? error.message : String(error);
1115
+ return c.json({ error: msg }, 500);
1116
+ }
1117
+ });
1118
+
1119
+ // 17. Check for NexusFlow updates
1120
+ app.get('/api/update-status', async (c) => {
1121
+ try {
1122
+ const status = await checkForUpdates(false);
1123
+ if (!status) {
1124
+ const currentVersion = getCurrentVersion();
1125
+ return c.json({ currentVersion, latestVersion: currentVersion, updateAvailable: false });
1126
+ }
1127
+ return c.json(status);
1128
+ } catch (error) {
1129
+ const msg = error instanceof Error ? error.message : String(error);
1130
+ return c.json({ error: msg }, 500);
1131
+ }
1132
+ });
1133
+
1134
+ // 17.5. Check tools updates status
1135
+ app.get('/api/updates/tools', async (c) => {
1136
+ try {
1137
+ const force = c.req.query('force') === 'true';
1138
+ const status = await getToolsStatus(force);
1139
+ return c.json(status);
1140
+ } catch (error) {
1141
+ const msg = error instanceof Error ? error.message : String(error);
1142
+ return c.json({ error: msg }, 500);
1143
+ }
1144
+ });
1145
+
1146
+ // 17.6. Install updates for a specific tool
1147
+ app.post('/api/updates/install', async (c) => {
1148
+ try {
1149
+ const { toolId } = await c.req.json() as { toolId: string };
1150
+ const tools = [
1151
+ { id: 'nexusflow', cmd: 'npm', args: ['install', '-g', '@mrpatronz/nexusflow'] },
1152
+ { id: 'antigravity', cmd: 'agy', args: ['update'] },
1153
+ { id: 'claude', cmd: 'npm', args: ['install', '-g', '@anthropic-ai/claude-code'] },
1154
+ ];
1155
+
1156
+ const target = tools.find(t => t.id === toolId);
1157
+ if (!target) {
1158
+ return c.json({ error: 'Tool not found' }, 404);
1159
+ }
1160
+
1161
+ const result = await execa(target.cmd, target.args, {
1162
+ reject: false,
1163
+ shell: process.platform === 'win32',
1164
+ });
1165
+ if (result.exitCode === 0) {
1166
+ return c.json({ success: true, output: result.stdout });
1167
+ } else {
1168
+ return c.json({ error: `Update failed: ${result.stderr || result.stdout}` }, 500);
1169
+ }
1170
+ } catch (error) {
1171
+ const msg = error instanceof Error ? error.message : String(error);
1172
+ return c.json({ error: msg }, 500);
1173
+ }
1174
+ });
1175
+
1176
+ let downloadedInstallerPath: string | null = null;
1177
+
1178
+ // 17.7. Download matching GitHub Release installer to temporary folder
1179
+ app.post('/api/updates/download', async (c) => {
1180
+ try {
1181
+ const { downloadUrl } = await c.req.json() as { downloadUrl: string };
1182
+ if (!downloadUrl) {
1183
+ return c.json({ error: 'Download URL is required' }, 400);
1184
+ }
1185
+
1186
+ const tempDir = os.tmpdir();
1187
+ const fileName = 'NexusFlowSetup_Update.exe';
1188
+ const targetPath = path.join(tempDir, fileName);
1189
+
1190
+ const response = await fetch(downloadUrl);
1191
+ if (!response.ok) {
1192
+ throw new Error(`HTTP error ${response.status}`);
1193
+ }
1194
+
1195
+ // Node fetch body stream download
1196
+ const fileStream = createWriteStream(targetPath);
1197
+ if (!response.body) {
1198
+ throw new Error('Response body is null');
1199
+ }
1200
+
1201
+ // Convert ReadableStream to Node stream
1202
+ await pipeline(response.body as any, fileStream);
1203
+
1204
+ downloadedInstallerPath = targetPath;
1205
+ return c.json({ success: true, path: targetPath });
1206
+ } catch (error) {
1207
+ const msg = error instanceof Error ? error.message : String(error);
1208
+ return c.json({ error: `Download failed: ${msg}` }, 500);
1209
+ }
1210
+ });
1211
+
1212
+ // 17.8. Launch the silent installer detached and exit server
1213
+ app.post('/api/updates/apply', async (c) => {
1214
+ if (!downloadedInstallerPath || !existsSync(downloadedInstallerPath)) {
1215
+ return c.json({ error: 'No downloaded installer found on disk' }, 400);
1216
+ }
1217
+
1218
+ try {
1219
+ const isWin = process.platform === 'win32';
1220
+ if (isWin) {
1221
+ // Inno Setup silent install flags
1222
+ console.log(`Applying update: Spawning detached installer at: ${downloadedInstallerPath}`);
1223
+ const child = spawn(downloadedInstallerPath, ['/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART'], {
1224
+ detached: true,
1225
+ stdio: 'ignore',
1226
+ });
1227
+ child.unref();
1228
+ }
1229
+
1230
+ // Gracefully exit server process in 1 second to release file locks
1231
+ setTimeout(() => {
1232
+ console.log('Update installer successfully spawned. Exiting Hono server process...');
1233
+ process.exit(0);
1234
+ }, 1000);
1235
+
1236
+ return c.json({ success: true, message: 'Installer spawned, app shutting down...' });
1237
+ } catch (error) {
1238
+ const msg = error instanceof Error ? error.message : String(error);
1239
+ return c.json({ error: `Failed to execute update: ${msg}` }, 500);
1240
+ }
1241
+ });
1242
+
1243
+ // 17.9. Get available workflow templates
1244
+ app.get('/api/workflows/templates', async (c) => {
1245
+ try {
1246
+ const templates = await getWorkflowTemplates();
1247
+ return c.json({ templates });
1248
+ } catch (error) {
1249
+ const msg = error instanceof Error ? error.message : String(error);
1250
+ return c.json({ error: msg }, 500);
1251
+ }
1252
+ });
1253
+
1254
+ // Save or update custom teamwork template
1255
+ app.post('/api/workflows/templates', async (c) => {
1256
+ try {
1257
+ const { id, name, content } = await c.req.json();
1258
+ if (!name || !content) {
1259
+ return c.json({ error: 'Name and content are required.' }, 400);
1260
+ }
1261
+ const template = await saveWorkflowTemplate(name, content, id);
1262
+ return c.json({ success: true, template });
1263
+ } catch (error) {
1264
+ const msg = error instanceof Error ? error.message : String(error);
1265
+ return c.json({ error: msg }, 500);
1266
+ }
1267
+ });
1268
+
1269
+ // Delete custom teamwork template
1270
+ app.delete('/api/workflows/templates/:id', async (c) => {
1271
+ try {
1272
+ const id = decodeURIComponent(c.req.param('id'));
1273
+ const templates = await getWorkflowTemplates();
1274
+ const target = templates.find(t => t.id === id);
1275
+ if (!target) {
1276
+ return c.json({ error: 'Template not found.' }, 404);
1277
+ }
1278
+ if (!target.custom) {
1279
+ return c.json({ error: 'Cannot delete built-in templates.' }, 403);
1280
+ }
1281
+ await deleteWorkflowTemplate(id);
1282
+ return c.json({ success: true });
1283
+ } catch (error) {
1284
+ const msg = error instanceof Error ? error.message : String(error);
1285
+ return c.json({ error: msg }, 500);
1286
+ }
1287
+ });
1288
+
1289
+ // Analyze teamwork template rules via selected AI coding assistant harness
1290
+ app.post('/api/workflows/templates/:id/analyze', async (c) => {
1291
+ try {
1292
+ const { content, assistant, comment } = await c.req.json();
1293
+ if (!content) {
1294
+ return c.json({ error: 'Content is required.' }, 400);
1295
+ }
1296
+
1297
+ const selectedAssistant = assistant || 'antigravity';
1298
+ let command = '';
1299
+ let args: string[] = [];
1300
+
1301
+ let prompt = `You are an expert AI system engineering reviewer. Analyze the following Agent Teamwork Strategy guidelines.
1302
+ Evaluate its instructions, identify any ambiguities or contradictions, rate its expected effectiveness for orchestrating subagents, and provide specific recommendations or improvements. Format your analysis in clean Markdown with clear headings (e.g. Overview, Strengths, Weaknesses, Recommendations).
1303
+
1304
+ After your analysis, provide a fully rewritten, optimized, and complete version of the strategy guidelines incorporating all your recommendations. This rewritten version must be suitable for production orchestration.
1305
+ You MUST prefix the rewritten version with the exact delimiter line:
1306
+ === SUGGESTED IMPROVEMENT START ===
1307
+ and suffix it with:
1308
+ === SUGGESTED IMPROVEMENT END ===`;
1309
+
1310
+ if (comment && comment.trim()) {
1311
+ prompt += `\n\nIMPORTANT: The user has provided the following specific instruction/comment that you MUST consider and prioritize during your evaluation and when rewriting the guidelines:\n"${comment.trim()}"`;
1312
+ }
1313
+
1314
+ prompt += `\n\n--- GUIDELINES START ---\n${content}\n--- GUIDELINES END ---`;
1315
+
1316
+ const assistants = await detectAIAssistants();
1317
+ const target = assistants.find(ai => ai.name === selectedAssistant);
1318
+
1319
+ if (!target || !target.detected || !target.command) {
1320
+ return c.json({
1321
+ error: `AI assistant harness '${selectedAssistant}' is not detected or does not support command-line execution.`
1322
+ }, 400);
1323
+ }
1324
+
1325
+ command = target.command;
1326
+ if (command === 'claude') {
1327
+ args = ['-p', prompt];
1328
+ } else if (command === 'agy') {
1329
+ args = [prompt];
1330
+ } else {
1331
+ args = [prompt];
1332
+ }
1333
+
1334
+ const result = await execa(command, args, {
1335
+ input: '',
1336
+ shell: false,
1337
+ reject: false
1338
+ });
1339
+
1340
+ if (result.exitCode !== 0) {
1341
+ return c.json({
1342
+ error: `AI Assistant harness execution failed (exit code ${result.exitCode}): ${result.stderr || result.stdout || 'Unknown error'}`
1343
+ }, 500);
1344
+ }
1345
+
1346
+ // Clean potential warning lines or stdout prefixes if present
1347
+ let cleanText = result.stdout;
1348
+ if (cleanText.includes('Warning: no stdin data received')) {
1349
+ cleanText = cleanText.replace(/Warning: no stdin data received in \d+s, proceeding without it\. If piping from a slow command, redirect stdin explicitly: < \/dev\/null to skip, or wait longer\.\r?\n?/, '');
1350
+ }
1351
+
1352
+ let analysis = cleanText.trim();
1353
+ let suggestedImprovement = '';
1354
+
1355
+ const startDelimiter = '=== SUGGESTED IMPROVEMENT START ===';
1356
+ const endDelimiter = '=== SUGGESTED IMPROVEMENT END ===';
1357
+
1358
+ const startIdx = cleanText.indexOf(startDelimiter);
1359
+ const endIdx = cleanText.indexOf(endDelimiter);
1360
+
1361
+ if (startIdx !== -1 && endIdx !== -1) {
1362
+ analysis = cleanText.substring(0, startIdx).trim();
1363
+ suggestedImprovement = cleanText.substring(startIdx + startDelimiter.length, endIdx).trim();
1364
+ }
1365
+
1366
+ return c.json({ analysis, suggestedImprovement });
1367
+ } catch (error) {
1368
+ const msg = error instanceof Error ? error.message : String(error);
1369
+ return c.json({ error: msg }, 500);
1370
+ }
1371
+ });
1372
+
1373
+ // Legacy pack endpoint removed.
1374
+
1375
+ // Serve index.html explicitly on root endpoint
1376
+ app.get('/', async (c) => {
1377
+ try {
1378
+ const html = await fs.readFile(path.join(guiPath, 'index.html'), 'utf-8');
1379
+ return c.html(html);
1380
+ } catch (error) {
1381
+ return c.text('GUI dashboard built assets not found. Run "npm run build" first.', 404);
1382
+ }
1383
+ });
1384
+
1385
+ // Serve static assets from GUI build folder
1386
+ app.use('/*', serveStatic({ root: path.relative(process.cwd(), guiPath) }));
1387
+
1388
+ export function startServer(port = 3000): Promise<{ port: number; server: any }> {
1389
+ return new Promise((resolve, reject) => {
1390
+ const server = serve({ fetch: app.fetch, port }, (info) => {
1391
+ resolve({ port: info.port, server });
1392
+ }) as import('node:http').Server;
1393
+
1394
+ server.on('error', (e: any) => {
1395
+ if (e.code === 'EADDRINUSE') {
1396
+ resolve(startServer(port + 1));
1397
+ } else {
1398
+ reject(e);
1399
+ }
1400
+ });
1401
+ });
1402
+ }