@mmmbuto/nexuscrew 0.2.3 → 0.2.5
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 +118 -94
- package/bin/nexuscrew.js +28 -11
- package/frontend/dist/assets/{index-BsldfYnr.js → index-CSPyKzmq.js} +46 -46
- package/frontend/dist/assets/{index-DcQGg3dp.css → index-Cl7bxc7X.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/lib/config/paths.js +30 -0
- package/lib/server/routes/hosts.js +16 -13
- package/lib/server/routes/launchers.js +6 -1
- package/lib/server/routes/models.js +7 -3
- package/lib/server/routes/runtime.js +5 -1
- package/lib/server/routes/send.js +72 -16
- package/lib/server/routes/status.js +10 -6
- package/lib/server/routes/tmux.js +8 -5
- package/lib/server/server.js +12 -8
- package/lib/services/engine-discovery.js +99 -34
- package/lib/services/tmux-manager.js +85 -2
- package/package.json +14 -5
|
@@ -10,6 +10,23 @@ const os = require('os');
|
|
|
10
10
|
|
|
11
11
|
const router = Router();
|
|
12
12
|
|
|
13
|
+
function buildPanePoller(tmux, sessionName, hostName, onChunk) {
|
|
14
|
+
let lastSnapshot = '';
|
|
15
|
+
return setInterval(() => {
|
|
16
|
+
const snapshot = tmux.capturePane(sessionName, hostName) || '';
|
|
17
|
+
if (!snapshot || snapshot === lastSnapshot) return;
|
|
18
|
+
|
|
19
|
+
const chunk = snapshot.startsWith(lastSnapshot)
|
|
20
|
+
? snapshot.slice(lastSnapshot.length)
|
|
21
|
+
: snapshot;
|
|
22
|
+
|
|
23
|
+
lastSnapshot = snapshot;
|
|
24
|
+
if (chunk.trim()) {
|
|
25
|
+
onChunk(chunk.trimEnd());
|
|
26
|
+
}
|
|
27
|
+
}, 700);
|
|
28
|
+
}
|
|
29
|
+
|
|
13
30
|
router.post('/', async (req, res) => {
|
|
14
31
|
const { tmux, store, logWatcher } = req;
|
|
15
32
|
const {
|
|
@@ -50,11 +67,12 @@ router.post('/', async (req, res) => {
|
|
|
50
67
|
}
|
|
51
68
|
|
|
52
69
|
if (!conversation) {
|
|
53
|
-
const existing = store.
|
|
70
|
+
const existing = store.listConversations({ host: resolvedHost, limit: 500 })
|
|
71
|
+
.find((item) => item.tmux_session === resolvedSession);
|
|
54
72
|
conversation = existing || store.createConversation({
|
|
55
73
|
title: resolvedSession,
|
|
56
74
|
engine: null,
|
|
57
|
-
workspace: (workspace || os.homedir()).trim() || os.homedir(),
|
|
75
|
+
workspace: (workspace || tmux.getRuntimeInfo(resolvedHost).homeDir || os.homedir()).trim() || os.homedir(),
|
|
58
76
|
host: resolvedHost,
|
|
59
77
|
tmuxSession: resolvedSession,
|
|
60
78
|
logPath: null
|
|
@@ -67,11 +85,15 @@ router.post('/', async (req, res) => {
|
|
|
67
85
|
}
|
|
68
86
|
|
|
69
87
|
let logPath = conversation.log_path;
|
|
88
|
+
let panePoller = null;
|
|
89
|
+
let maxTimer = null;
|
|
90
|
+
let idleTimer = null;
|
|
91
|
+
let closed = false;
|
|
70
92
|
if (!logPath) {
|
|
71
93
|
logPath = tmux.ensureSessionLogging(resolvedSession, resolvedHost);
|
|
72
94
|
store.updateConversation(conversation.id, {
|
|
73
95
|
tmux_session: resolvedSession,
|
|
74
|
-
workspace: conversation.workspace || (workspace || os.homedir()),
|
|
96
|
+
workspace: conversation.workspace || (workspace || tmux.getRuntimeInfo(resolvedHost).homeDir || os.homedir()),
|
|
75
97
|
log_path: logPath,
|
|
76
98
|
status: 'active'
|
|
77
99
|
});
|
|
@@ -94,15 +116,28 @@ router.post('/', async (req, res) => {
|
|
|
94
116
|
let responseBuffer = '';
|
|
95
117
|
let finished = false;
|
|
96
118
|
let activitySeen = false;
|
|
119
|
+
let lastMessageDone = '';
|
|
97
120
|
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
100
|
-
|
|
121
|
+
const cleanup = () => {
|
|
122
|
+
if (closed) return;
|
|
123
|
+
closed = true;
|
|
101
124
|
clearTimeout(maxTimer);
|
|
102
125
|
clearTimeout(idleTimer);
|
|
126
|
+
if (panePoller) clearInterval(panePoller);
|
|
103
127
|
logWatcher.off('data', onData);
|
|
128
|
+
};
|
|
104
129
|
|
|
105
|
-
|
|
130
|
+
const finishResponse = (content, usage) => {
|
|
131
|
+
if (finished) return;
|
|
132
|
+
finished = true;
|
|
133
|
+
cleanup();
|
|
134
|
+
|
|
135
|
+
const finalContent =
|
|
136
|
+
content ||
|
|
137
|
+
lastMessageDone ||
|
|
138
|
+
responseBuffer ||
|
|
139
|
+
tmux.capturePane(resolvedSession, resolvedHost) ||
|
|
140
|
+
'(no output captured)';
|
|
106
141
|
store.addMessage({
|
|
107
142
|
conversationId: conversation.id,
|
|
108
143
|
role: 'assistant',
|
|
@@ -127,7 +162,7 @@ router.post('/', async (req, res) => {
|
|
|
127
162
|
clearTimeout(idleTimer);
|
|
128
163
|
idleTimer = setTimeout(() => {
|
|
129
164
|
finishResponse(responseBuffer);
|
|
130
|
-
}, activitySeen ?
|
|
165
|
+
}, activitySeen ? 25000 : 30000);
|
|
131
166
|
};
|
|
132
167
|
|
|
133
168
|
const onData = (data) => {
|
|
@@ -140,8 +175,9 @@ router.post('/', async (req, res) => {
|
|
|
140
175
|
responseBuffer += `${data.content}\n`;
|
|
141
176
|
sendSSE('chunk', { content: data.content });
|
|
142
177
|
} else if (data.type === 'message_done') {
|
|
143
|
-
|
|
144
|
-
|
|
178
|
+
lastMessageDone = data.content || '';
|
|
179
|
+
if (lastMessageDone) responseBuffer = lastMessageDone;
|
|
180
|
+
return finishResponse(lastMessageDone || responseBuffer, data.usage);
|
|
145
181
|
} else if (data.type === 'done') {
|
|
146
182
|
return finishResponse(responseBuffer);
|
|
147
183
|
}
|
|
@@ -151,10 +187,25 @@ router.post('/', async (req, res) => {
|
|
|
151
187
|
|
|
152
188
|
logWatcher.on('data', onData);
|
|
153
189
|
|
|
154
|
-
|
|
190
|
+
if (logPath) {
|
|
191
|
+
logWatcher.watch(logPath, conversation.engine || 'generic', conversation.id);
|
|
192
|
+
} else {
|
|
193
|
+
panePoller = buildPanePoller(tmux, resolvedSession, resolvedHost, (content) => {
|
|
194
|
+
activitySeen = true;
|
|
195
|
+
responseBuffer = content;
|
|
196
|
+
sendSSE('chunk', { content });
|
|
197
|
+
bumpIdleTimer();
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
maxTimer = setTimeout(() => {
|
|
155
202
|
finishResponse(responseBuffer || '(timeout — check tmux session directly)');
|
|
156
|
-
},
|
|
157
|
-
|
|
203
|
+
}, 180000);
|
|
204
|
+
idleTimer = setTimeout(() => finishResponse(responseBuffer), 30000);
|
|
205
|
+
|
|
206
|
+
req.on('close', () => {
|
|
207
|
+
cleanup();
|
|
208
|
+
});
|
|
158
209
|
|
|
159
210
|
tmux.sendKeys(resolvedSession, message, { enter: true, host: resolvedHost });
|
|
160
211
|
} catch (err) {
|
|
@@ -166,15 +217,20 @@ router.post('/', async (req, res) => {
|
|
|
166
217
|
|
|
167
218
|
router.post('/interrupt', (req, res) => {
|
|
168
219
|
const { tmux, store } = req;
|
|
169
|
-
const { conversationId } = req.body;
|
|
220
|
+
const { conversationId, host: requestedHost } = req.body;
|
|
170
221
|
const conversation = conversationId ? store.getConversation(conversationId) : null;
|
|
171
222
|
|
|
172
223
|
if (!conversation?.tmux_session) {
|
|
173
224
|
return res.status(404).json({ error: 'Conversation has no active tmux session' });
|
|
174
225
|
}
|
|
175
226
|
|
|
176
|
-
|
|
177
|
-
|
|
227
|
+
const host = requestedHost || conversation.host || 'local';
|
|
228
|
+
if (!tmux.hasSession(conversation.tmux_session, host)) {
|
|
229
|
+
return res.status(409).json({ error: `tmux session "${conversation.tmux_session}" is not active on host "${host}"` });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
tmux.interrupt(conversation.tmux_session, host);
|
|
233
|
+
res.json({ success: true, host, tmuxSession: conversation.tmux_session });
|
|
178
234
|
});
|
|
179
235
|
|
|
180
236
|
module.exports = router;
|
|
@@ -9,11 +9,13 @@ const router = Router();
|
|
|
9
9
|
|
|
10
10
|
// Overall status
|
|
11
11
|
router.get('/', (req, res) => {
|
|
12
|
-
const { tmux
|
|
13
|
-
const
|
|
12
|
+
const { tmux } = req;
|
|
13
|
+
const host = req.query.host || 'local';
|
|
14
|
+
const sessions = tmux.listSessions(host === 'local' ? null : host);
|
|
14
15
|
const hosts = tmux.getAllHosts();
|
|
15
16
|
|
|
16
17
|
res.json({
|
|
18
|
+
host,
|
|
17
19
|
tmux: {
|
|
18
20
|
sessionCount: sessions.length,
|
|
19
21
|
sessions
|
|
@@ -28,22 +30,24 @@ router.get('/', (req, res) => {
|
|
|
28
30
|
// Detailed tmux windows
|
|
29
31
|
router.get('/tmux', (req, res) => {
|
|
30
32
|
const { tmux, store } = req;
|
|
31
|
-
const
|
|
33
|
+
const host = req.query.host || 'local';
|
|
34
|
+
const sessions = tmux.listSessions(host === 'local' ? null : host);
|
|
35
|
+
const conversations = store.listConversations({ host, limit: 500 });
|
|
32
36
|
|
|
33
37
|
const enriched = sessions.map((session) => {
|
|
34
|
-
const conv =
|
|
38
|
+
const conv = conversations.find((item) => item.tmux_session === session.sessionName);
|
|
35
39
|
return {
|
|
36
40
|
...session,
|
|
37
41
|
conversationId: conv?.id || null,
|
|
38
42
|
engine: conv?.engine || null,
|
|
39
43
|
model: conv?.model || null,
|
|
40
|
-
host
|
|
44
|
+
host,
|
|
41
45
|
title: conv?.title || session.sessionName,
|
|
42
46
|
workspace: conv?.workspace || ''
|
|
43
47
|
};
|
|
44
48
|
});
|
|
45
49
|
|
|
46
|
-
res.json({ sessions: enriched });
|
|
50
|
+
res.json({ host, sessions: enriched });
|
|
47
51
|
});
|
|
48
52
|
|
|
49
53
|
// Capture pane content
|
|
@@ -4,11 +4,14 @@ const router = Router();
|
|
|
4
4
|
|
|
5
5
|
router.get('/sessions', (req, res) => {
|
|
6
6
|
const { tmux, store } = req;
|
|
7
|
-
const
|
|
8
|
-
|
|
7
|
+
const host = req.query.host || 'local';
|
|
8
|
+
const resolvedHost = host === 'local' ? null : host;
|
|
9
|
+
const sessions = tmux.listSessions(resolvedHost).map((session) => {
|
|
10
|
+
const conversation = store.listConversations({ host, limit: 500 })
|
|
11
|
+
.find((item) => item.tmux_session === session.sessionName);
|
|
9
12
|
return {
|
|
10
13
|
...session,
|
|
11
|
-
host
|
|
14
|
+
host,
|
|
12
15
|
workspace: conversation?.workspace || '',
|
|
13
16
|
conversationId: conversation?.id || null,
|
|
14
17
|
launcherId: conversation?.launcher_id || null,
|
|
@@ -36,11 +39,11 @@ router.post('/sessions', (req, res) => {
|
|
|
36
39
|
return res.status(400).json({ error: 'launcherId is required' });
|
|
37
40
|
}
|
|
38
41
|
|
|
39
|
-
const launcher = engines.getLauncher(launcherId);
|
|
42
|
+
const launcher = engines.getLauncher(launcherId, host || 'local');
|
|
40
43
|
if (!launcher) {
|
|
41
44
|
return res.status(404).json({ error: `Launcher "${launcherId}" not found` });
|
|
42
45
|
}
|
|
43
|
-
if (!launcher.runnable) {
|
|
46
|
+
if (!launcher.runnable || launcher.bucket !== 'runnable') {
|
|
44
47
|
return res.status(409).json({
|
|
45
48
|
error: `Launcher "${launcher.label}" was detected from config but is not verified runnable in the current shell`
|
|
46
49
|
});
|
package/lib/server/server.js
CHANGED
|
@@ -12,6 +12,7 @@ const fs = require('fs');
|
|
|
12
12
|
const http = require('http');
|
|
13
13
|
const os = require('os');
|
|
14
14
|
const packageJson = require('../../package.json');
|
|
15
|
+
const { getConfigDir, getDefaultLogDir } = require('../config/paths');
|
|
15
16
|
|
|
16
17
|
const TmuxManager = require('../services/tmux-manager');
|
|
17
18
|
const EngineDiscovery = require('../services/engine-discovery');
|
|
@@ -30,7 +31,7 @@ const runtimeRouter = require('./routes/runtime');
|
|
|
30
31
|
class NexusCrewServer {
|
|
31
32
|
constructor(config = {}) {
|
|
32
33
|
this.port = config.port || process.env.PORT || 41820;
|
|
33
|
-
this.configDir = config.configDir ||
|
|
34
|
+
this.configDir = config.configDir || getConfigDir();
|
|
34
35
|
this.config = this._loadConfig();
|
|
35
36
|
this.app = express();
|
|
36
37
|
this.server = null;
|
|
@@ -45,11 +46,11 @@ class NexusCrewServer {
|
|
|
45
46
|
}
|
|
46
47
|
return loaded;
|
|
47
48
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
return {
|
|
50
|
+
port: this.port,
|
|
51
|
+
autoDiscovery: true,
|
|
52
|
+
preferredShell: '',
|
|
53
|
+
extraShellSources: []
|
|
53
54
|
};
|
|
54
55
|
}
|
|
55
56
|
|
|
@@ -64,12 +65,14 @@ class NexusCrewServer {
|
|
|
64
65
|
async start() {
|
|
65
66
|
const hosts = this._loadHosts();
|
|
66
67
|
const tmuxManager = new TmuxManager({
|
|
67
|
-
logDir: this.config.logDir ||
|
|
68
|
+
logDir: this.config.logDir || getDefaultLogDir(),
|
|
69
|
+
configDir: this.configDir,
|
|
68
70
|
preferredShell: this.config.preferredShell || process.env.SHELL || '/bin/sh',
|
|
69
71
|
hosts
|
|
70
72
|
});
|
|
71
73
|
|
|
72
74
|
const engineDiscovery = new EngineDiscovery({
|
|
75
|
+
tmux: tmuxManager,
|
|
73
76
|
preferredShell: this.config.preferredShell,
|
|
74
77
|
extraShellSources: this.config.extraShellSources || []
|
|
75
78
|
});
|
|
@@ -80,7 +83,7 @@ class NexusCrewServer {
|
|
|
80
83
|
|
|
81
84
|
const active = sessionStore.listConversations({ status: 'active' });
|
|
82
85
|
for (const conv of active) {
|
|
83
|
-
if (conv.log_path && fs.existsSync(conv.log_path)) {
|
|
86
|
+
if ((conv.host || 'local') === 'local' && conv.log_path && fs.existsSync(conv.log_path)) {
|
|
84
87
|
logWatcher.watch(conv.log_path, conv.engine || 'generic', conv.id);
|
|
85
88
|
}
|
|
86
89
|
}
|
|
@@ -107,6 +110,7 @@ class NexusCrewServer {
|
|
|
107
110
|
req.store = sessionStore;
|
|
108
111
|
req.logWatcher = logWatcher;
|
|
109
112
|
req.config = this.config;
|
|
113
|
+
req.configDir = this.configDir;
|
|
110
114
|
next();
|
|
111
115
|
});
|
|
112
116
|
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
* No user-specific config is assumed and no model catalog is hardcoded.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
const { execFileSync, execSync } = require('child_process');
|
|
13
12
|
const path = require('path');
|
|
14
13
|
const fs = require('fs');
|
|
15
14
|
const os = require('os');
|
|
@@ -20,14 +19,17 @@ class EngineDiscovery {
|
|
|
20
19
|
constructor(config = {}) {
|
|
21
20
|
this.preferredShell = config.preferredShell || '';
|
|
22
21
|
this.extraShellSources = Array.isArray(config.extraShellSources) ? config.extraShellSources : [];
|
|
23
|
-
this.
|
|
22
|
+
this.tmux = config.tmux || null;
|
|
23
|
+
this._cache = new Map();
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
discover() {
|
|
27
|
-
|
|
26
|
+
discover(host = null) {
|
|
27
|
+
const hostKey = this._hostKey(host);
|
|
28
|
+
if (this._cache.has(hostKey)) return this._cache.get(hostKey);
|
|
28
29
|
|
|
29
|
-
const
|
|
30
|
-
const
|
|
30
|
+
const runtime = this.getRuntimeInfo(host);
|
|
31
|
+
const shellPath = runtime.shell || this.getShellPath();
|
|
32
|
+
const shellName = path.basename(shellPath || '/bin/sh');
|
|
31
33
|
const launchers = [];
|
|
32
34
|
const seen = new Set();
|
|
33
35
|
|
|
@@ -45,50 +47,63 @@ class EngineDiscovery {
|
|
|
45
47
|
source: launcher.source,
|
|
46
48
|
shell: launcher.shell || shellName,
|
|
47
49
|
available: launcher.available !== false,
|
|
48
|
-
runnable: launcher.runnable !== false
|
|
50
|
+
runnable: launcher.runnable !== false,
|
|
51
|
+
bucket: launcher.bucket || this._classifyBucket(launcher)
|
|
49
52
|
});
|
|
50
53
|
};
|
|
51
54
|
|
|
52
|
-
this._discoverPathBinaries().forEach(addLauncher);
|
|
53
|
-
this._discoverLiveShell(shellPath).forEach(addLauncher);
|
|
54
|
-
this._discoverShellFiles(shellName).forEach(addLauncher);
|
|
55
|
+
this._discoverPathBinaries(host).forEach(addLauncher);
|
|
56
|
+
this._discoverLiveShell(shellPath, host).forEach(addLauncher);
|
|
57
|
+
this._discoverShellFiles(shellName, host).forEach(addLauncher);
|
|
55
58
|
|
|
56
59
|
launchers.sort((a, b) => a.label.localeCompare(b.label));
|
|
57
|
-
|
|
60
|
+
const discovery = {
|
|
58
61
|
shell: shellPath,
|
|
59
|
-
homeDir:
|
|
60
|
-
|
|
62
|
+
homeDir: runtime.homeDir,
|
|
63
|
+
platform: runtime.platform,
|
|
64
|
+
host: hostKey,
|
|
65
|
+
launchers,
|
|
66
|
+
buckets: this._bucketize(launchers)
|
|
61
67
|
};
|
|
62
|
-
|
|
68
|
+
this._cache.set(hostKey, discovery);
|
|
69
|
+
return discovery;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
getLaunchers(host = null) {
|
|
73
|
+
return this.discover(host).launchers;
|
|
63
74
|
}
|
|
64
75
|
|
|
65
|
-
|
|
66
|
-
return this.discover().
|
|
76
|
+
getBuckets(host = null) {
|
|
77
|
+
return this.discover(host).buckets;
|
|
67
78
|
}
|
|
68
79
|
|
|
69
|
-
getLauncher(id) {
|
|
70
|
-
return this.getLaunchers().find((launcher) => launcher.id === id) || null;
|
|
80
|
+
getLauncher(id, host = null) {
|
|
81
|
+
return this.getLaunchers(host).find((launcher) => launcher.id === id) || null;
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
getShellPath() {
|
|
74
85
|
return this.preferredShell || process.env.SHELL || '/bin/sh';
|
|
75
86
|
}
|
|
76
87
|
|
|
77
|
-
getRuntimeInfo() {
|
|
88
|
+
getRuntimeInfo(host = null) {
|
|
89
|
+
if (this.tmux) {
|
|
90
|
+
return this.tmux.getRuntimeInfo(host);
|
|
91
|
+
}
|
|
78
92
|
return {
|
|
79
93
|
homeDir: os.homedir(),
|
|
80
94
|
shell: this.getShellPath(),
|
|
81
|
-
platform: process.platform
|
|
95
|
+
platform: process.platform,
|
|
96
|
+
host: this._hostKey(host)
|
|
82
97
|
};
|
|
83
98
|
}
|
|
84
99
|
|
|
85
100
|
clearCache() {
|
|
86
|
-
this._cache
|
|
101
|
+
this._cache.clear();
|
|
87
102
|
}
|
|
88
103
|
|
|
89
|
-
_discoverPathBinaries() {
|
|
104
|
+
_discoverPathBinaries(host = null) {
|
|
90
105
|
return KNOWN_FAMILIES.flatMap((family) => {
|
|
91
|
-
const binPath = this._resolveCommand(family);
|
|
106
|
+
const binPath = this._resolveCommand(family, host);
|
|
92
107
|
if (!binPath) return [];
|
|
93
108
|
return [{
|
|
94
109
|
id: `binary:${family}`,
|
|
@@ -103,9 +118,9 @@ class EngineDiscovery {
|
|
|
103
118
|
});
|
|
104
119
|
}
|
|
105
120
|
|
|
106
|
-
_discoverLiveShell(shellPath) {
|
|
121
|
+
_discoverLiveShell(shellPath, host = null) {
|
|
107
122
|
const shellName = path.basename(shellPath);
|
|
108
|
-
if (!
|
|
123
|
+
if (!this._fileExists(shellPath, host)) return [];
|
|
109
124
|
|
|
110
125
|
try {
|
|
111
126
|
const script = [
|
|
@@ -114,18 +129,15 @@ class EngineDiscovery {
|
|
|
114
129
|
'typeset -f 2>/dev/null || declare -f 2>/dev/null || true'
|
|
115
130
|
].join('; ');
|
|
116
131
|
|
|
117
|
-
const output =
|
|
118
|
-
encoding: 'utf8',
|
|
119
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
120
|
-
});
|
|
132
|
+
const output = this._runInteractiveShell(shellPath, shellName, script, host);
|
|
121
133
|
return this._parseShellDump(output, shellName, 'live-shell');
|
|
122
134
|
} catch {
|
|
123
135
|
return [];
|
|
124
136
|
}
|
|
125
137
|
}
|
|
126
138
|
|
|
127
|
-
_discoverShellFiles(shellName) {
|
|
128
|
-
const homeDir = os.homedir();
|
|
139
|
+
_discoverShellFiles(shellName, host = null) {
|
|
140
|
+
const homeDir = this.getRuntimeInfo(host).homeDir || os.homedir();
|
|
129
141
|
const shellFiles = new Set();
|
|
130
142
|
|
|
131
143
|
for (const candidate of this._defaultShellFiles(shellName)) {
|
|
@@ -137,9 +149,9 @@ class EngineDiscovery {
|
|
|
137
149
|
|
|
138
150
|
const discovered = [];
|
|
139
151
|
for (const filePath of shellFiles) {
|
|
140
|
-
if (!
|
|
152
|
+
if (!this._fileExists(filePath, host)) continue;
|
|
141
153
|
try {
|
|
142
|
-
const content =
|
|
154
|
+
const content = this._readFile(filePath, host);
|
|
143
155
|
discovered.push(...this._parseShellFile(content, shellName, filePath));
|
|
144
156
|
} catch {}
|
|
145
157
|
}
|
|
@@ -218,8 +230,12 @@ class EngineDiscovery {
|
|
|
218
230
|
return KNOWN_FAMILIES.find((family) => haystack.includes(family)) || 'custom';
|
|
219
231
|
}
|
|
220
232
|
|
|
221
|
-
_resolveCommand(command) {
|
|
233
|
+
_resolveCommand(command, host = null) {
|
|
234
|
+
if (this.tmux) {
|
|
235
|
+
return this.tmux.resolveCommand(command, host);
|
|
236
|
+
}
|
|
222
237
|
try {
|
|
238
|
+
const { execSync } = require('child_process');
|
|
223
239
|
const resolved = execSync(`command -v ${command} 2>/dev/null`, {
|
|
224
240
|
encoding: 'utf8',
|
|
225
241
|
stdio: ['ignore', 'pipe', 'ignore']
|
|
@@ -247,6 +263,55 @@ class EngineDiscovery {
|
|
|
247
263
|
}
|
|
248
264
|
return ['-lc', script];
|
|
249
265
|
}
|
|
266
|
+
|
|
267
|
+
_runInteractiveShell(shellPath, shellName, script, host = null) {
|
|
268
|
+
if (this.tmux) {
|
|
269
|
+
return this.tmux.runInteractiveShell(shellPath, script, host);
|
|
270
|
+
}
|
|
271
|
+
const { execFileSync } = require('child_process');
|
|
272
|
+
return execFileSync(shellPath, this._shellArgs(shellName, script), {
|
|
273
|
+
encoding: 'utf8',
|
|
274
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
_fileExists(filePath, host = null) {
|
|
279
|
+
if (this.tmux) {
|
|
280
|
+
return this.tmux.fileExists(filePath, host);
|
|
281
|
+
}
|
|
282
|
+
return fs.existsSync(filePath);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
_readFile(filePath, host = null) {
|
|
286
|
+
if (this.tmux) {
|
|
287
|
+
return this.tmux.readFile(filePath, host);
|
|
288
|
+
}
|
|
289
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
_hostKey(host) {
|
|
293
|
+
if (!host || host === 'local') return 'local';
|
|
294
|
+
if (typeof host === 'string') return host;
|
|
295
|
+
return host.name || 'remote';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
_classifyBucket(launcher) {
|
|
299
|
+
const name = String(launcher.label || launcher.name || launcher.command || '').trim();
|
|
300
|
+
if (name.startsWith('_')) return 'internal';
|
|
301
|
+
if (launcher.kind === 'function' && /(^_|_bin$|_run$|_cmd$|_provider_|_chutes$)/.test(name)) {
|
|
302
|
+
return 'internal';
|
|
303
|
+
}
|
|
304
|
+
if (launcher.runnable === false) return 'detectedOnly';
|
|
305
|
+
return 'runnable';
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
_bucketize(launchers) {
|
|
309
|
+
return {
|
|
310
|
+
runnable: launchers.filter((launcher) => launcher.bucket === 'runnable'),
|
|
311
|
+
detectedOnly: launchers.filter((launcher) => launcher.bucket === 'detectedOnly'),
|
|
312
|
+
internal: launchers.filter((launcher) => launcher.bucket === 'internal')
|
|
313
|
+
};
|
|
314
|
+
}
|
|
250
315
|
}
|
|
251
316
|
|
|
252
317
|
module.exports = EngineDiscovery;
|
|
@@ -13,6 +13,7 @@ const os = require('os');
|
|
|
13
13
|
class TmuxManager {
|
|
14
14
|
constructor(config = {}) {
|
|
15
15
|
this.logDir = config.logDir || path.join(os.homedir(), '.nexuscrew', 'logs');
|
|
16
|
+
this.configDir = config.configDir || path.dirname(this.logDir);
|
|
16
17
|
this.hosts = config.hosts || [{ name: 'local', type: 'local' }];
|
|
17
18
|
this.shell = config.preferredShell || process.env.SHELL || '/bin/sh';
|
|
18
19
|
|
|
@@ -54,8 +55,9 @@ class TmuxManager {
|
|
|
54
55
|
|
|
55
56
|
createSession({ sessionName, cwd, host = null, launcherCommand = '', shell = null }) {
|
|
56
57
|
const effectiveHost = this._resolveHost(host);
|
|
57
|
-
const
|
|
58
|
-
const
|
|
58
|
+
const runtime = this.getRuntimeInfo(effectiveHost);
|
|
59
|
+
const effectiveShell = shell || runtime.shell || this.shell;
|
|
60
|
+
const workingDir = cwd || runtime.homeDir || os.homedir();
|
|
59
61
|
|
|
60
62
|
if (this.hasSession(sessionName, effectiveHost)) {
|
|
61
63
|
throw new Error(`tmux session "${sessionName}" already exists`);
|
|
@@ -78,6 +80,9 @@ class TmuxManager {
|
|
|
78
80
|
|
|
79
81
|
ensureSessionLogging(sessionName, host = null) {
|
|
80
82
|
const effectiveHost = this._resolveHost(host);
|
|
83
|
+
if (effectiveHost && effectiveHost.type !== 'local') {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
81
86
|
const logPath = path.join(this.logDir, `${sessionName}.log`);
|
|
82
87
|
const pipeCmd = this._cmd(
|
|
83
88
|
`tmux pipe-pane -o -t ${this._quote(sessionName)} ${this._quote(`cat >> ${logPath}`)}`,
|
|
@@ -163,6 +168,77 @@ class TmuxManager {
|
|
|
163
168
|
return host;
|
|
164
169
|
}
|
|
165
170
|
|
|
171
|
+
getRuntimeInfo(host = null) {
|
|
172
|
+
const effectiveHost = this._resolveHost(host);
|
|
173
|
+
if (!effectiveHost || effectiveHost.type === 'local') {
|
|
174
|
+
return {
|
|
175
|
+
homeDir: os.homedir(),
|
|
176
|
+
shell: this.shell,
|
|
177
|
+
platform: process.platform,
|
|
178
|
+
host: 'local'
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const output = this.execCommand(
|
|
184
|
+
"printf '__NEXUSCREW_HOME__%s\\n' \"$HOME\"; printf '__NEXUSCREW_SHELL__%s\\n' \"${SHELL:-/bin/sh}\"; uname -s",
|
|
185
|
+
effectiveHost,
|
|
186
|
+
5000
|
|
187
|
+
);
|
|
188
|
+
const lines = output.trim().split('\n');
|
|
189
|
+
const homeDir = lines.find((line) => line.startsWith('__NEXUSCREW_HOME__'))?.replace('__NEXUSCREW_HOME__', '') || '';
|
|
190
|
+
const shell = lines.find((line) => line.startsWith('__NEXUSCREW_SHELL__'))?.replace('__NEXUSCREW_SHELL__', '') || '/bin/sh';
|
|
191
|
+
const uname = lines[lines.length - 1] || '';
|
|
192
|
+
return {
|
|
193
|
+
homeDir: homeDir || '',
|
|
194
|
+
shell: shell || '/bin/sh',
|
|
195
|
+
platform: this._mapPlatform(uname),
|
|
196
|
+
host: effectiveHost.name || 'remote'
|
|
197
|
+
};
|
|
198
|
+
} catch {
|
|
199
|
+
return {
|
|
200
|
+
homeDir: '',
|
|
201
|
+
shell: '/bin/sh',
|
|
202
|
+
platform: 'unknown',
|
|
203
|
+
host: effectiveHost.name || 'remote'
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
execCommand(cmd, host = null, timeout = 10000) {
|
|
209
|
+
return this._exec(this._cmd(cmd, host), timeout);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
resolveCommand(command, host = null) {
|
|
213
|
+
try {
|
|
214
|
+
const output = this.execCommand(`command -v ${command} 2>/dev/null`, host, 5000).trim();
|
|
215
|
+
return output || null;
|
|
216
|
+
} catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
fileExists(filePath, host = null) {
|
|
222
|
+
try {
|
|
223
|
+
this.execCommand(`test -f ${this._quote(filePath)}`, host, 5000);
|
|
224
|
+
return true;
|
|
225
|
+
} catch {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
readFile(filePath, host = null) {
|
|
231
|
+
return this.execCommand(`cat ${this._quote(filePath)}`, host, 5000);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
runInteractiveShell(shellPath, script, host = null) {
|
|
235
|
+
const shellName = path.basename(shellPath || '/bin/sh');
|
|
236
|
+
const shellArgs = this._shellArgs(shellName, script)
|
|
237
|
+
.map((arg) => this._quote(arg))
|
|
238
|
+
.join(' ');
|
|
239
|
+
return this.execCommand(`${this._quote(shellPath)} ${shellArgs}`, host, 10000);
|
|
240
|
+
}
|
|
241
|
+
|
|
166
242
|
_interactiveShellCommand(shellPath) {
|
|
167
243
|
const shellName = path.basename(shellPath || '/bin/sh');
|
|
168
244
|
if (shellName === 'bash' || shellName === 'zsh') {
|
|
@@ -193,6 +269,13 @@ class TmuxManager {
|
|
|
193
269
|
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
194
270
|
}
|
|
195
271
|
|
|
272
|
+
_mapPlatform(uname) {
|
|
273
|
+
const value = String(uname || '').trim().toLowerCase();
|
|
274
|
+
if (value === 'darwin') return 'darwin';
|
|
275
|
+
if (value === 'linux') return 'linux';
|
|
276
|
+
return value || 'unknown';
|
|
277
|
+
}
|
|
278
|
+
|
|
196
279
|
_exec(cmd, timeout = 10000) {
|
|
197
280
|
return execSync(cmd, {
|
|
198
281
|
encoding: 'utf8',
|