@mmmbuto/nexuscrew 0.1.0-beta.1 → 0.2.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 (49) hide show
  1. package/README.md +67 -120
  2. package/bin/nexuscrew.js +468 -264
  3. package/frontend/dist/apple-touch-icon.png +0 -0
  4. package/frontend/dist/assets/{index-C7bAndew.js → index-BG1N7bSL.js} +1710 -1702
  5. package/frontend/dist/assets/index-CiAtinNP.css +1 -0
  6. package/frontend/dist/favicon.svg +20 -0
  7. package/frontend/dist/icon-192.png +0 -0
  8. package/frontend/dist/icon-512.png +0 -0
  9. package/frontend/dist/index.html +11 -3
  10. package/frontend/dist/site.webmanifest +29 -0
  11. package/lib/config/hosts.js +67 -0
  12. package/lib/config/manager.js +379 -0
  13. package/lib/config/models.js +408 -0
  14. package/lib/server/db/adapter.js +274 -0
  15. package/lib/server/db/drivers/sql-js.js +75 -0
  16. package/lib/server/db/migrate.js +174 -0
  17. package/lib/server/db/migrations/001_base_schema.sql +70 -0
  18. package/lib/server/middleware/auth.js +134 -0
  19. package/lib/server/middleware/rate-limit.js +63 -0
  20. package/lib/server/models/User.js +128 -0
  21. package/lib/server/routes/auth.js +168 -0
  22. package/lib/server/routes/hosts.js +11 -20
  23. package/lib/server/routes/keys.js +28 -0
  24. package/lib/server/routes/models.js +59 -4
  25. package/lib/server/routes/runtimes.js +34 -0
  26. package/lib/server/routes/send.js +76 -12
  27. package/lib/server/routes/sessions.js +39 -10
  28. package/lib/server/routes/speech.js +46 -0
  29. package/lib/server/routes/status.js +8 -6
  30. package/lib/server/routes/upload.js +135 -0
  31. package/lib/server/routes/wake-lock.js +95 -0
  32. package/lib/server/routes/workspaces.js +101 -0
  33. package/lib/server/server.js +66 -33
  34. package/lib/server/services/attachment-manager.js +57 -0
  35. package/lib/server/services/context-bridge.js +425 -0
  36. package/lib/server/services/runtime-manager.js +462 -0
  37. package/lib/server/services/speech-manager.js +76 -0
  38. package/lib/server/services/summary-generator.js +309 -0
  39. package/lib/server/services/workspace-manager.js +79 -0
  40. package/lib/services/engine-discovery.js +198 -13
  41. package/lib/services/log-watcher.js +40 -22
  42. package/lib/services/remote-pane-watcher.js +155 -0
  43. package/lib/services/session-store.js +60 -64
  44. package/lib/services/tmux-manager.js +127 -116
  45. package/lib/setup/postinstall.js +38 -0
  46. package/lib/utils/paths.js +124 -0
  47. package/lib/utils/termux.js +182 -0
  48. package/package.json +8 -4
  49. package/frontend/dist/assets/index-OENqI1_9.css +0 -1
@@ -10,13 +10,14 @@ const router = Router();
10
10
  // Overall status
11
11
  router.get('/', (req, res) => {
12
12
  const { tmux, store } = req;
13
- const windows = tmux.listWindows();
13
+ const { host = 'local' } = req.query;
14
+ const windows = tmux.listWindows(host);
14
15
  const hosts = tmux.getAllHosts();
15
16
 
16
17
  res.json({
17
18
  tmux: {
18
19
  session: tmux.sessionName,
19
- alive: tmux.hasSession(),
20
+ alive: tmux.hasSession(host),
20
21
  windowCount: windows.length,
21
22
  windows
22
23
  },
@@ -30,7 +31,8 @@ router.get('/', (req, res) => {
30
31
  // Detailed tmux windows
31
32
  router.get('/tmux', (req, res) => {
32
33
  const { tmux, store } = req;
33
- const windows = tmux.listWindows();
34
+ const { host = 'local' } = req.query;
35
+ const windows = tmux.listWindows(host);
34
36
 
35
37
  // Enrich with DB data
36
38
  const enriched = windows.map(w => {
@@ -40,7 +42,7 @@ router.get('/tmux', (req, res) => {
40
42
  conversationId: conv?.id || null,
41
43
  engine: conv?.engine || null,
42
44
  model: conv?.model || null,
43
- host: conv?.host || 'local',
45
+ host: conv?.host || host || 'local',
44
46
  title: conv?.title || w.name
45
47
  };
46
48
  });
@@ -51,8 +53,8 @@ router.get('/tmux', (req, res) => {
51
53
  // Capture pane content
52
54
  router.get('/pane/:window', (req, res) => {
53
55
  const { tmux } = req;
54
- const { host } = req.query;
55
- const content = tmux.capturePane(req.params.window, host || null);
56
+ const { host = 'local' } = req.query;
57
+ const content = tmux.capturePane(req.params.window, host);
56
58
 
57
59
  res.json({ window: req.params.window, content });
58
60
  });
@@ -0,0 +1,135 @@
1
+ const express = require('express');
2
+ const multer = require('multer');
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const { PATHS } = require('../../utils/paths');
6
+
7
+ const router = express.Router();
8
+
9
+ // NexusCrew: attachments in ~/.nexuscrew/attachments
10
+ const ATTACHMENTS_DIR = path.join(PATHS.ATTACHMENTS_DIR, 'incoming');
11
+
12
+ // Ensure directory exists
13
+ if (!fs.existsSync(ATTACHMENTS_DIR)) {
14
+ fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
15
+ console.log(`[Upload] Created attachments directory: ${ATTACHMENTS_DIR}`);
16
+ }
17
+
18
+ // Multer storage config
19
+ const storage = multer.diskStorage({
20
+ destination: (req, file, cb) => {
21
+ cb(null, ATTACHMENTS_DIR);
22
+ },
23
+ filename: (req, file, cb) => {
24
+ // Unique filename: timestamp_originalname
25
+ const timestamp = Date.now();
26
+ const safeName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_');
27
+ cb(null, `${timestamp}_${safeName}`);
28
+ }
29
+ });
30
+
31
+ // File filter - allow images and documents
32
+ const fileFilter = (req, file, cb) => {
33
+ const allowedMimes = [
34
+ // Images
35
+ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
36
+ // Documents
37
+ 'text/plain', 'text/markdown', 'text/csv', 'text/html', 'text/css',
38
+ 'application/json', 'application/xml', 'application/pdf',
39
+ 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
40
+ 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
41
+ // Code files (often sent as octet-stream)
42
+ 'application/octet-stream', 'text/x-python', 'text/javascript', 'application/javascript'
43
+ ];
44
+
45
+ // Also allow by extension for code files
46
+ const allowedExts = ['.js', '.ts', '.py', '.java', '.c', '.cpp', '.h', '.rb', '.php', '.go', '.rs', '.sh', '.bash', '.zsh', '.md', '.txt', '.json', '.yaml', '.yml', '.toml', '.xml', '.html', '.css', '.sql', '.log'];
47
+ const ext = path.extname(file.originalname).toLowerCase();
48
+
49
+ if (allowedMimes.includes(file.mimetype) || allowedExts.includes(ext)) {
50
+ cb(null, true);
51
+ } else {
52
+ cb(new Error(`File type not allowed: ${file.mimetype} (${ext})`), false);
53
+ }
54
+ };
55
+
56
+ // Multer config - 50MB limit
57
+ const upload = multer({
58
+ storage,
59
+ fileFilter,
60
+ limits: { fileSize: 50 * 1024 * 1024 }
61
+ });
62
+
63
+ // POST /api/v1/upload - Single file upload
64
+ router.post('/', upload.single('file'), (req, res) => {
65
+ try {
66
+ if (!req.file) {
67
+ return res.status(400).json({ error: 'No file uploaded' });
68
+ }
69
+
70
+ const filePath = req.file.path;
71
+ const fileName = req.file.filename;
72
+ const originalName = req.file.originalname;
73
+ const mimeType = req.file.mimetype;
74
+ const size = req.file.size;
75
+
76
+ console.log(`[Upload] File saved: ${filePath} (${size} bytes, ${mimeType})`);
77
+
78
+ res.json({
79
+ success: true,
80
+ file: {
81
+ path: filePath,
82
+ name: fileName,
83
+ originalName,
84
+ mimeType,
85
+ size
86
+ }
87
+ });
88
+ } catch (error) {
89
+ console.error('[Upload] Error:', error);
90
+ res.status(500).json({ error: error.message });
91
+ }
92
+ });
93
+
94
+ // POST /api/v1/upload/multiple - Multiple files upload
95
+ router.post('/multiple', upload.array('files', 10), (req, res) => {
96
+ try {
97
+ if (!req.files || req.files.length === 0) {
98
+ return res.status(400).json({ error: 'No files uploaded' });
99
+ }
100
+
101
+ const files = req.files.map(f => ({
102
+ path: f.path,
103
+ name: f.filename,
104
+ originalName: f.originalname,
105
+ mimeType: f.mimetype,
106
+ size: f.size
107
+ }));
108
+
109
+ console.log(`[Upload] ${files.length} files saved`);
110
+
111
+ res.json({
112
+ success: true,
113
+ files
114
+ });
115
+ } catch (error) {
116
+ console.error('[Upload] Error:', error);
117
+ res.status(500).json({ error: error.message });
118
+ }
119
+ });
120
+
121
+ // Error handler for multer
122
+ router.use((error, req, res, next) => {
123
+ if (error instanceof multer.MulterError) {
124
+ if (error.code === 'LIMIT_FILE_SIZE') {
125
+ return res.status(400).json({ error: 'File too large (max 50MB)' });
126
+ }
127
+ return res.status(400).json({ error: error.message });
128
+ }
129
+ if (error) {
130
+ return res.status(400).json({ error: error.message });
131
+ }
132
+ next();
133
+ });
134
+
135
+ module.exports = router;
@@ -0,0 +1,95 @@
1
+ const express = require('express');
2
+ const { execSync } = require('child_process');
3
+ const router = express.Router();
4
+
5
+ // State to track wake lock status
6
+ let wakeLockAcquired = false;
7
+
8
+ /**
9
+ * POST /api/v1/wake-lock
10
+ * Acquire wake lock (prevent Android from killing process)
11
+ * On non-Termux systems, this is a no-op but returns success to avoid errors
12
+ */
13
+ router.post('/wake-lock', (req, res) => {
14
+ try {
15
+ console.log('[WakeLock] Acquiring wake lock...');
16
+
17
+ const isTermux = process.env.PREFIX?.includes('com.termux');
18
+
19
+ if (isTermux) {
20
+ // Only execute on Termux/Android
21
+ try {
22
+ execSync('termux-wake-lock', { stdio: 'ignore' });
23
+ } catch (e) {
24
+ console.warn('[WakeLock] termux-wake-lock command failed (may not be available)');
25
+ }
26
+ } else {
27
+ console.log('[WakeLock] Not on Termux - wake-lock is a no-op');
28
+ }
29
+
30
+ wakeLockAcquired = true;
31
+
32
+ console.log('[WakeLock] ✅ Wake lock acquired (or skipped on non-Termux)');
33
+
34
+ res.json({
35
+ status: 'ok',
36
+ message: 'Wake lock acquired',
37
+ acquired: wakeLockAcquired,
38
+ platform: isTermux ? 'termux' : 'linux'
39
+ });
40
+ } catch (err) {
41
+ console.error('[WakeLock] ❌ Unexpected error:', err.message);
42
+
43
+ // Still return success to avoid breaking the app
44
+ res.status(200).json({
45
+ status: 'ok',
46
+ message: 'Wake lock handler executed',
47
+ acquired: true,
48
+ error: err.message // Log the error but don't fail the request
49
+ });
50
+ }
51
+ });
52
+
53
+ /**
54
+ * DELETE /api/v1/wake-lock
55
+ * Release wake lock
56
+ */
57
+ router.delete('/wake-lock', (req, res) => {
58
+ try {
59
+ console.log('[WakeLock] Releasing wake lock...');
60
+
61
+ // Execute termux-wake-unlock
62
+ execSync('termux-wake-unlock', { stdio: 'ignore' });
63
+
64
+ wakeLockAcquired = false;
65
+
66
+ console.log('[WakeLock] ✅ Wake lock released');
67
+
68
+ res.json({
69
+ status: 'ok',
70
+ message: 'Wake lock released',
71
+ acquired: wakeLockAcquired
72
+ });
73
+ } catch (err) {
74
+ console.error('[WakeLock] ❌ Failed to release wake lock:', err.message);
75
+
76
+ res.status(500).json({
77
+ status: 'error',
78
+ message: 'Failed to release wake lock',
79
+ error: err.message
80
+ });
81
+ }
82
+ });
83
+
84
+ /**
85
+ * GET /api/v1/wake-lock
86
+ * Get wake lock status
87
+ */
88
+ router.get('/wake-lock', (req, res) => {
89
+ res.json({
90
+ status: 'ok',
91
+ acquired: wakeLockAcquired
92
+ });
93
+ });
94
+
95
+ module.exports = router;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Workspace Routes - Express router for workspace management
3
+ *
4
+ * Endpoints:
5
+ * - GET /api/v1/workspaces - List discovered workspaces
6
+ * - POST /api/v1/workspaces/mount - Validate and set active workspace
7
+ */
8
+
9
+ const express = require('express');
10
+ const router = express.Router();
11
+
12
+ /**
13
+ * GET /api/v1/workspaces
14
+ * List all discovered workspaces from conversations table
15
+ */
16
+ router.get('/', (req, res) => {
17
+ try {
18
+ const WorkspaceManager = require('../services/workspace-manager');
19
+ const wm = new WorkspaceManager();
20
+ const workspaces = wm.discoverWorkspaces();
21
+
22
+ res.json({
23
+ workspaces,
24
+ count: workspaces.length
25
+ });
26
+ } catch (error) {
27
+ console.error('[Workspaces] Error listing workspaces:', error);
28
+ res.status(500).json({ error: 'Failed to list workspaces' });
29
+ }
30
+ });
31
+
32
+ /**
33
+ * POST /api/v1/workspaces/mount
34
+ * Validate workspace path and optionally return workspace details
35
+ * Body: { path: string }
36
+ */
37
+ router.post('/mount', (req, res) => {
38
+ try {
39
+ const { path: wsPath } = req.body;
40
+
41
+ if (!wsPath) {
42
+ return res.status(400).json({ error: 'Workspace path is required' });
43
+ }
44
+
45
+ const WorkspaceManager = require('../services/workspace-manager');
46
+ const wm = new WorkspaceManager();
47
+
48
+ // Validate workspace exists
49
+ const valid = wm.validateWorkspace(wsPath);
50
+
51
+ if (!valid) {
52
+ return res.status(400).json({ error: 'Invalid workspace path' });
53
+ }
54
+
55
+ // Get workspace details if it exists in conversations
56
+ const stats = wm.getWorkspaceStats(wsPath);
57
+ const conversations = wm.getWorkspaceConversations(wsPath);
58
+
59
+ res.json({
60
+ workspace: wsPath,
61
+ mounted: true,
62
+ stats,
63
+ conversations: conversations.slice(0, 10) // Return first 10
64
+ });
65
+ } catch (error) {
66
+ console.error('[Workspaces] Error mounting workspace:', error);
67
+ res.status(500).json({ error: 'Failed to mount workspace' });
68
+ }
69
+ });
70
+
71
+ /**
72
+ * GET /api/v1/workspaces/:path/stats
73
+ * Get statistics for a specific workspace
74
+ */
75
+ router.get('/:path/stats', (req, res) => {
76
+ try {
77
+ const { path: wsPath } = req.params;
78
+
79
+ const WorkspaceManager = require('../services/workspace-manager');
80
+ const wm = new WorkspaceManager();
81
+
82
+ // Validate workspace exists
83
+ const valid = wm.validateWorkspace(wsPath);
84
+
85
+ if (!valid) {
86
+ return res.status(404).json({ error: 'Workspace not found' });
87
+ }
88
+
89
+ const stats = wm.getWorkspaceStats(wsPath);
90
+
91
+ res.json({
92
+ workspace: wsPath,
93
+ stats
94
+ });
95
+ } catch (error) {
96
+ console.error('[Workspaces] Error getting workspace stats:', error);
97
+ res.status(500).json({ error: 'Failed to get workspace stats' });
98
+ }
99
+ });
100
+
101
+ module.exports = router;
@@ -10,56 +10,73 @@ const cors = require('cors');
10
10
  const path = require('path');
11
11
  const fs = require('fs');
12
12
  const http = require('http');
13
- const https = require('https');
14
13
 
14
+ const { initDb, saveDb } = require('./db/adapter');
15
15
  const TmuxManager = require('../services/tmux-manager');
16
16
  const EngineDiscovery = require('../services/engine-discovery');
17
17
  const SessionStore = require('../services/session-store');
18
18
  const LogWatcher = require('../services/log-watcher');
19
+ const RemotePaneWatcher = require('../services/remote-pane-watcher');
20
+ const AttachmentManager = require('./services/attachment-manager');
21
+ const SpeechManager = require('./services/speech-manager');
22
+ const { getConfig } = require('../config/manager');
19
23
 
24
+ // Routes
20
25
  const sendRouter = require('./routes/send');
21
26
  const sessionsRouter = require('./routes/sessions');
22
27
  const modelsRouter = require('./routes/models');
28
+ const runtimesRouter = require('./routes/runtimes');
23
29
  const statusRouter = require('./routes/status');
24
30
  const hostsRouter = require('./routes/hosts');
31
+ const wakeLockRouter = require('./routes/wake-lock');
32
+ const authRouter = require('./routes/auth');
33
+ const speechRouter = require('./routes/speech');
34
+ const keysRouter = require('./routes/keys');
35
+ const uploadRouter = require('./routes/upload');
36
+ const workspacesRouter = require('./routes/workspaces');
37
+
38
+ // Middleware
39
+ const { authMiddleware } = require('./middleware/auth');
25
40
 
26
41
  class NexusCrewServer {
27
42
  constructor(config = {}) {
28
- this.port = config.port || process.env.PORT || 41820;
29
43
  this.configDir = config.configDir || path.join(process.env.HOME, '.nexuscrew');
30
44
  this.config = this._loadConfig();
45
+ this.port = config.port || process.env.PORT || this.config.server?.port || 41820;
31
46
  this.app = express();
32
47
  this.server = null;
33
48
  }
34
49
 
35
50
  _loadConfig() {
36
- const configPath = path.join(this.configDir, 'config.json');
37
- if (fs.existsSync(configPath)) {
38
- return JSON.parse(fs.readFileSync(configPath, 'utf8'));
39
- }
40
- return { port: this.port, tmuxSession: 'nexuscrew', autoDiscovery: true };
51
+ return getConfig();
41
52
  }
42
53
 
43
- _loadHosts() {
44
- const hostsPath = path.join(this.configDir, 'hosts.json');
45
- if (fs.existsSync(hostsPath)) {
46
- return JSON.parse(fs.readFileSync(hostsPath, 'utf8'));
54
+ async start() {
55
+ // 1. Init database
56
+ await initDb();
57
+
58
+ // 2. Seed admin user if no users exist
59
+ const User = require('./models/User');
60
+ if (User.count() === 0) {
61
+ User.create('admin', 'admin', 'admin');
62
+ console.log(' Created initial admin account from local defaults');
47
63
  }
48
- return [{ name: 'local', type: 'local', default: true }];
49
- }
50
64
 
51
- async start() {
52
- // Init services
53
- const hosts = this._loadHosts();
65
+ // 3. Init services
54
66
  const tmuxManager = new TmuxManager({
55
67
  tmuxSession: this.config.tmuxSession || 'nexuscrew',
56
- logDir: this.config.logDir || path.join(this.configDir, 'logs'),
57
- hosts
68
+ logDir: this.config.logDir || path.join(this.configDir, 'logs')
58
69
  });
59
70
 
60
71
  const engineDiscovery = new EngineDiscovery(this.config.providersPath);
61
- const sessionStore = new SessionStore(path.join(this.configDir, 'nexuscrew.db'));
72
+ const sessionStore = new SessionStore();
73
+ await sessionStore.init();
62
74
  const logWatcher = new LogWatcher();
75
+ const remotePaneWatcher = new RemotePaneWatcher(tmuxManager);
76
+ const attachmentManager = new AttachmentManager(tmuxManager);
77
+ const speechManager = new SpeechManager();
78
+ this.app.locals.logWatcher = logWatcher;
79
+ this.app.locals.remotePaneWatcher = remotePaneWatcher;
63
80
 
64
81
  // Ensure tmux master session
65
82
  tmuxManager.ensureSession();
@@ -75,40 +92,52 @@ class NexusCrewServer {
75
92
  }
76
93
  }
77
94
 
78
- // Middleware
95
+ // 4. Middleware
79
96
  this.app.use(cors());
80
97
  this.app.use(express.json({ limit: '10mb' }));
81
98
 
82
- // Health check
99
+ // 5. Health check (public)
83
100
  this.app.get('/health', (req, res) => {
84
101
  res.json({
85
102
  status: 'ok',
86
103
  service: 'nexuscrew',
87
- version: '0.1.0',
104
+ version: '0.2.1',
88
105
  tmux: tmuxManager.hasSession(),
89
106
  windows: tmuxManager.listWindows().length,
90
107
  uptime: process.uptime()
91
108
  });
92
109
  });
93
110
 
94
- // Inject services into request
111
+ // 6. Inject services into request
95
112
  this.app.use((req, res, next) => {
96
113
  req.tmux = tmuxManager;
97
114
  req.engines = engineDiscovery;
98
115
  req.store = sessionStore;
99
116
  req.logWatcher = logWatcher;
117
+ req.remotePaneWatcher = remotePaneWatcher;
118
+ req.attachments = attachmentManager;
119
+ req.speech = speechManager;
100
120
  req.config = this.config;
101
121
  next();
102
122
  });
103
123
 
104
- // API routes
105
- this.app.use('/api/v1/send', sendRouter);
106
- this.app.use('/api/v1/sessions', sessionsRouter);
124
+ // 7. Public routes
125
+ this.app.use('/api/v1/auth', authRouter);
126
+ this.app.use('/api/v1', wakeLockRouter);
127
+ this.app.use('/api/v1/runtimes', runtimesRouter); // public for login screen
107
128
  this.app.use('/api/v1/models', modelsRouter);
108
- this.app.use('/api/v1/status', statusRouter);
109
- this.app.use('/api/v1/hosts', hostsRouter);
110
-
111
- // Serve frontend
129
+ this.app.use('/api/v1/keys', keysRouter); // public for STT key check
130
+
131
+ // 8. Protected routes
132
+ this.app.use('/api/v1/send', authMiddleware, sendRouter);
133
+ this.app.use('/api/v1/sessions', authMiddleware, sessionsRouter);
134
+ this.app.use('/api/v1/workspaces', authMiddleware, workspacesRouter);
135
+ this.app.use('/api/v1/status', authMiddleware, statusRouter);
136
+ this.app.use('/api/v1/hosts', authMiddleware, hostsRouter);
137
+ this.app.use('/api/v1/speech', authMiddleware, speechRouter);
138
+ this.app.use('/api/v1/upload', authMiddleware, uploadRouter);
139
+
140
+ // 9. Serve frontend
112
141
  const frontendDist = path.join(__dirname, '../../frontend/dist');
113
142
  if (fs.existsSync(frontendDist)) {
114
143
  this.app.use(express.static(frontendDist));
@@ -119,13 +148,13 @@ class NexusCrewServer {
119
148
  });
120
149
  }
121
150
 
122
- // Error handler
151
+ // 10. Error handler
123
152
  this.app.use((err, req, res, next) => {
124
153
  console.error('[Error]', err.message);
125
154
  res.status(500).json({ error: err.message });
126
155
  });
127
156
 
128
- // Start HTTP server
157
+ // 11. Start HTTP server
129
158
  this.server = http.createServer(this.app);
130
159
 
131
160
  return new Promise((resolve) => {
@@ -137,7 +166,7 @@ class NexusCrewServer {
137
166
  console.log('');
138
167
  console.log(` HTTP: http://localhost:${this.port}`);
139
168
  console.log(` tmux: session "${tmuxManager.sessionName}" (${tmuxManager.listWindows().length} windows)`);
140
- console.log(` Hosts: ${hosts.map(h => h.name).join(', ')}`);
169
+ console.log(` Hosts: ${tmuxManager.getAllHosts().map(h => h.name).join(', ')}`);
141
170
  console.log('');
142
171
 
143
172
  const engines = engineDiscovery.discover();
@@ -153,6 +182,10 @@ class NexusCrewServer {
153
182
 
154
183
  stop() {
155
184
  if (this.server) {
185
+ const appLocals = this.app?.locals || {};
186
+ appLocals.logWatcher?.unwatchAll?.();
187
+ appLocals.remotePaneWatcher?.unwatchAll?.();
188
+ saveDb();
156
189
  this.server.close();
157
190
  }
158
191
  }
@@ -0,0 +1,57 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { PATHS } = require('../../utils/paths');
4
+
5
+ class AttachmentManager {
6
+ constructor(tmux) {
7
+ this.tmux = tmux;
8
+ }
9
+
10
+ getLocalAttachmentDir(conversationId = 'shared') {
11
+ return path.join(PATHS.ATTACHMENTS_DIR, 'local', conversationId);
12
+ }
13
+
14
+ getRemoteAttachmentDir(conversationId = 'shared') {
15
+ return path.posix.join('.nexuscrew', 'attachments', conversationId);
16
+ }
17
+
18
+ stageForConversation(file, conversationId, hostRef) {
19
+ if (!file?.path) {
20
+ throw new Error('Attachment path is required');
21
+ }
22
+
23
+ const host = this.tmux.resolveHost(hostRef);
24
+ const baseName = path.basename(file.path);
25
+
26
+ if (!host || host.type === 'local') {
27
+ const localDir = this.getLocalAttachmentDir(conversationId);
28
+ fs.mkdirSync(localDir, { recursive: true });
29
+ const localPath = path.join(localDir, baseName);
30
+ if (file.path !== localPath) {
31
+ fs.copyFileSync(file.path, localPath);
32
+ }
33
+ return {
34
+ ...file,
35
+ host: 'local',
36
+ localPath,
37
+ promptPath: localPath,
38
+ ready: true
39
+ };
40
+ }
41
+
42
+ const remoteDir = this.getRemoteAttachmentDir(conversationId);
43
+ const remotePath = path.posix.join(remoteDir, baseName);
44
+ this.tmux.copyToHost(file.path, remotePath, host);
45
+
46
+ return {
47
+ ...file,
48
+ host: host.name,
49
+ localPath: file.path,
50
+ remotePath,
51
+ promptPath: `~/${remotePath}`,
52
+ ready: true
53
+ };
54
+ }
55
+ }
56
+
57
+ module.exports = AttachmentManager;