@mrpatronz/nexusflow 0.2.19 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (294) hide show
  1. package/README.md +37 -7
  2. package/dist/analyzers/index.d.ts +23 -0
  3. package/dist/analyzers/index.d.ts.map +1 -1
  4. package/dist/analyzers/index.js +62 -0
  5. package/dist/analyzers/index.js.map +1 -1
  6. package/dist/commands/add-repo.d.ts +9 -0
  7. package/dist/commands/add-repo.d.ts.map +1 -1
  8. package/dist/commands/add-repo.js +12 -1
  9. package/dist/commands/add-repo.js.map +1 -1
  10. package/dist/commands/add-repo.test.d.ts +2 -0
  11. package/dist/commands/add-repo.test.d.ts.map +1 -0
  12. package/dist/commands/add-repo.test.js +30 -0
  13. package/dist/commands/add-repo.test.js.map +1 -0
  14. package/dist/commands/commands.test.js +26 -6
  15. package/dist/commands/commands.test.js.map +1 -1
  16. package/dist/commands/commit.d.ts +7 -1
  17. package/dist/commands/commit.d.ts.map +1 -1
  18. package/dist/commands/commit.js +31 -8
  19. package/dist/commands/commit.js.map +1 -1
  20. package/dist/commands/commit.test.d.ts +2 -0
  21. package/dist/commands/commit.test.d.ts.map +1 -0
  22. package/dist/commands/commit.test.js +54 -0
  23. package/dist/commands/commit.test.js.map +1 -0
  24. package/dist/commands/diff.d.ts +9 -2
  25. package/dist/commands/diff.d.ts.map +1 -1
  26. package/dist/commands/diff.js +36 -12
  27. package/dist/commands/diff.js.map +1 -1
  28. package/dist/commands/doctor.d.ts.map +1 -1
  29. package/dist/commands/doctor.js +16 -20
  30. package/dist/commands/doctor.js.map +1 -1
  31. package/dist/commands/handoff.d.ts.map +1 -1
  32. package/dist/commands/handoff.js +11 -15
  33. package/dist/commands/handoff.js.map +1 -1
  34. package/dist/commands/refresh.d.ts +4 -2
  35. package/dist/commands/refresh.d.ts.map +1 -1
  36. package/dist/commands/refresh.js +20 -36
  37. package/dist/commands/refresh.js.map +1 -1
  38. package/dist/commands/schedule.d.ts +35 -0
  39. package/dist/commands/schedule.d.ts.map +1 -0
  40. package/dist/commands/schedule.js +151 -0
  41. package/dist/commands/schedule.js.map +1 -0
  42. package/dist/commands/sync.d.ts.map +1 -1
  43. package/dist/commands/sync.js +28 -7
  44. package/dist/commands/sync.js.map +1 -1
  45. package/dist/commands/tui.js +1 -1
  46. package/dist/commands/tui.js.map +1 -1
  47. package/dist/commands/ui.d.ts +1 -0
  48. package/dist/commands/ui.d.ts.map +1 -1
  49. package/dist/commands/ui.js +5 -2
  50. package/dist/commands/ui.js.map +1 -1
  51. package/dist/core/adapters/local-storage.d.ts +7 -0
  52. package/dist/core/adapters/local-storage.d.ts.map +1 -1
  53. package/dist/core/adapters/local-storage.js +13 -4
  54. package/dist/core/adapters/local-storage.js.map +1 -1
  55. package/dist/core/adapters/storage.test.js +18 -0
  56. package/dist/core/adapters/storage.test.js.map +1 -1
  57. package/dist/core/analysis-cache.d.ts +58 -0
  58. package/dist/core/analysis-cache.d.ts.map +1 -0
  59. package/dist/core/analysis-cache.js +116 -0
  60. package/dist/core/analysis-cache.js.map +1 -0
  61. package/dist/core/analysis-cache.test.d.ts +2 -0
  62. package/dist/core/analysis-cache.test.d.ts.map +1 -0
  63. package/dist/core/analysis-cache.test.js +108 -0
  64. package/dist/core/analysis-cache.test.js.map +1 -0
  65. package/dist/core/refresh.d.ts +41 -0
  66. package/dist/core/refresh.d.ts.map +1 -0
  67. package/dist/core/refresh.js +56 -0
  68. package/dist/core/refresh.js.map +1 -0
  69. package/dist/core/scheduler.d.ts +117 -0
  70. package/dist/core/scheduler.d.ts.map +1 -0
  71. package/dist/core/scheduler.js +361 -0
  72. package/dist/core/scheduler.js.map +1 -0
  73. package/dist/core/scheduler.test.d.ts +2 -0
  74. package/dist/core/scheduler.test.d.ts.map +1 -0
  75. package/dist/core/scheduler.test.js +163 -0
  76. package/dist/core/scheduler.test.js.map +1 -0
  77. package/dist/core/sync.d.ts +6 -0
  78. package/dist/core/sync.d.ts.map +1 -1
  79. package/dist/core/sync.js +17 -12
  80. package/dist/core/sync.js.map +1 -1
  81. package/dist/core/workspace.d.ts +6 -0
  82. package/dist/core/workspace.d.ts.map +1 -1
  83. package/dist/core/workspace.js +32 -15
  84. package/dist/core/workspace.js.map +1 -1
  85. package/dist/core/workspace.test.d.ts +2 -0
  86. package/dist/core/workspace.test.d.ts.map +1 -0
  87. package/dist/core/workspace.test.js +42 -0
  88. package/dist/core/workspace.test.js.map +1 -0
  89. package/dist/generators/index.d.ts +7 -1
  90. package/dist/generators/index.d.ts.map +1 -1
  91. package/dist/generators/index.js +14 -1
  92. package/dist/generators/index.js.map +1 -1
  93. package/dist/generators/map-generator.d.ts.map +1 -1
  94. package/dist/generators/map-generator.js +4 -3
  95. package/dist/generators/map-generator.js.map +1 -1
  96. package/dist/generators/map-generator.test.js +4 -2
  97. package/dist/generators/map-generator.test.js.map +1 -1
  98. package/dist/gui/assets/index-ElroPpSP.js +26 -0
  99. package/dist/gui/index.html +1 -1
  100. package/dist/index.js +108 -4
  101. package/dist/index.js.map +1 -1
  102. package/dist/orchestration/index.d.ts +1 -1
  103. package/dist/orchestration/index.d.ts.map +1 -1
  104. package/dist/orchestration/index.js +1 -1
  105. package/dist/orchestration/index.js.map +1 -1
  106. package/dist/orchestration/runner.d.ts +16 -1
  107. package/dist/orchestration/runner.d.ts.map +1 -1
  108. package/dist/orchestration/runner.js +41 -5
  109. package/dist/orchestration/runner.js.map +1 -1
  110. package/dist/orchestration/runner.test.d.ts +2 -0
  111. package/dist/orchestration/runner.test.d.ts.map +1 -0
  112. package/dist/orchestration/runner.test.js +71 -0
  113. package/dist/orchestration/runner.test.js.map +1 -0
  114. package/dist/server.d.ts +17 -1
  115. package/dist/server.d.ts.map +1 -1
  116. package/dist/server.js +275 -114
  117. package/dist/server.js.map +1 -1
  118. package/dist/server.test.js +158 -1
  119. package/dist/server.test.js.map +1 -1
  120. package/dist/utils/multi-git.d.ts +21 -0
  121. package/dist/utils/multi-git.d.ts.map +1 -1
  122. package/dist/utils/multi-git.js +40 -4
  123. package/dist/utils/multi-git.js.map +1 -1
  124. package/dist/utils/prompts.d.ts.map +1 -1
  125. package/dist/utils/prompts.js +8 -2
  126. package/dist/utils/prompts.js.map +1 -1
  127. package/package.json +13 -1
  128. package/.agents/skills/ccc/SKILL.md +0 -71
  129. package/.agents/skills/ccc/references/management.md +0 -110
  130. package/.agents/skills/ccc/references/settings.md +0 -126
  131. package/.github/dependabot.yml +0 -52
  132. package/.github/workflows/ci.yml +0 -44
  133. package/.github/workflows/dependabot-merge.yml +0 -53
  134. package/.github/workflows/release-desktop.yml +0 -67
  135. package/.github/workflows/release-npm.yml +0 -65
  136. package/.github/workflows/release-vscode.yml +0 -61
  137. package/.vscode/launch.json +0 -17
  138. package/.vscode/tasks.json +0 -17
  139. package/GETTING_STARTED.md +0 -114
  140. package/desktop/build-installer.js +0 -228
  141. package/desktop/build.js +0 -118
  142. package/desktop/installer/nexusflow.iss +0 -35
  143. package/desktop/neutralino.config.json +0 -39
  144. package/desktop/package-lock.json +0 -1060
  145. package/desktop/package.json +0 -14
  146. package/dist/gui/assets/index-D8iJPvnk.js +0 -26
  147. package/extension/media/icon.svg +0 -1
  148. package/extension/package-lock.json +0 -6044
  149. package/extension/package.json +0 -117
  150. package/extension/src/extension.ts +0 -653
  151. package/extension/tsconfig.json +0 -21
  152. package/gui/README.md +0 -73
  153. package/gui/e2e/dashboard.spec.ts +0 -61
  154. package/gui/e2e/wizard.spec.ts +0 -346
  155. package/gui/eslint.config.js +0 -26
  156. package/gui/index.html +0 -17
  157. package/gui/package-lock.json +0 -3137
  158. package/gui/package.json +0 -37
  159. package/gui/playwright.config.ts +0 -41
  160. package/gui/public/app_logo.png +0 -0
  161. package/gui/public/favicon.svg +0 -1
  162. package/gui/public/icons.svg +0 -24
  163. package/gui/src/App.css +0 -1
  164. package/gui/src/App.tsx +0 -3573
  165. package/gui/src/app/AppSidebar.tsx +0 -69
  166. package/gui/src/assets/hero.png +0 -0
  167. package/gui/src/assets/react.svg +0 -1
  168. package/gui/src/assets/vite.svg +0 -1
  169. package/gui/src/components/AddRepoPicker.tsx +0 -81
  170. package/gui/src/components/ui/Button.tsx +0 -40
  171. package/gui/src/components/ui/Card.tsx +0 -6
  172. package/gui/src/components/ui/EmptyState.tsx +0 -22
  173. package/gui/src/components/ui/Input.tsx +0 -21
  174. package/gui/src/components/ui/Menu.tsx +0 -71
  175. package/gui/src/components/ui/Modal.tsx +0 -39
  176. package/gui/src/components/ui/PageHeader.tsx +0 -21
  177. package/gui/src/components/ui/RepoStatusStrip.tsx +0 -33
  178. package/gui/src/components/ui/Skeleton.tsx +0 -5
  179. package/gui/src/components/ui/StatusPill.tsx +0 -36
  180. package/gui/src/components/ui/Tabs.tsx +0 -40
  181. package/gui/src/components/ui/cn.ts +0 -4
  182. package/gui/src/components/ui/index.ts +0 -16
  183. package/gui/src/features/changes/ChangesViewer.tsx +0 -388
  184. package/gui/src/features/knowledge/KnowledgeBase.tsx +0 -84
  185. package/gui/src/features/onboarding/OnboardingWizard.tsx +0 -230
  186. package/gui/src/features/plan/ImplementationPlan.tsx +0 -32
  187. package/gui/src/features/services/ServiceConsole.tsx +0 -215
  188. package/gui/src/features/sessions/SessionHistory.tsx +0 -195
  189. package/gui/src/index.css +0 -167
  190. package/gui/src/lib/status.ts +0 -24
  191. package/gui/src/main.tsx +0 -94
  192. package/gui/src/pages/DashboardPage.tsx +0 -129
  193. package/gui/src/pages/WorkspacesPage.tsx +0 -352
  194. package/gui/src/types.ts +0 -81
  195. package/gui/tsconfig.app.json +0 -25
  196. package/gui/tsconfig.json +0 -7
  197. package/gui/tsconfig.node.json +0 -24
  198. package/gui/vite.config.ts +0 -12
  199. package/resources/workflows/plan-implement-review.md +0 -8
  200. package/resources/workflows/research-verify.md +0 -6
  201. package/resources/workflows/solo-developer.md +0 -3
  202. package/scripts/simulate-workspaces.ts +0 -55
  203. package/skills-lock.json +0 -11
  204. package/src/analyzers/detect-apis.ts +0 -290
  205. package/src/analyzers/detect-deps.ts +0 -315
  206. package/src/analyzers/detect-existing.ts +0 -74
  207. package/src/analyzers/detect-ports.ts +0 -110
  208. package/src/analyzers/index.ts +0 -97
  209. package/src/analyzers/messaging-analyzer.ts +0 -254
  210. package/src/analyzers/readme-summarizer.ts +0 -102
  211. package/src/analyzers/run-analyzer.ts +0 -269
  212. package/src/analyzers/tech-stack.ts +0 -283
  213. package/src/commands/adapter.ts +0 -348
  214. package/src/commands/add-repo.ts +0 -156
  215. package/src/commands/commands.test.ts +0 -156
  216. package/src/commands/commit.ts +0 -131
  217. package/src/commands/config.ts +0 -52
  218. package/src/commands/create.ts +0 -204
  219. package/src/commands/desktop.ts +0 -128
  220. package/src/commands/diff.ts +0 -125
  221. package/src/commands/doctor.ts +0 -370
  222. package/src/commands/handoff.ts +0 -266
  223. package/src/commands/init.ts +0 -134
  224. package/src/commands/list.ts +0 -46
  225. package/src/commands/logs.ts +0 -63
  226. package/src/commands/mcp.ts +0 -94
  227. package/src/commands/open.ts +0 -129
  228. package/src/commands/refresh.ts +0 -137
  229. package/src/commands/remove.ts +0 -98
  230. package/src/commands/start.ts +0 -117
  231. package/src/commands/status.ts +0 -54
  232. package/src/commands/stop.ts +0 -55
  233. package/src/commands/sync.ts +0 -125
  234. package/src/commands/tui.ts +0 -484
  235. package/src/commands/ui.ts +0 -111
  236. package/src/core/adapters/local-storage.ts +0 -66
  237. package/src/core/adapters/obsidian-storage.ts +0 -151
  238. package/src/core/adapters/registry.ts +0 -44
  239. package/src/core/adapters/storage.test.ts +0 -174
  240. package/src/core/adapters/vault-storage.ts +0 -86
  241. package/src/core/config.test.ts +0 -96
  242. package/src/core/config.ts +0 -140
  243. package/src/core/graph.ts +0 -344
  244. package/src/core/plugins/index.ts +0 -17
  245. package/src/core/plugins/loader.ts +0 -36
  246. package/src/core/ports/storage.ts +0 -71
  247. package/src/core/scanner.test.ts +0 -61
  248. package/src/core/scanner.ts +0 -91
  249. package/src/core/storage.ts +0 -37
  250. package/src/core/sync.ts +0 -121
  251. package/src/core/workspace-state.test.ts +0 -108
  252. package/src/core/workspace-state.ts +0 -130
  253. package/src/core/workspace.ts +0 -487
  254. package/src/core/worktree.ts +0 -120
  255. package/src/generators/antigravity.ts +0 -32
  256. package/src/generators/base.ts +0 -243
  257. package/src/generators/claude.ts +0 -34
  258. package/src/generators/codex.ts +0 -48
  259. package/src/generators/copilot.ts +0 -56
  260. package/src/generators/cursor.ts +0 -44
  261. package/src/generators/diff-context.ts +0 -75
  262. package/src/generators/index.ts +0 -322
  263. package/src/generators/map-generator.test.ts +0 -159
  264. package/src/generators/map-generator.ts +0 -539
  265. package/src/generators/plan-generator.ts +0 -483
  266. package/src/generators/skills-generator.ts +0 -259
  267. package/src/index.ts +0 -539
  268. package/src/mcp/server.ts +0 -338
  269. package/src/orchestration/detect.ts +0 -313
  270. package/src/orchestration/index.ts +0 -7
  271. package/src/orchestration/runner.ts +0 -283
  272. package/src/server.test.ts +0 -618
  273. package/src/server.ts +0 -1402
  274. package/src/types.ts +0 -451
  275. package/src/utils/detect-ai.test.ts +0 -44
  276. package/src/utils/detect-ai.ts +0 -84
  277. package/src/utils/detect-editors.test.ts +0 -48
  278. package/src/utils/detect-editors.ts +0 -57
  279. package/src/utils/git.test.ts +0 -74
  280. package/src/utils/git.ts +0 -117
  281. package/src/utils/local-ai.test.ts +0 -130
  282. package/src/utils/local-ai.ts +0 -116
  283. package/src/utils/multi-git.test.ts +0 -158
  284. package/src/utils/multi-git.ts +0 -405
  285. package/src/utils/prompts.ts +0 -209
  286. package/src/utils/session-finder.ts +0 -483
  287. package/src/utils/system-scanner.test.ts +0 -89
  288. package/src/utils/system-scanner.ts +0 -96
  289. package/src/utils/update-check.ts +0 -286
  290. package/src/utils/workflow-advisor.ts +0 -118
  291. package/src/utils/workflows.test.ts +0 -114
  292. package/src/utils/workflows.ts +0 -178
  293. package/tsconfig.json +0 -19
  294. package/vitest.config.ts +0 -14
package/dist/server.js CHANGED
@@ -29,7 +29,9 @@ import { findSessions, getSessionTranscript } from './utils/session-finder.js';
29
29
  import { scanSystemSpecs } from './utils/system-scanner.js';
30
30
  import { getWorkspaceRepos, commitAndPush, getRepoStatus } from './utils/multi-git.js';
31
31
  import { syncWorkspace } from './core/sync.js';
32
- import { detectAllServices, detectOrchestrationTools, startServices, stopServices, loadRunningState, } from './orchestration/index.js';
32
+ import { refreshWorkspace } from './core/refresh.js';
33
+ import { addSchedule, loadSchedules, nextDueAt, parseInterval, removeSchedule, runJob, setScheduleEnabled, startScheduler, } from './core/scheduler.js';
34
+ import { detectAllServices, detectOrchestrationTools, startServices, stopServices, loadRunningState, getPm2List, } from './orchestration/index.js';
33
35
  import { checkForUpdates, getCurrentVersion, getToolsStatus } from './utils/update-check.js';
34
36
  import { getWorkflowTemplates, saveWorkflowTemplate, deleteWorkflowTemplate } from './utils/workflows.js';
35
37
  import { suggestWorkflow } from './utils/workflow-advisor.js';
@@ -42,8 +44,86 @@ const guiPath = path.join(__dirname, 'gui');
42
44
  export const app = new Hono();
43
45
  // Allowed editor binaries/scripts to prevent command injection
44
46
  const ALLOWED_EDITORS = new Set(['code', 'code-insiders', 'cursor', 'antigravity', 'agy', 'idea', 'charm', 'webstorm', 'subl', 'nano', 'vim', 'nvim', 'emacs']);
45
- // Enable CORS for frontend dev server
46
- app.use('/api/*', cors());
47
+ // ─── Path containment guards ──────────────────────────────────────────────
48
+ // The server exposes state-changing routes keyed by a workspace `:id` taken
49
+ // straight from the URL. Without containment checks, `..%2f..` sequences let a
50
+ // caller read/write/delete files outside the workspaces root. Every handler
51
+ // that turns an id (or repo name) into a filesystem path must go through these.
52
+ /** Thrown when a requested path escapes its permitted base directory. */
53
+ export class PathAccessError extends Error {
54
+ constructor(message = 'Invalid workspace path') {
55
+ super(message);
56
+ this.name = 'PathAccessError';
57
+ }
58
+ }
59
+ /** Resolve `target` and assert it stays within `baseDir` (or equals it). */
60
+ function assertWithin(baseDir, target) {
61
+ const base = path.resolve(baseDir);
62
+ const resolved = path.resolve(target);
63
+ // Trailing separator prevents a sibling like `feat-secret` from passing the
64
+ // prefix test for base `feat`.
65
+ if (resolved !== base && !resolved.startsWith(base + path.sep)) {
66
+ throw new PathAccessError();
67
+ }
68
+ return resolved;
69
+ }
70
+ /** Safe workspace directory for a route `:id`, contained within workspacesDir. */
71
+ export function resolveWorkspacePath(workspacesDir, id) {
72
+ return assertWithin(workspacesDir, path.join(workspacesDir, id));
73
+ }
74
+ /** Safe sub-repo path for a repo name, contained within the workspace. */
75
+ export function resolveRepoPath(workspacePath, repoName) {
76
+ return assertWithin(workspacePath, path.join(workspacePath, repoName));
77
+ }
78
+ /** Consistent error response; path-containment violations map to 400. */
79
+ function errorResponse(c, error) {
80
+ if (error instanceof PathAccessError) {
81
+ return c.json({ error: error.message }, 400);
82
+ }
83
+ const msg = error instanceof Error ? error.message : String(error);
84
+ return c.json({ error: msg }, 500);
85
+ }
86
+ /**
87
+ * Restrict the self-update download to GitHub release hosts over HTTPS so a
88
+ * cross-origin caller cannot make the server fetch and later execute an
89
+ * arbitrary binary.
90
+ */
91
+ export function isAllowedUpdateUrl(candidate) {
92
+ try {
93
+ const url = new URL(candidate);
94
+ if (url.protocol !== 'https:')
95
+ return false;
96
+ const host = url.hostname.toLowerCase();
97
+ return (host === 'github.com' ||
98
+ host.endsWith('.github.com') ||
99
+ host === 'githubusercontent.com' ||
100
+ host.endsWith('.githubusercontent.com'));
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ }
106
+ // Enable CORS for the local GUI only. This server can spawn processes, run
107
+ // package installs and delete worktrees, so a wildcard origin would let any
108
+ // web page the developer has open drive it cross-origin.
109
+ app.use('/api/*', cors({
110
+ origin: (origin) => {
111
+ // No Origin header → same-origin or a non-browser client (CLI/desktop).
112
+ if (!origin)
113
+ return origin;
114
+ try {
115
+ const { hostname } = new URL(origin);
116
+ const isLocal = hostname === 'localhost' ||
117
+ hostname === '127.0.0.1' ||
118
+ hostname === '::1' ||
119
+ hostname === '[::1]';
120
+ return isLocal ? origin : null;
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ },
126
+ }));
47
127
  // ─── API Endpoints ────────────────────────────────────────────────────────
48
128
  // 1. Get current configuration
49
129
  app.get('/api/config', async (c) => {
@@ -59,8 +139,7 @@ app.get('/api/config', async (c) => {
59
139
  return c.json({ config, exists });
60
140
  }
61
141
  catch (error) {
62
- const msg = error instanceof Error ? error.message : String(error);
63
- return c.json({ error: msg }, 500);
142
+ return errorResponse(c, error);
64
143
  }
65
144
  });
66
145
  // Get all registered storage adapters
@@ -70,8 +149,7 @@ app.get('/api/adapters', async (c) => {
70
149
  return c.json({ adapters });
71
150
  }
72
151
  catch (error) {
73
- const msg = error instanceof Error ? error.message : String(error);
74
- return c.json({ error: msg }, 500);
152
+ return errorResponse(c, error);
75
153
  }
76
154
  });
77
155
  function isSafeLocalEndpoint(urlStr) {
@@ -107,8 +185,7 @@ app.post('/api/config', async (c) => {
107
185
  return c.json({ success: true, config: newConfig });
108
186
  }
109
187
  catch (error) {
110
- const msg = error instanceof Error ? error.message : String(error);
111
- return c.json({ error: msg }, 500);
188
+ return errorResponse(c, error);
112
189
  }
113
190
  });
114
191
  // 3. Scan development directory for repositories
@@ -119,8 +196,7 @@ app.get('/api/repos', async (c) => {
119
196
  return c.json(repos);
120
197
  }
121
198
  catch (error) {
122
- const msg = error instanceof Error ? error.message : String(error);
123
- return c.json({ error: msg }, 500);
199
+ return errorResponse(c, error);
124
200
  }
125
201
  });
126
202
  // 4. List existing workspaces
@@ -131,8 +207,7 @@ app.get('/api/workspaces', async (c) => {
131
207
  return c.json(workspaces);
132
208
  }
133
209
  catch (error) {
134
- const msg = error instanceof Error ? error.message : String(error);
135
- return c.json({ error: msg }, 500);
210
+ return errorResponse(c, error);
136
211
  }
137
212
  });
138
213
  // Severity ranking for picking the worst per-repo sync outcome in a workspace.
@@ -164,6 +239,9 @@ app.get('/api/workspaces/status', async (c) => {
164
239
  try {
165
240
  const config = await loadConfig();
166
241
  const workspaces = await listWorkspaces(config.workspacesDir);
242
+ // Fetch the PM2 process list once for the whole overview instead of
243
+ // spawning `npx pm2 jlist` per workspace (slow, especially on Windows).
244
+ const pm2List = await getPm2List();
167
245
  const entries = await Promise.all(workspaces.map(async (ws) => {
168
246
  const workspacePath = ws.workspacePath || path.join(config.workspacesDir, ws.branchName);
169
247
  const status = {
@@ -187,7 +265,7 @@ app.get('/api/workspaces/status', async (c) => {
187
265
  }
188
266
  // Running services (cached running-state, PM2-verified — same source as
189
267
  // the Services tab; only workspaces that ever started services touch PM2).
190
- const runningState = await loadRunningState(workspacePath);
268
+ const runningState = await loadRunningState(workspacePath, pm2List);
191
269
  status.runningServices = runningState?.services?.length ?? 0;
192
270
  // Sync state: worst-case classification + any repo pending validation.
193
271
  const wsState = await loadWorkspaceState(workspacePath);
@@ -208,8 +286,7 @@ app.get('/api/workspaces/status', async (c) => {
208
286
  return c.json(byWorkspace);
209
287
  }
210
288
  catch (error) {
211
- const msg = error instanceof Error ? error.message : String(error);
212
- return c.json({ error: msg }, 500);
289
+ return errorResponse(c, error);
213
290
  }
214
291
  });
215
292
  // 5. Detect available AI assistants
@@ -219,8 +296,7 @@ app.get('/api/ai-detect', async (c) => {
219
296
  return c.json(assistants);
220
297
  }
221
298
  catch (error) {
222
- const msg = error instanceof Error ? error.message : String(error);
223
- return c.json({ error: msg }, 500);
299
+ return errorResponse(c, error);
224
300
  }
225
301
  });
226
302
  // 6. Detect available editors
@@ -230,8 +306,7 @@ app.get('/api/editor-detect', async (c) => {
230
306
  return c.json(editors);
231
307
  }
232
308
  catch (error) {
233
- const msg = error instanceof Error ? error.message : String(error);
234
- return c.json({ error: msg }, 500);
309
+ return errorResponse(c, error);
235
310
  }
236
311
  });
237
312
  // 6.5. Local LLM test & recommendation
@@ -292,6 +367,16 @@ app.get('/api/local-llm/recommend', async (c) => {
292
367
  }
293
368
  });
294
369
  const creationJobs = new Map();
370
+ // Keep a finished job around briefly so a late SSE reconnect can still read its
371
+ // final state, then drop it so the map doesn't grow unbounded.
372
+ const FINISHED_JOB_TTL_MS = 5 * 60 * 1000;
373
+ function scheduleJobCleanup(jobId) {
374
+ const job = creationJobs.get(jobId);
375
+ if (!job || job.cleanupTimer)
376
+ return;
377
+ job.cleanupTimer = setTimeout(() => creationJobs.delete(jobId), FINISHED_JOB_TTL_MS);
378
+ job.cleanupTimer.unref?.();
379
+ }
295
380
  function updateJobStep(jobId, stepId, status, message, extraData) {
296
381
  const job = creationJobs.get(jobId);
297
382
  if (!job)
@@ -331,10 +416,13 @@ function updateJobStep(jobId, stepId, status, message, extraData) {
331
416
  for (const listener of job.listeners) {
332
417
  listener(eventPayload);
333
418
  }
419
+ if (job.status === 'completed' || job.status === 'failed') {
420
+ scheduleJobCleanup(jobId);
421
+ }
334
422
  }
335
423
  async function runCreationJob(jobId, body, config) {
336
424
  try {
337
- const workspacePath = path.join(config.workspacesDir, body.branchName);
425
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, body.branchName);
338
426
  const job = creationJobs.get(jobId);
339
427
  if (job) {
340
428
  job.workspacePath = workspacePath;
@@ -420,8 +508,7 @@ app.post('/api/workspace', async (c) => {
420
508
  return c.json({ success: true, jobId });
421
509
  }
422
510
  catch (error) {
423
- const msg = error instanceof Error ? error.message : String(error);
424
- return c.json({ error: msg }, 500);
511
+ return errorResponse(c, error);
425
512
  }
426
513
  });
427
514
  // 7.5. Create workspace SSE stream
@@ -451,23 +538,33 @@ app.get('/api/workspace/create-stream/:jobId', async (c) => {
451
538
  if (job.status === 'completed' || job.status === 'failed') {
452
539
  return;
453
540
  }
454
- const listener = async (event) => {
455
- try {
456
- await stream.writeSSE({
457
- event: event.type,
458
- data: JSON.stringify(event.data),
459
- });
460
- }
461
- catch (err) {
541
+ // Keep the connection open until the job finishes, driven by job events
542
+ // rather than polling.
543
+ await new Promise((resolve) => {
544
+ const listener = async (event) => {
545
+ try {
546
+ await stream.writeSSE({
547
+ event: event.type,
548
+ data: JSON.stringify(event.data),
549
+ });
550
+ }
551
+ catch {
552
+ job.listeners.delete(listener);
553
+ resolve();
554
+ return;
555
+ }
556
+ if (event.data?.status === 'completed' || event.data?.status === 'failed') {
557
+ job.listeners.delete(listener);
558
+ resolve();
559
+ }
560
+ };
561
+ job.listeners.add(listener);
562
+ // Guard against the job finishing between the initial state write and now.
563
+ if (job.status === 'completed' || job.status === 'failed') {
462
564
  job.listeners.delete(listener);
565
+ resolve();
463
566
  }
464
- };
465
- job.listeners.add(listener);
466
- // Keep connection alive until job finishes
467
- while (job.status === 'running') {
468
- await new Promise((resolve) => setTimeout(resolve, 1000));
469
- }
470
- job.listeners.delete(listener);
567
+ });
471
568
  });
472
569
  });
473
570
  // 7.6. Delete workspace
@@ -475,13 +572,12 @@ app.delete('/api/workspace/:id', async (c) => {
475
572
  try {
476
573
  const id = decodeURIComponent(c.req.param('id'));
477
574
  const config = await loadConfig();
478
- const workspacePath = path.join(config.workspacesDir, id);
575
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
479
576
  await deleteWorkspace(workspacePath);
480
577
  return c.json({ success: true });
481
578
  }
482
579
  catch (error) {
483
- const msg = error instanceof Error ? error.message : String(error);
484
- return c.json({ error: msg }, 500);
580
+ return errorResponse(c, error);
485
581
  }
486
582
  });
487
583
  // 7.7. Add repo to workspace
@@ -490,13 +586,12 @@ app.post('/api/workspace/:id/repo', async (c) => {
490
586
  const id = decodeURIComponent(c.req.param('id'));
491
587
  const { repoPath } = await c.req.json();
492
588
  const config = await loadConfig();
493
- const workspacePath = path.join(config.workspacesDir, id);
589
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
494
590
  await addRepoToWorkspace(workspacePath, repoPath);
495
591
  return c.json({ success: true });
496
592
  }
497
593
  catch (error) {
498
- const msg = error instanceof Error ? error.message : String(error);
499
- return c.json({ error: msg }, 500);
594
+ return errorResponse(c, error);
500
595
  }
501
596
  });
502
597
  // 7.8. Suggest workflow strategy based on feature description & selected repos
@@ -511,8 +606,7 @@ app.post('/api/workspace/suggest-workflow', async (c) => {
511
606
  });
512
607
  }
513
608
  catch (error) {
514
- const msg = error instanceof Error ? error.message : String(error);
515
- return c.json({ error: msg }, 500);
609
+ return errorResponse(c, error);
516
610
  }
517
611
  });
518
612
  // 8. Open workspace in editor
@@ -547,8 +641,7 @@ app.post('/api/open-editor', async (c) => {
547
641
  return c.json({ success: true });
548
642
  }
549
643
  catch (error) {
550
- const msg = error instanceof Error ? error.message : String(error);
551
- return c.json({ error: msg }, 500);
644
+ return errorResponse(c, error);
552
645
  }
553
646
  });
554
647
  // 9. Get status of running services in all/specific workspace
@@ -556,7 +649,7 @@ app.get('/api/workspace/:id/services', async (c) => {
556
649
  try {
557
650
  const id = c.req.param('id');
558
651
  const config = await loadConfig();
559
- const workspacePath = path.join(config.workspacesDir, id);
652
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
560
653
  // Detect all services
561
654
  const services = await detectAllServices(workspacePath);
562
655
  // Detect existing tools
@@ -570,8 +663,7 @@ app.get('/api/workspace/:id/services', async (c) => {
570
663
  });
571
664
  }
572
665
  catch (error) {
573
- const msg = error instanceof Error ? error.message : String(error);
574
- return c.json({ error: msg }, 500);
666
+ return errorResponse(c, error);
575
667
  }
576
668
  });
577
669
  // 10. Start services in workspace
@@ -580,14 +672,13 @@ app.post('/api/workspace/:id/services/start', async (c) => {
580
672
  const id = c.req.param('id');
581
673
  const { services } = await c.req.json();
582
674
  const config = await loadConfig();
583
- const workspacePath = path.join(config.workspacesDir, id);
675
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
584
676
  const logDir = path.join(workspacePath, '.nexusflow-logs');
585
677
  await startServices(services, workspacePath, logDir);
586
678
  return c.json({ success: true });
587
679
  }
588
680
  catch (error) {
589
- const msg = error instanceof Error ? error.message : String(error);
590
- return c.json({ error: msg }, 500);
681
+ return errorResponse(c, error);
591
682
  }
592
683
  });
593
684
  // 11. Stop services in workspace
@@ -595,13 +686,12 @@ app.post('/api/workspace/:id/services/stop', async (c) => {
595
686
  try {
596
687
  const id = c.req.param('id');
597
688
  const config = await loadConfig();
598
- const workspacePath = path.join(config.workspacesDir, id);
689
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
599
690
  await stopServices(workspacePath);
600
691
  return c.json({ success: true });
601
692
  }
602
693
  catch (error) {
603
- const msg = error instanceof Error ? error.message : String(error);
604
- return c.json({ error: msg }, 500);
694
+ return errorResponse(c, error);
605
695
  }
606
696
  });
607
697
  // 12. Get service logs
@@ -610,7 +700,7 @@ app.get('/api/workspace/:id/services/logs/:serviceName', async (c) => {
610
700
  const id = c.req.param('id');
611
701
  const serviceName = c.req.param('serviceName');
612
702
  const config = await loadConfig();
613
- const workspacePath = path.join(config.workspacesDir, id);
703
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
614
704
  const logFile = path.join(workspacePath, '.nexusflow-logs', `${serviceName}.log`);
615
705
  let content = '';
616
706
  try {
@@ -630,8 +720,7 @@ app.get('/api/workspace/:id/services/logs/:serviceName', async (c) => {
630
720
  return c.json({ logs: content });
631
721
  }
632
722
  catch (error) {
633
- const msg = error instanceof Error ? error.message : String(error);
634
- return c.json({ error: msg }, 500);
723
+ return errorResponse(c, error);
635
724
  }
636
725
  });
637
726
  // 13. Get git changes in workspace sub-repositories
@@ -639,7 +728,7 @@ app.get('/api/workspace/:id/changes', async (c) => {
639
728
  try {
640
729
  const id = c.req.param('id');
641
730
  const config = await loadConfig();
642
- const workspacePath = path.join(config.workspacesDir, id);
731
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
643
732
  // Load feature config to get repo paths
644
733
  const feature = await loadFeatureConfig(workspacePath);
645
734
  if (!feature) {
@@ -709,8 +798,7 @@ app.get('/api/workspace/:id/changes', async (c) => {
709
798
  return c.json({ changes: results });
710
799
  }
711
800
  catch (error) {
712
- const msg = error instanceof Error ? error.message : String(error);
713
- return c.json({ error: msg }, 500);
801
+ return errorResponse(c, error);
714
802
  }
715
803
  });
716
804
  // 13_diff. Get git diff for a specific file in workspace sub-repositories
@@ -723,13 +811,9 @@ app.get('/api/workspace/:id/changes/diff', async (c) => {
723
811
  return c.json({ error: 'Missing repo or file query parameter.' }, 400);
724
812
  }
725
813
  const config = await loadConfig();
726
- const workspacePath = path.join(config.workspacesDir, id);
727
- const worktreePath = path.join(workspacePath, repoName);
728
- // Security check: ensure path is within workspace directory
729
- const resolvedPath = path.resolve(worktreePath);
730
- if (!resolvedPath.startsWith(path.resolve(workspacePath))) {
731
- return c.json({ error: 'Invalid repository path.' }, 400);
732
- }
814
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
815
+ // Contained within the workspace; rejects `..` and sibling-prefix escapes.
816
+ const worktreePath = resolveRepoPath(workspacePath, repoName);
733
817
  let diff = '';
734
818
  // Check git status for the file to know if it's untracked
735
819
  try {
@@ -761,8 +845,7 @@ app.get('/api/workspace/:id/changes/diff', async (c) => {
761
845
  return c.json({ diff });
762
846
  }
763
847
  catch (error) {
764
- const msg = error instanceof Error ? error.message : String(error);
765
- return c.json({ error: msg }, 500);
848
+ return errorResponse(c, error);
766
849
  }
767
850
  });
768
851
  // 13a. Get workspace knowledge (nexusflow-knowledge.md)
@@ -770,7 +853,7 @@ app.get('/api/workspace/:id/knowledge', async (c) => {
770
853
  try {
771
854
  const id = c.req.param('id');
772
855
  const config = await loadConfig();
773
- const workspacePath = path.join(config.workspacesDir, id);
856
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
774
857
  const knowledgeFile = path.join(workspacePath, 'nexusflow-knowledge.md');
775
858
  let content = '';
776
859
  try {
@@ -782,8 +865,7 @@ app.get('/api/workspace/:id/knowledge', async (c) => {
782
865
  return c.json({ content });
783
866
  }
784
867
  catch (error) {
785
- const msg = error instanceof Error ? error.message : String(error);
786
- return c.json({ error: msg }, 500);
868
+ return errorResponse(c, error);
787
869
  }
788
870
  });
789
871
  // 13b. Update workspace knowledge (nexusflow-knowledge.md)
@@ -792,14 +874,13 @@ app.put('/api/workspace/:id/knowledge', async (c) => {
792
874
  const id = c.req.param('id');
793
875
  const { content } = await c.req.json();
794
876
  const config = await loadConfig();
795
- const workspacePath = path.join(config.workspacesDir, id);
877
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
796
878
  const knowledgeFile = path.join(workspacePath, 'nexusflow-knowledge.md');
797
879
  await fs.writeFile(knowledgeFile, content, 'utf-8');
798
880
  return c.json({ success: true });
799
881
  }
800
882
  catch (error) {
801
- const msg = error instanceof Error ? error.message : String(error);
802
- return c.json({ error: msg }, 500);
883
+ return errorResponse(c, error);
803
884
  }
804
885
  });
805
886
  // 13c. Get workspace plan (nexusflow-plan.md)
@@ -807,7 +888,7 @@ app.get('/api/workspace/:id/plan', async (c) => {
807
888
  try {
808
889
  const id = c.req.param('id');
809
890
  const config = await loadConfig();
810
- const workspacePath = path.join(config.workspacesDir, id);
891
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
811
892
  const planFile = path.join(workspacePath, 'nexusflow-plan.md');
812
893
  let content = '';
813
894
  try {
@@ -819,8 +900,7 @@ app.get('/api/workspace/:id/plan', async (c) => {
819
900
  return c.json({ content });
820
901
  }
821
902
  catch (error) {
822
- const msg = error instanceof Error ? error.message : String(error);
823
- return c.json({ error: msg }, 500);
903
+ return errorResponse(c, error);
824
904
  }
825
905
  });
826
906
  // 13d. Sync all repositories in workspace
@@ -828,7 +908,7 @@ app.post('/api/workspace/:id/sync', async (c) => {
828
908
  try {
829
909
  const id = c.req.param('id');
830
910
  const config = await loadConfig();
831
- const workspacePath = path.join(config.workspacesDir, id);
911
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
832
912
  const report = await syncWorkspace(workspacePath);
833
913
  const results = report.repos.map((repo) => ({
834
914
  repoName: repo.name,
@@ -845,8 +925,7 @@ app.post('/api/workspace/:id/sync', async (c) => {
845
925
  });
846
926
  }
847
927
  catch (error) {
848
- const msg = error instanceof Error ? error.message : String(error);
849
- return c.json({ error: msg }, 500);
928
+ return errorResponse(c, error);
850
929
  }
851
930
  });
852
931
  // 13e. Commit changes in all repositories in workspace
@@ -855,7 +934,7 @@ app.post('/api/workspace/:id/commit', async (c) => {
855
934
  const id = c.req.param('id');
856
935
  const { message } = await c.req.json();
857
936
  const config = await loadConfig();
858
- const workspacePath = path.join(config.workspacesDir, id);
937
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
859
938
  const repos = await getWorkspaceRepos(workspacePath);
860
939
  const results = [];
861
940
  for (const repo of repos) {
@@ -874,8 +953,7 @@ app.post('/api/workspace/:id/commit', async (c) => {
874
953
  return c.json({ results });
875
954
  }
876
955
  catch (error) {
877
- const msg = error instanceof Error ? error.message : String(error);
878
- return c.json({ error: msg }, 500);
956
+ return errorResponse(c, error);
879
957
  }
880
958
  });
881
959
  // 14. Resume session in workspace (copies CLI resume command and opens editor)
@@ -883,7 +961,7 @@ app.post('/api/workspace/:id/resume', async (c) => {
883
961
  try {
884
962
  const id = decodeURIComponent(c.req.param('id'));
885
963
  const config = await loadConfig();
886
- const workspacePath = path.join(config.workspacesDir, id);
964
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
887
965
  // Load feature config to get repo paths
888
966
  const feature = await loadFeatureConfig(workspacePath);
889
967
  if (!feature) {
@@ -956,8 +1034,7 @@ app.post('/api/workspace/:id/resume', async (c) => {
956
1034
  return c.json({ success: true, resumeCommand, workspacePath });
957
1035
  }
958
1036
  catch (error) {
959
- const msg = error instanceof Error ? error.message : String(error);
960
- return c.json({ error: msg }, 500);
1037
+ return errorResponse(c, error);
961
1038
  }
962
1039
  });
963
1040
  // 15. List past AI sessions for a workspace
@@ -965,7 +1042,7 @@ app.get('/api/workspace/:id/sessions', async (c) => {
965
1042
  try {
966
1043
  const id = decodeURIComponent(c.req.param('id'));
967
1044
  const config = await loadConfig();
968
- const workspacePath = path.join(config.workspacesDir, id);
1045
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
969
1046
  const feature = await loadFeatureConfig(workspacePath);
970
1047
  if (!feature) {
971
1048
  return c.json({ error: 'Workspace configuration not found.' }, 404);
@@ -974,8 +1051,7 @@ app.get('/api/workspace/:id/sessions', async (c) => {
974
1051
  return c.json({ sessions });
975
1052
  }
976
1053
  catch (error) {
977
- const msg = error instanceof Error ? error.message : String(error);
978
- return c.json({ error: msg }, 500);
1054
+ return errorResponse(c, error);
979
1055
  }
980
1056
  });
981
1057
  // 16. Fetch transcript for a specific AI session
@@ -987,8 +1063,7 @@ app.get('/api/session/:assistant/:sessionId/transcript', async (c) => {
987
1063
  return c.json({ messages });
988
1064
  }
989
1065
  catch (error) {
990
- const msg = error instanceof Error ? error.message : String(error);
991
- return c.json({ error: msg }, 500);
1066
+ return errorResponse(c, error);
992
1067
  }
993
1068
  });
994
1069
  // 17. Check for NexusFlow updates
@@ -1002,8 +1077,7 @@ app.get('/api/update-status', async (c) => {
1002
1077
  return c.json(status);
1003
1078
  }
1004
1079
  catch (error) {
1005
- const msg = error instanceof Error ? error.message : String(error);
1006
- return c.json({ error: msg }, 500);
1080
+ return errorResponse(c, error);
1007
1081
  }
1008
1082
  });
1009
1083
  // 17.5. Check tools updates status
@@ -1014,8 +1088,7 @@ app.get('/api/updates/tools', async (c) => {
1014
1088
  return c.json(status);
1015
1089
  }
1016
1090
  catch (error) {
1017
- const msg = error instanceof Error ? error.message : String(error);
1018
- return c.json({ error: msg }, 500);
1091
+ return errorResponse(c, error);
1019
1092
  }
1020
1093
  });
1021
1094
  // 17.6. Install updates for a specific tool
@@ -1043,8 +1116,7 @@ app.post('/api/updates/install', async (c) => {
1043
1116
  }
1044
1117
  }
1045
1118
  catch (error) {
1046
- const msg = error instanceof Error ? error.message : String(error);
1047
- return c.json({ error: msg }, 500);
1119
+ return errorResponse(c, error);
1048
1120
  }
1049
1121
  });
1050
1122
  let downloadedInstallerPath = null;
@@ -1055,6 +1127,9 @@ app.post('/api/updates/download', async (c) => {
1055
1127
  if (!downloadUrl) {
1056
1128
  return c.json({ error: 'Download URL is required' }, 400);
1057
1129
  }
1130
+ if (!isAllowedUpdateUrl(downloadUrl)) {
1131
+ return c.json({ error: 'Download URL is not an allowed update host' }, 400);
1132
+ }
1058
1133
  const tempDir = os.tmpdir();
1059
1134
  const fileName = 'NexusFlowSetup_Update.exe';
1060
1135
  const targetPath = path.join(tempDir, fileName);
@@ -1112,8 +1187,7 @@ app.get('/api/workflows/templates', async (c) => {
1112
1187
  return c.json({ templates });
1113
1188
  }
1114
1189
  catch (error) {
1115
- const msg = error instanceof Error ? error.message : String(error);
1116
- return c.json({ error: msg }, 500);
1190
+ return errorResponse(c, error);
1117
1191
  }
1118
1192
  });
1119
1193
  // Save or update custom teamwork template
@@ -1127,8 +1201,7 @@ app.post('/api/workflows/templates', async (c) => {
1127
1201
  return c.json({ success: true, template });
1128
1202
  }
1129
1203
  catch (error) {
1130
- const msg = error instanceof Error ? error.message : String(error);
1131
- return c.json({ error: msg }, 500);
1204
+ return errorResponse(c, error);
1132
1205
  }
1133
1206
  });
1134
1207
  // Delete custom teamwork template
@@ -1147,8 +1220,7 @@ app.delete('/api/workflows/templates/:id', async (c) => {
1147
1220
  return c.json({ success: true });
1148
1221
  }
1149
1222
  catch (error) {
1150
- const msg = error instanceof Error ? error.message : String(error);
1151
- return c.json({ error: msg }, 500);
1223
+ return errorResponse(c, error);
1152
1224
  }
1153
1225
  });
1154
1226
  // Analyze teamwork template rules via selected AI coding assistant harness
@@ -1218,8 +1290,83 @@ and suffix it with:
1218
1290
  return c.json({ analysis, suggestedImprovement });
1219
1291
  }
1220
1292
  catch (error) {
1221
- const msg = error instanceof Error ? error.message : String(error);
1222
- return c.json({ error: msg }, 500);
1293
+ return errorResponse(c, error);
1294
+ }
1295
+ });
1296
+ // ─── Schedules: recurring workspace jobs (sync/refresh) ─────────────────
1297
+ // List all scheduled jobs (with computed next-due time)
1298
+ app.get('/api/schedules', async (c) => {
1299
+ const store = await loadSchedules();
1300
+ const jobs = store.jobs.map((job) => ({
1301
+ ...job,
1302
+ nextDueAt: nextDueAt(job)?.toISOString() ?? null,
1303
+ }));
1304
+ return c.json({ jobs });
1305
+ });
1306
+ // Create a scheduled job
1307
+ app.post('/api/schedules', async (c) => {
1308
+ const body = await c.req.json().catch(() => ({}));
1309
+ const task = body.task === 'refresh' ? 'refresh' : body.task === 'sync' ? 'sync' : null;
1310
+ if (!task) {
1311
+ return c.json({ error: 'task must be "sync" or "refresh"' }, 400);
1312
+ }
1313
+ const intervalMinutes = typeof body.intervalMinutes === 'number' && body.intervalMinutes > 0
1314
+ ? Math.floor(body.intervalMinutes)
1315
+ : parseInterval(String(body.every ?? ''));
1316
+ if (!intervalMinutes) {
1317
+ return c.json({ error: 'Provide intervalMinutes (> 0) or every (e.g. "30m", "2h", "1d")' }, 400);
1318
+ }
1319
+ const config = await loadConfig();
1320
+ const workspacePath = body.workspacePath
1321
+ ? String(body.workspacePath)
1322
+ : body.workspaceId
1323
+ ? path.join(config.workspacesDir, String(body.workspaceId))
1324
+ : null;
1325
+ if (!workspacePath) {
1326
+ return c.json({ error: 'Provide workspacePath or workspaceId' }, 400);
1327
+ }
1328
+ const feature = await loadFeatureConfig(workspacePath);
1329
+ if (!feature) {
1330
+ return c.json({ error: `No NexusFlow workspace found at ${workspacePath}` }, 404);
1331
+ }
1332
+ const job = await addSchedule({ workspacePath, task, intervalMinutes });
1333
+ return c.json({ job }, 201);
1334
+ });
1335
+ // Enable/disable a scheduled job
1336
+ app.post('/api/schedules/:id/enabled', async (c) => {
1337
+ const body = await c.req.json().catch(() => ({}));
1338
+ const job = await setScheduleEnabled(c.req.param('id'), Boolean(body.enabled));
1339
+ if (!job)
1340
+ return c.json({ error: 'Schedule not found' }, 404);
1341
+ return c.json({ job });
1342
+ });
1343
+ // Run a scheduled job immediately
1344
+ app.post('/api/schedules/:id/run', async (c) => {
1345
+ const store = await loadSchedules();
1346
+ const job = store.jobs.find((j) => j.id === c.req.param('id'));
1347
+ if (!job)
1348
+ return c.json({ error: 'Schedule not found' }, 404);
1349
+ const result = await runJob(job);
1350
+ return c.json({ result });
1351
+ });
1352
+ // Delete a scheduled job
1353
+ app.delete('/api/schedules/:id', async (c) => {
1354
+ const removed = await removeSchedule(c.req.param('id'));
1355
+ if (!removed)
1356
+ return c.json({ error: 'Schedule not found' }, 404);
1357
+ return c.json({ ok: true });
1358
+ });
1359
+ // Refresh a workspace's context files on demand (cache-aware)
1360
+ app.post('/api/workspace/:id/refresh', async (c) => {
1361
+ try {
1362
+ const config = await loadConfig();
1363
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, c.req.param('id'));
1364
+ const body = await c.req.json().catch(() => ({}));
1365
+ const report = await refreshWorkspace(workspacePath, { force: Boolean(body.force) });
1366
+ return c.json({ report });
1367
+ }
1368
+ catch (error) {
1369
+ return errorResponse(c, error);
1223
1370
  }
1224
1371
  });
1225
1372
  // Legacy pack endpoint removed.
@@ -1233,16 +1380,30 @@ app.get('/', async (c) => {
1233
1380
  return c.text('GUI dashboard built assets not found. Run "npm run build" first.', 404);
1234
1381
  }
1235
1382
  });
1236
- // Serve static assets from GUI build folder
1237
- app.use('/*', serveStatic({ root: path.relative(process.cwd(), guiPath) }));
1238
- export function startServer(port = 3000) {
1383
+ // Serve static assets from GUI build folder. Use the absolute build path
1384
+ // (serveStatic joins root + request path) so asset serving does not depend on
1385
+ // the server's cwd a cwd on another drive made the old cwd-relative path
1386
+ // resolve wrong and serve a blank GUI, e.g. under `ui --daemon`.
1387
+ app.use('/*', serveStatic({ root: guiPath }));
1388
+ export function startServer(port = 3000, opts = {}) {
1239
1389
  return new Promise((resolve, reject) => {
1240
- const server = serve({ fetch: app.fetch, port }, (info) => {
1390
+ const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, (info) => {
1391
+ // The dashboard server doubles as the host for recurring workspace
1392
+ // jobs (nexusflow schedule ...); jobs are re-read from disk each tick.
1393
+ startScheduler({ log: (message) => console.log(`[scheduler] ${message}`) });
1241
1394
  resolve({ port: info.port, server });
1242
1395
  });
1243
1396
  server.on('error', (e) => {
1244
1397
  if (e.code === 'EADDRINUSE') {
1245
- resolve(startServer(port + 1));
1398
+ // Callers that own their backend (desktop/extension) pass strictPort so
1399
+ // they can rely on the port they requested instead of chasing a silent
1400
+ // increment they'd never find.
1401
+ if (opts.strictPort) {
1402
+ reject(new Error(`Port ${port} is already in use.`));
1403
+ }
1404
+ else {
1405
+ resolve(startServer(port + 1, opts));
1406
+ }
1246
1407
  }
1247
1408
  else {
1248
1409
  reject(e);