@mmmbuto/nexuscrew 0.2.0 → 0.2.2

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 (41) hide show
  1. package/README.md +124 -56
  2. package/bin/nexuscrew.js +79 -152
  3. package/frontend/dist/assets/{index-8pw4eMB-.js → index-BsldfYnr.js} +1704 -1716
  4. package/frontend/dist/assets/index-DcQGg3dp.css +1 -0
  5. package/frontend/dist/index.html +2 -2
  6. package/lib/server/routes/hosts.js +3 -2
  7. package/lib/server/routes/launchers.js +12 -0
  8. package/lib/server/routes/models.js +16 -63
  9. package/lib/server/routes/runtime.js +10 -0
  10. package/lib/server/routes/send.js +97 -218
  11. package/lib/server/routes/sessions.js +7 -39
  12. package/lib/server/routes/status.js +16 -18
  13. package/lib/server/routes/tmux.js +88 -0
  14. package/lib/server/server.js +45 -65
  15. package/lib/services/engine-discovery.js +192 -445
  16. package/lib/services/session-store.js +86 -60
  17. package/lib/services/tmux-manager.js +103 -154
  18. package/package.json +3 -7
  19. package/frontend/dist/assets/index-BF0tdvNT.css +0 -1
  20. package/lib/config/manager.js +0 -362
  21. package/lib/config/models.js +0 -408
  22. package/lib/server/db/adapter.js +0 -274
  23. package/lib/server/db/drivers/sql-js.js +0 -75
  24. package/lib/server/db/migrate.js +0 -174
  25. package/lib/server/db/migrations/001_base_schema.sql +0 -70
  26. package/lib/server/middleware/auth.js +0 -134
  27. package/lib/server/middleware/rate-limit.js +0 -63
  28. package/lib/server/models/User.js +0 -128
  29. package/lib/server/routes/auth.js +0 -168
  30. package/lib/server/routes/keys.js +0 -15
  31. package/lib/server/routes/runtimes.js +0 -34
  32. package/lib/server/routes/speech.js +0 -75
  33. package/lib/server/routes/upload.js +0 -134
  34. package/lib/server/routes/wake-lock.js +0 -95
  35. package/lib/server/routes/workspaces.js +0 -101
  36. package/lib/server/services/context-bridge.js +0 -425
  37. package/lib/server/services/runtime-manager.js +0 -462
  38. package/lib/server/services/summary-generator.js +0 -309
  39. package/lib/server/services/workspace-manager.js +0 -79
  40. package/lib/utils/paths.js +0 -107
  41. package/lib/utils/termux.js +0 -145
@@ -1,38 +1,33 @@
1
1
  /**
2
- * POST /api/v1/send — Unified send endpoint
2
+ * POST /api/v1/send — send a message to an existing tmux session
3
+ * POST /api/v1/send/interrupt — interrupt current generation
3
4
  *
4
- * Routes message to the correct tmux window.
5
- * Creates new window if needed, resumes existing.
5
+ * This route never creates tmux sessions implicitly.
6
6
  */
7
7
 
8
8
  const { Router } = require('express');
9
- const { v4: uuidv4 } = require('uuid');
10
- const path = require('path');
9
+ const os = require('os');
11
10
 
12
11
  const router = Router();
13
12
 
14
13
  router.post('/', async (req, res) => {
15
- const { tmux, engines, store, logWatcher, config } = req;
14
+ const { tmux, store, logWatcher } = req;
16
15
  const {
17
16
  conversationId,
18
17
  message,
19
- model,
20
- engine: engineOverride,
18
+ tmuxSession,
21
19
  workspace,
22
- host: hostName,
23
- alias,
24
- resumeSession
20
+ host: hostName
25
21
  } = req.body;
26
22
 
27
23
  if (!message || !message.trim()) {
28
24
  return res.status(400).json({ error: 'Message is required' });
29
25
  }
30
26
 
31
- // Set SSE headers
32
27
  res.writeHead(200, {
33
28
  'Content-Type': 'text/event-stream',
34
29
  'Cache-Control': 'no-cache',
35
- 'Connection': 'keep-alive',
30
+ Connection: 'keep-alive',
36
31
  'X-Accel-Buffering': 'no'
37
32
  });
38
33
 
@@ -41,192 +36,127 @@ router.post('/', async (req, res) => {
41
36
  };
42
37
 
43
38
  try {
44
- // Resolve engine and model
45
- const resolvedEngine = engineOverride || resolveEngine(model, engines);
46
- const host = hostName || 'local';
47
- const hostObj = host !== 'local' ? tmux.getHost(host) : null;
39
+ let conversation = conversationId ? store.getConversation(conversationId) : null;
40
+ const resolvedHost = hostName || conversation?.host || 'local';
41
+ const resolvedSession = (tmuxSession || conversation?.tmux_session || '').trim();
48
42
 
49
- // Get or create conversation
50
- let conversation;
51
- let isNew = false;
52
-
53
- if (conversationId) {
54
- conversation = store.getConversation(conversationId);
43
+ if (!resolvedSession) {
44
+ sendSSE('error', { error: 'tmuxSession is required' });
45
+ return res.end();
46
+ }
47
+ if (!tmux.hasSession(resolvedSession, resolvedHost)) {
48
+ sendSSE('error', { error: `tmux session "${resolvedSession}" is not active` });
49
+ return res.end();
55
50
  }
56
51
 
57
52
  if (!conversation) {
58
- isNew = true;
59
- conversation = store.createConversation({
60
- engine: resolvedEngine,
61
- model: model || null,
62
- workspace: workspace || process.env.HOME,
63
- host
53
+ const existing = store.findByTmuxSession(resolvedSession);
54
+ conversation = existing || store.createConversation({
55
+ title: resolvedSession,
56
+ engine: null,
57
+ workspace: (workspace || os.homedir()).trim() || os.homedir(),
58
+ host: resolvedHost,
59
+ tmuxSession: resolvedSession,
60
+ logPath: null
64
61
  });
62
+ store.updateConversation(conversation.id, { title: resolvedSession });
65
63
  sendSSE('session_created', {
66
64
  conversationId: conversation.id,
67
65
  sessionId: conversation.id
68
66
  });
69
67
  }
70
68
 
71
- // Context Bridge: if engine changed, add context to message
72
- let fullMessage = message;
73
- if (!isNew && conversationId && conversation.engine && conversation.engine !== resolvedEngine) {
74
- try {
75
- const { default: ContextBridge } = require('../services/context-bridge');
76
- const ctx = await ContextBridge.buildContext({
77
- conversationId: conversation.id,
78
- fromEngine: conversation.engine,
79
- toEngine: resolvedEngine,
80
- userMessage: message
81
- });
82
- if (ctx.prompt) {
83
- fullMessage = ctx.prompt;
84
- console.log(`[send] Applied context bridge: ${conversation.engine} -> ${resolvedEngine} (${ctx.contextTokens} tokens)`);
85
- }
86
- } catch (err) {
87
- console.warn('[send] Context bridge failed, proceeding without context:', err.message);
88
- // Continue without context - non-fatal
89
- }
69
+ let logPath = conversation.log_path;
70
+ if (!logPath) {
71
+ logPath = tmux.ensureSessionLogging(resolvedSession, resolvedHost);
72
+ store.updateConversation(conversation.id, {
73
+ tmux_session: resolvedSession,
74
+ workspace: conversation.workspace || (workspace || os.homedir()),
75
+ log_path: logPath,
76
+ status: 'active'
77
+ });
78
+ conversation = store.getConversation(conversation.id);
90
79
  }
91
80
 
92
- // Save user message
93
81
  store.addMessage({
94
82
  conversationId: conversation.id,
95
83
  role: 'user',
96
- content: fullMessage,
97
- engine: resolvedEngine,
98
- model: model
84
+ content: message,
85
+ engine: conversation.engine,
86
+ model: conversation.model
99
87
  });
100
88
 
101
- sendSSE('message_start', { role: 'user' });
102
-
103
- // Determine tmux window name
104
- const windowName = `nc-${conversation.id.substring(0, 8)}`;
105
-
106
- // Build command for the engine
107
- const engineInfo = engines.discover()[resolvedEngine];
108
- let command;
109
-
110
- if (alias) {
111
- // Use a custom alias from providers.zsh
112
- command = alias;
113
- } else if (engineInfo?.available) {
114
- command = buildEngineCommand(resolvedEngine, engineInfo, {
115
- model,
116
- message: fullMessage, // Pass fullMessage for codex exec mode
117
- resume: resumeSession || (conversation.tmux_window ? conversation.id : null)
118
- });
119
- } else {
120
- sendSSE('error', { error: `Engine "${resolvedEngine}" not available` });
121
- res.end();
122
- return;
123
- }
124
-
125
- // Create or reuse tmux window
126
- let logPath;
127
-
128
- if (conversation.tmux_window && tmux.hasWindow(conversation.tmux_window, hostObj)) {
129
- // Resume existing window — send message directly
130
- tmux.sendKeys(conversation.tmux_window, fullMessage, { enter: true, host: hostName });
131
- logPath = conversation.log_path;
132
- } else {
133
- // Create new tmux window
134
- const result = tmux.createWindow({
135
- windowName,
136
- command,
137
- cwd: conversation.workspace || workspace || process.env.HOME,
138
- host: hostName
139
- });
140
- logPath = result.logPath;
141
-
142
- store.updateConversation(conversation.id, {
143
- tmux_window: windowName,
144
- log_path: logPath,
145
- status: 'active'
146
- });
89
+ logWatcher.watch(logPath, conversation.engine || 'generic', conversation.id);
147
90
 
148
- // Wait a moment for the CLI to start, then send the message
149
- if (resolvedEngine !== 'codex') {
150
- // Interactive CLIs need a moment to start
151
- await new Promise(r => setTimeout(r, 1500));
152
- tmux.sendKeys(windowName, fullMessage, { enter: true, host: hostName });
153
- }
154
- // Codex exec mode gets the message via the command itself
155
- }
156
-
157
- // Start watching the log
158
- if (logPath) {
159
- logWatcher.watch(logPath, resolvedEngine, conversation.id);
160
- }
161
-
162
- sendSSE('status', { content: 'Sending to tmux...' });
91
+ sendSSE('message_start', { role: 'user' });
92
+ sendSSE('status', { content: `Sending to tmux session ${resolvedSession}...` });
163
93
 
164
- // Collect response from log watcher
165
94
  let responseBuffer = '';
166
- let doneFired = false;
167
- const timeout = setTimeout(() => {
168
- if (!doneFired) {
169
- doneFired = true;
170
- finishResponse(responseBuffer || '(timeout — check tmux window directly)');
171
- }
172
- }, 120000); // 2 min timeout
173
-
174
- const onData = (data) => {
175
- if (data.conversationId !== conversation.id) return;
176
-
177
- switch (data.type) {
178
- case 'status':
179
- sendSSE('status', { content: data.content });
180
- break;
181
- case 'chunk':
182
- responseBuffer += data.content;
183
- sendSSE('chunk', { content: data.content });
184
- break;
185
- case 'message_done':
186
- if (!doneFired) {
187
- doneFired = true;
188
- clearTimeout(timeout);
189
- finishResponse(data.content || responseBuffer, data.usage);
190
- }
191
- break;
192
- case 'done':
193
- if (!doneFired) {
194
- doneFired = true;
195
- clearTimeout(timeout);
196
- finishResponse(responseBuffer || '(completed)');
197
- }
198
- break;
199
- }
200
- };
201
-
202
- logWatcher.on('data', onData);
203
-
204
- function finishResponse(content, usage) {
95
+ let finished = false;
96
+ let activitySeen = false;
97
+
98
+ const finishResponse = (content, usage) => {
99
+ if (finished) return;
100
+ finished = true;
101
+ clearTimeout(maxTimer);
102
+ clearTimeout(idleTimer);
205
103
  logWatcher.off('data', onData);
206
- clearTimeout(timeout);
207
104
 
208
- // Save assistant message
105
+ const finalContent = content || responseBuffer || tmux.capturePane(resolvedSession, resolvedHost) || '(no output captured)';
209
106
  store.addMessage({
210
107
  conversationId: conversation.id,
211
108
  role: 'assistant',
212
- content,
213
- engine: resolvedEngine,
214
- model: model,
109
+ content: finalContent,
110
+ engine: conversation.engine,
111
+ model: conversation.model,
215
112
  tokensIn: usage?.input_tokens || 0,
216
113
  tokensOut: usage?.output_tokens || 0
217
114
  });
218
115
 
219
116
  sendSSE('message_done', {
220
- content,
117
+ content: finalContent,
221
118
  usage,
222
119
  conversationId: conversation.id,
223
- engine: resolvedEngine,
224
- model: model
120
+ tmuxSession: resolvedSession
225
121
  });
226
122
 
227
123
  res.end();
228
- }
124
+ };
125
+
126
+ const bumpIdleTimer = () => {
127
+ clearTimeout(idleTimer);
128
+ idleTimer = setTimeout(() => {
129
+ finishResponse(responseBuffer);
130
+ }, activitySeen ? 15000 : 20000);
131
+ };
132
+
133
+ const onData = (data) => {
134
+ if (data.conversationId !== conversation.id) return;
135
+ activitySeen = true;
136
+
137
+ if (data.type === 'status') {
138
+ sendSSE('status', { content: data.content });
139
+ } else if (data.type === 'chunk' || data.type === 'raw') {
140
+ responseBuffer += `${data.content}\n`;
141
+ sendSSE('chunk', { content: data.content });
142
+ } else if (data.type === 'message_done') {
143
+ responseBuffer += data.content || '';
144
+ return finishResponse(data.content || responseBuffer, data.usage);
145
+ } else if (data.type === 'done') {
146
+ return finishResponse(responseBuffer);
147
+ }
148
+
149
+ bumpIdleTimer();
150
+ };
151
+
152
+ logWatcher.on('data', onData);
153
+
154
+ const maxTimer = setTimeout(() => {
155
+ finishResponse(responseBuffer || '(timeout — check tmux session directly)');
156
+ }, 120000);
157
+ let idleTimer = setTimeout(() => finishResponse(responseBuffer), 20000);
229
158
 
159
+ tmux.sendKeys(resolvedSession, message, { enter: true, host: resolvedHost });
230
160
  } catch (err) {
231
161
  console.error('[send] Error:', err);
232
162
  sendSSE('error', { error: err.message });
@@ -234,68 +164,17 @@ router.post('/', async (req, res) => {
234
164
  }
235
165
  });
236
166
 
237
- /**
238
- * POST /api/v1/send/interrupt — Interrupt current generation
239
- */
240
167
  router.post('/interrupt', (req, res) => {
241
168
  const { tmux, store } = req;
242
169
  const { conversationId } = req.body;
170
+ const conversation = conversationId ? store.getConversation(conversationId) : null;
243
171
 
244
- if (!conversationId) {
245
- return res.status(400).json({ error: 'conversationId required' });
172
+ if (!conversation?.tmux_session) {
173
+ return res.status(404).json({ error: 'Conversation has no active tmux session' });
246
174
  }
247
175
 
248
- const conversation = store.getConversation(conversationId);
249
- if (!conversation || !conversation.tmux_window) {
250
- return res.json({ success: false, reason: 'No active tmux window' });
251
- }
252
-
253
- const host = conversation.host !== 'local' ? conversation.host : null;
254
- tmux.interrupt(conversation.tmux_window, host);
255
-
256
- res.json({ success: true, method: 'tmux interrupt' });
176
+ tmux.interrupt(conversation.tmux_session, conversation.host || 'local');
177
+ res.json({ success: true });
257
178
  });
258
179
 
259
- // --- Helpers ---
260
-
261
- function resolveEngine(model, engineDiscovery) {
262
- if (!model) return 'claude';
263
- const allModels = engineDiscovery.getAllModels();
264
- const found = allModels.find(m => m.id === model);
265
- return found ? found.engine : 'claude';
266
- }
267
-
268
- function buildEngineCommand(engine, engineInfo, opts = {}) {
269
- const bin = engineInfo.path;
270
- if (!bin) return engine;
271
-
272
- switch (engine) {
273
- case 'claude':
274
- if (opts.resume) {
275
- return `${bin} --output-format json -r ${opts.resume}${opts.model ? ` --model ${opts.model}` : ''}`;
276
- }
277
- return `${bin} --output-format json${opts.model ? ` --model ${opts.model}` : ''}`;
278
-
279
- case 'codex':
280
- // For codex, message is passed via --prompt flag in exec mode
281
- const prompt = opts.message ? ` --prompt "${opts.message.replace(/"/g, '\\"')}"` : '';
282
- return `${bin} exec --json${opts.model ? ` -m ${opts.model}` : ''}${opts.reasoningEffort ? ` --reasoning ${opts.reasoningEffort}` : ''}${prompt}`;
283
-
284
- case 'gemini':
285
- if (opts.resume) {
286
- return `${bin} --resume ${opts.resume}${opts.model ? ` -m ${opts.model}` : ''}`;
287
- }
288
- return `${bin}${opts.model ? ` -m ${opts.model}` : ''}`;
289
-
290
- case 'qwen':
291
- if (opts.resume) {
292
- return `${bin} --resume ${opts.resume}${opts.model ? ` -m ${opts.model}` : ''}`;
293
- }
294
- return `${bin}${opts.model ? ` -m ${opts.model}` : ''}`;
295
-
296
- default:
297
- return bin;
298
- }
299
- }
300
-
301
180
  module.exports = router;
@@ -1,9 +1,8 @@
1
1
  /**
2
- * GET /api/v1/sessions — List/load sessions (with tmux_alive status)
2
+ * GET /api/v1/sessions — List/load sessions
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
7
6
  * DELETE /api/v1/sessions/:id — Delete session
8
7
  */
9
8
 
@@ -12,39 +11,15 @@ const router = Router();
12
11
 
13
12
  // List conversations (optionally grouped by date)
14
13
  router.get('/', (req, res) => {
15
- const { store, tmux } = req;
14
+ const { store } = req;
16
15
  const { workspace, groupBy } = req.query;
17
16
 
18
17
  if (groupBy === 'date') {
19
18
  const grouped = store.listGroupedByDate({ workspace });
20
-
21
- // Enrich with tmux_alive status
22
- Object.keys(grouped).forEach(dateGroup => {
23
- grouped[dateGroup].forEach(conv => {
24
- if (conv.tmux_window) {
25
- const host = conv.host !== 'local' ? conv.host : null;
26
- conv.tmux_alive = tmux.hasWindow(conv.tmux_window, host);
27
- } else {
28
- conv.tmux_alive = false;
29
- }
30
- });
31
- });
32
-
33
19
  return res.json(grouped);
34
20
  }
35
21
 
36
22
  const convs = store.listConversations({ workspace });
37
-
38
- // Enrich with tmux_alive status
39
- convs.forEach(conv => {
40
- if (conv.tmux_window) {
41
- const host = conv.host !== 'local' ? conv.host : null;
42
- conv.tmux_alive = tmux.hasWindow(conv.tmux_window, host);
43
- } else {
44
- conv.tmux_alive = false;
45
- }
46
- });
47
-
48
23
  res.json({ conversations: convs });
49
24
  });
50
25
 
@@ -59,7 +34,8 @@ router.get('/:id', (req, res) => {
59
34
 
60
35
  // Check tmux window status
61
36
  const host = conv.host !== 'local' ? conv.host : null;
62
- const alive = conv.tmux_window ? tmux.hasWindow(conv.tmux_window, host) : false;
37
+ const target = conv.tmux_session || conv.tmux_window;
38
+ const alive = target ? tmux.hasSession(target, host) : false;
63
39
 
64
40
  res.json({
65
41
  session: {
@@ -104,24 +80,16 @@ router.post('/:id/rename', (req, res) => {
104
80
  res.json({ success: true, title });
105
81
  });
106
82
 
107
- // Pin/unpin conversation
108
- router.post('/:id/pin', (req, res) => {
109
- const { store } = req;
110
- const result = store.pinConversation(req.params.id);
111
- res.json({ pinned: result });
112
- });
113
-
114
- // Delete conversation (kills tmux window too)
83
+ // Delete conversation (kills tmux session too)
115
84
  router.delete('/:id', (req, res) => {
116
85
  const { store, tmux, logWatcher } = req;
117
86
  const conv = store.getConversation(req.params.id);
118
87
 
119
88
  if (!conv) return res.status(404).json({ error: 'Not found' });
120
89
 
121
- // Kill tmux window if active
122
- if (conv.tmux_window) {
90
+ if (conv.tmux_session) {
123
91
  const host = conv.host !== 'local' ? conv.host : null;
124
- tmux.killWindow(conv.tmux_window, host);
92
+ tmux.killSession(conv.tmux_session, host);
125
93
  }
126
94
 
127
95
  // Stop log watcher
@@ -1,7 +1,7 @@
1
1
  /**
2
- * GET /api/v1/status — Tmux status (windows, hosts)
3
- * GET /api/v1/status/tmux — Detailed tmux info
4
- * GET /api/v1/status/pane/:window — Capture pane content
2
+ * GET /api/v1/status — tmux status
3
+ * GET /api/v1/status/tmux — Detailed tmux session info
4
+ * GET /api/v1/status/pane/:session — Capture pane content
5
5
  */
6
6
 
7
7
  const { Router } = require('express');
@@ -10,15 +10,13 @@ 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 sessions = tmux.listSessions();
14
14
  const hosts = tmux.getAllHosts();
15
15
 
16
16
  res.json({
17
17
  tmux: {
18
- session: tmux.sessionName,
19
- alive: tmux.hasSession(),
20
- windowCount: windows.length,
21
- windows
18
+ sessionCount: sessions.length,
19
+ sessions
22
20
  },
23
21
  hosts: hosts.map(h => ({
24
22
  ...h,
@@ -30,31 +28,31 @@ router.get('/', (req, res) => {
30
28
  // Detailed tmux windows
31
29
  router.get('/tmux', (req, res) => {
32
30
  const { tmux, store } = req;
33
- const windows = tmux.listWindows();
31
+ const sessions = tmux.listSessions();
34
32
 
35
- // Enrich with DB data
36
- const enriched = windows.map(w => {
37
- const conv = store.findByWindow(w.name);
33
+ const enriched = sessions.map((session) => {
34
+ const conv = store.findByTmuxSession(session.sessionName);
38
35
  return {
39
- ...w,
36
+ ...session,
40
37
  conversationId: conv?.id || null,
41
38
  engine: conv?.engine || null,
42
39
  model: conv?.model || null,
43
40
  host: conv?.host || 'local',
44
- title: conv?.title || w.name
41
+ title: conv?.title || session.sessionName,
42
+ workspace: conv?.workspace || ''
45
43
  };
46
44
  });
47
45
 
48
- res.json({ windows: enriched });
46
+ res.json({ sessions: enriched });
49
47
  });
50
48
 
51
49
  // Capture pane content
52
- router.get('/pane/:window', (req, res) => {
50
+ router.get('/pane/:session', (req, res) => {
53
51
  const { tmux } = req;
54
52
  const { host } = req.query;
55
- const content = tmux.capturePane(req.params.window, host || null);
53
+ const content = tmux.capturePane(req.params.session, host || null);
56
54
 
57
- res.json({ window: req.params.window, content });
55
+ res.json({ session: req.params.session, content });
58
56
  });
59
57
 
60
58
  module.exports = router;
@@ -0,0 +1,88 @@
1
+ const { Router } = require('express');
2
+
3
+ const router = Router();
4
+
5
+ router.get('/sessions', (req, res) => {
6
+ const { tmux, store } = req;
7
+ const sessions = tmux.listSessions().map((session) => {
8
+ const conversation = store.findByTmuxSession(session.sessionName);
9
+ return {
10
+ ...session,
11
+ host: conversation?.host || 'local',
12
+ workspace: conversation?.workspace || '',
13
+ conversationId: conversation?.id || null,
14
+ launcherId: conversation?.launcher_id || null,
15
+ engine: conversation?.engine || null,
16
+ status: conversation?.status || 'external'
17
+ };
18
+ });
19
+
20
+ res.json({ sessions });
21
+ });
22
+
23
+ router.post('/sessions', (req, res) => {
24
+ const { tmux, store, engines } = req;
25
+ const {
26
+ sessionName,
27
+ launcherId,
28
+ workspace,
29
+ host
30
+ } = req.body;
31
+
32
+ if (!sessionName || !sessionName.trim()) {
33
+ return res.status(400).json({ error: 'sessionName is required' });
34
+ }
35
+ if (!launcherId) {
36
+ return res.status(400).json({ error: 'launcherId is required' });
37
+ }
38
+
39
+ const launcher = engines.getLauncher(launcherId);
40
+ if (!launcher) {
41
+ return res.status(404).json({ error: `Launcher "${launcherId}" not found` });
42
+ }
43
+ if (!launcher.runnable) {
44
+ return res.status(409).json({
45
+ error: `Launcher "${launcher.label}" was detected from config but is not verified runnable in the current shell`
46
+ });
47
+ }
48
+
49
+ const targetHost = host || 'local';
50
+ if (tmux.hasSession(sessionName.trim(), targetHost)) {
51
+ return res.status(409).json({ error: `tmux session "${sessionName.trim()}" already exists` });
52
+ }
53
+
54
+ const runtime = engines.getRuntimeInfo();
55
+ const createResult = tmux.createSession({
56
+ sessionName: sessionName.trim(),
57
+ cwd: (workspace || runtime.homeDir || '').trim() || runtime.homeDir,
58
+ host: targetHost,
59
+ shell: runtime.shell,
60
+ launcherCommand: launcher.command
61
+ });
62
+
63
+ const conversation = store.createConversation({
64
+ title: sessionName.trim(),
65
+ engine: launcher.family,
66
+ workspace: (workspace || runtime.homeDir || '').trim() || runtime.homeDir,
67
+ host: targetHost,
68
+ tmuxSession: createResult.sessionName,
69
+ logPath: createResult.logPath,
70
+ launcherId: launcher.id
71
+ });
72
+ store.updateConversation(conversation.id, { title: sessionName.trim() });
73
+
74
+ res.status(201).json({
75
+ session: {
76
+ sessionName: createResult.sessionName,
77
+ logPath: createResult.logPath,
78
+ host: targetHost,
79
+ workspace: conversation.workspace,
80
+ conversationId: conversation.id,
81
+ launcherId: launcher.id,
82
+ engine: launcher.family,
83
+ runnable: true
84
+ }
85
+ });
86
+ });
87
+
88
+ module.exports = router;