@mmmbuto/nexuscrew 0.1.0-beta.1

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 (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +175 -0
  3. package/bin/nexuscrew.js +353 -0
  4. package/frontend/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  5. package/frontend/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  6. package/frontend/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  7. package/frontend/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  8. package/frontend/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  9. package/frontend/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  10. package/frontend/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  11. package/frontend/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  12. package/frontend/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  13. package/frontend/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  14. package/frontend/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  15. package/frontend/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  16. package/frontend/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  17. package/frontend/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  18. package/frontend/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  19. package/frontend/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  20. package/frontend/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  21. package/frontend/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  22. package/frontend/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  23. package/frontend/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  24. package/frontend/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  25. package/frontend/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  26. package/frontend/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  27. package/frontend/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  28. package/frontend/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  29. package/frontend/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  30. package/frontend/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  31. package/frontend/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  32. package/frontend/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  33. package/frontend/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  34. package/frontend/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  35. package/frontend/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  36. package/frontend/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  37. package/frontend/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  38. package/frontend/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  39. package/frontend/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  40. package/frontend/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  41. package/frontend/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  42. package/frontend/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  43. package/frontend/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  44. package/frontend/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  45. package/frontend/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  46. package/frontend/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  47. package/frontend/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  48. package/frontend/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  49. package/frontend/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  50. package/frontend/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  51. package/frontend/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  52. package/frontend/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  53. package/frontend/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  54. package/frontend/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  55. package/frontend/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  56. package/frontend/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  57. package/frontend/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  58. package/frontend/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  59. package/frontend/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  60. package/frontend/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  61. package/frontend/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  62. package/frontend/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  63. package/frontend/dist/assets/index-C7bAndew.js +8613 -0
  64. package/frontend/dist/assets/index-OENqI1_9.css +1 -0
  65. package/frontend/dist/index.html +13 -0
  66. package/lib/server/routes/hosts.js +78 -0
  67. package/lib/server/routes/models.js +14 -0
  68. package/lib/server/routes/send.js +277 -0
  69. package/lib/server/routes/sessions.js +104 -0
  70. package/lib/server/routes/status.js +60 -0
  71. package/lib/server/server.js +173 -0
  72. package/lib/services/engine-discovery.js +320 -0
  73. package/lib/services/log-watcher.js +279 -0
  74. package/lib/services/session-store.js +176 -0
  75. package/lib/services/tmux-manager.js +256 -0
  76. package/package.json +55 -0
@@ -0,0 +1,279 @@
1
+ /**
2
+ * LogWatcher — Monitor tmux tee log files for structured output
3
+ *
4
+ * Watches log files created by `tee` in tmux windows.
5
+ * Parses output per-engine and emits events.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { EventEmitter } = require('events');
11
+
12
+ class LogWatcher extends EventEmitter {
13
+ constructor() {
14
+ super();
15
+ this.watchers = new Map(); // logPath → { watcher, offset, parser }
16
+ this.pollIntervals = new Map(); // logPath → intervalId
17
+ }
18
+
19
+ /**
20
+ * Start watching a log file
21
+ * @param {string} logPath - Path to the tee log file
22
+ * @param {string} engine - Engine type (claude, codex, gemini, qwen)
23
+ * @param {string} conversationId - Associated conversation
24
+ */
25
+ watch(logPath, engine, conversationId) {
26
+ if (this.watchers.has(logPath)) return;
27
+
28
+ const state = {
29
+ offset: 0,
30
+ engine,
31
+ conversationId,
32
+ buffer: '',
33
+ parser: this._getParser(engine)
34
+ };
35
+
36
+ // Read existing content (in case log already has data)
37
+ if (fs.existsSync(logPath)) {
38
+ state.offset = fs.statSync(logPath).size;
39
+ }
40
+
41
+ // Poll-based watching (more reliable than fs.watch for log files)
42
+ const intervalId = setInterval(() => this._poll(logPath, state), 200);
43
+ this.pollIntervals.set(logPath, intervalId);
44
+
45
+ // Also use fs.watch for immediate responsiveness
46
+ try {
47
+ const watcher = fs.watch(path.dirname(logPath), (eventType, filename) => {
48
+ if (filename === path.basename(logPath)) {
49
+ this._poll(logPath, state);
50
+ }
51
+ });
52
+ this.watchers.set(logPath, { ...state, watcher });
53
+ } catch {
54
+ // fs.watch may fail in some environments, polling still works
55
+ this.watchers.set(logPath, state);
56
+ }
57
+
58
+ this.emit('watching', { logPath, engine, conversationId });
59
+ }
60
+
61
+ /**
62
+ * Stop watching a log file
63
+ */
64
+ unwatch(logPath) {
65
+ const state = this.watchers.get(logPath);
66
+ if (state?.watcher) state.watcher.close();
67
+ this.watchers.delete(logPath);
68
+
69
+ const intervalId = this.pollIntervals.get(logPath);
70
+ if (intervalId) {
71
+ clearInterval(intervalId);
72
+ this.pollIntervals.delete(logPath);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Stop all watchers
78
+ */
79
+ unwatchAll() {
80
+ for (const logPath of this.watchers.keys()) {
81
+ this.unwatch(logPath);
82
+ }
83
+ }
84
+
85
+ // --- Polling ---
86
+
87
+ _poll(logPath, state) {
88
+ try {
89
+ if (!fs.existsSync(logPath)) return;
90
+
91
+ const stat = fs.statSync(logPath);
92
+ if (stat.size <= state.offset) return;
93
+
94
+ const fd = fs.openSync(logPath, 'r');
95
+ const buf = Buffer.alloc(stat.size - state.offset);
96
+ fs.readSync(fd, buf, 0, buf.length, state.offset);
97
+ fs.closeSync(fd);
98
+
99
+ state.offset = stat.size;
100
+ const chunk = buf.toString('utf8');
101
+ state.buffer += chunk;
102
+
103
+ // Process complete lines
104
+ this._processBuffer(state);
105
+ } catch (err) {
106
+ // File might be temporarily unavailable
107
+ }
108
+ }
109
+
110
+ _processBuffer(state) {
111
+ const lines = state.buffer.split('\n');
112
+ state.buffer = lines.pop() || ''; // Keep incomplete line in buffer
113
+
114
+ for (const line of lines) {
115
+ if (!line.trim()) continue;
116
+ try {
117
+ const events = state.parser(line, state);
118
+ for (const event of events) {
119
+ this.emit('data', {
120
+ ...event,
121
+ conversationId: state.conversationId,
122
+ engine: state.engine
123
+ });
124
+ }
125
+ } catch {
126
+ // Emit raw text if parser fails
127
+ this.emit('data', {
128
+ type: 'raw',
129
+ content: line,
130
+ conversationId: state.conversationId,
131
+ engine: state.engine
132
+ });
133
+ }
134
+ }
135
+ }
136
+
137
+ // --- Parsers ---
138
+
139
+ _getParser(engine) {
140
+ switch (engine) {
141
+ case 'claude': return this._parseClaude.bind(this);
142
+ case 'codex': return this._parseCodex.bind(this);
143
+ case 'gemini': return this._parseGemini.bind(this);
144
+ case 'qwen': return this._parseQwen.bind(this);
145
+ default: return this._parseGeneric.bind(this);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Claude Code JSON output parser
151
+ * Claude emits JSON objects per-line when --output-format json is used
152
+ */
153
+ _parseClaude(line, state) {
154
+ const events = [];
155
+
156
+ // Try JSON parse (Claude --output-format json)
157
+ try {
158
+ const obj = JSON.parse(line);
159
+
160
+ if (obj.type === 'assistant') {
161
+ events.push({ type: 'status', content: 'thinking...' });
162
+ } else if (obj.type === 'content_block_delta') {
163
+ events.push({ type: 'chunk', content: obj.delta?.text || '' });
164
+ } else if (obj.type === 'message_stop') {
165
+ events.push({ type: 'done' });
166
+ } else if (obj.type === 'tool_use') {
167
+ events.push({ type: 'status', content: `tool: ${obj.name || 'unknown'}` });
168
+ } else if (obj.result !== undefined) {
169
+ // Final result
170
+ events.push({
171
+ type: 'message_done',
172
+ content: typeof obj.result === 'string' ? obj.result : JSON.stringify(obj.result),
173
+ usage: obj.usage
174
+ });
175
+ }
176
+ return events;
177
+ } catch {}
178
+
179
+ // Fallback: plain text parsing
180
+ if (line.includes('thinking...') || line.includes('Thinking...')) {
181
+ events.push({ type: 'status', content: 'thinking...' });
182
+ } else if (line.includes('tool:')) {
183
+ events.push({ type: 'status', content: line.trim() });
184
+ } else if (line.trim()) {
185
+ events.push({ type: 'chunk', content: line });
186
+ }
187
+
188
+ return events;
189
+ }
190
+
191
+ /**
192
+ * Codex exec --json output parser (JSONL)
193
+ */
194
+ _parseCodex(line) {
195
+ const events = [];
196
+
197
+ try {
198
+ const obj = JSON.parse(line);
199
+ if (obj.type === 'status') {
200
+ events.push({ type: 'status', content: obj.message || obj.status });
201
+ } else if (obj.type === 'response' || obj.type === 'result') {
202
+ events.push({
203
+ type: 'message_done',
204
+ content: obj.content || obj.result || obj.text || '',
205
+ usage: obj.usage
206
+ });
207
+ } else if (obj.type === 'tool_use' || obj.type === 'patch') {
208
+ events.push({ type: 'status', content: `${obj.type}: ${obj.name || obj.path || ''}` });
209
+ } else if (obj.delta) {
210
+ events.push({ type: 'chunk', content: obj.delta });
211
+ } else if (obj.content) {
212
+ events.push({ type: 'chunk', content: typeof obj.content === 'string' ? obj.content : JSON.stringify(obj.content) });
213
+ }
214
+ } catch {
215
+ if (line.trim()) {
216
+ events.push({ type: 'chunk', content: line });
217
+ }
218
+ }
219
+
220
+ return events;
221
+ }
222
+
223
+ /**
224
+ * Gemini output parser
225
+ */
226
+ _parseGemini(line) {
227
+ const events = [];
228
+
229
+ try {
230
+ const obj = JSON.parse(line);
231
+ if (obj.type === 'status') {
232
+ events.push({ type: 'status', content: obj.message });
233
+ } else if (obj.content) {
234
+ events.push({ type: 'chunk', content: obj.content });
235
+ }
236
+ } catch {
237
+ // Detect Gemini status markers
238
+ if (line.match(/^(⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏)/)) {
239
+ events.push({ type: 'status', content: line.trim() });
240
+ } else if (line.trim()) {
241
+ events.push({ type: 'chunk', content: line });
242
+ }
243
+ }
244
+
245
+ return events;
246
+ }
247
+
248
+ /**
249
+ * Qwen output parser
250
+ */
251
+ _parseQwen(line) {
252
+ const events = [];
253
+
254
+ try {
255
+ const obj = JSON.parse(line);
256
+ if (obj.type === 'status') {
257
+ events.push({ type: 'status', content: obj.message });
258
+ } else if (obj.content) {
259
+ events.push({ type: 'chunk', content: obj.content });
260
+ }
261
+ } catch {
262
+ if (line.trim()) {
263
+ events.push({ type: 'chunk', content: line });
264
+ }
265
+ }
266
+
267
+ return events;
268
+ }
269
+
270
+ /**
271
+ * Generic fallback parser
272
+ */
273
+ _parseGeneric(line) {
274
+ if (!line.trim()) return [];
275
+ return [{ type: 'chunk', content: line }];
276
+ }
277
+ }
278
+
279
+ module.exports = LogWatcher;
@@ -0,0 +1,176 @@
1
+ /**
2
+ * SessionStore — SQLite persistence for nexuscrew
3
+ *
4
+ * Schema:
5
+ * - conversations: id, title, engine, model, tmux_window, log_path, workspace, host, status, created_at, updated_at
6
+ * - messages: id, conversation_id, role, content, engine, model, tokens_in, tokens_out, created_at
7
+ */
8
+
9
+ const Database = require('better-sqlite3');
10
+ const path = require('path');
11
+ const fs = require('fs');
12
+ const { v4: uuidv4 } = require('uuid');
13
+
14
+ class SessionStore {
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 NOT NULL DEFAULT 'claude',
31
+ model TEXT,
32
+ tmux_window TEXT,
33
+ log_path TEXT,
34
+ workspace TEXT DEFAULT '',
35
+ host TEXT DEFAULT 'local',
36
+ status TEXT DEFAULT 'active',
37
+ created_at INTEGER DEFAULT (unixepoch()),
38
+ updated_at INTEGER DEFAULT (unixepoch())
39
+ );
40
+
41
+ CREATE TABLE IF NOT EXISTS messages (
42
+ id TEXT PRIMARY KEY,
43
+ conversation_id TEXT NOT NULL,
44
+ role TEXT NOT NULL,
45
+ content TEXT DEFAULT '',
46
+ engine TEXT,
47
+ model TEXT,
48
+ tokens_in INTEGER DEFAULT 0,
49
+ tokens_out INTEGER DEFAULT 0,
50
+ created_at INTEGER DEFAULT (unixepoch()),
51
+ FOREIGN KEY (conversation_id) REFERENCES conversations(id)
52
+ );
53
+
54
+ CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, created_at);
55
+ CREATE INDEX IF NOT EXISTS idx_conversations_status ON conversations(status, updated_at);
56
+ `);
57
+ }
58
+
59
+ // --- Conversations ---
60
+
61
+ createConversation({ engine, model, workspace, host, tmuxWindow, logPath } = {}) {
62
+ const id = uuidv4();
63
+ this.db.prepare(`
64
+ INSERT INTO conversations (id, engine, model, workspace, host, tmux_window, log_path)
65
+ VALUES (?, ?, ?, ?, ?, ?, ?)
66
+ `).run(id, engine || 'claude', model || null, workspace || '', host || 'local', tmuxWindow || null, logPath || null);
67
+ return this.getConversation(id);
68
+ }
69
+
70
+ getConversation(id) {
71
+ return this.db.prepare('SELECT * FROM conversations WHERE id = ?').get(id);
72
+ }
73
+
74
+ updateConversation(id, updates) {
75
+ const fields = [];
76
+ const values = [];
77
+ for (const [key, value] of Object.entries(updates)) {
78
+ if (['title', 'engine', 'model', 'tmux_window', 'log_path', 'workspace', 'host', 'status'].includes(key)) {
79
+ fields.push(`${key} = ?`);
80
+ values.push(value);
81
+ }
82
+ }
83
+ if (fields.length === 0) return;
84
+ fields.push('updated_at = unixepoch()');
85
+ values.push(id);
86
+ this.db.prepare(`UPDATE conversations SET ${fields.join(', ')} WHERE id = ?`).run(...values);
87
+ }
88
+
89
+ listConversations({ workspace, host, status, limit = 50 } = {}) {
90
+ let sql = 'SELECT * FROM conversations WHERE 1=1';
91
+ const params = [];
92
+ if (workspace) { sql += ' AND workspace = ?'; params.push(workspace); }
93
+ if (host) { sql += ' AND host = ?'; params.push(host); }
94
+ if (status) { sql += ' AND status = ?'; params.push(status); }
95
+ sql += ' ORDER BY updated_at DESC LIMIT ?';
96
+ params.push(limit);
97
+ return this.db.prepare(sql).all(...params);
98
+ }
99
+
100
+ deleteConversation(id) {
101
+ this.db.prepare('DELETE FROM messages WHERE conversation_id = ?').run(id);
102
+ this.db.prepare('DELETE FROM conversations WHERE id = ?').run(id);
103
+ }
104
+
105
+ /** Find conversation by tmux window name */
106
+ findByWindow(windowName) {
107
+ return this.db.prepare('SELECT * FROM conversations WHERE tmux_window = ?').get(windowName);
108
+ }
109
+
110
+ /** Get conversations grouped by date */
111
+ listGroupedByDate({ workspace, limit = 100 } = {}) {
112
+ const convs = this.listConversations({ workspace, limit });
113
+ const grouped = {};
114
+ for (const c of convs) {
115
+ const date = new Date(c.updated_at * 1000).toISOString().split('T')[0];
116
+ if (!grouped[date]) grouped[date] = [];
117
+ grouped[date].push(c);
118
+ }
119
+ return grouped;
120
+ }
121
+
122
+ // --- Messages ---
123
+
124
+ addMessage({ conversationId, role, content, engine, model, tokensIn, tokensOut } = {}) {
125
+ const id = uuidv4();
126
+ this.db.prepare(`
127
+ INSERT INTO messages (id, conversation_id, role, content, engine, model, tokens_in, tokens_out)
128
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
129
+ `).run(id, conversationId, role, content || '', engine || null, model || null, tokensIn || 0, tokensOut || 0);
130
+
131
+ // Update conversation timestamp
132
+ this.db.prepare('UPDATE conversations SET updated_at = unixepoch() WHERE id = ?').run(conversationId);
133
+ return id;
134
+ }
135
+
136
+ getMessages(conversationId, { limit = 50, before } = {}) {
137
+ let sql = 'SELECT * FROM messages WHERE conversation_id = ?';
138
+ const params = [conversationId];
139
+ if (before) {
140
+ sql += ' AND created_at < ?';
141
+ params.push(before);
142
+ }
143
+ sql += ' ORDER BY created_at ASC LIMIT ?';
144
+ params.push(limit);
145
+ return this.db.prepare(sql).all(...params);
146
+ }
147
+
148
+ getMessageCount(conversationId) {
149
+ return this.db.prepare('SELECT COUNT(*) as count FROM messages WHERE conversation_id = ?').get(conversationId).count;
150
+ }
151
+
152
+ // --- Recovery ---
153
+
154
+ /**
155
+ * Sync DB with actual tmux state
156
+ * Mark conversations as 'dead' if their tmux window no longer exists
157
+ */
158
+ syncWithTmux(tmuxManager) {
159
+ const active = this.db.prepare("SELECT * FROM conversations WHERE status = 'active'").all();
160
+ for (const conv of active) {
161
+ if (conv.tmux_window) {
162
+ const exists = tmuxManager.hasWindow(conv.tmux_window, conv.host !== 'local' ? conv.host : null);
163
+ if (!exists) {
164
+ this.updateConversation(conv.id, { status: 'dead' });
165
+ }
166
+ }
167
+ }
168
+ }
169
+
170
+ /** Close DB connection */
171
+ close() {
172
+ this.db.close();
173
+ }
174
+ }
175
+
176
+ module.exports = SessionStore;
@@ -0,0 +1,256 @@
1
+ /**
2
+ * TmuxManager — Core tmux operations for nexuscrew
3
+ *
4
+ * Manages tmux sessions locally and remotely via SSH.
5
+ * Every AI session = one tmux window inside the master session.
6
+ */
7
+
8
+ const { execSync, exec } = require('child_process');
9
+ const path = require('path');
10
+ const fs = require('fs');
11
+
12
+ const TMUX_SESSION = 'nexuscrew';
13
+
14
+ class TmuxManager {
15
+ constructor(config = {}) {
16
+ this.sessionName = config.tmuxSession || TMUX_SESSION;
17
+ this.logDir = config.logDir || path.join(process.env.HOME, '.nexuscrew', 'logs');
18
+ this.hosts = config.hosts || [{ name: 'local', type: 'local' }];
19
+
20
+ // Ensure log dir
21
+ if (!fs.existsSync(this.logDir)) {
22
+ fs.mkdirSync(this.logDir, { recursive: true });
23
+ }
24
+ }
25
+
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);
32
+ }
33
+
34
+ /** Check if master session exists */
35
+ hasSession(host = null) {
36
+ try {
37
+ this._exec(this._cmd('tmux has-session -t ${SESSION} 2>/dev/null', host));
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
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);
60
+
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}`;
65
+
66
+ const cmd = this._cmd(
67
+ `tmux new-window -t \${SESSION} -n ${windowName} -c '${cwd || process.env.HOME}' "${fullCommand.replace(/"/g, '\\"')}"`,
68
+ host
69
+ );
70
+
71
+ this._exec(cmd);
72
+
73
+ return { windowName, logPath };
74
+ }
75
+
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 = {}) {
85
+ const enterSuffix = opts.enter ? ' Enter' : '';
86
+ const escapedText = text.replace(/'/g, "'\\''");
87
+ const cmd = this._cmd(
88
+ `tmux send-keys -t \${SESSION}:${windowName} '${escapedText}'${enterSuffix}`,
89
+ opts.host
90
+ );
91
+ this._exec(cmd);
92
+ }
93
+
94
+ /**
95
+ * Send a special key (C-c, Escape, etc.)
96
+ */
97
+ sendSpecialKey(windowName, key, host = null) {
98
+ const cmd = this._cmd(
99
+ `tmux send-keys -t \${SESSION}:${windowName} ${key}`,
100
+ host
101
+ );
102
+ this._exec(cmd);
103
+ }
104
+
105
+ /** Interrupt: Escape then C-c (works for Claude/Codex/Gemini/Qwen) */
106
+ interrupt(windowName, host = null) {
107
+ this.sendSpecialKey(windowName, 'Escape', host);
108
+ setTimeout(() => {
109
+ this.sendSpecialKey(windowName, 'C-c', host);
110
+ }, 100);
111
+ }
112
+
113
+ /**
114
+ * Capture current pane content
115
+ * @returns {string} visible text in the pane
116
+ */
117
+ capturePane(windowName, host = null) {
118
+ try {
119
+ const cmd = this._cmd(
120
+ `tmux capture-pane -t \${SESSION}:${windowName} -p -S -100`,
121
+ host
122
+ );
123
+ return this._exec(cmd);
124
+ } catch {
125
+ return '';
126
+ }
127
+ }
128
+
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) {
149
+ try {
150
+ const cmd = this._cmd(
151
+ `tmux kill-window -t \${SESSION}:${windowName} 2>/dev/null`,
152
+ host
153
+ );
154
+ this._exec(cmd);
155
+ } catch {}
156
+ }
157
+
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
+ getHost(name) {
203
+ if (!name || name === 'local') return null;
204
+ return this.hosts.find(h => h.name === name) || null;
205
+ }
206
+
207
+ /** Get all configured hosts */
208
+ getAllHosts() {
209
+ return this.hosts;
210
+ }
211
+
212
+ /** Test SSH connectivity */
213
+ testHost(host) {
214
+ if (!host || host.type === 'local') return true;
215
+ try {
216
+ const sshCmd = this._sshCmd(host, 'echo ok');
217
+ this._exec(sshCmd, 5000);
218
+ return true;
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+
224
+ // --- Internal ---
225
+
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);
236
+ }
237
+
238
+ _sshCmd(host, cmd) {
239
+ const parts = ['ssh', '-o', 'ConnectTimeout=5', '-o', 'BatchMode=yes'];
240
+ if (host.port && host.port !== '22') parts.push('-p', host.port);
241
+ if (host.key) parts.push('-i', host.key);
242
+ parts.push(`${host.user}@${host.host}`);
243
+ parts.push(cmd);
244
+ return parts.join(' ');
245
+ }
246
+
247
+ _exec(cmd, timeout = 10000) {
248
+ return execSync(cmd, {
249
+ encoding: 'utf8',
250
+ timeout,
251
+ stdio: ['pipe', 'pipe', 'pipe']
252
+ });
253
+ }
254
+ }
255
+
256
+ module.exports = TmuxManager;