@mrpatronz/nexusflow 1.8.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 (222) hide show
  1. package/README.md +48 -15
  2. package/dist/commands/create.d.ts +8 -7
  3. package/dist/commands/create.d.ts.map +1 -1
  4. package/dist/commands/create.js +134 -65
  5. package/dist/commands/create.js.map +1 -1
  6. package/dist/commands/doctor.d.ts.map +1 -1
  7. package/dist/commands/doctor.js +0 -1
  8. package/dist/commands/doctor.js.map +1 -1
  9. package/dist/commands/init.d.ts.map +1 -1
  10. package/dist/commands/init.js +1 -72
  11. package/dist/commands/init.js.map +1 -1
  12. package/dist/commands/list.d.ts.map +1 -1
  13. package/dist/commands/list.js +2 -1
  14. package/dist/commands/list.js.map +1 -1
  15. package/dist/commands/project.d.ts +30 -0
  16. package/dist/commands/project.d.ts.map +1 -0
  17. package/dist/commands/project.js +125 -0
  18. package/dist/commands/project.js.map +1 -0
  19. package/dist/commands/refresh.js +1 -1
  20. package/dist/commands/refresh.js.map +1 -1
  21. package/dist/commands/start.d.ts.map +1 -1
  22. package/dist/commands/start.js +18 -2
  23. package/dist/commands/start.js.map +1 -1
  24. package/dist/commands/stop.d.ts +1 -1
  25. package/dist/commands/stop.d.ts.map +1 -1
  26. package/dist/commands/stop.js +19 -2
  27. package/dist/commands/stop.js.map +1 -1
  28. package/dist/commands/tui.js +0 -1
  29. package/dist/commands/tui.js.map +1 -1
  30. package/dist/core/config.d.ts.map +1 -1
  31. package/dist/core/config.js +0 -9
  32. package/dist/core/config.js.map +1 -1
  33. package/dist/core/config.test.js +0 -9
  34. package/dist/core/config.test.js.map +1 -1
  35. package/dist/core/doctor.d.ts +1 -3
  36. package/dist/core/doctor.d.ts.map +1 -1
  37. package/dist/core/doctor.js +18 -69
  38. package/dist/core/doctor.js.map +1 -1
  39. package/dist/core/finish.d.ts.map +1 -1
  40. package/dist/core/finish.js +5 -0
  41. package/dist/core/finish.js.map +1 -1
  42. package/dist/core/graph.d.ts.map +1 -1
  43. package/dist/core/graph.js +2 -1
  44. package/dist/core/graph.js.map +1 -1
  45. package/dist/core/projects.d.ts +58 -0
  46. package/dist/core/projects.d.ts.map +1 -0
  47. package/dist/core/projects.js +190 -0
  48. package/dist/core/projects.js.map +1 -0
  49. package/dist/core/projects.test.d.ts +2 -0
  50. package/dist/core/projects.test.d.ts.map +1 -0
  51. package/dist/core/projects.test.js +165 -0
  52. package/dist/core/projects.test.js.map +1 -0
  53. package/dist/core/sync.d.ts.map +1 -1
  54. package/dist/core/sync.js +21 -0
  55. package/dist/core/sync.js.map +1 -1
  56. package/dist/core/sync.test.js +15 -0
  57. package/dist/core/sync.test.js.map +1 -1
  58. package/dist/core/workspace.create.test.js +68 -1
  59. package/dist/core/workspace.create.test.js.map +1 -1
  60. package/dist/core/workspace.d.ts +4 -3
  61. package/dist/core/workspace.d.ts.map +1 -1
  62. package/dist/core/workspace.js +138 -64
  63. package/dist/core/workspace.js.map +1 -1
  64. package/dist/generators/antigravity.js +1 -1
  65. package/dist/generators/antigravity.js.map +1 -1
  66. package/dist/generators/base.d.ts +1 -8
  67. package/dist/generators/base.d.ts.map +1 -1
  68. package/dist/generators/base.js +107 -58
  69. package/dist/generators/base.js.map +1 -1
  70. package/dist/generators/base.test.d.ts +2 -0
  71. package/dist/generators/base.test.d.ts.map +1 -0
  72. package/dist/generators/base.test.js +80 -0
  73. package/dist/generators/base.test.js.map +1 -0
  74. package/dist/generators/claude.js +1 -1
  75. package/dist/generators/claude.js.map +1 -1
  76. package/dist/generators/codex.js +1 -1
  77. package/dist/generators/codex.js.map +1 -1
  78. package/dist/generators/copilot.js +1 -1
  79. package/dist/generators/copilot.js.map +1 -1
  80. package/dist/generators/cursor.js +1 -1
  81. package/dist/generators/cursor.js.map +1 -1
  82. package/dist/generators/diff-context.d.ts.map +1 -1
  83. package/dist/generators/diff-context.js +3 -1
  84. package/dist/generators/diff-context.js.map +1 -1
  85. package/dist/generators/index.js +1 -1
  86. package/dist/generators/index.js.map +1 -1
  87. package/dist/generators/map-generator.d.ts.map +1 -1
  88. package/dist/generators/map-generator.js +3 -1
  89. package/dist/generators/map-generator.js.map +1 -1
  90. package/dist/gui/assets/DashboardPage-C3NH97y2.js +1 -0
  91. package/dist/gui/assets/GettingStartedPage-og-Kv7Sm.js +1 -0
  92. package/dist/gui/assets/ProjectsPage-COqqadEr.js +1 -0
  93. package/dist/gui/assets/ScaffoldRepoInline-DRipQsos.js +1 -0
  94. package/dist/gui/assets/Separator-Chx6O62Y.js +1 -0
  95. package/dist/gui/assets/SettingsPage-CpU3V9oU.js +1 -0
  96. package/dist/gui/assets/StartWorkPage-DIND0hJy.js +1 -0
  97. package/dist/gui/assets/StrategiesPage-6LWOnKdk.js +3 -0
  98. package/dist/gui/assets/WorkspacesPage-BUqgOhuy.js +6 -0
  99. package/dist/gui/assets/badge-BnkhqVEF.js +1 -0
  100. package/dist/gui/assets/button-cPldH5S4.js +1 -0
  101. package/dist/gui/assets/card-82INhRRE.js +1 -0
  102. package/dist/gui/assets/circle-plus-C0PNc7h4.js +1 -0
  103. package/dist/gui/assets/cpu-CPUNnHRM.js +1 -0
  104. package/dist/gui/assets/empty-DWyDwDYB.js +1 -0
  105. package/dist/gui/assets/external-link-BtYhMxno.js +1 -0
  106. package/dist/gui/assets/git-branch-Dw55pQWa.js +1 -0
  107. package/dist/gui/assets/index-CsA2SueF.css +2 -0
  108. package/dist/gui/assets/index-CyYbQwRf.js +52 -0
  109. package/dist/gui/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  110. package/dist/gui/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  111. package/dist/gui/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  112. package/dist/gui/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  113. package/dist/gui/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  114. package/dist/gui/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  115. package/dist/gui/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  116. package/dist/gui/assets/jetbrains-mono-latin-100-normal-DlYB2XW3.woff2 +0 -0
  117. package/dist/gui/assets/jetbrains-mono-latin-100-normal-DzH8uRxw.woff +0 -0
  118. package/dist/gui/assets/jetbrains-mono-latin-200-normal-CHn02WOn.woff +0 -0
  119. package/dist/gui/assets/jetbrains-mono-latin-200-normal-DvzYDkvL.woff2 +0 -0
  120. package/dist/gui/assets/jetbrains-mono-latin-300-normal-BYcAiAh2.woff +0 -0
  121. package/dist/gui/assets/jetbrains-mono-latin-300-normal-DuMDZskh.woff2 +0 -0
  122. package/dist/gui/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  123. package/dist/gui/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  124. package/dist/gui/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  125. package/dist/gui/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
  126. package/dist/gui/assets/jetbrains-mono-latin-600-normal-BfsvjouI.woff +0 -0
  127. package/dist/gui/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
  128. package/dist/gui/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
  129. package/dist/gui/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
  130. package/dist/gui/assets/jetbrains-mono-latin-800-normal-D2mQHRMK.woff2 +0 -0
  131. package/dist/gui/assets/jetbrains-mono-latin-800-normal-Dj9qwObk.woff +0 -0
  132. package/dist/gui/assets/label-JFgMNsTn.js +1 -0
  133. package/dist/gui/assets/search-ChVovkOL.js +1 -0
  134. package/dist/gui/assets/skeleton-CCPbfn1L.js +1 -0
  135. package/dist/gui/assets/spinner-DXt6mnJk.js +1 -0
  136. package/dist/gui/assets/textarea-BLU5HXmZ.js +1 -0
  137. package/dist/gui/assets/trash-2-Da8F2gKd.js +1 -0
  138. package/dist/gui/assets/useAriaLabelledBy-CjNlctb9.js +1 -0
  139. package/dist/gui/assets/useCompositeListItem-DWGFLKqA.js +1 -0
  140. package/dist/gui/index.html +16 -6
  141. package/dist/index.js +50 -0
  142. package/dist/index.js.map +1 -1
  143. package/dist/mcp/tools.d.ts.map +1 -1
  144. package/dist/mcp/tools.js +4 -63
  145. package/dist/mcp/tools.js.map +1 -1
  146. package/dist/orchestration/detect.d.ts.map +1 -1
  147. package/dist/orchestration/detect.js +72 -26
  148. package/dist/orchestration/detect.js.map +1 -1
  149. package/dist/orchestration/index.d.ts +3 -1
  150. package/dist/orchestration/index.d.ts.map +1 -1
  151. package/dist/orchestration/index.js +3 -1
  152. package/dist/orchestration/index.js.map +1 -1
  153. package/dist/orchestration/log-tail.d.ts +27 -0
  154. package/dist/orchestration/log-tail.d.ts.map +1 -0
  155. package/dist/orchestration/log-tail.js +93 -0
  156. package/dist/orchestration/log-tail.js.map +1 -0
  157. package/dist/orchestration/log-tail.test.d.ts +2 -0
  158. package/dist/orchestration/log-tail.test.d.ts.map +1 -0
  159. package/dist/orchestration/log-tail.test.js +72 -0
  160. package/dist/orchestration/log-tail.test.js.map +1 -0
  161. package/dist/orchestration/orchestrator.d.ts +26 -0
  162. package/dist/orchestration/orchestrator.d.ts.map +1 -0
  163. package/dist/orchestration/orchestrator.js +102 -0
  164. package/dist/orchestration/orchestrator.js.map +1 -0
  165. package/dist/orchestration/orchestrator.test.d.ts +2 -0
  166. package/dist/orchestration/orchestrator.test.d.ts.map +1 -0
  167. package/dist/orchestration/orchestrator.test.js +85 -0
  168. package/dist/orchestration/orchestrator.test.js.map +1 -0
  169. package/dist/orchestration/runner.d.ts +62 -3
  170. package/dist/orchestration/runner.d.ts.map +1 -1
  171. package/dist/orchestration/runner.js +198 -103
  172. package/dist/orchestration/runner.js.map +1 -1
  173. package/dist/orchestration/runner.test.js +125 -1
  174. package/dist/orchestration/runner.test.js.map +1 -1
  175. package/dist/server.d.ts.map +1 -1
  176. package/dist/server.js +286 -124
  177. package/dist/server.js.map +1 -1
  178. package/dist/server.test.js +111 -123
  179. package/dist/server.test.js.map +1 -1
  180. package/dist/types.d.ts +79 -16
  181. package/dist/types.d.ts.map +1 -1
  182. package/dist/utils/feature.d.ts +42 -0
  183. package/dist/utils/feature.d.ts.map +1 -0
  184. package/dist/utils/feature.js +52 -0
  185. package/dist/utils/feature.js.map +1 -0
  186. package/dist/utils/multi-git.d.ts.map +1 -1
  187. package/dist/utils/multi-git.js +30 -7
  188. package/dist/utils/multi-git.js.map +1 -1
  189. package/dist/utils/slug.d.ts +12 -0
  190. package/dist/utils/slug.d.ts.map +1 -0
  191. package/dist/utils/slug.js +18 -0
  192. package/dist/utils/slug.js.map +1 -0
  193. package/dist/utils/test-command.d.ts +12 -0
  194. package/dist/utils/test-command.d.ts.map +1 -0
  195. package/dist/utils/test-command.js +22 -0
  196. package/dist/utils/test-command.js.map +1 -0
  197. package/dist/utils/update-check.d.ts +8 -0
  198. package/dist/utils/update-check.d.ts.map +1 -1
  199. package/dist/utils/update-check.js +13 -7
  200. package/dist/utils/update-check.js.map +1 -1
  201. package/dist/utils/update-check.test.d.ts +2 -0
  202. package/dist/utils/update-check.test.d.ts.map +1 -0
  203. package/dist/utils/update-check.test.js +25 -0
  204. package/dist/utils/update-check.test.js.map +1 -0
  205. package/dist/utils/workflow-advisor.d.ts +2 -2
  206. package/dist/utils/workflow-advisor.d.ts.map +1 -1
  207. package/dist/utils/workflow-advisor.js +50 -92
  208. package/dist/utils/workflow-advisor.js.map +1 -1
  209. package/dist/utils/workflows.d.ts.map +1 -1
  210. package/dist/utils/workflows.js +2 -4
  211. package/dist/utils/workflows.js.map +1 -1
  212. package/package.json +2 -2
  213. package/dist/gui/assets/index-CQTEzHj2.css +0 -1
  214. package/dist/gui/assets/index-DLmLdpqC.js +0 -58
  215. package/dist/utils/local-ai.d.ts +0 -27
  216. package/dist/utils/local-ai.d.ts.map +0 -1
  217. package/dist/utils/local-ai.js +0 -100
  218. package/dist/utils/local-ai.js.map +0 -1
  219. package/dist/utils/local-ai.test.d.ts +0 -2
  220. package/dist/utils/local-ai.test.d.ts.map +0 -1
  221. package/dist/utils/local-ai.test.js +0 -108
  222. package/dist/utils/local-ai.test.js.map +0 -1
package/dist/server.js CHANGED
@@ -20,18 +20,18 @@ import { loadConfig, saveConfig, getConfigDir } from './core/config.js';
20
20
  import { listStorageProviders } from './core/adapters/registry.js';
21
21
  import { scanForRepos } from './core/scanner.js';
22
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';
23
25
  import { listBranches } from './utils/git.js';
24
26
  import { createWorkspace, listWorkspaces, loadFeatureConfig, deleteWorkspace, addRepoToWorkspace } from './core/workspace.js';
25
27
  import { loadWorkspaceState } from './core/workspace-state.js';
26
28
  import { analyzeAllRepos } from './analyzers/index.js';
27
29
  import { generateContextFiles } from './generators/index.js';
28
- import { isOllamaModelAvailable, getOpenAiCompatibleUrl, callLocalLlm } from './utils/local-ai.js';
29
30
  import { detectAIAssistants } from './utils/detect-ai.js';
30
31
  import { detectEditors } from './utils/detect-editors.js';
31
32
  import { findSessions, getSessionTranscript } from './utils/session-finder.js';
32
33
  import { ProviderRegistry } from './agent/adapters.js';
33
34
  import { isValidSessionUuid } from './agent/session.js';
34
- import { scanSystemSpecs } from './utils/system-scanner.js';
35
35
  import { getRepoStatus } from './utils/multi-git.js';
36
36
  import { syncWorkspace } from './core/sync.js';
37
37
  import { commitWorkspace } from './core/commit.js';
@@ -39,7 +39,7 @@ import { refreshWorkspace } from './core/refresh.js';
39
39
  import { writeWorkspaceFile } from './core/storage.js';
40
40
  import { readWorkspaceKnowledge, addWorkspaceKnowledge, addBaseKnowledge, } from './core/knowledge.js';
41
41
  import { addSchedule, loadSchedules, nextDueAt, parseInterval, removeSchedule, runJob, setScheduleEnabled, startScheduler, } from './core/scheduler.js';
42
- 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';
43
43
  import { checkForUpdates, getCurrentVersion, getToolsStatus } from './utils/update-check.js';
44
44
  import { getWorkflowTemplates, saveWorkflowTemplate, deleteWorkflowTemplate } from './utils/workflows.js';
45
45
  import { suggestWorkflow } from './utils/workflow-advisor.js';
@@ -263,35 +263,10 @@ app.get('/api/adapters', async (c) => {
263
263
  return errorResponse(c, error);
264
264
  }
265
265
  });
266
- function isSafeLocalEndpoint(urlStr) {
267
- try {
268
- const url = new URL(urlStr);
269
- const hostname = url.hostname.toLowerCase();
270
- if (url.protocol === 'https:') {
271
- return true;
272
- }
273
- if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]') {
274
- return true;
275
- }
276
- const ipv4Pattern = /^(?:10|127|192\.168|172\.(?:1[6-9]|2[0-9]|3[01]))\.\d+\.\d+\.\d+$/;
277
- if (ipv4Pattern.test(hostname)) {
278
- return true;
279
- }
280
- return false;
281
- }
282
- catch {
283
- return false;
284
- }
285
- }
286
266
  // 2. Save configuration
287
267
  app.post('/api/config', async (c) => {
288
268
  try {
289
269
  const newConfig = await c.req.json();
290
- if (newConfig?.localLlm?.enabled && newConfig.localLlm.endpoint) {
291
- if (!isSafeLocalEndpoint(newConfig.localLlm.endpoint)) {
292
- return c.json({ error: 'Local AI endpoint must be HTTPS, localhost, 127.0.0.1, or a private LAN IP.' }, 400);
293
- }
294
- }
295
270
  await saveConfig(newConfig);
296
271
  return c.json({ success: true, config: newConfig });
297
272
  }
@@ -342,6 +317,72 @@ app.post('/api/repos/new', async (c) => {
342
317
  return errorResponse(c, error);
343
318
  }
344
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
+ });
345
386
  // 4. List existing workspaces
346
387
  app.get('/api/workspaces', async (c) => {
347
388
  try {
@@ -397,9 +438,10 @@ app.get('/api/workspaces/status', async (c) => {
397
438
  pendingValidation: false,
398
439
  };
399
440
  try {
400
- // 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.
401
443
  for (const repoPath of ws.repos) {
402
- const worktreePath = path.join(workspacePath, path.basename(repoPath));
444
+ const worktreePath = resolveFeatureRepoPath(ws, workspacePath, repoPath);
403
445
  const repoStatus = await getRepoStatus(worktreePath);
404
446
  if (repoStatus.hasChanges) {
405
447
  status.dirtyRepos += 1;
@@ -409,7 +451,8 @@ app.get('/api/workspaces/status', async (c) => {
409
451
  // Running services (cached running-state, PM2-verified — same source as
410
452
  // the Services tab; only workspaces that ever started services touch PM2).
411
453
  const runningState = await loadRunningState(workspacePath, pm2List);
412
- status.runningServices = runningState?.services?.length ?? 0;
454
+ status.runningServices =
455
+ (runningState?.services?.length ?? 0) + (runningState?.orchestrators?.length ?? 0);
413
456
  // Sync state: worst-case classification + any repo pending validation.
414
457
  const wsState = await loadWorkspaceState(workspacePath);
415
458
  const repoStates = Object.values(wsState.repos);
@@ -452,63 +495,6 @@ app.get('/api/editor-detect', async (c) => {
452
495
  return errorResponse(c, error);
453
496
  }
454
497
  });
455
- // 6.5. Local LLM test & recommendation
456
- app.post('/api/local-llm/test', async (c) => {
457
- try {
458
- const { provider, endpoint, model, apiKey, shoot } = await c.req.json();
459
- if (!endpoint || !isSafeLocalEndpoint(endpoint)) {
460
- return c.json({ success: false, error: 'Local AI endpoint must be HTTPS, localhost, 127.0.0.1, or a private LAN IP.' }, 400);
461
- }
462
- const cleanEndpoint = endpoint.replace(/\/$/, '');
463
- if (shoot) {
464
- const responseText = await callLocalLlm({ enabled: true, provider, endpoint, model, apiKey }, [{ role: 'user', content: 'Respond with the exact word "OK" and nothing else.' }]);
465
- const cleanResponse = responseText.trim();
466
- return c.json({
467
- success: true,
468
- modelReady: true,
469
- message: `Inference test succeeded! Response from model: "${cleanResponse}"`
470
- });
471
- }
472
- if (provider === 'ollama') {
473
- const res = await fetch(`${cleanEndpoint}/api/tags`);
474
- if (!res.ok)
475
- throw new Error(`Ollama responded with status ${res.status}`);
476
- const data = await res.json();
477
- const models = data?.models || [];
478
- const isModelLoaded = isOllamaModelAvailable(models, model);
479
- return c.json({
480
- success: true,
481
- modelReady: isModelLoaded,
482
- message: isModelLoaded ? 'Connected successfully! Model is ready.' : `Connected successfully, but model "${model}" is not pulled. Run "ollama pull ${model}" to install it.`
483
- });
484
- }
485
- else {
486
- const testUrl = getOpenAiCompatibleUrl(cleanEndpoint, '/v1/models');
487
- const res = await fetch(testUrl);
488
- if (!res.ok)
489
- throw new Error(`OpenAI-compatible server responded with status ${res.status}`);
490
- return c.json({ success: true, modelReady: true, message: 'Connected successfully to OpenAI-compatible server!' });
491
- }
492
- }
493
- catch (error) {
494
- const msg = error instanceof Error ? error.message : String(error);
495
- return c.json({ success: false, error: msg }, 400);
496
- }
497
- });
498
- app.get('/api/local-llm/recommend', async (c) => {
499
- try {
500
- const specs = await scanSystemSpecs();
501
- return c.json(specs);
502
- }
503
- catch (error) {
504
- return c.json({
505
- totalRamGb: 8,
506
- gpuName: 'Unknown/Integrated',
507
- hasHardwareAcceleration: false,
508
- recommendedModel: 'qwen2.5-coder:1.5b',
509
- });
510
- }
511
- });
512
498
  const creationJobs = new Map();
513
499
  // Keep a finished job around briefly so a late SSE reconnect can still read its
514
500
  // final state, then drop it so the map doesn't grow unbounded.
@@ -565,7 +551,11 @@ function updateJobStep(jobId, stepId, status, message, extraData) {
565
551
  }
566
552
  async function runCreationJob(jobId, body, config) {
567
553
  try {
568
- 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);
569
559
  const job = creationJobs.get(jobId);
570
560
  if (job) {
571
561
  job.workspacePath = workspacePath;
@@ -578,32 +568,40 @@ async function runCreationJob(jobId, body, config) {
578
568
  }
579
569
  }
580
570
  const feature = {
581
- id: body.branchName,
582
- 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,
583
577
  description: body.description,
584
- 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)),
585
581
  originalRepos: body.repos.map((r) => r.path),
586
- repoBranches: Object.keys(repoBranches).length > 0 ? repoBranches : undefined,
582
+ repoBranches: !inPlace && Object.keys(repoBranches).length > 0 ? repoBranches : undefined,
587
583
  assistants: body.assistants,
588
584
  workspacePath,
589
585
  createdAt: new Date().toISOString(),
590
586
  resumption: body.resumption,
591
- localLlmEnabled: body.localLlmEnabled,
592
587
  teamworkInstructions: body.teamworkInstructions,
593
588
  };
594
589
  if (job) {
595
590
  job.feature = feature;
596
591
  }
597
- // Step 1: Create worktrees
598
- 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...');
599
595
  await createWorkspace(feature, body.repos);
600
- updateJobStep(jobId, 'worktrees', 'completed', 'Git worktrees created successfully.');
601
- // 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.
602
598
  updateJobStep(jobId, 'analysis', 'running', 'Analyzing projects and dependencies...');
603
- const workspaceRepos = body.repos.map((repo) => ({
604
- ...repo,
605
- path: path.join(workspacePath, repo.name),
606
- }));
599
+ const workspaceRepos = inPlace
600
+ ? body.repos
601
+ : body.repos.map((repo) => ({
602
+ ...repo,
603
+ path: path.join(workspacePath, repo.name),
604
+ }));
607
605
  const analysis = await analyzeAllRepos(workspaceRepos);
608
606
  updateJobStep(jobId, 'analysis', 'completed', 'Project analysis complete.');
609
607
  // Step 3: Generate AI context files
@@ -612,7 +610,6 @@ async function runCreationJob(jobId, body, config) {
612
610
  feature,
613
611
  repos: workspaceRepos,
614
612
  analysis,
615
- localLlm: config.localLlm,
616
613
  };
617
614
  await generateContextFiles(ctx, body.assistants, workspacePath);
618
615
  updateJobStep(jobId, 'context', 'completed', 'AI context files generated.');
@@ -623,7 +620,7 @@ async function runCreationJob(jobId, body, config) {
623
620
  const job = creationJobs.get(jobId);
624
621
  if (job) {
625
622
  const runningStep = job.steps.find((s) => s.status === 'running');
626
- const failedStepId = runningStep ? runningStep.id : 'worktrees';
623
+ const failedStepId = runningStep ? runningStep.id : 'workspace';
627
624
  updateJobStep(jobId, failedStepId, 'failed', msg);
628
625
  }
629
626
  }
@@ -632,8 +629,19 @@ async function runCreationJob(jobId, body, config) {
632
629
  app.post('/api/workspace', async (c) => {
633
630
  try {
634
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
+ }
635
639
  const config = await loadConfig();
636
- 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
+ }
637
645
  if (creationJobs.has(jobId)) {
638
646
  const existing = creationJobs.get(jobId);
639
647
  if (existing.status === 'running') {
@@ -641,7 +649,9 @@ app.post('/api/workspace', async (c) => {
641
649
  }
642
650
  }
643
651
  const steps = [
644
- { 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...' },
645
655
  { id: 'analysis', name: 'Analyze Repositories', status: 'pending', message: 'Waiting...' },
646
656
  { id: 'context', name: 'Generate AI Context Files', status: 'pending', message: 'Waiting...' },
647
657
  ];
@@ -710,10 +720,27 @@ app.get('/api/workspace/create-stream/:jobId', async (c) => {
710
720
  }
711
721
  };
712
722
  job.listeners.add(listener);
713
- // 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.
714
727
  if (job.status === 'completed' || job.status === 'failed') {
715
728
  job.listeners.delete(listener);
716
- 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());
717
744
  }
718
745
  });
719
746
  });
@@ -750,7 +777,7 @@ app.post('/api/workspace/suggest-workflow', async (c) => {
750
777
  try {
751
778
  const { description, repos } = await c.req.json();
752
779
  const config = await loadConfig();
753
- const suggestion = await suggestWorkflow(description, repos, config.localLlm);
780
+ const suggestion = await suggestWorkflow(description, repos);
754
781
  return c.json({
755
782
  success: true,
756
783
  ...suggestion
@@ -811,20 +838,22 @@ app.get('/api/workspace/:id/services', async (c) => {
811
838
  services,
812
839
  orchestrationTools: tools,
813
840
  runningState: runningState?.services || [],
841
+ runningOrchestrators: runningState?.orchestrators || [],
814
842
  });
815
843
  }
816
844
  catch (error) {
817
845
  return errorResponse(c, error);
818
846
  }
819
847
  });
820
- // 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.
821
850
  app.post('/api/workspace/:id/services/start', async (c) => {
822
851
  try {
823
852
  const id = c.req.param('id');
824
- const { services } = await c.req.json();
825
853
  const config = await loadConfig();
826
854
  const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
827
855
  const logDir = path.join(workspacePath, '.nexusflow-logs');
856
+ const services = await detectAllServices(workspacePath);
828
857
  await startServices(services, workspacePath, logDir);
829
858
  return c.json({ success: true });
830
859
  }
@@ -845,19 +874,53 @@ app.post('/api/workspace/:id/services/stop', async (c) => {
845
874
  return errorResponse(c, error);
846
875
  }
847
876
  });
848
- // 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.
849
912
  app.get('/api/workspace/:id/services/logs/:serviceName', async (c) => {
850
913
  try {
851
914
  const id = c.req.param('id');
852
- const serviceName = c.req.param('serviceName');
915
+ const serviceName = decodeURIComponent(c.req.param('serviceName'));
853
916
  const config = await loadConfig();
854
917
  const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
855
- const logFile = path.join(workspacePath, '.nexusflow-logs', `${serviceName}.log`);
918
+ const logFile = resolveServiceLogFile(workspacePath, serviceName);
856
919
  let content = '';
920
+ let size = 0;
857
921
  try {
858
- // Read last 200 lines or 50KB
859
922
  const stats = await fs.stat(logFile);
860
- const size = stats.size;
923
+ size = stats.size;
861
924
  const start = Math.max(0, size - 50000);
862
925
  const fd = await fs.open(logFile, 'r');
863
926
  const buffer = Buffer.alloc(size - start);
@@ -868,7 +931,82 @@ app.get('/api/workspace/:id/services/logs/:serviceName', async (c) => {
868
931
  catch {
869
932
  content = 'No logs available yet.';
870
933
  }
871
- 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 });
872
1010
  }
873
1011
  catch (error) {
874
1012
  return errorResponse(c, error);
@@ -886,10 +1024,10 @@ app.get('/api/workspace/:id/changes', async (c) => {
886
1024
  return c.json({ error: 'Workspace configuration not found.' }, 404);
887
1025
  }
888
1026
  const results = [];
889
- // Check git status in each repo
1027
+ // Check git status in each repo (worktree, or source repo for in-place)
890
1028
  for (const repoPath of feature.repos) {
891
1029
  const repoName = path.basename(repoPath);
892
- const worktreePath = path.join(workspacePath, repoName);
1030
+ const worktreePath = resolveFeatureRepoPath(feature, workspacePath, repoPath);
893
1031
  try {
894
1032
  const { stdout } = await execa('git', ['status', '--porcelain'], { cwd: worktreePath });
895
1033
  const lines = stdout.split('\n').map((l) => l.trim()).filter(Boolean);
@@ -963,8 +1101,25 @@ app.get('/api/workspace/:id/changes/diff', async (c) => {
963
1101
  }
964
1102
  const config = await loadConfig();
965
1103
  const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
966
- // Contained within the workspace; rejects `..` and sibling-prefix escapes.
967
- 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
+ }
968
1123
  let diff = '';
969
1124
  // Check git status for the file to know if it's untracked
970
1125
  try {
@@ -1196,7 +1351,9 @@ app.post('/api/workspace/:id/resume', async (c) => {
1196
1351
  console.error(`Failed to launch editor ${body.command} for path ${workspacePath}:`, err);
1197
1352
  });
1198
1353
  }
1199
- 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) });
1200
1357
  }
1201
1358
  catch (error) {
1202
1359
  return errorResponse(c, error);
@@ -1325,9 +1482,14 @@ app.post('/api/updates/apply', async (c) => {
1325
1482
  try {
1326
1483
  const isWin = process.platform === 'win32';
1327
1484
  if (isWin) {
1328
- // 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).
1329
1491
  console.log(`Applying update: Spawning detached installer at: ${downloadedInstallerPath}`);
1330
- const child = spawn(downloadedInstallerPath, ['/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART'], {
1492
+ const child = spawn(downloadedInstallerPath, ['/S'], {
1331
1493
  detached: true,
1332
1494
  stdio: 'ignore',
1333
1495
  });