@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.
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/bin/nexuscrew.js +353 -0
- package/frontend/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/frontend/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/frontend/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/frontend/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/frontend/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/frontend/dist/assets/index-C7bAndew.js +8613 -0
- package/frontend/dist/assets/index-OENqI1_9.css +1 -0
- package/frontend/dist/index.html +13 -0
- package/lib/server/routes/hosts.js +78 -0
- package/lib/server/routes/models.js +14 -0
- package/lib/server/routes/send.js +277 -0
- package/lib/server/routes/sessions.js +104 -0
- package/lib/server/routes/status.js +60 -0
- package/lib/server/server.js +173 -0
- package/lib/services/engine-discovery.js +320 -0
- package/lib/services/log-watcher.js +279 -0
- package/lib/services/session-store.js +176 -0
- package/lib/services/tmux-manager.js +256 -0
- 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;
|