@mmmbuto/nexuscrew 0.2.2 → 0.2.4

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.
@@ -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.findByTmuxSession(resolvedSession);
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 finishResponse = (content, usage) => {
99
- if (finished) return;
100
- finished = true;
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
- const finalContent = content || responseBuffer || tmux.capturePane(resolvedSession, resolvedHost) || '(no output captured)';
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 ? 15000 : 20000);
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
- responseBuffer += data.content || '';
144
- return finishResponse(data.content || responseBuffer, data.usage);
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
- const maxTimer = setTimeout(() => {
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
- }, 120000);
157
- let idleTimer = setTimeout(() => finishResponse(responseBuffer), 20000);
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
- tmux.interrupt(conversation.tmux_session, conversation.host || 'local');
177
- res.json({ success: true });
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, store } = req;
13
- const sessions = tmux.listSessions();
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 sessions = tmux.listSessions();
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 = store.findByTmuxSession(session.sessionName);
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: conv?.host || 'local',
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 sessions = tmux.listSessions().map((session) => {
8
- const conversation = store.findByTmuxSession(session.sessionName);
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: conversation?.host || 'local',
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
  });
@@ -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 || path.join(os.homedir(), '.nexuscrew');
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
- return {
49
- port: this.port,
50
- autoDiscovery: true,
51
- preferredShell: '',
52
- extraShellSources: []
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 || path.join(this.configDir, 'logs'),
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._cache = null;
22
+ this.tmux = config.tmux || null;
23
+ this._cache = new Map();
24
24
  }
25
25
 
26
- discover() {
27
- if (this._cache) return this._cache;
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 shellPath = this.getShellPath();
30
- const shellName = path.basename(shellPath);
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
- this._cache = {
60
+ const discovery = {
58
61
  shell: shellPath,
59
- homeDir: os.homedir(),
60
- launchers
62
+ homeDir: runtime.homeDir,
63
+ platform: runtime.platform,
64
+ host: hostKey,
65
+ launchers,
66
+ buckets: this._bucketize(launchers)
61
67
  };
62
- return this._cache;
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
- getLaunchers() {
66
- return this.discover().launchers;
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 = null;
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 (!fs.existsSync(shellPath)) return [];
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 = execFileSync(shellPath, this._shellArgs(shellName, script), {
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 (!fs.existsSync(filePath)) continue;
152
+ if (!this._fileExists(filePath, host)) continue;
141
153
  try {
142
- const content = fs.readFileSync(filePath, 'utf8');
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 effectiveShell = shell || this.shell;
58
- const workingDir = cwd || os.homedir();
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',