@mrpatronz/nexusflow 1.7.0 → 1.9.0

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 (302) hide show
  1. package/README.md +57 -23
  2. package/dist/agent/AntigravityCliAdapter.d.ts +7 -0
  3. package/dist/agent/AntigravityCliAdapter.d.ts.map +1 -0
  4. package/dist/agent/AntigravityCliAdapter.js +13 -0
  5. package/dist/agent/AntigravityCliAdapter.js.map +1 -0
  6. package/dist/agent/ClaudeCliAdapter.d.ts +16 -0
  7. package/dist/agent/ClaudeCliAdapter.d.ts.map +1 -0
  8. package/dist/agent/ClaudeCliAdapter.js +34 -0
  9. package/dist/agent/ClaudeCliAdapter.js.map +1 -0
  10. package/dist/agent/CliAdapterBase.d.ts +48 -0
  11. package/dist/agent/CliAdapterBase.d.ts.map +1 -0
  12. package/dist/agent/CliAdapterBase.js +120 -0
  13. package/dist/agent/CliAdapterBase.js.map +1 -0
  14. package/dist/agent/NativeAgent.d.ts +13 -0
  15. package/dist/agent/NativeAgent.d.ts.map +1 -0
  16. package/dist/agent/NativeAgent.js +97 -0
  17. package/dist/agent/NativeAgent.js.map +1 -0
  18. package/dist/agent/NativeAgentBase.d.ts +27 -0
  19. package/dist/agent/NativeAgentBase.d.ts.map +1 -0
  20. package/dist/agent/NativeAgentBase.js +58 -0
  21. package/dist/agent/NativeAgentBase.js.map +1 -0
  22. package/dist/agent/NativeClaudeAgent.d.ts +13 -0
  23. package/dist/agent/NativeClaudeAgent.d.ts.map +1 -0
  24. package/dist/agent/NativeClaudeAgent.js +115 -0
  25. package/dist/agent/NativeClaudeAgent.js.map +1 -0
  26. package/dist/agent/NativeGoogleAgent.d.ts +13 -0
  27. package/dist/agent/NativeGoogleAgent.d.ts.map +1 -0
  28. package/dist/agent/NativeGoogleAgent.js +90 -0
  29. package/dist/agent/NativeGoogleAgent.js.map +1 -0
  30. package/dist/agent/ProviderRegistry.d.ts +31 -0
  31. package/dist/agent/ProviderRegistry.d.ts.map +1 -0
  32. package/dist/agent/ProviderRegistry.js +20 -0
  33. package/dist/agent/ProviderRegistry.js.map +1 -0
  34. package/dist/agent/ProviderRegistry.test.d.ts +2 -0
  35. package/dist/agent/ProviderRegistry.test.d.ts.map +1 -0
  36. package/dist/agent/ProviderRegistry.test.js +57 -0
  37. package/dist/agent/ProviderRegistry.test.js.map +1 -0
  38. package/dist/agent/adapters.d.ts +3 -0
  39. package/dist/agent/adapters.d.ts.map +1 -0
  40. package/dist/agent/adapters.js +48 -0
  41. package/dist/agent/adapters.js.map +1 -0
  42. package/dist/agent/nativeAgent.test.d.ts +2 -0
  43. package/dist/agent/nativeAgent.test.d.ts.map +1 -0
  44. package/dist/agent/nativeAgent.test.js +35 -0
  45. package/dist/agent/nativeAgent.test.js.map +1 -0
  46. package/dist/agent/nativeTools.d.ts +26 -0
  47. package/dist/agent/nativeTools.d.ts.map +1 -0
  48. package/dist/agent/nativeTools.js +62 -0
  49. package/dist/agent/nativeTools.js.map +1 -0
  50. package/dist/agent/nativeTools.test.d.ts +2 -0
  51. package/dist/agent/nativeTools.test.d.ts.map +1 -0
  52. package/dist/agent/nativeTools.test.js +67 -0
  53. package/dist/agent/nativeTools.test.js.map +1 -0
  54. package/dist/agent/session.d.ts +25 -0
  55. package/dist/agent/session.d.ts.map +1 -0
  56. package/dist/agent/session.js +31 -0
  57. package/dist/agent/session.js.map +1 -0
  58. package/dist/agent/session.test.d.ts +2 -0
  59. package/dist/agent/session.test.d.ts.map +1 -0
  60. package/dist/agent/session.test.js +44 -0
  61. package/dist/agent/session.test.js.map +1 -0
  62. package/dist/commands/create.d.ts +8 -7
  63. package/dist/commands/create.d.ts.map +1 -1
  64. package/dist/commands/create.js +134 -65
  65. package/dist/commands/create.js.map +1 -1
  66. package/dist/commands/desktop.d.ts +4 -5
  67. package/dist/commands/desktop.d.ts.map +1 -1
  68. package/dist/commands/desktop.js +35 -89
  69. package/dist/commands/desktop.js.map +1 -1
  70. package/dist/commands/doctor.d.ts.map +1 -1
  71. package/dist/commands/doctor.js +0 -1
  72. package/dist/commands/doctor.js.map +1 -1
  73. package/dist/commands/init.d.ts.map +1 -1
  74. package/dist/commands/init.js +1 -72
  75. package/dist/commands/init.js.map +1 -1
  76. package/dist/commands/list.d.ts.map +1 -1
  77. package/dist/commands/list.js +2 -1
  78. package/dist/commands/list.js.map +1 -1
  79. package/dist/commands/project.d.ts +30 -0
  80. package/dist/commands/project.d.ts.map +1 -0
  81. package/dist/commands/project.js +125 -0
  82. package/dist/commands/project.js.map +1 -0
  83. package/dist/commands/refresh.js +1 -1
  84. package/dist/commands/refresh.js.map +1 -1
  85. package/dist/commands/start.d.ts.map +1 -1
  86. package/dist/commands/start.js +18 -2
  87. package/dist/commands/start.js.map +1 -1
  88. package/dist/commands/stop.d.ts +1 -1
  89. package/dist/commands/stop.d.ts.map +1 -1
  90. package/dist/commands/stop.js +19 -2
  91. package/dist/commands/stop.js.map +1 -1
  92. package/dist/commands/tui.js +0 -1
  93. package/dist/commands/tui.js.map +1 -1
  94. package/dist/commands/ui.d.ts +7 -3
  95. package/dist/commands/ui.d.ts.map +1 -1
  96. package/dist/commands/ui.js +12 -6
  97. package/dist/commands/ui.js.map +1 -1
  98. package/dist/core/config.d.ts.map +1 -1
  99. package/dist/core/config.js +0 -9
  100. package/dist/core/config.js.map +1 -1
  101. package/dist/core/config.test.js +0 -9
  102. package/dist/core/config.test.js.map +1 -1
  103. package/dist/core/doctor.d.ts +1 -3
  104. package/dist/core/doctor.d.ts.map +1 -1
  105. package/dist/core/doctor.js +18 -69
  106. package/dist/core/doctor.js.map +1 -1
  107. package/dist/core/finish.d.ts.map +1 -1
  108. package/dist/core/finish.js +5 -0
  109. package/dist/core/finish.js.map +1 -1
  110. package/dist/core/graph.d.ts.map +1 -1
  111. package/dist/core/graph.js +2 -1
  112. package/dist/core/graph.js.map +1 -1
  113. package/dist/core/projects.d.ts +58 -0
  114. package/dist/core/projects.d.ts.map +1 -0
  115. package/dist/core/projects.js +190 -0
  116. package/dist/core/projects.js.map +1 -0
  117. package/dist/core/projects.test.d.ts +2 -0
  118. package/dist/core/projects.test.d.ts.map +1 -0
  119. package/dist/core/projects.test.js +165 -0
  120. package/dist/core/projects.test.js.map +1 -0
  121. package/dist/core/sync.d.ts.map +1 -1
  122. package/dist/core/sync.js +21 -0
  123. package/dist/core/sync.js.map +1 -1
  124. package/dist/core/sync.test.js +15 -0
  125. package/dist/core/sync.test.js.map +1 -1
  126. package/dist/core/workspace.create.test.js +68 -1
  127. package/dist/core/workspace.create.test.js.map +1 -1
  128. package/dist/core/workspace.d.ts +4 -3
  129. package/dist/core/workspace.d.ts.map +1 -1
  130. package/dist/core/workspace.js +138 -64
  131. package/dist/core/workspace.js.map +1 -1
  132. package/dist/desktop-server.d.ts +13 -0
  133. package/dist/desktop-server.d.ts.map +1 -0
  134. package/dist/desktop-server.js +23 -0
  135. package/dist/desktop-server.js.map +1 -0
  136. package/dist/generators/antigravity.js +1 -1
  137. package/dist/generators/antigravity.js.map +1 -1
  138. package/dist/generators/base.d.ts +1 -8
  139. package/dist/generators/base.d.ts.map +1 -1
  140. package/dist/generators/base.js +107 -58
  141. package/dist/generators/base.js.map +1 -1
  142. package/dist/generators/base.test.d.ts +2 -0
  143. package/dist/generators/base.test.d.ts.map +1 -0
  144. package/dist/generators/base.test.js +80 -0
  145. package/dist/generators/base.test.js.map +1 -0
  146. package/dist/generators/claude.js +1 -1
  147. package/dist/generators/claude.js.map +1 -1
  148. package/dist/generators/codex.js +1 -1
  149. package/dist/generators/codex.js.map +1 -1
  150. package/dist/generators/copilot.js +1 -1
  151. package/dist/generators/copilot.js.map +1 -1
  152. package/dist/generators/cursor.js +1 -1
  153. package/dist/generators/cursor.js.map +1 -1
  154. package/dist/generators/diff-context.d.ts.map +1 -1
  155. package/dist/generators/diff-context.js +3 -1
  156. package/dist/generators/diff-context.js.map +1 -1
  157. package/dist/generators/index.js +1 -1
  158. package/dist/generators/index.js.map +1 -1
  159. package/dist/generators/map-generator.d.ts.map +1 -1
  160. package/dist/generators/map-generator.js +3 -1
  161. package/dist/generators/map-generator.js.map +1 -1
  162. package/dist/gui/assets/DashboardPage-C3NH97y2.js +1 -0
  163. package/dist/gui/assets/GettingStartedPage-og-Kv7Sm.js +1 -0
  164. package/dist/gui/assets/ProjectsPage-COqqadEr.js +1 -0
  165. package/dist/gui/assets/ScaffoldRepoInline-DRipQsos.js +1 -0
  166. package/dist/gui/assets/Separator-Chx6O62Y.js +1 -0
  167. package/dist/gui/assets/SettingsPage-CpU3V9oU.js +1 -0
  168. package/dist/gui/assets/StartWorkPage-DIND0hJy.js +1 -0
  169. package/dist/gui/assets/StrategiesPage-6LWOnKdk.js +3 -0
  170. package/dist/gui/assets/WorkspacesPage-BUqgOhuy.js +6 -0
  171. package/dist/gui/assets/badge-BnkhqVEF.js +1 -0
  172. package/dist/gui/assets/button-cPldH5S4.js +1 -0
  173. package/dist/gui/assets/card-82INhRRE.js +1 -0
  174. package/dist/gui/assets/circle-plus-C0PNc7h4.js +1 -0
  175. package/dist/gui/assets/cpu-CPUNnHRM.js +1 -0
  176. package/dist/gui/assets/empty-DWyDwDYB.js +1 -0
  177. package/dist/gui/assets/external-link-BtYhMxno.js +1 -0
  178. package/dist/gui/assets/git-branch-Dw55pQWa.js +1 -0
  179. package/dist/gui/assets/index-CsA2SueF.css +2 -0
  180. package/dist/gui/assets/index-CyYbQwRf.js +52 -0
  181. package/dist/gui/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  182. package/dist/gui/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  183. package/dist/gui/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  184. package/dist/gui/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  185. package/dist/gui/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  186. package/dist/gui/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  187. package/dist/gui/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  188. package/dist/gui/assets/jetbrains-mono-latin-100-normal-DlYB2XW3.woff2 +0 -0
  189. package/dist/gui/assets/jetbrains-mono-latin-100-normal-DzH8uRxw.woff +0 -0
  190. package/dist/gui/assets/jetbrains-mono-latin-200-normal-CHn02WOn.woff +0 -0
  191. package/dist/gui/assets/jetbrains-mono-latin-200-normal-DvzYDkvL.woff2 +0 -0
  192. package/dist/gui/assets/jetbrains-mono-latin-300-normal-BYcAiAh2.woff +0 -0
  193. package/dist/gui/assets/jetbrains-mono-latin-300-normal-DuMDZskh.woff2 +0 -0
  194. package/dist/gui/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  195. package/dist/gui/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  196. package/dist/gui/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  197. package/dist/gui/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
  198. package/dist/gui/assets/jetbrains-mono-latin-600-normal-BfsvjouI.woff +0 -0
  199. package/dist/gui/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
  200. package/dist/gui/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
  201. package/dist/gui/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
  202. package/dist/gui/assets/jetbrains-mono-latin-800-normal-D2mQHRMK.woff2 +0 -0
  203. package/dist/gui/assets/jetbrains-mono-latin-800-normal-Dj9qwObk.woff +0 -0
  204. package/dist/gui/assets/label-JFgMNsTn.js +1 -0
  205. package/dist/gui/assets/search-ChVovkOL.js +1 -0
  206. package/dist/gui/assets/skeleton-CCPbfn1L.js +1 -0
  207. package/dist/gui/assets/spinner-DXt6mnJk.js +1 -0
  208. package/dist/gui/assets/textarea-BLU5HXmZ.js +1 -0
  209. package/dist/gui/assets/trash-2-Da8F2gKd.js +1 -0
  210. package/dist/gui/assets/useAriaLabelledBy-CjNlctb9.js +1 -0
  211. package/dist/gui/assets/useCompositeListItem-DWGFLKqA.js +1 -0
  212. package/dist/gui/index.html +16 -6
  213. package/dist/index.js +56 -5
  214. package/dist/index.js.map +1 -1
  215. package/dist/mcp/tools.d.ts.map +1 -1
  216. package/dist/mcp/tools.js +4 -63
  217. package/dist/mcp/tools.js.map +1 -1
  218. package/dist/orchestration/detect.d.ts.map +1 -1
  219. package/dist/orchestration/detect.js +72 -26
  220. package/dist/orchestration/detect.js.map +1 -1
  221. package/dist/orchestration/index.d.ts +3 -1
  222. package/dist/orchestration/index.d.ts.map +1 -1
  223. package/dist/orchestration/index.js +3 -1
  224. package/dist/orchestration/index.js.map +1 -1
  225. package/dist/orchestration/log-tail.d.ts +27 -0
  226. package/dist/orchestration/log-tail.d.ts.map +1 -0
  227. package/dist/orchestration/log-tail.js +93 -0
  228. package/dist/orchestration/log-tail.js.map +1 -0
  229. package/dist/orchestration/log-tail.test.d.ts +2 -0
  230. package/dist/orchestration/log-tail.test.d.ts.map +1 -0
  231. package/dist/orchestration/log-tail.test.js +72 -0
  232. package/dist/orchestration/log-tail.test.js.map +1 -0
  233. package/dist/orchestration/orchestrator.d.ts +26 -0
  234. package/dist/orchestration/orchestrator.d.ts.map +1 -0
  235. package/dist/orchestration/orchestrator.js +102 -0
  236. package/dist/orchestration/orchestrator.js.map +1 -0
  237. package/dist/orchestration/orchestrator.test.d.ts +2 -0
  238. package/dist/orchestration/orchestrator.test.d.ts.map +1 -0
  239. package/dist/orchestration/orchestrator.test.js +85 -0
  240. package/dist/orchestration/orchestrator.test.js.map +1 -0
  241. package/dist/orchestration/runner.d.ts +62 -3
  242. package/dist/orchestration/runner.d.ts.map +1 -1
  243. package/dist/orchestration/runner.js +198 -103
  244. package/dist/orchestration/runner.js.map +1 -1
  245. package/dist/orchestration/runner.test.js +125 -1
  246. package/dist/orchestration/runner.test.js.map +1 -1
  247. package/dist/server.d.ts.map +1 -1
  248. package/dist/server.js +395 -126
  249. package/dist/server.js.map +1 -1
  250. package/dist/server.test.js +111 -123
  251. package/dist/server.test.js.map +1 -1
  252. package/dist/types.d.ts +79 -16
  253. package/dist/types.d.ts.map +1 -1
  254. package/dist/utils/feature.d.ts +42 -0
  255. package/dist/utils/feature.d.ts.map +1 -0
  256. package/dist/utils/feature.js +52 -0
  257. package/dist/utils/feature.js.map +1 -0
  258. package/dist/utils/multi-git.d.ts.map +1 -1
  259. package/dist/utils/multi-git.js +30 -7
  260. package/dist/utils/multi-git.js.map +1 -1
  261. package/dist/utils/session-finder.d.ts +21 -1
  262. package/dist/utils/session-finder.d.ts.map +1 -1
  263. package/dist/utils/session-finder.js +220 -143
  264. package/dist/utils/session-finder.js.map +1 -1
  265. package/dist/utils/session-finder.test.d.ts +2 -0
  266. package/dist/utils/session-finder.test.d.ts.map +1 -0
  267. package/dist/utils/session-finder.test.js +65 -0
  268. package/dist/utils/session-finder.test.js.map +1 -0
  269. package/dist/utils/slug.d.ts +12 -0
  270. package/dist/utils/slug.d.ts.map +1 -0
  271. package/dist/utils/slug.js +18 -0
  272. package/dist/utils/slug.js.map +1 -0
  273. package/dist/utils/test-command.d.ts +12 -0
  274. package/dist/utils/test-command.d.ts.map +1 -0
  275. package/dist/utils/test-command.js +22 -0
  276. package/dist/utils/test-command.js.map +1 -0
  277. package/dist/utils/update-check.d.ts +8 -0
  278. package/dist/utils/update-check.d.ts.map +1 -1
  279. package/dist/utils/update-check.js +13 -7
  280. package/dist/utils/update-check.js.map +1 -1
  281. package/dist/utils/update-check.test.d.ts +2 -0
  282. package/dist/utils/update-check.test.d.ts.map +1 -0
  283. package/dist/utils/update-check.test.js +25 -0
  284. package/dist/utils/update-check.test.js.map +1 -0
  285. package/dist/utils/workflow-advisor.d.ts +2 -2
  286. package/dist/utils/workflow-advisor.d.ts.map +1 -1
  287. package/dist/utils/workflow-advisor.js +50 -92
  288. package/dist/utils/workflow-advisor.js.map +1 -1
  289. package/dist/utils/workflows.d.ts.map +1 -1
  290. package/dist/utils/workflows.js +2 -4
  291. package/dist/utils/workflows.js.map +1 -1
  292. package/package.json +10 -1
  293. package/dist/gui/assets/index-CB-Ekvrs.css +0 -1
  294. package/dist/gui/assets/index-CixLsYSa.js +0 -26
  295. package/dist/utils/local-ai.d.ts +0 -27
  296. package/dist/utils/local-ai.d.ts.map +0 -1
  297. package/dist/utils/local-ai.js +0 -100
  298. package/dist/utils/local-ai.js.map +0 -1
  299. package/dist/utils/local-ai.test.d.ts +0 -2
  300. package/dist/utils/local-ai.test.d.ts.map +0 -1
  301. package/dist/utils/local-ai.test.js +0 -108
  302. package/dist/utils/local-ai.test.js.map +0 -1
package/dist/server.js CHANGED
@@ -7,6 +7,7 @@ import { serve } from '@hono/node-server';
7
7
  import { serveStatic } from '@hono/node-server/serve-static';
8
8
  import { cors } from 'hono/cors';
9
9
  import { streamSSE } from 'hono/streaming';
10
+ import { createNodeWebSocket } from '@hono/node-ws';
10
11
  import * as fs from 'node:fs/promises';
11
12
  import { createWriteStream, existsSync } from 'node:fs';
12
13
  import * as path from 'node:path';
@@ -19,16 +20,18 @@ import { loadConfig, saveConfig, getConfigDir } from './core/config.js';
19
20
  import { listStorageProviders } from './core/adapters/registry.js';
20
21
  import { scanForRepos } from './core/scanner.js';
21
22
  import { createNewRepo } from './core/new-repo.js';
23
+ import { loadProjects, createProject, updateProject, removeProject, slugifyProjectName } from './core/projects.js';
24
+ import { getSessionCwd, isInPlace, resolveFeatureRepoPath } from './utils/feature.js';
22
25
  import { listBranches } from './utils/git.js';
23
26
  import { createWorkspace, listWorkspaces, loadFeatureConfig, deleteWorkspace, addRepoToWorkspace } from './core/workspace.js';
24
27
  import { loadWorkspaceState } from './core/workspace-state.js';
25
28
  import { analyzeAllRepos } from './analyzers/index.js';
26
29
  import { generateContextFiles } from './generators/index.js';
27
- import { isOllamaModelAvailable, getOpenAiCompatibleUrl, callLocalLlm } from './utils/local-ai.js';
28
30
  import { detectAIAssistants } from './utils/detect-ai.js';
29
31
  import { detectEditors } from './utils/detect-editors.js';
30
32
  import { findSessions, getSessionTranscript } from './utils/session-finder.js';
31
- import { scanSystemSpecs } from './utils/system-scanner.js';
33
+ import { ProviderRegistry } from './agent/adapters.js';
34
+ import { isValidSessionUuid } from './agent/session.js';
32
35
  import { getRepoStatus } from './utils/multi-git.js';
33
36
  import { syncWorkspace } from './core/sync.js';
34
37
  import { commitWorkspace } from './core/commit.js';
@@ -36,7 +39,7 @@ import { refreshWorkspace } from './core/refresh.js';
36
39
  import { writeWorkspaceFile } from './core/storage.js';
37
40
  import { readWorkspaceKnowledge, addWorkspaceKnowledge, addBaseKnowledge, } from './core/knowledge.js';
38
41
  import { addSchedule, loadSchedules, nextDueAt, parseInterval, removeSchedule, runJob, setScheduleEnabled, startScheduler, } from './core/scheduler.js';
39
- import { detectAllServices, detectOrchestrationTools, startServices, stopServices, loadRunningState, getPm2List, } from './orchestration/index.js';
42
+ import { detectAllServices, detectOrchestrationTools, startServices, stopServices, startService, stopService, restartService, startOrchestrator, stopOrchestrator, tailLogFile, loadRunningState, getPm2List, } from './orchestration/index.js';
40
43
  import { checkForUpdates, getCurrentVersion, getToolsStatus } from './utils/update-check.js';
41
44
  import { getWorkflowTemplates, saveWorkflowTemplate, deleteWorkflowTemplate } from './utils/workflows.js';
42
45
  import { suggestWorkflow } from './utils/workflow-advisor.js';
@@ -47,6 +50,109 @@ const __dirname = path.dirname(__filename);
47
50
  // In production, it will be served from dist/gui as well
48
51
  const guiPath = path.join(__dirname, 'gui');
49
52
  export const app = new Hono();
53
+ const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
54
+ app.get('/api/adapters/status', (c) => {
55
+ return c.json(ProviderRegistry.getAllStatus());
56
+ });
57
+ app.get('/ws', async (c, next) => {
58
+ // Prevent Cross-Site WebSocket Hijacking (CSWSH)
59
+ const origin = c.req.header('origin');
60
+ if (origin) {
61
+ try {
62
+ const { hostname } = new URL(origin);
63
+ const isLocal = ['localhost', '127.0.0.1', '::1', '[::1]'].includes(hostname);
64
+ if (!isLocal)
65
+ return c.text('Forbidden', 403);
66
+ }
67
+ catch {
68
+ return c.text('Forbidden', 403);
69
+ }
70
+ }
71
+ // Chat protocol:
72
+ // client -> server: {type:'start', command, cwd, sessionId?, resume?} | {type:'input', input} | {type:'stop'} | 'ping'
73
+ // server -> client: {type:'stream', text} | {type:'status', state:'busy'|'idle'} | {type:'system', message}
74
+ // | {type:'error', message} | {type:'close', code} | {type:'pong'}
75
+ return upgradeWebSocket((c) => {
76
+ let agent = null;
77
+ return {
78
+ onMessage(event, ws) {
79
+ if (typeof event.data === 'string') {
80
+ if (event.data === 'ping') {
81
+ ws.send(JSON.stringify({ type: 'pong' }));
82
+ return;
83
+ }
84
+ try {
85
+ const payload = JSON.parse(event.data);
86
+ if (payload.type === 'ping') {
87
+ ws.send(JSON.stringify({ type: 'pong' }));
88
+ }
89
+ else if (payload.type === 'start') {
90
+ if (agent) {
91
+ agent.stop();
92
+ }
93
+ const provider = ProviderRegistry.getProvider(payload.command);
94
+ if (!provider) {
95
+ ws.send(JSON.stringify({ type: 'error', message: `No provider found for ${payload.command}. Please create a dedicated adapter.` }));
96
+ return;
97
+ }
98
+ let session;
99
+ if (payload.sessionId !== undefined && payload.sessionId !== null) {
100
+ if (!isValidSessionUuid(payload.sessionId)) {
101
+ ws.send(JSON.stringify({ type: 'error', message: 'Invalid session id.' }));
102
+ return;
103
+ }
104
+ session = { id: payload.sessionId, resume: Boolean(payload.resume) };
105
+ }
106
+ agent = provider.createInstance();
107
+ agent.on('data', (text) => {
108
+ ws.send(JSON.stringify({ type: 'stream', text }));
109
+ });
110
+ agent.on('system', (message) => {
111
+ ws.send(JSON.stringify({ type: 'system', message }));
112
+ });
113
+ agent.on('idle', () => {
114
+ ws.send(JSON.stringify({ type: 'status', state: 'idle' }));
115
+ });
116
+ agent.on('close', (code) => {
117
+ ws.send(JSON.stringify({ type: 'close', code }));
118
+ });
119
+ agent.on('error', (error) => {
120
+ ws.send(JSON.stringify({ type: 'error', message: error?.message ?? String(error) }));
121
+ ws.send(JSON.stringify({ type: 'status', state: 'idle' }));
122
+ });
123
+ agent.start(payload.cwd, session);
124
+ }
125
+ else if (payload.type === 'input') {
126
+ if (agent) {
127
+ agent.send(payload.input);
128
+ ws.send(JSON.stringify({ type: 'status', state: 'busy' }));
129
+ }
130
+ }
131
+ else if (payload.type === 'stop') {
132
+ if (agent) {
133
+ agent.stop();
134
+ agent = null;
135
+ }
136
+ }
137
+ }
138
+ catch (err) {
139
+ console.error('[WS] Failed to parse message', err);
140
+ }
141
+ }
142
+ },
143
+ onOpen(_event, _ws) {
144
+ console.log('[WS] Client connected');
145
+ },
146
+ onClose(_event, _ws) {
147
+ console.log('[WS] Client disconnected');
148
+ if (agent) {
149
+ agent.stop();
150
+ agent = null;
151
+ }
152
+ },
153
+ };
154
+ })(c, next);
155
+ });
50
156
  // Allowed editor binaries/scripts to prevent command injection
51
157
  const ALLOWED_EDITORS = new Set(['code', 'code-insiders', 'cursor', 'antigravity', 'agy', 'idea', 'charm', 'webstorm', 'subl', 'nano', 'vim', 'nvim', 'emacs']);
52
158
  // ─── Path containment guards ──────────────────────────────────────────────
@@ -157,35 +263,10 @@ app.get('/api/adapters', async (c) => {
157
263
  return errorResponse(c, error);
158
264
  }
159
265
  });
160
- function isSafeLocalEndpoint(urlStr) {
161
- try {
162
- const url = new URL(urlStr);
163
- const hostname = url.hostname.toLowerCase();
164
- if (url.protocol === 'https:') {
165
- return true;
166
- }
167
- if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]') {
168
- return true;
169
- }
170
- const ipv4Pattern = /^(?:10|127|192\.168|172\.(?:1[6-9]|2[0-9]|3[01]))\.\d+\.\d+\.\d+$/;
171
- if (ipv4Pattern.test(hostname)) {
172
- return true;
173
- }
174
- return false;
175
- }
176
- catch {
177
- return false;
178
- }
179
- }
180
266
  // 2. Save configuration
181
267
  app.post('/api/config', async (c) => {
182
268
  try {
183
269
  const newConfig = await c.req.json();
184
- if (newConfig?.localLlm?.enabled && newConfig.localLlm.endpoint) {
185
- if (!isSafeLocalEndpoint(newConfig.localLlm.endpoint)) {
186
- return c.json({ error: 'Local AI endpoint must be HTTPS, localhost, 127.0.0.1, or a private LAN IP.' }, 400);
187
- }
188
- }
189
270
  await saveConfig(newConfig);
190
271
  return c.json({ success: true, config: newConfig });
191
272
  }
@@ -236,6 +317,72 @@ app.post('/api/repos/new', async (c) => {
236
317
  return errorResponse(c, error);
237
318
  }
238
319
  });
320
+ // 3c. Project registry — named groups of source repos that features start from.
321
+ app.get('/api/projects', async (c) => {
322
+ try {
323
+ return c.json(await loadProjects({ quiet: true }));
324
+ }
325
+ catch (error) {
326
+ return errorResponse(c, error);
327
+ }
328
+ });
329
+ app.post('/api/projects', async (c) => {
330
+ try {
331
+ const body = await c.req.json();
332
+ if (!body.name || typeof body.name !== 'string') {
333
+ return c.json({ error: 'Missing "name" in request body' }, 400);
334
+ }
335
+ if (!Array.isArray(body.repos) || body.repos.length === 0) {
336
+ return c.json({ error: 'Missing "repos" in request body' }, 400);
337
+ }
338
+ const config = await loadConfig();
339
+ // Only repos under devDir are offered by the scanner; refuse anything else.
340
+ const repos = body.repos.map((r) => assertWithin(config.devDir, r));
341
+ const project = await createProject(body.name, repos, body.description);
342
+ return c.json(project, 201);
343
+ }
344
+ catch (error) {
345
+ return errorResponse(c, error);
346
+ }
347
+ });
348
+ app.put('/api/projects/:id', async (c) => {
349
+ try {
350
+ const body = await c.req.json();
351
+ let repoPaths;
352
+ if (body.repos !== undefined) {
353
+ if (!Array.isArray(body.repos) || body.repos.length === 0) {
354
+ return c.json({ error: '"repos" must be a non-empty array' }, 400);
355
+ }
356
+ const config = await loadConfig();
357
+ repoPaths = body.repos.map((r) => assertWithin(config.devDir, r));
358
+ }
359
+ const project = await updateProject(c.req.param('id'), {
360
+ name: body.name,
361
+ description: body.description,
362
+ repoPaths,
363
+ });
364
+ return c.json(project);
365
+ }
366
+ catch (error) {
367
+ if (error instanceof Error && error.message.startsWith('No project')) {
368
+ return c.json({ error: error.message }, 404);
369
+ }
370
+ return errorResponse(c, error);
371
+ }
372
+ });
373
+ // Registry-only delete — never touches repos or workspaces on disk.
374
+ app.delete('/api/projects/:id', async (c) => {
375
+ try {
376
+ const removed = await removeProject(c.req.param('id'));
377
+ if (!removed) {
378
+ return c.json({ error: `No project with id "${c.req.param('id')}"` }, 404);
379
+ }
380
+ return c.json({ success: true });
381
+ }
382
+ catch (error) {
383
+ return errorResponse(c, error);
384
+ }
385
+ });
239
386
  // 4. List existing workspaces
240
387
  app.get('/api/workspaces', async (c) => {
241
388
  try {
@@ -291,9 +438,10 @@ app.get('/api/workspaces/status', async (c) => {
291
438
  pendingValidation: false,
292
439
  };
293
440
  try {
294
- // Uncommitted changes across the workspace's repo worktrees.
441
+ // Uncommitted changes across the workspace's repos: worktrees inside
442
+ // the workspace dir, or the source repos themselves for in-place.
295
443
  for (const repoPath of ws.repos) {
296
- const worktreePath = path.join(workspacePath, path.basename(repoPath));
444
+ const worktreePath = resolveFeatureRepoPath(ws, workspacePath, repoPath);
297
445
  const repoStatus = await getRepoStatus(worktreePath);
298
446
  if (repoStatus.hasChanges) {
299
447
  status.dirtyRepos += 1;
@@ -303,7 +451,8 @@ app.get('/api/workspaces/status', async (c) => {
303
451
  // Running services (cached running-state, PM2-verified — same source as
304
452
  // the Services tab; only workspaces that ever started services touch PM2).
305
453
  const runningState = await loadRunningState(workspacePath, pm2List);
306
- status.runningServices = runningState?.services?.length ?? 0;
454
+ status.runningServices =
455
+ (runningState?.services?.length ?? 0) + (runningState?.orchestrators?.length ?? 0);
307
456
  // Sync state: worst-case classification + any repo pending validation.
308
457
  const wsState = await loadWorkspaceState(workspacePath);
309
458
  const repoStates = Object.values(wsState.repos);
@@ -346,63 +495,6 @@ app.get('/api/editor-detect', async (c) => {
346
495
  return errorResponse(c, error);
347
496
  }
348
497
  });
349
- // 6.5. Local LLM test & recommendation
350
- app.post('/api/local-llm/test', async (c) => {
351
- try {
352
- const { provider, endpoint, model, apiKey, shoot } = await c.req.json();
353
- if (!endpoint || !isSafeLocalEndpoint(endpoint)) {
354
- return c.json({ success: false, error: 'Local AI endpoint must be HTTPS, localhost, 127.0.0.1, or a private LAN IP.' }, 400);
355
- }
356
- const cleanEndpoint = endpoint.replace(/\/$/, '');
357
- if (shoot) {
358
- const responseText = await callLocalLlm({ enabled: true, provider, endpoint, model, apiKey }, [{ role: 'user', content: 'Respond with the exact word "OK" and nothing else.' }]);
359
- const cleanResponse = responseText.trim();
360
- return c.json({
361
- success: true,
362
- modelReady: true,
363
- message: `Inference test succeeded! Response from model: "${cleanResponse}"`
364
- });
365
- }
366
- if (provider === 'ollama') {
367
- const res = await fetch(`${cleanEndpoint}/api/tags`);
368
- if (!res.ok)
369
- throw new Error(`Ollama responded with status ${res.status}`);
370
- const data = await res.json();
371
- const models = data?.models || [];
372
- const isModelLoaded = isOllamaModelAvailable(models, model);
373
- return c.json({
374
- success: true,
375
- modelReady: isModelLoaded,
376
- message: isModelLoaded ? 'Connected successfully! Model is ready.' : `Connected successfully, but model "${model}" is not pulled. Run "ollama pull ${model}" to install it.`
377
- });
378
- }
379
- else {
380
- const testUrl = getOpenAiCompatibleUrl(cleanEndpoint, '/v1/models');
381
- const res = await fetch(testUrl);
382
- if (!res.ok)
383
- throw new Error(`OpenAI-compatible server responded with status ${res.status}`);
384
- return c.json({ success: true, modelReady: true, message: 'Connected successfully to OpenAI-compatible server!' });
385
- }
386
- }
387
- catch (error) {
388
- const msg = error instanceof Error ? error.message : String(error);
389
- return c.json({ success: false, error: msg }, 400);
390
- }
391
- });
392
- app.get('/api/local-llm/recommend', async (c) => {
393
- try {
394
- const specs = await scanSystemSpecs();
395
- return c.json(specs);
396
- }
397
- catch (error) {
398
- return c.json({
399
- totalRamGb: 8,
400
- gpuName: 'Unknown/Integrated',
401
- hasHardwareAcceleration: false,
402
- recommendedModel: 'qwen2.5-coder:1.5b',
403
- });
404
- }
405
- });
406
498
  const creationJobs = new Map();
407
499
  // Keep a finished job around briefly so a late SSE reconnect can still read its
408
500
  // final state, then drop it so the map doesn't grow unbounded.
@@ -459,7 +551,11 @@ function updateJobStep(jobId, stepId, status, message, extraData) {
459
551
  }
460
552
  async function runCreationJob(jobId, body, config) {
461
553
  try {
462
- const workspacePath = resolveWorkspacePath(config.workspacesDir, body.branchName);
554
+ const inPlace = body.mode === 'in-place';
555
+ // Workspace id doubles as the directory name: the branch for worktree
556
+ // mode, the (slugified) workspace name for in-place mode.
557
+ const workspaceId = inPlace ? jobId : body.branchName;
558
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, workspaceId);
463
559
  const job = creationJobs.get(jobId);
464
560
  if (job) {
465
561
  job.workspacePath = workspacePath;
@@ -472,32 +568,40 @@ async function runCreationJob(jobId, body, config) {
472
568
  }
473
569
  }
474
570
  const feature = {
475
- id: body.branchName,
476
- branchName: body.branchName,
571
+ id: workspaceId,
572
+ mode: inPlace ? 'in-place' : 'worktree',
573
+ projectId: body.projectId,
574
+ // In-place features never create a branch; keeping branchName populated
575
+ // (= id) avoids breaking every consumer of the non-optional field.
576
+ branchName: inPlace ? workspaceId : body.branchName,
477
577
  description: body.description,
478
- repos: body.repos.map((r) => path.join(workspacePath, r.name)),
578
+ repos: inPlace
579
+ ? body.repos.map((r) => r.path)
580
+ : body.repos.map((r) => path.join(workspacePath, r.name)),
479
581
  originalRepos: body.repos.map((r) => r.path),
480
- repoBranches: Object.keys(repoBranches).length > 0 ? repoBranches : undefined,
582
+ repoBranches: !inPlace && Object.keys(repoBranches).length > 0 ? repoBranches : undefined,
481
583
  assistants: body.assistants,
482
584
  workspacePath,
483
585
  createdAt: new Date().toISOString(),
484
586
  resumption: body.resumption,
485
- localLlmEnabled: body.localLlmEnabled,
486
587
  teamworkInstructions: body.teamworkInstructions,
487
588
  };
488
589
  if (job) {
489
590
  job.feature = feature;
490
591
  }
491
- // Step 1: Create worktrees
492
- updateJobStep(jobId, 'worktrees', 'running', 'Creating git worktrees...');
592
+ // Step 1: Materialize the workspace (worktrees, or just the lightweight
593
+ // dir). One stable step id for both modes — only the wording differs.
594
+ updateJobStep(jobId, 'workspace', 'running', inPlace ? 'Registering workspace...' : 'Creating git worktrees...');
493
595
  await createWorkspace(feature, body.repos);
494
- updateJobStep(jobId, 'worktrees', 'completed', 'Git worktrees created successfully.');
495
- // Step 2: Analyze repos
596
+ updateJobStep(jobId, 'workspace', 'completed', inPlace ? 'Workspace registered — working in-place in the source repos.' : 'Git worktrees created successfully.');
597
+ // Step 2: Analyze repos — against the worktrees, or the source repos in-place.
496
598
  updateJobStep(jobId, 'analysis', 'running', 'Analyzing projects and dependencies...');
497
- const workspaceRepos = body.repos.map((repo) => ({
498
- ...repo,
499
- path: path.join(workspacePath, repo.name),
500
- }));
599
+ const workspaceRepos = inPlace
600
+ ? body.repos
601
+ : body.repos.map((repo) => ({
602
+ ...repo,
603
+ path: path.join(workspacePath, repo.name),
604
+ }));
501
605
  const analysis = await analyzeAllRepos(workspaceRepos);
502
606
  updateJobStep(jobId, 'analysis', 'completed', 'Project analysis complete.');
503
607
  // Step 3: Generate AI context files
@@ -506,7 +610,6 @@ async function runCreationJob(jobId, body, config) {
506
610
  feature,
507
611
  repos: workspaceRepos,
508
612
  analysis,
509
- localLlm: config.localLlm,
510
613
  };
511
614
  await generateContextFiles(ctx, body.assistants, workspacePath);
512
615
  updateJobStep(jobId, 'context', 'completed', 'AI context files generated.');
@@ -517,7 +620,7 @@ async function runCreationJob(jobId, body, config) {
517
620
  const job = creationJobs.get(jobId);
518
621
  if (job) {
519
622
  const runningStep = job.steps.find((s) => s.status === 'running');
520
- const failedStepId = runningStep ? runningStep.id : 'worktrees';
623
+ const failedStepId = runningStep ? runningStep.id : 'workspace';
521
624
  updateJobStep(jobId, failedStepId, 'failed', msg);
522
625
  }
523
626
  }
@@ -526,8 +629,19 @@ async function runCreationJob(jobId, body, config) {
526
629
  app.post('/api/workspace', async (c) => {
527
630
  try {
528
631
  const body = await c.req.json();
632
+ const inPlace = body.mode === 'in-place';
633
+ if (inPlace && !body.name?.trim()) {
634
+ return c.json({ error: 'In-place workspaces need a "name"' }, 400);
635
+ }
636
+ if (!inPlace && !body.branchName) {
637
+ return c.json({ error: 'Missing "branchName" in request body' }, 400);
638
+ }
529
639
  const config = await loadConfig();
530
- const jobId = body.branchName;
640
+ // The job id doubles as the workspace directory name.
641
+ const jobId = inPlace ? slugifyProjectName(body.name) : body.branchName;
642
+ if (!jobId) {
643
+ return c.json({ error: `Workspace name "${body.name}" contains no usable characters` }, 400);
644
+ }
531
645
  if (creationJobs.has(jobId)) {
532
646
  const existing = creationJobs.get(jobId);
533
647
  if (existing.status === 'running') {
@@ -535,7 +649,9 @@ app.post('/api/workspace', async (c) => {
535
649
  }
536
650
  }
537
651
  const steps = [
538
- { id: 'worktrees', name: 'Create Git Worktrees', status: 'pending', message: 'Waiting...' },
652
+ // Stable id 'workspace' in both modes so progress consumers never need
653
+ // to know the mode; only the display name differs.
654
+ { id: 'workspace', name: inPlace ? 'Register Workspace' : 'Create Git Worktrees', status: 'pending', message: 'Waiting...' },
539
655
  { id: 'analysis', name: 'Analyze Repositories', status: 'pending', message: 'Waiting...' },
540
656
  { id: 'context', name: 'Generate AI Context Files', status: 'pending', message: 'Waiting...' },
541
657
  ];
@@ -604,10 +720,27 @@ app.get('/api/workspace/create-stream/:jobId', async (c) => {
604
720
  }
605
721
  };
606
722
  job.listeners.add(listener);
607
- // Guard against the job finishing between the initial state write and now.
723
+ // Guard against the job finishing between the initial state write and
724
+ // now: the client only saw a 'running' frame, so send the terminal
725
+ // state before closing — silently ending the stream here would leave
726
+ // the client believing the connection dropped mid-run.
608
727
  if (job.status === 'completed' || job.status === 'failed') {
609
728
  job.listeners.delete(listener);
610
- resolve();
729
+ stream
730
+ .writeSSE({
731
+ event: 'progress',
732
+ data: JSON.stringify({
733
+ id: job.id,
734
+ status: job.status,
735
+ progress: job.progress,
736
+ error: job.error,
737
+ steps: job.steps,
738
+ workspacePath: job.workspacePath,
739
+ feature: job.feature,
740
+ }),
741
+ })
742
+ .catch(() => { })
743
+ .finally(() => resolve());
611
744
  }
612
745
  });
613
746
  });
@@ -644,7 +777,7 @@ app.post('/api/workspace/suggest-workflow', async (c) => {
644
777
  try {
645
778
  const { description, repos } = await c.req.json();
646
779
  const config = await loadConfig();
647
- const suggestion = await suggestWorkflow(description, repos, config.localLlm);
780
+ const suggestion = await suggestWorkflow(description, repos);
648
781
  return c.json({
649
782
  success: true,
650
783
  ...suggestion
@@ -705,20 +838,22 @@ app.get('/api/workspace/:id/services', async (c) => {
705
838
  services,
706
839
  orchestrationTools: tools,
707
840
  runningState: runningState?.services || [],
841
+ runningOrchestrators: runningState?.orchestrators || [],
708
842
  });
709
843
  }
710
844
  catch (error) {
711
845
  return errorResponse(c, error);
712
846
  }
713
847
  });
714
- // 10. Start services in workspace
848
+ // 10. Start services in workspace. Configs are re-detected server-side —
849
+ // the client only says "start", never what to execute.
715
850
  app.post('/api/workspace/:id/services/start', async (c) => {
716
851
  try {
717
852
  const id = c.req.param('id');
718
- const { services } = await c.req.json();
719
853
  const config = await loadConfig();
720
854
  const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
721
855
  const logDir = path.join(workspacePath, '.nexusflow-logs');
856
+ const services = await detectAllServices(workspacePath);
722
857
  await startServices(services, workspacePath, logDir);
723
858
  return c.json({ success: true });
724
859
  }
@@ -739,19 +874,53 @@ app.post('/api/workspace/:id/services/stop', async (c) => {
739
874
  return errorResponse(c, error);
740
875
  }
741
876
  });
742
- // 12. Get service logs
877
+ // 11b. Per-service start / stop / restart. The service config is re-detected
878
+ // by name server-side; the client never supplies a command.
879
+ app.post('/api/workspace/:id/services/:serviceName/:action{start|stop|restart}', async (c) => {
880
+ try {
881
+ const id = c.req.param('id');
882
+ const serviceName = decodeURIComponent(c.req.param('serviceName'));
883
+ const action = c.req.param('action');
884
+ const config = await loadConfig();
885
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
886
+ const logDir = path.join(workspacePath, '.nexusflow-logs');
887
+ if (action === 'stop') {
888
+ const stopped = await stopService(workspacePath, serviceName);
889
+ return c.json({ success: true, stopped });
890
+ }
891
+ const services = await detectAllServices(workspacePath);
892
+ const service = services.find((s) => s.name === serviceName);
893
+ if (!service) {
894
+ return c.json({ error: `Unknown service "${serviceName}" in this workspace.` }, 404);
895
+ }
896
+ const running = action === 'restart'
897
+ ? await restartService(service, workspacePath, logDir)
898
+ : await startService(service, workspacePath, logDir);
899
+ return c.json({ success: running !== null, service: running });
900
+ }
901
+ catch (error) {
902
+ return errorResponse(c, error);
903
+ }
904
+ });
905
+ /** Resolve + contain a service log path; names may contain '/' (repo/sub). */
906
+ function resolveServiceLogFile(workspacePath, serviceName) {
907
+ const logDir = path.join(workspacePath, '.nexusflow-logs');
908
+ return assertWithin(logDir, path.join(logDir, `${serviceName}.log`));
909
+ }
910
+ // 12. Get service logs (backfill). Returns the trailing 50KB and the byte
911
+ // offset the read ended at, so the SSE stream can resume exactly there.
743
912
  app.get('/api/workspace/:id/services/logs/:serviceName', async (c) => {
744
913
  try {
745
914
  const id = c.req.param('id');
746
- const serviceName = c.req.param('serviceName');
915
+ const serviceName = decodeURIComponent(c.req.param('serviceName'));
747
916
  const config = await loadConfig();
748
917
  const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
749
- const logFile = path.join(workspacePath, '.nexusflow-logs', `${serviceName}.log`);
918
+ const logFile = resolveServiceLogFile(workspacePath, serviceName);
750
919
  let content = '';
920
+ let size = 0;
751
921
  try {
752
- // Read last 200 lines or 50KB
753
922
  const stats = await fs.stat(logFile);
754
- const size = stats.size;
923
+ size = stats.size;
755
924
  const start = Math.max(0, size - 50000);
756
925
  const fd = await fs.open(logFile, 'r');
757
926
  const buffer = Buffer.alloc(size - start);
@@ -762,7 +931,82 @@ app.get('/api/workspace/:id/services/logs/:serviceName', async (c) => {
762
931
  catch {
763
932
  content = 'No logs available yet.';
764
933
  }
765
- return c.json({ logs: content });
934
+ return c.json({ logs: content, size });
935
+ }
936
+ catch (error) {
937
+ return errorResponse(c, error);
938
+ }
939
+ });
940
+ // 12b. Live log stream (SSE): tails the log file from ?offset onward,
941
+ // emitting 'log' events with JSON-encoded chunks (raw SSE frames would mangle
942
+ // embedded newlines). A 15s ping keeps idle streams alive.
943
+ app.get('/api/workspace/:id/services/logs/:serviceName/stream', async (c) => {
944
+ // Resolve + validate the log path BEFORE opening the stream so a bad name
945
+ // (e.g. a traversal attempt) returns a clean error, mirroring the backfill
946
+ // route, rather than a raw 500 from an uncaught throw.
947
+ let logFile;
948
+ let startOffset;
949
+ try {
950
+ const id = c.req.param('id');
951
+ const serviceName = decodeURIComponent(c.req.param('serviceName'));
952
+ const config = await loadConfig();
953
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
954
+ logFile = resolveServiceLogFile(workspacePath, serviceName);
955
+ const offsetParam = Number.parseInt(c.req.query('offset') ?? '', 10);
956
+ startOffset = Number.isFinite(offsetParam) && offsetParam >= 0 ? offsetParam : undefined;
957
+ }
958
+ catch (error) {
959
+ return errorResponse(c, error);
960
+ }
961
+ c.header('Content-Type', 'text/event-stream');
962
+ c.header('Cache-Control', 'no-cache');
963
+ c.header('Connection', 'keep-alive');
964
+ return streamSSE(c, async (stream) => {
965
+ await stream.writeSSE({ event: 'init', data: JSON.stringify({ offset: startOffset ?? null }) });
966
+ await new Promise((resolve) => {
967
+ let done = false;
968
+ const cleanup = () => {
969
+ if (done)
970
+ return;
971
+ done = true;
972
+ tail.stop();
973
+ clearInterval(heartbeat);
974
+ resolve();
975
+ };
976
+ const tail = tailLogFile(logFile, (chunk) => {
977
+ stream.writeSSE({ event: 'log', data: JSON.stringify({ chunk }) }).catch(cleanup);
978
+ }, { startOffset });
979
+ const heartbeat = setInterval(() => {
980
+ stream.writeSSE({ event: 'ping', data: '{}' }).catch(cleanup);
981
+ }, 15_000);
982
+ stream.onAbort(cleanup);
983
+ });
984
+ });
985
+ });
986
+ // 12c. Orchestration tools: start/stop by detection id only — the tool is
987
+ // re-detected server-side and the client can never supply a command.
988
+ app.post('/api/workspace/:id/orchestrators/:action{start|stop}', async (c) => {
989
+ try {
990
+ const id = c.req.param('id');
991
+ const action = c.req.param('action');
992
+ const body = await c.req.json();
993
+ if (!body.id || typeof body.id !== 'string') {
994
+ return c.json({ error: 'Missing orchestrator "id" in request body' }, 400);
995
+ }
996
+ const config = await loadConfig();
997
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
998
+ const logDir = path.join(workspacePath, '.nexusflow-logs');
999
+ const tools = await detectOrchestrationTools(workspacePath);
1000
+ const detection = tools.find((t) => t.id === body.id);
1001
+ if (!detection) {
1002
+ return c.json({ error: `Unknown orchestration tool "${body.id}" in this workspace.` }, 404);
1003
+ }
1004
+ if (action === 'start') {
1005
+ const running = await startOrchestrator(detection, workspacePath, logDir);
1006
+ return c.json({ success: true, orchestrator: running });
1007
+ }
1008
+ await stopOrchestrator(detection, workspacePath);
1009
+ return c.json({ success: true });
766
1010
  }
767
1011
  catch (error) {
768
1012
  return errorResponse(c, error);
@@ -780,10 +1024,10 @@ app.get('/api/workspace/:id/changes', async (c) => {
780
1024
  return c.json({ error: 'Workspace configuration not found.' }, 404);
781
1025
  }
782
1026
  const results = [];
783
- // Check git status in each repo
1027
+ // Check git status in each repo (worktree, or source repo for in-place)
784
1028
  for (const repoPath of feature.repos) {
785
1029
  const repoName = path.basename(repoPath);
786
- const worktreePath = path.join(workspacePath, repoName);
1030
+ const worktreePath = resolveFeatureRepoPath(feature, workspacePath, repoPath);
787
1031
  try {
788
1032
  const { stdout } = await execa('git', ['status', '--porcelain'], { cwd: worktreePath });
789
1033
  const lines = stdout.split('\n').map((l) => l.trim()).filter(Boolean);
@@ -857,8 +1101,25 @@ app.get('/api/workspace/:id/changes/diff', async (c) => {
857
1101
  }
858
1102
  const config = await loadConfig();
859
1103
  const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
860
- // Contained within the workspace; rejects `..` and sibling-prefix escapes.
861
- const worktreePath = resolveRepoPath(workspacePath, repoName);
1104
+ // Resolve by mode, manifest first: an in-place repo name may only resolve
1105
+ // to an exact manifest entry (its repos live outside the workspace), and
1106
+ // must never be shadowed by a stray same-named subdirectory inside the
1107
+ // workspace dir. Worktree mode keeps the path-containment guard.
1108
+ const feature = await loadFeatureConfig(workspacePath);
1109
+ let worktreePath;
1110
+ if (feature && isInPlace(feature)) {
1111
+ const matches = feature.repos.filter((r) => path.basename(r) === repoName);
1112
+ if (matches.length === 0) {
1113
+ return c.json({ error: `Unknown repo "${repoName}" in this workspace.` }, 404);
1114
+ }
1115
+ if (matches.length > 1) {
1116
+ return c.json({ error: `Repo name "${repoName}" is ambiguous in this workspace.` }, 400);
1117
+ }
1118
+ worktreePath = matches[0];
1119
+ }
1120
+ else {
1121
+ worktreePath = resolveRepoPath(workspacePath, repoName);
1122
+ }
862
1123
  let diff = '';
863
1124
  // Check git status for the file to know if it's untracked
864
1125
  try {
@@ -1090,7 +1351,9 @@ app.post('/api/workspace/:id/resume', async (c) => {
1090
1351
  console.error(`Failed to launch editor ${body.command} for path ${workspacePath}:`, err);
1091
1352
  });
1092
1353
  }
1093
- return c.json({ success: true, resumeCommand, workspacePath });
1354
+ // Where the resume command should be run: the workspace dir, or the repo
1355
+ // root for single-repo in-place features.
1356
+ return c.json({ success: true, resumeCommand, workspacePath, sessionCwd: getSessionCwd(feature) });
1094
1357
  }
1095
1358
  catch (error) {
1096
1359
  return errorResponse(c, error);
@@ -1115,9 +1378,9 @@ app.get('/api/workspace/:id/sessions', async (c) => {
1115
1378
  });
1116
1379
  // 16. Fetch transcript for a specific AI session
1117
1380
  app.get('/api/session/:assistant/:sessionId/transcript', async (c) => {
1381
+ const assistant = c.req.param('assistant');
1382
+ const sessionId = c.req.param('sessionId');
1118
1383
  try {
1119
- const assistant = c.req.param('assistant');
1120
- const sessionId = c.req.param('sessionId');
1121
1384
  const messages = await getSessionTranscript(assistant, sessionId);
1122
1385
  return c.json({ messages });
1123
1386
  }
@@ -1219,9 +1482,14 @@ app.post('/api/updates/apply', async (c) => {
1219
1482
  try {
1220
1483
  const isWin = process.platform === 'win32';
1221
1484
  if (isWin) {
1222
- // Inno Setup silent install flags
1485
+ // The desktop app ships an electron-builder NSIS installer (build target
1486
+ // 'nsis'), whose silent-install switch is `/S` — NOT Inno Setup's
1487
+ // /VERYSILENT. With the wrong flags the one-click installer waits for UI
1488
+ // that a detached (stdio: 'ignore') process can never provide, so the
1489
+ // update silently fails to apply. `/S` runs it unattended and relaunches
1490
+ // the app on finish (electron-builder default).
1223
1491
  console.log(`Applying update: Spawning detached installer at: ${downloadedInstallerPath}`);
1224
- const child = spawn(downloadedInstallerPath, ['/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART'], {
1492
+ const child = spawn(downloadedInstallerPath, ['/S'], {
1225
1493
  detached: true,
1226
1494
  stdio: 'ignore',
1227
1495
  });
@@ -1452,6 +1720,7 @@ export function startServer(port = 3000, opts = {}) {
1452
1720
  startScheduler({ log: (message) => console.log(`[scheduler] ${message}`) });
1453
1721
  resolve({ port: info.port, server });
1454
1722
  });
1723
+ injectWebSocket(server);
1455
1724
  server.on('error', (e) => {
1456
1725
  if (e.code === 'EADDRINUSE') {
1457
1726
  // Callers that own their backend (desktop/extension) pass strictPort so