@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.
- package/README.md +57 -23
- package/dist/agent/AntigravityCliAdapter.d.ts +7 -0
- package/dist/agent/AntigravityCliAdapter.d.ts.map +1 -0
- package/dist/agent/AntigravityCliAdapter.js +13 -0
- package/dist/agent/AntigravityCliAdapter.js.map +1 -0
- package/dist/agent/ClaudeCliAdapter.d.ts +16 -0
- package/dist/agent/ClaudeCliAdapter.d.ts.map +1 -0
- package/dist/agent/ClaudeCliAdapter.js +34 -0
- package/dist/agent/ClaudeCliAdapter.js.map +1 -0
- package/dist/agent/CliAdapterBase.d.ts +48 -0
- package/dist/agent/CliAdapterBase.d.ts.map +1 -0
- package/dist/agent/CliAdapterBase.js +120 -0
- package/dist/agent/CliAdapterBase.js.map +1 -0
- package/dist/agent/NativeAgent.d.ts +13 -0
- package/dist/agent/NativeAgent.d.ts.map +1 -0
- package/dist/agent/NativeAgent.js +97 -0
- package/dist/agent/NativeAgent.js.map +1 -0
- package/dist/agent/NativeAgentBase.d.ts +27 -0
- package/dist/agent/NativeAgentBase.d.ts.map +1 -0
- package/dist/agent/NativeAgentBase.js +58 -0
- package/dist/agent/NativeAgentBase.js.map +1 -0
- package/dist/agent/NativeClaudeAgent.d.ts +13 -0
- package/dist/agent/NativeClaudeAgent.d.ts.map +1 -0
- package/dist/agent/NativeClaudeAgent.js +115 -0
- package/dist/agent/NativeClaudeAgent.js.map +1 -0
- package/dist/agent/NativeGoogleAgent.d.ts +13 -0
- package/dist/agent/NativeGoogleAgent.d.ts.map +1 -0
- package/dist/agent/NativeGoogleAgent.js +90 -0
- package/dist/agent/NativeGoogleAgent.js.map +1 -0
- package/dist/agent/ProviderRegistry.d.ts +31 -0
- package/dist/agent/ProviderRegistry.d.ts.map +1 -0
- package/dist/agent/ProviderRegistry.js +20 -0
- package/dist/agent/ProviderRegistry.js.map +1 -0
- package/dist/agent/ProviderRegistry.test.d.ts +2 -0
- package/dist/agent/ProviderRegistry.test.d.ts.map +1 -0
- package/dist/agent/ProviderRegistry.test.js +57 -0
- package/dist/agent/ProviderRegistry.test.js.map +1 -0
- package/dist/agent/adapters.d.ts +3 -0
- package/dist/agent/adapters.d.ts.map +1 -0
- package/dist/agent/adapters.js +48 -0
- package/dist/agent/adapters.js.map +1 -0
- package/dist/agent/nativeAgent.test.d.ts +2 -0
- package/dist/agent/nativeAgent.test.d.ts.map +1 -0
- package/dist/agent/nativeAgent.test.js +35 -0
- package/dist/agent/nativeAgent.test.js.map +1 -0
- package/dist/agent/nativeTools.d.ts +26 -0
- package/dist/agent/nativeTools.d.ts.map +1 -0
- package/dist/agent/nativeTools.js +62 -0
- package/dist/agent/nativeTools.js.map +1 -0
- package/dist/agent/nativeTools.test.d.ts +2 -0
- package/dist/agent/nativeTools.test.d.ts.map +1 -0
- package/dist/agent/nativeTools.test.js +67 -0
- package/dist/agent/nativeTools.test.js.map +1 -0
- package/dist/agent/session.d.ts +25 -0
- package/dist/agent/session.d.ts.map +1 -0
- package/dist/agent/session.js +31 -0
- package/dist/agent/session.js.map +1 -0
- package/dist/agent/session.test.d.ts +2 -0
- package/dist/agent/session.test.d.ts.map +1 -0
- package/dist/agent/session.test.js +44 -0
- package/dist/agent/session.test.js.map +1 -0
- package/dist/commands/create.d.ts +8 -7
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +134 -65
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/desktop.d.ts +4 -5
- package/dist/commands/desktop.d.ts.map +1 -1
- package/dist/commands/desktop.js +35 -89
- package/dist/commands/desktop.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +0 -1
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +1 -72
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/list.d.ts.map +1 -1
- package/dist/commands/list.js +2 -1
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/project.d.ts +30 -0
- package/dist/commands/project.d.ts.map +1 -0
- package/dist/commands/project.js +125 -0
- package/dist/commands/project.js.map +1 -0
- package/dist/commands/refresh.js +1 -1
- package/dist/commands/refresh.js.map +1 -1
- package/dist/commands/start.d.ts.map +1 -1
- package/dist/commands/start.js +18 -2
- package/dist/commands/start.js.map +1 -1
- package/dist/commands/stop.d.ts +1 -1
- package/dist/commands/stop.d.ts.map +1 -1
- package/dist/commands/stop.js +19 -2
- package/dist/commands/stop.js.map +1 -1
- package/dist/commands/tui.js +0 -1
- package/dist/commands/tui.js.map +1 -1
- package/dist/commands/ui.d.ts +7 -3
- package/dist/commands/ui.d.ts.map +1 -1
- package/dist/commands/ui.js +12 -6
- package/dist/commands/ui.js.map +1 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +0 -9
- package/dist/core/config.js.map +1 -1
- package/dist/core/config.test.js +0 -9
- package/dist/core/config.test.js.map +1 -1
- package/dist/core/doctor.d.ts +1 -3
- package/dist/core/doctor.d.ts.map +1 -1
- package/dist/core/doctor.js +18 -69
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/finish.d.ts.map +1 -1
- package/dist/core/finish.js +5 -0
- package/dist/core/finish.js.map +1 -1
- package/dist/core/graph.d.ts.map +1 -1
- package/dist/core/graph.js +2 -1
- package/dist/core/graph.js.map +1 -1
- package/dist/core/projects.d.ts +58 -0
- package/dist/core/projects.d.ts.map +1 -0
- package/dist/core/projects.js +190 -0
- package/dist/core/projects.js.map +1 -0
- package/dist/core/projects.test.d.ts +2 -0
- package/dist/core/projects.test.d.ts.map +1 -0
- package/dist/core/projects.test.js +165 -0
- package/dist/core/projects.test.js.map +1 -0
- package/dist/core/sync.d.ts.map +1 -1
- package/dist/core/sync.js +21 -0
- package/dist/core/sync.js.map +1 -1
- package/dist/core/sync.test.js +15 -0
- package/dist/core/sync.test.js.map +1 -1
- package/dist/core/workspace.create.test.js +68 -1
- package/dist/core/workspace.create.test.js.map +1 -1
- package/dist/core/workspace.d.ts +4 -3
- package/dist/core/workspace.d.ts.map +1 -1
- package/dist/core/workspace.js +138 -64
- package/dist/core/workspace.js.map +1 -1
- package/dist/desktop-server.d.ts +13 -0
- package/dist/desktop-server.d.ts.map +1 -0
- package/dist/desktop-server.js +23 -0
- package/dist/desktop-server.js.map +1 -0
- package/dist/generators/antigravity.js +1 -1
- package/dist/generators/antigravity.js.map +1 -1
- package/dist/generators/base.d.ts +1 -8
- package/dist/generators/base.d.ts.map +1 -1
- package/dist/generators/base.js +107 -58
- package/dist/generators/base.js.map +1 -1
- package/dist/generators/base.test.d.ts +2 -0
- package/dist/generators/base.test.d.ts.map +1 -0
- package/dist/generators/base.test.js +80 -0
- package/dist/generators/base.test.js.map +1 -0
- package/dist/generators/claude.js +1 -1
- package/dist/generators/claude.js.map +1 -1
- package/dist/generators/codex.js +1 -1
- package/dist/generators/codex.js.map +1 -1
- package/dist/generators/copilot.js +1 -1
- package/dist/generators/copilot.js.map +1 -1
- package/dist/generators/cursor.js +1 -1
- package/dist/generators/cursor.js.map +1 -1
- package/dist/generators/diff-context.d.ts.map +1 -1
- package/dist/generators/diff-context.js +3 -1
- package/dist/generators/diff-context.js.map +1 -1
- package/dist/generators/index.js +1 -1
- package/dist/generators/index.js.map +1 -1
- package/dist/generators/map-generator.d.ts.map +1 -1
- package/dist/generators/map-generator.js +3 -1
- package/dist/generators/map-generator.js.map +1 -1
- package/dist/gui/assets/DashboardPage-C3NH97y2.js +1 -0
- package/dist/gui/assets/GettingStartedPage-og-Kv7Sm.js +1 -0
- package/dist/gui/assets/ProjectsPage-COqqadEr.js +1 -0
- package/dist/gui/assets/ScaffoldRepoInline-DRipQsos.js +1 -0
- package/dist/gui/assets/Separator-Chx6O62Y.js +1 -0
- package/dist/gui/assets/SettingsPage-CpU3V9oU.js +1 -0
- package/dist/gui/assets/StartWorkPage-DIND0hJy.js +1 -0
- package/dist/gui/assets/StrategiesPage-6LWOnKdk.js +3 -0
- package/dist/gui/assets/WorkspacesPage-BUqgOhuy.js +6 -0
- package/dist/gui/assets/badge-BnkhqVEF.js +1 -0
- package/dist/gui/assets/button-cPldH5S4.js +1 -0
- package/dist/gui/assets/card-82INhRRE.js +1 -0
- package/dist/gui/assets/circle-plus-C0PNc7h4.js +1 -0
- package/dist/gui/assets/cpu-CPUNnHRM.js +1 -0
- package/dist/gui/assets/empty-DWyDwDYB.js +1 -0
- package/dist/gui/assets/external-link-BtYhMxno.js +1 -0
- package/dist/gui/assets/git-branch-Dw55pQWa.js +1 -0
- package/dist/gui/assets/index-CsA2SueF.css +2 -0
- package/dist/gui/assets/index-CyYbQwRf.js +52 -0
- package/dist/gui/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/dist/gui/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/dist/gui/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/dist/gui/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/dist/gui/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/dist/gui/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/dist/gui/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/dist/gui/assets/jetbrains-mono-latin-100-normal-DlYB2XW3.woff2 +0 -0
- package/dist/gui/assets/jetbrains-mono-latin-100-normal-DzH8uRxw.woff +0 -0
- package/dist/gui/assets/jetbrains-mono-latin-200-normal-CHn02WOn.woff +0 -0
- package/dist/gui/assets/jetbrains-mono-latin-200-normal-DvzYDkvL.woff2 +0 -0
- package/dist/gui/assets/jetbrains-mono-latin-300-normal-BYcAiAh2.woff +0 -0
- package/dist/gui/assets/jetbrains-mono-latin-300-normal-DuMDZskh.woff2 +0 -0
- package/dist/gui/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- package/dist/gui/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/dist/gui/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
- package/dist/gui/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
- package/dist/gui/assets/jetbrains-mono-latin-600-normal-BfsvjouI.woff +0 -0
- package/dist/gui/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
- package/dist/gui/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
- package/dist/gui/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
- package/dist/gui/assets/jetbrains-mono-latin-800-normal-D2mQHRMK.woff2 +0 -0
- package/dist/gui/assets/jetbrains-mono-latin-800-normal-Dj9qwObk.woff +0 -0
- package/dist/gui/assets/label-JFgMNsTn.js +1 -0
- package/dist/gui/assets/search-ChVovkOL.js +1 -0
- package/dist/gui/assets/skeleton-CCPbfn1L.js +1 -0
- package/dist/gui/assets/spinner-DXt6mnJk.js +1 -0
- package/dist/gui/assets/textarea-BLU5HXmZ.js +1 -0
- package/dist/gui/assets/trash-2-Da8F2gKd.js +1 -0
- package/dist/gui/assets/useAriaLabelledBy-CjNlctb9.js +1 -0
- package/dist/gui/assets/useCompositeListItem-DWGFLKqA.js +1 -0
- package/dist/gui/index.html +16 -6
- package/dist/index.js +56 -5
- package/dist/index.js.map +1 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +4 -63
- package/dist/mcp/tools.js.map +1 -1
- package/dist/orchestration/detect.d.ts.map +1 -1
- package/dist/orchestration/detect.js +72 -26
- package/dist/orchestration/detect.js.map +1 -1
- package/dist/orchestration/index.d.ts +3 -1
- package/dist/orchestration/index.d.ts.map +1 -1
- package/dist/orchestration/index.js +3 -1
- package/dist/orchestration/index.js.map +1 -1
- package/dist/orchestration/log-tail.d.ts +27 -0
- package/dist/orchestration/log-tail.d.ts.map +1 -0
- package/dist/orchestration/log-tail.js +93 -0
- package/dist/orchestration/log-tail.js.map +1 -0
- package/dist/orchestration/log-tail.test.d.ts +2 -0
- package/dist/orchestration/log-tail.test.d.ts.map +1 -0
- package/dist/orchestration/log-tail.test.js +72 -0
- package/dist/orchestration/log-tail.test.js.map +1 -0
- package/dist/orchestration/orchestrator.d.ts +26 -0
- package/dist/orchestration/orchestrator.d.ts.map +1 -0
- package/dist/orchestration/orchestrator.js +102 -0
- package/dist/orchestration/orchestrator.js.map +1 -0
- package/dist/orchestration/orchestrator.test.d.ts +2 -0
- package/dist/orchestration/orchestrator.test.d.ts.map +1 -0
- package/dist/orchestration/orchestrator.test.js +85 -0
- package/dist/orchestration/orchestrator.test.js.map +1 -0
- package/dist/orchestration/runner.d.ts +62 -3
- package/dist/orchestration/runner.d.ts.map +1 -1
- package/dist/orchestration/runner.js +198 -103
- package/dist/orchestration/runner.js.map +1 -1
- package/dist/orchestration/runner.test.js +125 -1
- package/dist/orchestration/runner.test.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +395 -126
- package/dist/server.js.map +1 -1
- package/dist/server.test.js +111 -123
- package/dist/server.test.js.map +1 -1
- package/dist/types.d.ts +79 -16
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/feature.d.ts +42 -0
- package/dist/utils/feature.d.ts.map +1 -0
- package/dist/utils/feature.js +52 -0
- package/dist/utils/feature.js.map +1 -0
- package/dist/utils/multi-git.d.ts.map +1 -1
- package/dist/utils/multi-git.js +30 -7
- package/dist/utils/multi-git.js.map +1 -1
- package/dist/utils/session-finder.d.ts +21 -1
- package/dist/utils/session-finder.d.ts.map +1 -1
- package/dist/utils/session-finder.js +220 -143
- package/dist/utils/session-finder.js.map +1 -1
- package/dist/utils/session-finder.test.d.ts +2 -0
- package/dist/utils/session-finder.test.d.ts.map +1 -0
- package/dist/utils/session-finder.test.js +65 -0
- package/dist/utils/session-finder.test.js.map +1 -0
- package/dist/utils/slug.d.ts +12 -0
- package/dist/utils/slug.d.ts.map +1 -0
- package/dist/utils/slug.js +18 -0
- package/dist/utils/slug.js.map +1 -0
- package/dist/utils/test-command.d.ts +12 -0
- package/dist/utils/test-command.d.ts.map +1 -0
- package/dist/utils/test-command.js +22 -0
- package/dist/utils/test-command.js.map +1 -0
- package/dist/utils/update-check.d.ts +8 -0
- package/dist/utils/update-check.d.ts.map +1 -1
- package/dist/utils/update-check.js +13 -7
- package/dist/utils/update-check.js.map +1 -1
- package/dist/utils/update-check.test.d.ts +2 -0
- package/dist/utils/update-check.test.d.ts.map +1 -0
- package/dist/utils/update-check.test.js +25 -0
- package/dist/utils/update-check.test.js.map +1 -0
- package/dist/utils/workflow-advisor.d.ts +2 -2
- package/dist/utils/workflow-advisor.d.ts.map +1 -1
- package/dist/utils/workflow-advisor.js +50 -92
- package/dist/utils/workflow-advisor.js.map +1 -1
- package/dist/utils/workflows.d.ts.map +1 -1
- package/dist/utils/workflows.js +2 -4
- package/dist/utils/workflows.js.map +1 -1
- package/package.json +10 -1
- package/dist/gui/assets/index-CB-Ekvrs.css +0 -1
- package/dist/gui/assets/index-CixLsYSa.js +0 -26
- package/dist/utils/local-ai.d.ts +0 -27
- package/dist/utils/local-ai.d.ts.map +0 -1
- package/dist/utils/local-ai.js +0 -100
- package/dist/utils/local-ai.js.map +0 -1
- package/dist/utils/local-ai.test.d.ts +0 -2
- package/dist/utils/local-ai.test.d.ts.map +0 -1
- package/dist/utils/local-ai.test.js +0 -108
- 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 {
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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:
|
|
476
|
-
|
|
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:
|
|
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:
|
|
492
|
-
|
|
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, '
|
|
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 =
|
|
498
|
-
|
|
499
|
-
|
|
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 : '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
861
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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, ['/
|
|
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
|