@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,49 +1,94 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SessionStore — SQLite persistence for nexuscrew
|
|
2
|
+
* SessionStore — SQLite persistence for nexuscrew
|
|
3
3
|
*
|
|
4
4
|
* Schema:
|
|
5
|
-
* - conversations: id, title, engine, model,
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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', '
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
return stmt.all(...params);
|
|
110
|
+
return this.db.prepare(sql).all(...params);
|
|
68
111
|
}
|
|
69
112
|
|
|
70
113
|
deleteConversation(id) {
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
return stmt.all(...params);
|
|
162
|
+
return this.db.prepare(sql).all(...params);
|
|
140
163
|
}
|
|
141
164
|
|
|
142
165
|
getMessageCount(conversationId) {
|
|
143
|
-
|
|
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
|
|
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.
|
|
158
|
-
const exists = tmuxManager.
|
|
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
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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.
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
hasSession(host = null) {
|
|
45
|
+
hasSession(sessionName, host = null) {
|
|
36
46
|
try {
|
|
37
|
-
this.
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
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.
|
|
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 {
|
|
76
|
+
return { sessionName, logPath };
|
|
74
77
|
}
|
|
75
78
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
89
|
-
|
|
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
|
-
|
|
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
|
|
100
|
-
|
|
104
|
+
`tmux send-keys -t ${this._quote(sessionName)} ${key}`,
|
|
105
|
+
effectiveHost
|
|
101
106
|
);
|
|
102
107
|
this._exec(cmd);
|
|
103
108
|
}
|
|
104
109
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
this.sendSpecialKey(
|
|
110
|
+
interrupt(sessionName, host = null) {
|
|
111
|
+
const effectiveHost = this._resolveHost(host);
|
|
112
|
+
this.sendSpecialKey(sessionName, 'Escape', effectiveHost);
|
|
108
113
|
setTimeout(() => {
|
|
109
|
-
this.sendSpecialKey(
|
|
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
|
|
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-
|
|
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
|
-
|
|
150
|
+
const effectiveHost = this._resolveHost(host);
|
|
151
|
+
if (!effectiveHost || effectiveHost.type === 'local') return true;
|
|
215
152
|
try {
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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.
|
|
4
|
-
"description": "tmux-based AI cockpit
|
|
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
|
-
"
|
|
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": {},
|