@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,49 +1,94 @@
1
1
  /**
2
- * SessionStore — SQLite persistence for nexuscrew (sql.js version)
2
+ * SessionStore — SQLite persistence for nexuscrew
3
3
  *
4
4
  * Schema:
5
- * - conversations: id, title, engine, model, tmux_window, log_path, workspace, host, status, pinned, created_at, updated_at
5
+ * - conversations: id, title, engine, model, tmux_session, log_path, workspace, host, status, launcher_id, created_at, updated_at
6
6
  * - messages: id, conversation_id, role, content, engine, model, tokens_in, tokens_out, created_at
7
7
  */
8
8
 
9
+ const Database = require('better-sqlite3');
9
10
  const path = require('path');
10
11
  const fs = require('fs');
11
12
  const { v4: uuidv4 } = require('uuid');
12
- const { prepare, saveDb } = require('../server/db/adapter');
13
13
 
14
14
  class SessionStore {
15
- constructor() {
16
- // DB is managed by adapter - no direct connection here
17
- this.initialized = false;
15
+ constructor(dbPath) {
16
+ const dir = path.dirname(dbPath);
17
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
18
+
19
+ this.db = new Database(dbPath);
20
+ this.db.pragma('journal_mode = WAL');
21
+ this.db.pragma('foreign_keys = ON');
22
+ this._migrate();
23
+ }
24
+
25
+ _migrate() {
26
+ this.db.exec(`
27
+ CREATE TABLE IF NOT EXISTS conversations (
28
+ id TEXT PRIMARY KEY,
29
+ title TEXT DEFAULT 'New Chat',
30
+ engine TEXT,
31
+ model TEXT,
32
+ tmux_window TEXT,
33
+ tmux_session TEXT,
34
+ log_path TEXT,
35
+ workspace TEXT DEFAULT '',
36
+ host TEXT DEFAULT 'local',
37
+ launcher_id TEXT,
38
+ status TEXT DEFAULT 'active',
39
+ created_at INTEGER DEFAULT (unixepoch()),
40
+ updated_at INTEGER DEFAULT (unixepoch())
41
+ );
42
+
43
+ CREATE TABLE IF NOT EXISTS messages (
44
+ id TEXT PRIMARY KEY,
45
+ conversation_id TEXT NOT NULL,
46
+ role TEXT NOT NULL,
47
+ content TEXT DEFAULT '',
48
+ engine TEXT,
49
+ model TEXT,
50
+ tokens_in INTEGER DEFAULT 0,
51
+ tokens_out INTEGER DEFAULT 0,
52
+ created_at INTEGER DEFAULT (unixepoch()),
53
+ FOREIGN KEY (conversation_id) REFERENCES conversations(id)
54
+ );
55
+
56
+ CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, created_at);
57
+ CREATE INDEX IF NOT EXISTS idx_conversations_status ON conversations(status, updated_at);
58
+ `);
59
+
60
+ this._ensureColumn('conversations', 'tmux_session', 'TEXT');
61
+ this._ensureColumn('conversations', 'launcher_id', 'TEXT');
62
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_conversations_tmux_session ON conversations(tmux_session)');
18
63
  }
19
64
 
20
- async init() {
21
- // Adapter is already initialized by server.js
22
- this.initialized = true;
65
+ _ensureColumn(tableName, columnName, definition) {
66
+ const columns = this.db.prepare(`PRAGMA table_info(${tableName})`).all();
67
+ if (!columns.find((column) => column.name === columnName)) {
68
+ this.db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`);
69
+ }
23
70
  }
24
71
 
25
72
  // --- Conversations ---
26
73
 
27
- createConversation({ engine, model, workspace, host, tmuxWindow, logPath } = {}) {
74
+ createConversation({ engine, model, workspace, host, tmuxWindow, tmuxSession, logPath, launcherId } = {}) {
28
75
  const id = uuidv4();
29
- const stmt = prepare(`
30
- INSERT INTO conversations (id, engine, model, workspace, host, tmux_window, log_path)
31
- VALUES (?, ?, ?, ?, ?, ?, ?)
32
- `);
33
- stmt.run(id, engine || 'claude', model || null, workspace || '', host || 'local', tmuxWindow || null, logPath || null);
76
+ this.db.prepare(`
77
+ INSERT INTO conversations (id, engine, model, workspace, host, tmux_window, tmux_session, log_path, launcher_id)
78
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
79
+ `).run(id, engine || null, model || null, workspace || '', host || 'local', tmuxWindow || null, tmuxSession || null, logPath || null, launcherId || null);
34
80
  return this.getConversation(id);
35
81
  }
36
82
 
37
83
  getConversation(id) {
38
- const stmt = prepare('SELECT * FROM conversations WHERE id = ?');
39
- return stmt.get(id);
84
+ return this.db.prepare('SELECT * FROM conversations WHERE id = ?').get(id);
40
85
  }
41
86
 
42
87
  updateConversation(id, updates) {
43
88
  const fields = [];
44
89
  const values = [];
45
90
  for (const [key, value] of Object.entries(updates)) {
46
- if (['title', 'engine', 'model', 'tmux_window', 'log_path', 'workspace', 'host', 'status', 'pinned'].includes(key)) {
91
+ if (['title', 'engine', 'model', 'tmux_window', 'tmux_session', 'log_path', 'workspace', 'host', 'status', 'launcher_id'].includes(key)) {
47
92
  fields.push(`${key} = ?`);
48
93
  values.push(value);
49
94
  }
@@ -51,8 +96,7 @@ class SessionStore {
51
96
  if (fields.length === 0) return;
52
97
  fields.push('updated_at = unixepoch()');
53
98
  values.push(id);
54
- const stmt = prepare(`UPDATE conversations SET ${fields.join(', ')} WHERE id = ?`);
55
- stmt.run(...values);
99
+ this.db.prepare(`UPDATE conversations SET ${fields.join(', ')} WHERE id = ?`).run(...values);
56
100
  }
57
101
 
58
102
  listConversations({ workspace, host, status, limit = 50 } = {}) {
@@ -63,21 +107,21 @@ class SessionStore {
63
107
  if (status) { sql += ' AND status = ?'; params.push(status); }
64
108
  sql += ' ORDER BY updated_at DESC LIMIT ?';
65
109
  params.push(limit);
66
- const stmt = prepare(sql);
67
- return stmt.all(...params);
110
+ return this.db.prepare(sql).all(...params);
68
111
  }
69
112
 
70
113
  deleteConversation(id) {
71
- const stmt1 = prepare('DELETE FROM messages WHERE conversation_id = ?');
72
- stmt1.run(id);
73
- const stmt2 = prepare('DELETE FROM conversations WHERE id = ?');
74
- stmt2.run(id);
114
+ this.db.prepare('DELETE FROM messages WHERE conversation_id = ?').run(id);
115
+ this.db.prepare('DELETE FROM conversations WHERE id = ?').run(id);
75
116
  }
76
117
 
77
118
  /** Find conversation by tmux window name */
78
119
  findByWindow(windowName) {
79
- const stmt = prepare('SELECT * FROM conversations WHERE tmux_window = ?');
80
- return stmt.get(windowName);
120
+ return this.db.prepare('SELECT * FROM conversations WHERE tmux_window = ?').get(windowName);
121
+ }
122
+
123
+ findByTmuxSession(sessionName) {
124
+ return this.db.prepare('SELECT * FROM conversations WHERE tmux_session = ?').get(sessionName);
81
125
  }
82
126
 
83
127
  /** Get conversations grouped by date */
@@ -92,37 +136,17 @@ class SessionStore {
92
136
  return grouped;
93
137
  }
94
138
 
95
- /** Toggle conversation pinned status */
96
- pinConversation(id) {
97
- const stmt = prepare('SELECT pinned FROM conversations WHERE id = ?');
98
- const conv = stmt.get(id);
99
- if (!conv) return;
100
-
101
- const newPinned = conv.pinned ? 0 : 1;
102
- const updateStmt = prepare('UPDATE conversations SET pinned = ? WHERE id = ?');
103
- updateStmt.run(newPinned, id);
104
- return newPinned === 1;
105
- }
106
-
107
- /** Search conversations by title */
108
- searchConversations(query) {
109
- const stmt = prepare('SELECT * FROM conversations WHERE title LIKE ? ORDER BY updated_at DESC LIMIT 50');
110
- return stmt.all(`%${query}%`);
111
- }
112
-
113
139
  // --- Messages ---
114
140
 
115
141
  addMessage({ conversationId, role, content, engine, model, tokensIn, tokensOut } = {}) {
116
142
  const id = uuidv4();
117
- const stmt = prepare(`
143
+ this.db.prepare(`
118
144
  INSERT INTO messages (id, conversation_id, role, content, engine, model, tokens_in, tokens_out)
119
145
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
120
- `);
121
- stmt.run(id, conversationId, role, content || '', engine || null, model || null, tokensIn || 0, tokensOut || 0);
146
+ `).run(id, conversationId, role, content || '', engine || null, model || null, tokensIn || 0, tokensOut || 0);
122
147
 
123
148
  // Update conversation timestamp
124
- const updateStmt = prepare('UPDATE conversations SET updated_at = unixepoch() WHERE id = ?');
125
- updateStmt.run(conversationId);
149
+ this.db.prepare('UPDATE conversations SET updated_at = unixepoch() WHERE id = ?').run(conversationId);
126
150
  return id;
127
151
  }
128
152
 
@@ -135,13 +159,11 @@ class SessionStore {
135
159
  }
136
160
  sql += ' ORDER BY created_at ASC LIMIT ?';
137
161
  params.push(limit);
138
- const stmt = prepare(sql);
139
- return stmt.all(...params);
162
+ return this.db.prepare(sql).all(...params);
140
163
  }
141
164
 
142
165
  getMessageCount(conversationId) {
143
- const stmt = prepare('SELECT COUNT(*) as count FROM messages WHERE conversation_id = ?');
144
- return stmt.get(conversationId).count;
166
+ return this.db.prepare('SELECT COUNT(*) as count FROM messages WHERE conversation_id = ?').get(conversationId).count;
145
167
  }
146
168
 
147
169
  // --- Recovery ---
@@ -151,11 +173,15 @@ class SessionStore {
151
173
  * Mark conversations as 'dead' if their tmux window no longer exists
152
174
  */
153
175
  syncWithTmux(tmuxManager) {
154
- const stmt = prepare("SELECT * FROM conversations WHERE status = 'active'");
155
- const active = stmt.all();
176
+ const active = this.db.prepare("SELECT * FROM conversations WHERE status = 'active'").all();
156
177
  for (const conv of active) {
157
- if (conv.tmux_window) {
158
- const exists = tmuxManager.hasWindow(conv.tmux_window, conv.host !== 'local' ? conv.host : null);
178
+ if (conv.tmux_session) {
179
+ const exists = tmuxManager.hasSession(conv.tmux_session, conv.host !== 'local' ? conv.host : null);
180
+ if (!exists) {
181
+ this.updateConversation(conv.id, { status: 'dead' });
182
+ }
183
+ } else if (conv.tmux_window) {
184
+ const exists = tmuxManager.hasSession(conv.tmux_window, conv.host !== 'local' ? conv.host : null);
159
185
  if (!exists) {
160
186
  this.updateConversation(conv.id, { status: 'dead' });
161
187
  }
@@ -165,7 +191,7 @@ class SessionStore {
165
191
 
166
192
  /** Close DB connection */
167
193
  close() {
168
- saveDb();
194
+ this.db.close();
169
195
  }
170
196
  }
171
197
 
@@ -1,124 +1,125 @@
1
1
  /**
2
2
  * TmuxManager — Core tmux operations for nexuscrew
3
3
  *
4
- * Manages tmux sessions locally and remotely via SSH.
5
- * Every AI session = one tmux window inside the master session.
4
+ * The primary unit is a standalone tmux session. nexuscrew never creates
5
+ * implicit "master" sessions and never assumes ownership of all tmux state.
6
6
  */
7
7
 
8
- const { execSync, exec } = require('child_process');
8
+ const { execSync } = require('child_process');
9
9
  const path = require('path');
10
10
  const fs = require('fs');
11
-
12
- const TMUX_SESSION = 'nexuscrew';
11
+ const os = require('os');
13
12
 
14
13
  class TmuxManager {
15
14
  constructor(config = {}) {
16
- this.sessionName = config.tmuxSession || TMUX_SESSION;
17
- this.logDir = config.logDir || path.join(process.env.HOME, '.nexuscrew', 'logs');
15
+ this.logDir = config.logDir || path.join(os.homedir(), '.nexuscrew', 'logs');
18
16
  this.hosts = config.hosts || [{ name: 'local', type: 'local' }];
17
+ this.shell = config.preferredShell || process.env.SHELL || '/bin/sh';
19
18
 
20
- // Ensure log dir
21
19
  if (!fs.existsSync(this.logDir)) {
22
20
  fs.mkdirSync(this.logDir, { recursive: true });
23
21
  }
24
22
  }
25
23
 
26
- // --- Session management ---
27
-
28
- /** Ensure master tmux session exists */
29
- ensureSession(host = null) {
30
- const cmd = this._cmd('tmux new-session -d -s ${SESSION} -x 200 -y 50 2>/dev/null || true', host);
31
- this._exec(cmd);
24
+ listSessions(host = null) {
25
+ try {
26
+ const cmd = this._cmd(
27
+ "tmux list-sessions -F '#{session_name}\t#{session_attached}\t#{session_windows}\t#{session_created}' 2>/dev/null",
28
+ host
29
+ );
30
+ const output = this._exec(cmd);
31
+ return output.trim().split('\n').filter(Boolean).map((line) => {
32
+ const [sessionName, attached, windows, created] = line.split('\t');
33
+ return {
34
+ sessionName,
35
+ attached: attached === '1',
36
+ windows: Number(windows || 0),
37
+ createdAt: created ? Number(created) : null
38
+ };
39
+ });
40
+ } catch {
41
+ return [];
42
+ }
32
43
  }
33
44
 
34
- /** Check if master session exists */
35
- hasSession(host = null) {
45
+ hasSession(sessionName, host = null) {
36
46
  try {
37
- this._exec(this._cmd('tmux has-session -t ${SESSION} 2>/dev/null', host));
47
+ const cmd = this._cmd(`tmux has-session -t ${this._quote(sessionName)} 2>/dev/null`, host);
48
+ this._exec(cmd);
38
49
  return true;
39
50
  } catch {
40
51
  return false;
41
52
  }
42
53
  }
43
54
 
44
- // --- Window management ---
45
-
46
- /**
47
- * Create a new tmux window running a CLI command
48
- * @param {Object} opts
49
- * @param {string} opts.windowName - Unique window name (e.g. "claude-abc123")
50
- * @param {string} opts.command - Full command to run
51
- * @param {string} opts.cwd - Working directory
52
- * @param {string} opts.host - Host name or null for local
53
- * @returns {{ windowName, logPath }}
54
- */
55
- createWindow({ windowName, command, cwd, host = null }) {
56
- this.ensureSession(host);
57
-
58
- const logPath = path.join(this.logDir, `${windowName}.log`);
59
- const logDir = path.dirname(logPath);
55
+ createSession({ sessionName, cwd, host = null, launcherCommand = '', shell = null }) {
56
+ const effectiveHost = this._resolveHost(host);
57
+ const effectiveShell = shell || this.shell;
58
+ const workingDir = cwd || os.homedir();
60
59
 
61
- // For remote hosts, logs go to a local path
62
- const fullCommand = cwd
63
- ? `cd ${cwd} && ${command} 2>&1 | tee ${logPath}`
64
- : `${command} 2>&1 | tee ${logPath}`;
60
+ if (this.hasSession(sessionName, effectiveHost)) {
61
+ throw new Error(`tmux session "${sessionName}" already exists`);
62
+ }
65
63
 
66
- const cmd = this._cmd(
67
- `tmux new-window -t \${SESSION} -n ${windowName} -c '${cwd || process.env.HOME}' "${fullCommand.replace(/"/g, '\\"')}"`,
68
- host
64
+ const shellCmd = this._interactiveShellCommand(effectiveShell);
65
+ const createCmd = this._cmd(
66
+ `tmux new-session -d -s ${this._quote(sessionName)} -c ${this._quote(workingDir)} ${this._quote(shellCmd)}`,
67
+ effectiveHost
69
68
  );
69
+ this._exec(createCmd);
70
70
 
71
- this._exec(cmd);
71
+ const logPath = this.ensureSessionLogging(sessionName, effectiveHost);
72
+ if (launcherCommand && launcherCommand.trim()) {
73
+ this.sendKeys(sessionName, launcherCommand.trim(), { enter: true, host: effectiveHost });
74
+ }
72
75
 
73
- return { windowName, logPath };
76
+ return { sessionName, logPath };
74
77
  }
75
78
 
76
- /**
77
- * Send text to a tmux window (like typing)
78
- * @param {string} windowName
79
- * @param {string} text - Text to send
80
- * @param {Object} opts
81
- * @param {boolean} opts.enter - Press Enter after text
82
- * @param {string} opts.host - Host name or null
83
- */
84
- sendKeys(windowName, text, opts = {}) {
79
+ ensureSessionLogging(sessionName, host = null) {
80
+ const effectiveHost = this._resolveHost(host);
81
+ const logPath = path.join(this.logDir, `${sessionName}.log`);
82
+ const pipeCmd = this._cmd(
83
+ `tmux pipe-pane -o -t ${this._quote(sessionName)} ${this._quote(`cat >> ${logPath}`)}`,
84
+ effectiveHost
85
+ );
86
+ this._exec(pipeCmd);
87
+ return logPath;
88
+ }
89
+
90
+ sendKeys(sessionName, text, opts = {}) {
91
+ const effectiveHost = this._resolveHost(opts.host);
85
92
  const enterSuffix = opts.enter ? ' Enter' : '';
86
93
  const escapedText = text.replace(/'/g, "'\\''");
87
94
  const cmd = this._cmd(
88
- `tmux send-keys -t \${SESSION}:${windowName} '${escapedText}'${enterSuffix}`,
89
- opts.host
95
+ `tmux send-keys -t ${this._quote(sessionName)} '${escapedText}'${enterSuffix}`,
96
+ effectiveHost
90
97
  );
91
98
  this._exec(cmd);
92
99
  }
93
100
 
94
- /**
95
- * Send a special key (C-c, Escape, etc.)
96
- */
97
- sendSpecialKey(windowName, key, host = null) {
101
+ sendSpecialKey(sessionName, key, host = null) {
102
+ const effectiveHost = this._resolveHost(host);
98
103
  const cmd = this._cmd(
99
- `tmux send-keys -t \${SESSION}:${windowName} ${key}`,
100
- host
104
+ `tmux send-keys -t ${this._quote(sessionName)} ${key}`,
105
+ effectiveHost
101
106
  );
102
107
  this._exec(cmd);
103
108
  }
104
109
 
105
- /** Interrupt: Escape then C-c (works for Claude/Codex/Gemini/Qwen) */
106
- interrupt(windowName, host = null) {
107
- this.sendSpecialKey(windowName, 'Escape', host);
110
+ interrupt(sessionName, host = null) {
111
+ const effectiveHost = this._resolveHost(host);
112
+ this.sendSpecialKey(sessionName, 'Escape', effectiveHost);
108
113
  setTimeout(() => {
109
- this.sendSpecialKey(windowName, 'C-c', host);
114
+ this.sendSpecialKey(sessionName, 'C-c', effectiveHost);
110
115
  }, 100);
111
116
  }
112
117
 
113
- /**
114
- * Capture current pane content
115
- * @returns {string} visible text in the pane
116
- */
117
- capturePane(windowName, host = null) {
118
+ capturePane(sessionName, host = null) {
118
119
  try {
119
120
  const cmd = this._cmd(
120
- `tmux capture-pane -t \${SESSION}:${windowName} -p -S -100`,
121
- host
121
+ `tmux capture-pane -t ${this._quote(sessionName)} -p -S -100`,
122
+ this._resolveHost(host)
122
123
  );
123
124
  return this._exec(cmd);
124
125
  } catch {
@@ -126,113 +127,57 @@ class TmuxManager {
126
127
  }
127
128
  }
128
129
 
129
- /**
130
- * Check if a window exists
131
- */
132
- hasWindow(windowName, host = null) {
133
- try {
134
- const cmd = this._cmd(
135
- `tmux list-windows -t \${SESSION} -F '#{{window_name}}' 2>/dev/null | grep -qx '${windowName}'`,
136
- host
137
- );
138
- this._exec(cmd);
139
- return true;
140
- } catch {
141
- return false;
142
- }
143
- }
144
-
145
- /**
146
- * Kill a window
147
- */
148
- killWindow(windowName, host = null) {
130
+ killSession(sessionName, host = null) {
149
131
  try {
150
132
  const cmd = this._cmd(
151
- `tmux kill-window -t \${SESSION}:${windowName} 2>/dev/null`,
152
- host
133
+ `tmux kill-session -t ${this._quote(sessionName)} 2>/dev/null`,
134
+ this._resolveHost(host)
153
135
  );
154
136
  this._exec(cmd);
155
137
  } catch {}
156
138
  }
157
139
 
158
- /**
159
- * List all windows in the master session
160
- * @returns {Array<{index, name, active, size}>}
161
- */
162
- listWindows(host = null) {
163
- try {
164
- const cmd = this._cmd(
165
- `tmux list-windows -t \${SESSION} -F '#{{window_index}}:#{{window_name}} #{{window_width}}x#{{window_height}} #{{?window_active,1,0}}' 2>/dev/null`,
166
- host
167
- );
168
- const output = this._exec(cmd);
169
- return output.trim().split('\n').filter(Boolean).map(line => {
170
- const [idxName, size, active] = line.split(' ');
171
- const [index, ...nameParts] = idxName.split(':');
172
- return {
173
- index: parseInt(index),
174
- name: nameParts.join(':'),
175
- size,
176
- active: active === '1'
177
- };
178
- });
179
- } catch {
180
- return [];
181
- }
182
- }
183
-
184
- /**
185
- * Get process PID running in a window
186
- */
187
- getWindowPid(windowName, host = null) {
188
- try {
189
- const cmd = this._cmd(
190
- `tmux list-panes -t \${SESSION}:${windowName} -F '#{{pane_pid}}' 2>/dev/null`,
191
- host
192
- );
193
- return this._exec(cmd).trim();
194
- } catch {
195
- return null;
196
- }
197
- }
198
-
199
- // --- Host resolution ---
200
-
201
- /** Get host config by name */
202
140
  getHost(name) {
203
141
  if (!name || name === 'local') return null;
204
- return this.hosts.find(h => h.name === name) || null;
142
+ return this.hosts.find((h) => h.name === name) || null;
205
143
  }
206
144
 
207
- /** Get all configured hosts */
208
145
  getAllHosts() {
209
146
  return this.hosts;
210
147
  }
211
148
 
212
- /** Test SSH connectivity */
213
149
  testHost(host) {
214
- if (!host || host.type === 'local') return true;
150
+ const effectiveHost = this._resolveHost(host);
151
+ if (!effectiveHost || effectiveHost.type === 'local') return true;
215
152
  try {
216
- const sshCmd = this._sshCmd(host, 'echo ok');
217
- this._exec(sshCmd, 5000);
153
+ this._exec(this._sshCmd(effectiveHost, 'echo ok'), 5000);
218
154
  return true;
219
155
  } catch {
220
156
  return false;
221
157
  }
222
158
  }
223
159
 
224
- // --- Internal ---
160
+ _resolveHost(host) {
161
+ if (!host || host === 'local') return null;
162
+ if (typeof host === 'string') return this.getHost(host);
163
+ return host;
164
+ }
165
+
166
+ _interactiveShellCommand(shellPath) {
167
+ const shellName = path.basename(shellPath || '/bin/sh');
168
+ if (shellName === 'bash' || shellName === 'zsh') {
169
+ return `${shellPath} -il`;
170
+ }
171
+ if (shellName === 'fish') {
172
+ return `${shellPath} -i`;
173
+ }
174
+ return `${shellPath} -l`;
175
+ }
225
176
 
226
- /**
227
- * Build command with SESSION variable substitution
228
- * @param {string} tmpl - Command template with ${SESSION}
229
- * @param {Object|null} host - Host config
230
- * @returns {string} final command
231
- */
232
- _cmd(tmpl, host) {
233
- const cmd = tmpl.replace(/\$\{SESSION\}/g, this.sessionName);
234
- if (!host || host.type === 'local') return cmd;
235
- return this._sshCmd(host, cmd);
177
+ _cmd(cmd, host) {
178
+ const effectiveHost = this._resolveHost(host);
179
+ if (!effectiveHost || effectiveHost.type === 'local') return cmd;
180
+ return this._sshCmd(effectiveHost, cmd);
236
181
  }
237
182
 
238
183
  _sshCmd(host, cmd) {
@@ -240,10 +185,14 @@ class TmuxManager {
240
185
  if (host.port && host.port !== '22') parts.push('-p', host.port);
241
186
  if (host.key) parts.push('-i', host.key);
242
187
  parts.push(`${host.user}@${host.host}`);
243
- parts.push(cmd);
188
+ parts.push(this._quote(cmd));
244
189
  return parts.join(' ');
245
190
  }
246
191
 
192
+ _quote(value) {
193
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
194
+ }
195
+
247
196
  _exec(cmd, timeout = 10000) {
248
197
  return execSync(cmd, {
249
198
  encoding: 'utf8',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.2.0",
4
- "description": "tmux-based AI cockpit persistent sessions for Claude Code, Codex CLI, Gemini CLI, Qwen Code with remote SSH host support",
3
+ "version": "0.2.2",
4
+ "description": "tmux-based AI cockpit for real active sessions and launcher discovery across Termux, Linux, and macOS",
5
5
  "main": "lib/server/server.js",
6
6
  "bin": {
7
7
  "nexuscrew": "./bin/nexuscrew.js"
@@ -22,14 +22,10 @@
22
22
  "postinstall": "node bin/nexuscrew.js init --silent"
23
23
  },
24
24
  "dependencies": {
25
- "bcryptjs": "^3.0.3",
25
+ "better-sqlite3": "npm:@mmmbuto/better-sqlite3-termux@12.8.0-termux.0",
26
26
  "commander": "^12.1.0",
27
27
  "cors": "^2.8.5",
28
28
  "express": "^4.21.0",
29
- "express-rate-limit": "^8.2.1",
30
- "jsonwebtoken": "^9.0.2",
31
- "multer": "^2.0.2",
32
- "sql.js": "^1.13.0",
33
29
  "uuid": "^10.0.0"
34
30
  },
35
31
  "optionalDependencies": {},