@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.
- package/README.md +124 -56
- package/bin/nexuscrew.js +79 -152
- package/frontend/dist/assets/{index-8pw4eMB-.js → index-BsldfYnr.js} +1704 -1716
- package/frontend/dist/assets/index-DcQGg3dp.css +1 -0
- package/frontend/dist/index.html +2 -2
- package/lib/server/routes/hosts.js +3 -2
- package/lib/server/routes/launchers.js +12 -0
- package/lib/server/routes/models.js +16 -63
- package/lib/server/routes/runtime.js +10 -0
- package/lib/server/routes/send.js +97 -218
- package/lib/server/routes/sessions.js +7 -39
- package/lib/server/routes/status.js +16 -18
- package/lib/server/routes/tmux.js +88 -0
- package/lib/server/server.js +45 -65
- package/lib/services/engine-discovery.js +192 -445
- package/lib/services/session-store.js +86 -60
- package/lib/services/tmux-manager.js +103 -154
- package/package.json +3 -7
- package/frontend/dist/assets/index-BF0tdvNT.css +0 -1
- package/lib/config/manager.js +0 -362
- package/lib/config/models.js +0 -408
- package/lib/server/db/adapter.js +0 -274
- package/lib/server/db/drivers/sql-js.js +0 -75
- package/lib/server/db/migrate.js +0 -174
- package/lib/server/db/migrations/001_base_schema.sql +0 -70
- package/lib/server/middleware/auth.js +0 -134
- package/lib/server/middleware/rate-limit.js +0 -63
- package/lib/server/models/User.js +0 -128
- package/lib/server/routes/auth.js +0 -168
- package/lib/server/routes/keys.js +0 -15
- package/lib/server/routes/runtimes.js +0 -34
- package/lib/server/routes/speech.js +0 -75
- package/lib/server/routes/upload.js +0 -134
- package/lib/server/routes/wake-lock.js +0 -95
- package/lib/server/routes/workspaces.js +0 -101
- package/lib/server/services/context-bridge.js +0 -425
- package/lib/server/services/runtime-manager.js +0 -462
- package/lib/server/services/summary-generator.js +0 -309
- package/lib/server/services/workspace-manager.js +0 -79
- package/lib/utils/paths.js +0 -107
- package/lib/utils/termux.js +0 -145
|
@@ -1,38 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* POST /api/v1/send —
|
|
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
|
-
*
|
|
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
|
|
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,
|
|
14
|
+
const { tmux, store, logWatcher } = req;
|
|
16
15
|
const {
|
|
17
16
|
conversationId,
|
|
18
17
|
message,
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
const
|
|
46
|
-
const
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
|
|
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
|
-
|
|
59
|
-
conversation = store.createConversation({
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
workspace: workspace ||
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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:
|
|
97
|
-
engine:
|
|
98
|
-
model: model
|
|
84
|
+
content: message,
|
|
85
|
+
engine: conversation.engine,
|
|
86
|
+
model: conversation.model
|
|
99
87
|
});
|
|
100
88
|
|
|
101
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 (!
|
|
245
|
-
return res.status(
|
|
172
|
+
if (!conversation?.tmux_session) {
|
|
173
|
+
return res.status(404).json({ error: 'Conversation has no active tmux session' });
|
|
246
174
|
}
|
|
247
175
|
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
122
|
-
if (conv.tmux_window) {
|
|
90
|
+
if (conv.tmux_session) {
|
|
123
91
|
const host = conv.host !== 'local' ? conv.host : null;
|
|
124
|
-
tmux.
|
|
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 —
|
|
3
|
-
* GET /api/v1/status/tmux — Detailed tmux info
|
|
4
|
-
* GET /api/v1/status/pane/:
|
|
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
|
|
13
|
+
const sessions = tmux.listSessions();
|
|
14
14
|
const hosts = tmux.getAllHosts();
|
|
15
15
|
|
|
16
16
|
res.json({
|
|
17
17
|
tmux: {
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
31
|
+
const sessions = tmux.listSessions();
|
|
34
32
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const conv = store.findByWindow(w.name);
|
|
33
|
+
const enriched = sessions.map((session) => {
|
|
34
|
+
const conv = store.findByTmuxSession(session.sessionName);
|
|
38
35
|
return {
|
|
39
|
-
...
|
|
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 ||
|
|
41
|
+
title: conv?.title || session.sessionName,
|
|
42
|
+
workspace: conv?.workspace || ''
|
|
45
43
|
};
|
|
46
44
|
});
|
|
47
45
|
|
|
48
|
-
res.json({
|
|
46
|
+
res.json({ sessions: enriched });
|
|
49
47
|
});
|
|
50
48
|
|
|
51
49
|
// Capture pane content
|
|
52
|
-
router.get('/pane/:
|
|
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.
|
|
53
|
+
const content = tmux.capturePane(req.params.session, host || null);
|
|
56
54
|
|
|
57
|
-
res.json({
|
|
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;
|