@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
@@ -0,0 +1,168 @@
1
+ const express = require('express');
2
+ const rateLimit = require('express-rate-limit');
3
+ const bcrypt = require('bcryptjs');
4
+ const User = require('../models/User');
5
+ const { generateToken, authMiddleware } = require('../middleware/auth');
6
+ const { getConfig } = require('../../config/manager');
7
+
8
+ const router = express.Router();
9
+
10
+ /**
11
+ * Check config user (admin from init)
12
+ * Returns user object compatible with DB user structure
13
+ */
14
+ function findConfigUser(username) {
15
+ const config = getConfig();
16
+ if (config.auth && config.auth.user === username && config.auth.pass_hash) {
17
+ return {
18
+ id: 'config-admin',
19
+ username: config.auth.user,
20
+ password_hash: config.auth.pass_hash,
21
+ role: 'admin',
22
+ is_locked: false,
23
+ failed_attempts: 0,
24
+ created_at: Date.now()
25
+ };
26
+ }
27
+ return null;
28
+ }
29
+
30
+ /**
31
+ * Verify password for config user
32
+ */
33
+ function verifyConfigPassword(passHash, password) {
34
+ return bcrypt.compareSync(password, passHash);
35
+ }
36
+
37
+ // Rate limiter: max 5 login attempts per 15 minutes per IP
38
+ const loginLimiter = rateLimit({
39
+ windowMs: 15 * 60 * 1000, // 15 minutes
40
+ max: 10,
41
+ message: {
42
+ error: 'Too many login attempts, please try again later',
43
+ retry_after: 15 * 60
44
+ },
45
+ standardHeaders: true,
46
+ legacyHeaders: false
47
+ });
48
+
49
+ // POST /api/v1/auth/login
50
+ router.post('/login', loginLimiter, async (req, res) => {
51
+ try {
52
+ const { username, password } = req.body;
53
+ const ipAddress = req.ip || req.connection.remoteAddress;
54
+
55
+ if (!username || !password) {
56
+ return res.status(400).json({ error: 'Username and password required' });
57
+ }
58
+
59
+ // Check IP rate limiting (additional layer)
60
+ const recentAttempts = User.getRecentLoginAttempts(ipAddress);
61
+ if (recentAttempts > 20) {
62
+ return res.status(429).json({
63
+ error: 'Too many failed attempts from this IP',
64
+ retry_after: 15 * 60
65
+ });
66
+ }
67
+
68
+ // First check config user (admin from init)
69
+ let user = findConfigUser(username);
70
+ let isConfigUser = !!user;
71
+
72
+ // If not config user, check database
73
+ if (!user) {
74
+ user = User.findByUsername(username);
75
+ }
76
+
77
+ if (!user) {
78
+ // Log failed attempt even for non-existent user
79
+ User.logLoginAttempt(ipAddress, username, false);
80
+ return res.status(401).json({ error: 'Invalid credentials' });
81
+ }
82
+
83
+ // Check if account is locked (only for DB users)
84
+ if (!isConfigUser && User.isAccountLocked(user)) {
85
+ User.logLoginAttempt(ipAddress, username, false);
86
+ const remainingMs = user.locked_until - Date.now();
87
+ return res.status(403).json({
88
+ error: 'Account locked due to failed login attempts',
89
+ locked_until: user.locked_until,
90
+ retry_after: Math.ceil(remainingMs / 1000)
91
+ });
92
+ }
93
+
94
+ // Verify password
95
+ let isValid;
96
+ if (isConfigUser) {
97
+ isValid = verifyConfigPassword(user.password_hash, password);
98
+ } else {
99
+ isValid = User.verifyPassword(user, password);
100
+ }
101
+
102
+ if (!isValid) {
103
+ User.logLoginAttempt(ipAddress, username, false);
104
+ if (!isConfigUser) {
105
+ User.incrementFailedAttempts(user.id);
106
+ }
107
+ return res.status(401).json({ error: 'Invalid credentials' });
108
+ }
109
+
110
+ // Success
111
+ User.logLoginAttempt(ipAddress, username, true);
112
+ if (!isConfigUser) {
113
+ User.resetFailedAttempts(user.id);
114
+ User.updateLastLogin(user.id);
115
+ }
116
+
117
+ const token = generateToken(user);
118
+
119
+ res.json({
120
+ token,
121
+ user: {
122
+ id: user.id,
123
+ username: user.username,
124
+ role: user.role
125
+ }
126
+ });
127
+ } catch (error) {
128
+ console.error('Login error:', error);
129
+ res.status(500).json({ error: 'Internal server error' });
130
+ }
131
+ });
132
+
133
+ // GET /api/v1/auth/me
134
+ router.get('/me', authMiddleware, (req, res) => {
135
+ // Config user is already validated by authMiddleware
136
+ // Just return the user info from req.user
137
+ if (req.user.id === 'config-admin') {
138
+ const config = getConfig();
139
+ return res.json({
140
+ id: req.user.id,
141
+ username: req.user.username,
142
+ role: req.user.role,
143
+ created_at: Date.now(),
144
+ last_login: null
145
+ });
146
+ }
147
+
148
+ const user = User.findById(req.user.id);
149
+ if (!user) {
150
+ return res.status(404).json({ error: 'User not found' });
151
+ }
152
+
153
+ res.json({
154
+ id: user.id,
155
+ username: user.username,
156
+ role: user.role,
157
+ created_at: user.created_at,
158
+ last_login: user.last_login
159
+ });
160
+ });
161
+
162
+ // POST /api/v1/auth/logout
163
+ router.post('/logout', authMiddleware, (req, res) => {
164
+ // With JWT, logout is handled client-side by removing token
165
+ res.json({ message: 'Logged out successfully' });
166
+ });
167
+
168
+ module.exports = router;
@@ -6,23 +6,9 @@
6
6
  */
7
7
 
8
8
  const { Router } = require('express');
9
- const fs = require('fs');
10
- const path = require('path');
9
+ const { readHosts, writeHosts, normalizeHost } = require('../../config/hosts');
11
10
  const router = Router();
12
11
 
13
- const HOSTS_PATH = path.join(process.env.HOME || '', '.nexuscrew', 'hosts.json');
14
-
15
- function readHosts() {
16
- if (fs.existsSync(HOSTS_PATH)) {
17
- return JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'));
18
- }
19
- return [{ name: 'local', type: 'local', default: true }];
20
- }
21
-
22
- function writeHosts(hosts) {
23
- fs.writeFileSync(HOSTS_PATH, JSON.stringify(hosts, null, 2));
24
- }
25
-
26
12
  router.get('/', (req, res) => {
27
13
  const { tmux } = req;
28
14
  const hosts = readHosts();
@@ -36,22 +22,27 @@ router.get('/', (req, res) => {
36
22
  });
37
23
 
38
24
  router.post('/', (req, res) => {
39
- const { name, host, user, port, key } = req.body;
25
+ const { name, host, user, port, key, default: isDefault } = req.body;
40
26
  if (!name || !host) return res.status(400).json({ error: 'name and host required' });
41
27
 
42
- const hosts = readHosts();
28
+ let hosts = readHosts();
43
29
  if (hosts.find(h => h.name === name)) {
44
30
  return res.status(409).json({ error: 'Host already exists' });
45
31
  }
46
32
 
47
- hosts.push({
33
+ if (isDefault) {
34
+ hosts = hosts.map((entry) => ({ ...entry, default: false }));
35
+ }
36
+
37
+ hosts.push(normalizeHost({
48
38
  name,
49
39
  type: 'ssh',
50
40
  host,
51
41
  user: user || 'dag',
52
42
  port: port || '22',
53
- key: key || undefined
54
- });
43
+ key: key || undefined,
44
+ default: Boolean(isDefault)
45
+ }));
55
46
 
56
47
  writeHosts(hosts);
57
48
  res.json({ success: true, name });
@@ -0,0 +1,28 @@
1
+ const express = require('express');
2
+ const router = express.Router();
3
+ const { getApiKey } = require('../db/adapter');
4
+
5
+ /**
6
+ * GET /api/v1/keys/check/:provider
7
+ * Check if an API key is configured for a provider
8
+ * Returns: { configured: boolean }
9
+ */
10
+ router.get('/check/:provider', (req, res) => {
11
+ if (req.params.provider === 'stt') {
12
+ const speech = req.speech || req.app?.locals?.speech;
13
+ if (!speech) {
14
+ return res.json({ configured: false, provider: 'unknown' });
15
+ }
16
+ const status = speech.getStatus();
17
+ return res.json({
18
+ configured: Boolean(status.whisper?.configured && status.whisper?.available),
19
+ provider: status.provider,
20
+ fallback: status.fallback
21
+ });
22
+ }
23
+
24
+ const key = getApiKey(req.params.provider);
25
+ res.json({ configured: !!key });
26
+ });
27
+
28
+ module.exports = router;
@@ -1,14 +1,69 @@
1
1
  /**
2
2
  * GET /api/v1/models — List available models and engines
3
+ *
4
+ * Merges engine-discovery results with runtime-manager status
3
5
  */
4
6
 
5
7
  const { Router } = require('express');
8
+ const RuntimeManager = require('../services/runtime-manager');
6
9
  const router = Router();
7
10
 
8
- router.get('/', (req, res) => {
9
- const { engines } = req;
10
- const data = engines.getForFrontend();
11
- res.json(data);
11
+ const runtimeManager = new RuntimeManager();
12
+
13
+ router.get('/', async (req, res) => {
14
+ try {
15
+ // Get engine discovery results
16
+ const { engines } = req;
17
+ const discoveryData = engines.getForFrontend();
18
+
19
+ // Get runtime-aware inventory
20
+ const runtimeInventory = await runtimeManager.getRuntimeInventory();
21
+ const runtimeMap = Object.fromEntries(
22
+ runtimeInventory.map(r => [r.runtimeId, r])
23
+ );
24
+
25
+ // Merge data
26
+ const merged = {};
27
+
28
+ for (const [engineName, engineData] of Object.entries(discoveryData)) {
29
+ merged[engineName] = {
30
+ ...engineData,
31
+ models: engineData.models.map(model => {
32
+ const runtimeId = model.lane === 'custom'
33
+ ? `${engineName}-custom`
34
+ : `${engineName}-native`;
35
+
36
+ const runtime = runtimeMap[runtimeId];
37
+
38
+ return {
39
+ id: model.id,
40
+ label: model.name,
41
+ name: model.name,
42
+ lane: model.lane,
43
+ available: runtime?.available ?? false,
44
+ runtimeStatus: runtime?.status ?? 'unknown',
45
+ runtimeCommand: runtime?.command || null,
46
+ runtimeSource: runtime?.source || null,
47
+ providerId: model.providerId || null,
48
+ ...(model.alias && { alias: model.alias }),
49
+ ...(model.modelHint && { modelHint: model.modelHint }),
50
+ };
51
+ }),
52
+ aliases: engineData.aliases.map(alias => {
53
+ const runtime = runtimeMap[`${engineName}-custom`];
54
+ return {
55
+ ...alias,
56
+ available: runtime?.available ?? false,
57
+ };
58
+ })
59
+ };
60
+ }
61
+
62
+ res.json(merged);
63
+ } catch (error) {
64
+ console.error('[Models] Error merging discovery and runtime data:', error);
65
+ res.status(500).json({ error: 'Failed to fetch models' });
66
+ }
12
67
  });
13
68
 
14
69
  module.exports = router;
@@ -0,0 +1,34 @@
1
+ const express = require('express');
2
+ const RuntimeManager = require('../services/runtime-manager');
3
+
4
+ const router = express.Router();
5
+ const runtimeManager = new RuntimeManager();
6
+
7
+ router.get('/', async (_req, res) => {
8
+ try {
9
+ const inventory = await runtimeManager.getRuntimeInventory();
10
+ res.json({
11
+ platform: runtimeManager.platformId,
12
+ runtimes: inventory,
13
+ });
14
+ } catch (error) {
15
+ console.error('[Runtimes] Inventory error:', error);
16
+ res.status(500).json({ error: 'Failed to fetch runtime inventory' });
17
+ }
18
+ });
19
+
20
+ router.post('/check', async (_req, res) => {
21
+ try {
22
+ const inventory = await runtimeManager.getRuntimeInventory();
23
+ res.json({
24
+ platform: runtimeManager.platformId,
25
+ runtimes: inventory,
26
+ checkedAt: new Date().toISOString(),
27
+ });
28
+ } catch (error) {
29
+ console.error('[Runtimes] Check error:', error);
30
+ res.status(500).json({ error: 'Failed to check runtimes' });
31
+ }
32
+ });
33
+
34
+ module.exports = router;
@@ -12,10 +12,11 @@ const path = require('path');
12
12
  const router = Router();
13
13
 
14
14
  router.post('/', async (req, res) => {
15
- const { tmux, engines, store, logWatcher, config } = req;
15
+ const { tmux, engines, store, logWatcher, remotePaneWatcher, attachments: attachmentManager } = req;
16
16
  const {
17
17
  conversationId,
18
18
  message,
19
+ attachments = [],
19
20
  model,
20
21
  engine: engineOverride,
21
22
  workspace,
@@ -44,7 +45,10 @@ router.post('/', async (req, res) => {
44
45
  // Resolve engine and model
45
46
  const resolvedEngine = engineOverride || resolveEngine(model, engines);
46
47
  const host = hostName || 'local';
47
- const hostObj = host !== 'local' ? tmux.getHost(host) : null;
48
+ const hostObj = tmux.resolveHost(host);
49
+ if (!hostObj) {
50
+ throw new Error(`Unknown host "${host}"`);
51
+ }
48
52
 
49
53
  // Get or create conversation
50
54
  let conversation;
@@ -68,11 +72,41 @@ router.post('/', async (req, res) => {
68
72
  });
69
73
  }
70
74
 
75
+ let fullMessage = message;
76
+ if (Array.isArray(attachments) && attachments.length > 0) {
77
+ const staged = attachments.map((file) =>
78
+ attachmentManager.stageForConversation(file, conversation.id, hostObj)
79
+ );
80
+ const fileLines = staged.map((file) => `- ${file.originalName || file.name} (${file.promptPath})`);
81
+ fullMessage = `[Attached files:\n${fileLines.join('\n')}\n]\n\n${fullMessage}`;
82
+ sendSSE('status', { content: `Prepared ${staged.length} attachment(s)` });
83
+ }
84
+
85
+ // Context Bridge: if engine changed, add context to message
86
+ if (!isNew && conversationId && conversation.engine && conversation.engine !== resolvedEngine) {
87
+ try {
88
+ const { default: ContextBridge } = require('../services/context-bridge');
89
+ const ctx = await ContextBridge.buildContext({
90
+ conversationId: conversation.id,
91
+ fromEngine: conversation.engine,
92
+ toEngine: resolvedEngine,
93
+ userMessage: message
94
+ });
95
+ if (ctx.prompt) {
96
+ fullMessage = ctx.prompt;
97
+ console.log(`[send] Applied context bridge: ${conversation.engine} -> ${resolvedEngine} (${ctx.contextTokens} tokens)`);
98
+ }
99
+ } catch (err) {
100
+ console.warn('[send] Context bridge failed, proceeding without context:', err.message);
101
+ // Continue without context - non-fatal
102
+ }
103
+ }
104
+
71
105
  // Save user message
72
106
  store.addMessage({
73
107
  conversationId: conversation.id,
74
108
  role: 'user',
75
- content: message,
109
+ content: fullMessage,
76
110
  engine: resolvedEngine,
77
111
  model: model
78
112
  });
@@ -92,6 +126,7 @@ router.post('/', async (req, res) => {
92
126
  } else if (engineInfo?.available) {
93
127
  command = buildEngineCommand(resolvedEngine, engineInfo, {
94
128
  model,
129
+ message: fullMessage, // Pass fullMessage for codex exec mode
95
130
  resume: resumeSession || (conversation.tmux_window ? conversation.id : null)
96
131
  });
97
132
  } else {
@@ -102,10 +137,14 @@ router.post('/', async (req, res) => {
102
137
 
103
138
  // Create or reuse tmux window
104
139
  let logPath;
140
+ let remoteInitialSnapshot = '';
105
141
 
106
142
  if (conversation.tmux_window && tmux.hasWindow(conversation.tmux_window, hostObj)) {
143
+ if (hostObj.type !== 'local') {
144
+ remoteInitialSnapshot = tmux.capturePane(conversation.tmux_window, hostObj, 250);
145
+ }
107
146
  // Resume existing window — send message directly
108
- tmux.sendKeys(conversation.tmux_window, message, { enter: true, host: hostName });
147
+ tmux.sendKeys(conversation.tmux_window, fullMessage, { enter: true, host: hostObj });
109
148
  logPath = conversation.log_path;
110
149
  } else {
111
150
  // Create new tmux window
@@ -113,7 +152,7 @@ router.post('/', async (req, res) => {
113
152
  windowName,
114
153
  command,
115
154
  cwd: conversation.workspace || workspace || process.env.HOME,
116
- host: hostName
155
+ host: hostObj
117
156
  });
118
157
  logPath = result.logPath;
119
158
 
@@ -127,13 +166,18 @@ router.post('/', async (req, res) => {
127
166
  if (resolvedEngine !== 'codex') {
128
167
  // Interactive CLIs need a moment to start
129
168
  await new Promise(r => setTimeout(r, 1500));
130
- tmux.sendKeys(windowName, message, { enter: true, host: hostName });
169
+ if (hostObj.type !== 'local') {
170
+ remoteInitialSnapshot = tmux.capturePane(windowName, hostObj, 250);
171
+ }
172
+ tmux.sendKeys(windowName, fullMessage, { enter: true, host: hostObj });
173
+ } else if (hostObj.type !== 'local') {
174
+ remoteInitialSnapshot = tmux.capturePane(windowName, hostObj, 250);
131
175
  }
132
176
  // Codex exec mode gets the message via the command itself
133
177
  }
134
178
 
135
179
  // Start watching the log
136
- if (logPath) {
180
+ if (hostObj.type === 'local' && logPath) {
137
181
  logWatcher.watch(logPath, resolvedEngine, conversation.id);
138
182
  }
139
183
 
@@ -160,6 +204,10 @@ router.post('/', async (req, res) => {
160
204
  responseBuffer += data.content;
161
205
  sendSSE('chunk', { content: data.content });
162
206
  break;
207
+ case 'raw':
208
+ responseBuffer += data.content;
209
+ sendSSE('chunk', { content: data.content });
210
+ break;
163
211
  case 'message_done':
164
212
  if (!doneFired) {
165
213
  doneFired = true;
@@ -177,10 +225,25 @@ router.post('/', async (req, res) => {
177
225
  }
178
226
  };
179
227
 
180
- logWatcher.on('data', onData);
228
+ const activeWatcher = hostObj.type === 'local' ? logWatcher : remotePaneWatcher;
229
+ activeWatcher.on('data', onData);
230
+
231
+ if (hostObj.type !== 'local') {
232
+ remotePaneWatcher.watch({
233
+ conversationId: conversation.id,
234
+ engine: resolvedEngine,
235
+ host: hostObj,
236
+ windowName: conversation.tmux_window || windowName,
237
+ promptText: fullMessage,
238
+ initialSnapshot: remoteInitialSnapshot
239
+ });
240
+ }
181
241
 
182
242
  function finishResponse(content, usage) {
183
- logWatcher.off('data', onData);
243
+ activeWatcher.off('data', onData);
244
+ if (hostObj.type !== 'local') {
245
+ remotePaneWatcher.unwatchByConversation(conversation.id);
246
+ }
184
247
  clearTimeout(timeout);
185
248
 
186
249
  // Save assistant message
@@ -228,8 +291,7 @@ router.post('/interrupt', (req, res) => {
228
291
  return res.json({ success: false, reason: 'No active tmux window' });
229
292
  }
230
293
 
231
- const host = conversation.host !== 'local' ? conversation.host : null;
232
- tmux.interrupt(conversation.tmux_window, host);
294
+ tmux.interrupt(conversation.tmux_window, conversation.host || 'local');
233
295
 
234
296
  res.json({ success: true, method: 'tmux interrupt' });
235
297
  });
@@ -255,7 +317,9 @@ function buildEngineCommand(engine, engineInfo, opts = {}) {
255
317
  return `${bin} --output-format json${opts.model ? ` --model ${opts.model}` : ''}`;
256
318
 
257
319
  case 'codex':
258
- return `${bin} exec --json${opts.model ? ` -m ${opts.model}` : ''}${opts.reasoningEffort ? ` --reasoning ${opts.reasoningEffort}` : ''}`;
320
+ // For codex, message is passed via --prompt flag in exec mode
321
+ const prompt = opts.message ? ` --prompt ${JSON.stringify(opts.message)}` : '';
322
+ return `${bin} exec --json${opts.model ? ` -m ${opts.model}` : ''}${opts.reasoningEffort ? ` --reasoning ${opts.reasoningEffort}` : ''}${prompt}`;
259
323
 
260
324
  case 'gemini':
261
325
  if (opts.resume) {
@@ -1,8 +1,9 @@
1
1
  /**
2
- * GET /api/v1/sessions — List/load sessions
2
+ * GET /api/v1/sessions — List/load sessions (with tmux_alive status)
3
3
  * GET /api/v1/sessions/:id/messages — Get session messages
4
4
  * GET /api/v1/sessions/:id/summary — Get session summary
5
5
  * POST /api/v1/sessions/:id/rename — Rename session
6
+ * POST /api/v1/sessions/:id/pin — Pin/unpin session
6
7
  * DELETE /api/v1/sessions/:id — Delete session
7
8
  */
8
9
 
@@ -11,15 +12,37 @@ const router = Router();
11
12
 
12
13
  // List conversations (optionally grouped by date)
13
14
  router.get('/', (req, res) => {
14
- const { store } = req;
15
- const { workspace, groupBy } = req.query;
15
+ const { store, tmux } = req;
16
+ const { workspace, groupBy, host } = req.query;
16
17
 
17
18
  if (groupBy === 'date') {
18
- const grouped = store.listGroupedByDate({ workspace });
19
+ const grouped = store.listGroupedByDate({ workspace, host });
20
+
21
+ // Enrich with tmux_alive status
22
+ Object.keys(grouped).forEach(dateGroup => {
23
+ grouped[dateGroup].forEach(conv => {
24
+ if (conv.tmux_window) {
25
+ conv.tmux_alive = tmux.hasWindow(conv.tmux_window, conv.host || 'local');
26
+ } else {
27
+ conv.tmux_alive = false;
28
+ }
29
+ });
30
+ });
31
+
19
32
  return res.json(grouped);
20
33
  }
21
34
 
22
- const convs = store.listConversations({ workspace });
35
+ const convs = store.listConversations({ workspace, host });
36
+
37
+ // Enrich with tmux_alive status
38
+ convs.forEach(conv => {
39
+ if (conv.tmux_window) {
40
+ conv.tmux_alive = tmux.hasWindow(conv.tmux_window, conv.host || 'local');
41
+ } else {
42
+ conv.tmux_alive = false;
43
+ }
44
+ });
45
+
23
46
  res.json({ conversations: convs });
24
47
  });
25
48
 
@@ -33,8 +56,7 @@ router.get('/:id', (req, res) => {
33
56
  }
34
57
 
35
58
  // Check tmux window status
36
- const host = conv.host !== 'local' ? conv.host : null;
37
- const alive = conv.tmux_window ? tmux.hasWindow(conv.tmux_window, host) : false;
59
+ const alive = conv.tmux_window ? tmux.hasWindow(conv.tmux_window, conv.host || 'local') : false;
38
60
 
39
61
  res.json({
40
62
  session: {
@@ -79,23 +101,30 @@ router.post('/:id/rename', (req, res) => {
79
101
  res.json({ success: true, title });
80
102
  });
81
103
 
104
+ // Pin/unpin conversation
105
+ router.post('/:id/pin', (req, res) => {
106
+ const { store } = req;
107
+ const result = store.pinConversation(req.params.id);
108
+ res.json({ pinned: result });
109
+ });
110
+
82
111
  // Delete conversation (kills tmux window too)
83
112
  router.delete('/:id', (req, res) => {
84
- const { store, tmux, logWatcher } = req;
113
+ const { store, tmux, logWatcher, remotePaneWatcher } = req;
85
114
  const conv = store.getConversation(req.params.id);
86
115
 
87
116
  if (!conv) return res.status(404).json({ error: 'Not found' });
88
117
 
89
118
  // Kill tmux window if active
90
119
  if (conv.tmux_window) {
91
- const host = conv.host !== 'local' ? conv.host : null;
92
- tmux.killWindow(conv.tmux_window, host);
120
+ tmux.killWindow(conv.tmux_window, conv.host || 'local');
93
121
  }
94
122
 
95
123
  // Stop log watcher
96
124
  if (conv.log_path) {
97
125
  logWatcher.unwatch(conv.log_path);
98
126
  }
127
+ remotePaneWatcher?.unwatchByConversation?.(req.params.id);
99
128
 
100
129
  store.deleteConversation(req.params.id);
101
130
  res.json({ success: true });
@@ -0,0 +1,46 @@
1
+ const express = require('express');
2
+ const router = express.Router();
3
+ const multer = require('multer');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const { PATHS } = require('../../utils/paths');
8
+
9
+ const upload = multer({
10
+ storage: multer.memoryStorage(),
11
+ limits: { fileSize: 25 * 1024 * 1024 }
12
+ });
13
+
14
+ router.get('/status', (req, res) => {
15
+ res.json(req.speech.getStatus());
16
+ });
17
+
18
+ router.post('/transcribe', upload.single('audio'), async (req, res) => {
19
+ let tempPath = null;
20
+
21
+ try {
22
+ if (!req.file) {
23
+ return res.status(400).json({ error: 'No audio file provided' });
24
+ }
25
+
26
+ fs.mkdirSync(PATHS.DATA_DIR, { recursive: true });
27
+ tempPath = path.join(PATHS.DATA_DIR, `stt-${Date.now()}-${Math.random().toString(16).slice(2)}.webm`);
28
+ fs.writeFileSync(tempPath, req.file.buffer);
29
+
30
+ const result = await req.speech.transcribe({
31
+ audioPath: tempPath,
32
+ language: req.body.language
33
+ });
34
+
35
+ res.json(result);
36
+ } catch (error) {
37
+ console.error('[Speech] Transcription error:', error);
38
+ res.status(500).json({ error: error.message });
39
+ } finally {
40
+ if (tempPath && fs.existsSync(tempPath)) {
41
+ fs.unlinkSync(tempPath);
42
+ }
43
+ }
44
+ });
45
+
46
+ module.exports = router;