@mmmbuto/nexuscrew 0.1.0-beta.1 → 0.2.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/README.md +67 -120
- package/bin/nexuscrew.js +468 -264
- package/frontend/dist/apple-touch-icon.png +0 -0
- package/frontend/dist/assets/{index-C7bAndew.js → index-BG1N7bSL.js} +1710 -1702
- package/frontend/dist/assets/index-CiAtinNP.css +1 -0
- package/frontend/dist/favicon.svg +20 -0
- package/frontend/dist/icon-192.png +0 -0
- package/frontend/dist/icon-512.png +0 -0
- package/frontend/dist/index.html +11 -3
- package/frontend/dist/site.webmanifest +29 -0
- package/lib/config/hosts.js +67 -0
- package/lib/config/manager.js +379 -0
- package/lib/config/models.js +408 -0
- package/lib/server/db/adapter.js +274 -0
- package/lib/server/db/drivers/sql-js.js +75 -0
- package/lib/server/db/migrate.js +174 -0
- package/lib/server/db/migrations/001_base_schema.sql +70 -0
- package/lib/server/middleware/auth.js +134 -0
- package/lib/server/middleware/rate-limit.js +63 -0
- package/lib/server/models/User.js +128 -0
- package/lib/server/routes/auth.js +168 -0
- package/lib/server/routes/hosts.js +11 -20
- package/lib/server/routes/keys.js +28 -0
- package/lib/server/routes/models.js +59 -4
- package/lib/server/routes/runtimes.js +34 -0
- package/lib/server/routes/send.js +76 -12
- package/lib/server/routes/sessions.js +39 -10
- package/lib/server/routes/speech.js +46 -0
- package/lib/server/routes/status.js +8 -6
- package/lib/server/routes/upload.js +135 -0
- package/lib/server/routes/wake-lock.js +95 -0
- package/lib/server/routes/workspaces.js +101 -0
- package/lib/server/server.js +66 -33
- package/lib/server/services/attachment-manager.js +57 -0
- package/lib/server/services/context-bridge.js +425 -0
- package/lib/server/services/runtime-manager.js +462 -0
- package/lib/server/services/speech-manager.js +76 -0
- package/lib/server/services/summary-generator.js +309 -0
- package/lib/server/services/workspace-manager.js +79 -0
- package/lib/services/engine-discovery.js +198 -13
- package/lib/services/log-watcher.js +40 -22
- package/lib/services/remote-pane-watcher.js +155 -0
- package/lib/services/session-store.js +60 -64
- package/lib/services/tmux-manager.js +127 -116
- package/lib/setup/postinstall.js +38 -0
- package/lib/utils/paths.js +124 -0
- package/lib/utils/termux.js +182 -0
- package/package.json +8 -4
- package/frontend/dist/assets/index-OENqI1_9.css +0 -1
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const rateLimit = require('express-rate-limit');
|
|
3
|
+
const bcrypt = require('bcryptjs');
|
|
4
|
+
const User = require('../models/User');
|
|
5
|
+
const { generateToken, authMiddleware } = require('../middleware/auth');
|
|
6
|
+
const { getConfig } = require('../../config/manager');
|
|
7
|
+
|
|
8
|
+
const router = express.Router();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check config user (admin from init)
|
|
12
|
+
* Returns user object compatible with DB user structure
|
|
13
|
+
*/
|
|
14
|
+
function findConfigUser(username) {
|
|
15
|
+
const config = getConfig();
|
|
16
|
+
if (config.auth && config.auth.user === username && config.auth.pass_hash) {
|
|
17
|
+
return {
|
|
18
|
+
id: 'config-admin',
|
|
19
|
+
username: config.auth.user,
|
|
20
|
+
password_hash: config.auth.pass_hash,
|
|
21
|
+
role: 'admin',
|
|
22
|
+
is_locked: false,
|
|
23
|
+
failed_attempts: 0,
|
|
24
|
+
created_at: Date.now()
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Verify password for config user
|
|
32
|
+
*/
|
|
33
|
+
function verifyConfigPassword(passHash, password) {
|
|
34
|
+
return bcrypt.compareSync(password, passHash);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Rate limiter: max 5 login attempts per 15 minutes per IP
|
|
38
|
+
const loginLimiter = rateLimit({
|
|
39
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
40
|
+
max: 10,
|
|
41
|
+
message: {
|
|
42
|
+
error: 'Too many login attempts, please try again later',
|
|
43
|
+
retry_after: 15 * 60
|
|
44
|
+
},
|
|
45
|
+
standardHeaders: true,
|
|
46
|
+
legacyHeaders: false
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// POST /api/v1/auth/login
|
|
50
|
+
router.post('/login', loginLimiter, async (req, res) => {
|
|
51
|
+
try {
|
|
52
|
+
const { username, password } = req.body;
|
|
53
|
+
const ipAddress = req.ip || req.connection.remoteAddress;
|
|
54
|
+
|
|
55
|
+
if (!username || !password) {
|
|
56
|
+
return res.status(400).json({ error: 'Username and password required' });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Check IP rate limiting (additional layer)
|
|
60
|
+
const recentAttempts = User.getRecentLoginAttempts(ipAddress);
|
|
61
|
+
if (recentAttempts > 20) {
|
|
62
|
+
return res.status(429).json({
|
|
63
|
+
error: 'Too many failed attempts from this IP',
|
|
64
|
+
retry_after: 15 * 60
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// First check config user (admin from init)
|
|
69
|
+
let user = findConfigUser(username);
|
|
70
|
+
let isConfigUser = !!user;
|
|
71
|
+
|
|
72
|
+
// If not config user, check database
|
|
73
|
+
if (!user) {
|
|
74
|
+
user = User.findByUsername(username);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!user) {
|
|
78
|
+
// Log failed attempt even for non-existent user
|
|
79
|
+
User.logLoginAttempt(ipAddress, username, false);
|
|
80
|
+
return res.status(401).json({ error: 'Invalid credentials' });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check if account is locked (only for DB users)
|
|
84
|
+
if (!isConfigUser && User.isAccountLocked(user)) {
|
|
85
|
+
User.logLoginAttempt(ipAddress, username, false);
|
|
86
|
+
const remainingMs = user.locked_until - Date.now();
|
|
87
|
+
return res.status(403).json({
|
|
88
|
+
error: 'Account locked due to failed login attempts',
|
|
89
|
+
locked_until: user.locked_until,
|
|
90
|
+
retry_after: Math.ceil(remainingMs / 1000)
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Verify password
|
|
95
|
+
let isValid;
|
|
96
|
+
if (isConfigUser) {
|
|
97
|
+
isValid = verifyConfigPassword(user.password_hash, password);
|
|
98
|
+
} else {
|
|
99
|
+
isValid = User.verifyPassword(user, password);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!isValid) {
|
|
103
|
+
User.logLoginAttempt(ipAddress, username, false);
|
|
104
|
+
if (!isConfigUser) {
|
|
105
|
+
User.incrementFailedAttempts(user.id);
|
|
106
|
+
}
|
|
107
|
+
return res.status(401).json({ error: 'Invalid credentials' });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Success
|
|
111
|
+
User.logLoginAttempt(ipAddress, username, true);
|
|
112
|
+
if (!isConfigUser) {
|
|
113
|
+
User.resetFailedAttempts(user.id);
|
|
114
|
+
User.updateLastLogin(user.id);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const token = generateToken(user);
|
|
118
|
+
|
|
119
|
+
res.json({
|
|
120
|
+
token,
|
|
121
|
+
user: {
|
|
122
|
+
id: user.id,
|
|
123
|
+
username: user.username,
|
|
124
|
+
role: user.role
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.error('Login error:', error);
|
|
129
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// GET /api/v1/auth/me
|
|
134
|
+
router.get('/me', authMiddleware, (req, res) => {
|
|
135
|
+
// Config user is already validated by authMiddleware
|
|
136
|
+
// Just return the user info from req.user
|
|
137
|
+
if (req.user.id === 'config-admin') {
|
|
138
|
+
const config = getConfig();
|
|
139
|
+
return res.json({
|
|
140
|
+
id: req.user.id,
|
|
141
|
+
username: req.user.username,
|
|
142
|
+
role: req.user.role,
|
|
143
|
+
created_at: Date.now(),
|
|
144
|
+
last_login: null
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const user = User.findById(req.user.id);
|
|
149
|
+
if (!user) {
|
|
150
|
+
return res.status(404).json({ error: 'User not found' });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
res.json({
|
|
154
|
+
id: user.id,
|
|
155
|
+
username: user.username,
|
|
156
|
+
role: user.role,
|
|
157
|
+
created_at: user.created_at,
|
|
158
|
+
last_login: user.last_login
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// POST /api/v1/auth/logout
|
|
163
|
+
router.post('/logout', authMiddleware, (req, res) => {
|
|
164
|
+
// With JWT, logout is handled client-side by removing token
|
|
165
|
+
res.json({ message: 'Logged out successfully' });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
module.exports = router;
|
|
@@ -6,23 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
const { Router } = require('express');
|
|
9
|
-
const
|
|
10
|
-
const path = require('path');
|
|
9
|
+
const { readHosts, writeHosts, normalizeHost } = require('../../config/hosts');
|
|
11
10
|
const router = Router();
|
|
12
11
|
|
|
13
|
-
const HOSTS_PATH = path.join(process.env.HOME || '', '.nexuscrew', 'hosts.json');
|
|
14
|
-
|
|
15
|
-
function readHosts() {
|
|
16
|
-
if (fs.existsSync(HOSTS_PATH)) {
|
|
17
|
-
return JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'));
|
|
18
|
-
}
|
|
19
|
-
return [{ name: 'local', type: 'local', default: true }];
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function writeHosts(hosts) {
|
|
23
|
-
fs.writeFileSync(HOSTS_PATH, JSON.stringify(hosts, null, 2));
|
|
24
|
-
}
|
|
25
|
-
|
|
26
12
|
router.get('/', (req, res) => {
|
|
27
13
|
const { tmux } = req;
|
|
28
14
|
const hosts = readHosts();
|
|
@@ -36,22 +22,27 @@ router.get('/', (req, res) => {
|
|
|
36
22
|
});
|
|
37
23
|
|
|
38
24
|
router.post('/', (req, res) => {
|
|
39
|
-
const { name, host, user, port, key } = req.body;
|
|
25
|
+
const { name, host, user, port, key, default: isDefault } = req.body;
|
|
40
26
|
if (!name || !host) return res.status(400).json({ error: 'name and host required' });
|
|
41
27
|
|
|
42
|
-
|
|
28
|
+
let hosts = readHosts();
|
|
43
29
|
if (hosts.find(h => h.name === name)) {
|
|
44
30
|
return res.status(409).json({ error: 'Host already exists' });
|
|
45
31
|
}
|
|
46
32
|
|
|
47
|
-
|
|
33
|
+
if (isDefault) {
|
|
34
|
+
hosts = hosts.map((entry) => ({ ...entry, default: false }));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
hosts.push(normalizeHost({
|
|
48
38
|
name,
|
|
49
39
|
type: 'ssh',
|
|
50
40
|
host,
|
|
51
41
|
user: user || 'dag',
|
|
52
42
|
port: port || '22',
|
|
53
|
-
key: key || undefined
|
|
54
|
-
|
|
43
|
+
key: key || undefined,
|
|
44
|
+
default: Boolean(isDefault)
|
|
45
|
+
}));
|
|
55
46
|
|
|
56
47
|
writeHosts(hosts);
|
|
57
48
|
res.json({ success: true, name });
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const router = express.Router();
|
|
3
|
+
const { getApiKey } = require('../db/adapter');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GET /api/v1/keys/check/:provider
|
|
7
|
+
* Check if an API key is configured for a provider
|
|
8
|
+
* Returns: { configured: boolean }
|
|
9
|
+
*/
|
|
10
|
+
router.get('/check/:provider', (req, res) => {
|
|
11
|
+
if (req.params.provider === 'stt') {
|
|
12
|
+
const speech = req.speech || req.app?.locals?.speech;
|
|
13
|
+
if (!speech) {
|
|
14
|
+
return res.json({ configured: false, provider: 'unknown' });
|
|
15
|
+
}
|
|
16
|
+
const status = speech.getStatus();
|
|
17
|
+
return res.json({
|
|
18
|
+
configured: Boolean(status.whisper?.configured && status.whisper?.available),
|
|
19
|
+
provider: status.provider,
|
|
20
|
+
fallback: status.fallback
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const key = getApiKey(req.params.provider);
|
|
25
|
+
res.json({ configured: !!key });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
module.exports = router;
|
|
@@ -1,14 +1,69 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* GET /api/v1/models — List available models and engines
|
|
3
|
+
*
|
|
4
|
+
* Merges engine-discovery results with runtime-manager status
|
|
3
5
|
*/
|
|
4
6
|
|
|
5
7
|
const { Router } = require('express');
|
|
8
|
+
const RuntimeManager = require('../services/runtime-manager');
|
|
6
9
|
const router = Router();
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
const runtimeManager = new RuntimeManager();
|
|
12
|
+
|
|
13
|
+
router.get('/', async (req, res) => {
|
|
14
|
+
try {
|
|
15
|
+
// Get engine discovery results
|
|
16
|
+
const { engines } = req;
|
|
17
|
+
const discoveryData = engines.getForFrontend();
|
|
18
|
+
|
|
19
|
+
// Get runtime-aware inventory
|
|
20
|
+
const runtimeInventory = await runtimeManager.getRuntimeInventory();
|
|
21
|
+
const runtimeMap = Object.fromEntries(
|
|
22
|
+
runtimeInventory.map(r => [r.runtimeId, r])
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
// Merge data
|
|
26
|
+
const merged = {};
|
|
27
|
+
|
|
28
|
+
for (const [engineName, engineData] of Object.entries(discoveryData)) {
|
|
29
|
+
merged[engineName] = {
|
|
30
|
+
...engineData,
|
|
31
|
+
models: engineData.models.map(model => {
|
|
32
|
+
const runtimeId = model.lane === 'custom'
|
|
33
|
+
? `${engineName}-custom`
|
|
34
|
+
: `${engineName}-native`;
|
|
35
|
+
|
|
36
|
+
const runtime = runtimeMap[runtimeId];
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
id: model.id,
|
|
40
|
+
label: model.name,
|
|
41
|
+
name: model.name,
|
|
42
|
+
lane: model.lane,
|
|
43
|
+
available: runtime?.available ?? false,
|
|
44
|
+
runtimeStatus: runtime?.status ?? 'unknown',
|
|
45
|
+
runtimeCommand: runtime?.command || null,
|
|
46
|
+
runtimeSource: runtime?.source || null,
|
|
47
|
+
providerId: model.providerId || null,
|
|
48
|
+
...(model.alias && { alias: model.alias }),
|
|
49
|
+
...(model.modelHint && { modelHint: model.modelHint }),
|
|
50
|
+
};
|
|
51
|
+
}),
|
|
52
|
+
aliases: engineData.aliases.map(alias => {
|
|
53
|
+
const runtime = runtimeMap[`${engineName}-custom`];
|
|
54
|
+
return {
|
|
55
|
+
...alias,
|
|
56
|
+
available: runtime?.available ?? false,
|
|
57
|
+
};
|
|
58
|
+
})
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
res.json(merged);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.error('[Models] Error merging discovery and runtime data:', error);
|
|
65
|
+
res.status(500).json({ error: 'Failed to fetch models' });
|
|
66
|
+
}
|
|
12
67
|
});
|
|
13
68
|
|
|
14
69
|
module.exports = router;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const RuntimeManager = require('../services/runtime-manager');
|
|
3
|
+
|
|
4
|
+
const router = express.Router();
|
|
5
|
+
const runtimeManager = new RuntimeManager();
|
|
6
|
+
|
|
7
|
+
router.get('/', async (_req, res) => {
|
|
8
|
+
try {
|
|
9
|
+
const inventory = await runtimeManager.getRuntimeInventory();
|
|
10
|
+
res.json({
|
|
11
|
+
platform: runtimeManager.platformId,
|
|
12
|
+
runtimes: inventory,
|
|
13
|
+
});
|
|
14
|
+
} catch (error) {
|
|
15
|
+
console.error('[Runtimes] Inventory error:', error);
|
|
16
|
+
res.status(500).json({ error: 'Failed to fetch runtime inventory' });
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
router.post('/check', async (_req, res) => {
|
|
21
|
+
try {
|
|
22
|
+
const inventory = await runtimeManager.getRuntimeInventory();
|
|
23
|
+
res.json({
|
|
24
|
+
platform: runtimeManager.platformId,
|
|
25
|
+
runtimes: inventory,
|
|
26
|
+
checkedAt: new Date().toISOString(),
|
|
27
|
+
});
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.error('[Runtimes] Check error:', error);
|
|
30
|
+
res.status(500).json({ error: 'Failed to check runtimes' });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
module.exports = router;
|
|
@@ -12,10 +12,11 @@ const path = require('path');
|
|
|
12
12
|
const router = Router();
|
|
13
13
|
|
|
14
14
|
router.post('/', async (req, res) => {
|
|
15
|
-
const { tmux, engines, store, logWatcher,
|
|
15
|
+
const { tmux, engines, store, logWatcher, remotePaneWatcher, attachments: attachmentManager } = req;
|
|
16
16
|
const {
|
|
17
17
|
conversationId,
|
|
18
18
|
message,
|
|
19
|
+
attachments = [],
|
|
19
20
|
model,
|
|
20
21
|
engine: engineOverride,
|
|
21
22
|
workspace,
|
|
@@ -44,7 +45,10 @@ router.post('/', async (req, res) => {
|
|
|
44
45
|
// Resolve engine and model
|
|
45
46
|
const resolvedEngine = engineOverride || resolveEngine(model, engines);
|
|
46
47
|
const host = hostName || 'local';
|
|
47
|
-
const hostObj =
|
|
48
|
+
const hostObj = tmux.resolveHost(host);
|
|
49
|
+
if (!hostObj) {
|
|
50
|
+
throw new Error(`Unknown host "${host}"`);
|
|
51
|
+
}
|
|
48
52
|
|
|
49
53
|
// Get or create conversation
|
|
50
54
|
let conversation;
|
|
@@ -68,11 +72,41 @@ router.post('/', async (req, res) => {
|
|
|
68
72
|
});
|
|
69
73
|
}
|
|
70
74
|
|
|
75
|
+
let fullMessage = message;
|
|
76
|
+
if (Array.isArray(attachments) && attachments.length > 0) {
|
|
77
|
+
const staged = attachments.map((file) =>
|
|
78
|
+
attachmentManager.stageForConversation(file, conversation.id, hostObj)
|
|
79
|
+
);
|
|
80
|
+
const fileLines = staged.map((file) => `- ${file.originalName || file.name} (${file.promptPath})`);
|
|
81
|
+
fullMessage = `[Attached files:\n${fileLines.join('\n')}\n]\n\n${fullMessage}`;
|
|
82
|
+
sendSSE('status', { content: `Prepared ${staged.length} attachment(s)` });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Context Bridge: if engine changed, add context to message
|
|
86
|
+
if (!isNew && conversationId && conversation.engine && conversation.engine !== resolvedEngine) {
|
|
87
|
+
try {
|
|
88
|
+
const { default: ContextBridge } = require('../services/context-bridge');
|
|
89
|
+
const ctx = await ContextBridge.buildContext({
|
|
90
|
+
conversationId: conversation.id,
|
|
91
|
+
fromEngine: conversation.engine,
|
|
92
|
+
toEngine: resolvedEngine,
|
|
93
|
+
userMessage: message
|
|
94
|
+
});
|
|
95
|
+
if (ctx.prompt) {
|
|
96
|
+
fullMessage = ctx.prompt;
|
|
97
|
+
console.log(`[send] Applied context bridge: ${conversation.engine} -> ${resolvedEngine} (${ctx.contextTokens} tokens)`);
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.warn('[send] Context bridge failed, proceeding without context:', err.message);
|
|
101
|
+
// Continue without context - non-fatal
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
71
105
|
// Save user message
|
|
72
106
|
store.addMessage({
|
|
73
107
|
conversationId: conversation.id,
|
|
74
108
|
role: 'user',
|
|
75
|
-
content:
|
|
109
|
+
content: fullMessage,
|
|
76
110
|
engine: resolvedEngine,
|
|
77
111
|
model: model
|
|
78
112
|
});
|
|
@@ -92,6 +126,7 @@ router.post('/', async (req, res) => {
|
|
|
92
126
|
} else if (engineInfo?.available) {
|
|
93
127
|
command = buildEngineCommand(resolvedEngine, engineInfo, {
|
|
94
128
|
model,
|
|
129
|
+
message: fullMessage, // Pass fullMessage for codex exec mode
|
|
95
130
|
resume: resumeSession || (conversation.tmux_window ? conversation.id : null)
|
|
96
131
|
});
|
|
97
132
|
} else {
|
|
@@ -102,10 +137,14 @@ router.post('/', async (req, res) => {
|
|
|
102
137
|
|
|
103
138
|
// Create or reuse tmux window
|
|
104
139
|
let logPath;
|
|
140
|
+
let remoteInitialSnapshot = '';
|
|
105
141
|
|
|
106
142
|
if (conversation.tmux_window && tmux.hasWindow(conversation.tmux_window, hostObj)) {
|
|
143
|
+
if (hostObj.type !== 'local') {
|
|
144
|
+
remoteInitialSnapshot = tmux.capturePane(conversation.tmux_window, hostObj, 250);
|
|
145
|
+
}
|
|
107
146
|
// Resume existing window — send message directly
|
|
108
|
-
tmux.sendKeys(conversation.tmux_window,
|
|
147
|
+
tmux.sendKeys(conversation.tmux_window, fullMessage, { enter: true, host: hostObj });
|
|
109
148
|
logPath = conversation.log_path;
|
|
110
149
|
} else {
|
|
111
150
|
// Create new tmux window
|
|
@@ -113,7 +152,7 @@ router.post('/', async (req, res) => {
|
|
|
113
152
|
windowName,
|
|
114
153
|
command,
|
|
115
154
|
cwd: conversation.workspace || workspace || process.env.HOME,
|
|
116
|
-
host:
|
|
155
|
+
host: hostObj
|
|
117
156
|
});
|
|
118
157
|
logPath = result.logPath;
|
|
119
158
|
|
|
@@ -127,13 +166,18 @@ router.post('/', async (req, res) => {
|
|
|
127
166
|
if (resolvedEngine !== 'codex') {
|
|
128
167
|
// Interactive CLIs need a moment to start
|
|
129
168
|
await new Promise(r => setTimeout(r, 1500));
|
|
130
|
-
|
|
169
|
+
if (hostObj.type !== 'local') {
|
|
170
|
+
remoteInitialSnapshot = tmux.capturePane(windowName, hostObj, 250);
|
|
171
|
+
}
|
|
172
|
+
tmux.sendKeys(windowName, fullMessage, { enter: true, host: hostObj });
|
|
173
|
+
} else if (hostObj.type !== 'local') {
|
|
174
|
+
remoteInitialSnapshot = tmux.capturePane(windowName, hostObj, 250);
|
|
131
175
|
}
|
|
132
176
|
// Codex exec mode gets the message via the command itself
|
|
133
177
|
}
|
|
134
178
|
|
|
135
179
|
// Start watching the log
|
|
136
|
-
if (logPath) {
|
|
180
|
+
if (hostObj.type === 'local' && logPath) {
|
|
137
181
|
logWatcher.watch(logPath, resolvedEngine, conversation.id);
|
|
138
182
|
}
|
|
139
183
|
|
|
@@ -160,6 +204,10 @@ router.post('/', async (req, res) => {
|
|
|
160
204
|
responseBuffer += data.content;
|
|
161
205
|
sendSSE('chunk', { content: data.content });
|
|
162
206
|
break;
|
|
207
|
+
case 'raw':
|
|
208
|
+
responseBuffer += data.content;
|
|
209
|
+
sendSSE('chunk', { content: data.content });
|
|
210
|
+
break;
|
|
163
211
|
case 'message_done':
|
|
164
212
|
if (!doneFired) {
|
|
165
213
|
doneFired = true;
|
|
@@ -177,10 +225,25 @@ router.post('/', async (req, res) => {
|
|
|
177
225
|
}
|
|
178
226
|
};
|
|
179
227
|
|
|
180
|
-
|
|
228
|
+
const activeWatcher = hostObj.type === 'local' ? logWatcher : remotePaneWatcher;
|
|
229
|
+
activeWatcher.on('data', onData);
|
|
230
|
+
|
|
231
|
+
if (hostObj.type !== 'local') {
|
|
232
|
+
remotePaneWatcher.watch({
|
|
233
|
+
conversationId: conversation.id,
|
|
234
|
+
engine: resolvedEngine,
|
|
235
|
+
host: hostObj,
|
|
236
|
+
windowName: conversation.tmux_window || windowName,
|
|
237
|
+
promptText: fullMessage,
|
|
238
|
+
initialSnapshot: remoteInitialSnapshot
|
|
239
|
+
});
|
|
240
|
+
}
|
|
181
241
|
|
|
182
242
|
function finishResponse(content, usage) {
|
|
183
|
-
|
|
243
|
+
activeWatcher.off('data', onData);
|
|
244
|
+
if (hostObj.type !== 'local') {
|
|
245
|
+
remotePaneWatcher.unwatchByConversation(conversation.id);
|
|
246
|
+
}
|
|
184
247
|
clearTimeout(timeout);
|
|
185
248
|
|
|
186
249
|
// Save assistant message
|
|
@@ -228,8 +291,7 @@ router.post('/interrupt', (req, res) => {
|
|
|
228
291
|
return res.json({ success: false, reason: 'No active tmux window' });
|
|
229
292
|
}
|
|
230
293
|
|
|
231
|
-
|
|
232
|
-
tmux.interrupt(conversation.tmux_window, host);
|
|
294
|
+
tmux.interrupt(conversation.tmux_window, conversation.host || 'local');
|
|
233
295
|
|
|
234
296
|
res.json({ success: true, method: 'tmux interrupt' });
|
|
235
297
|
});
|
|
@@ -255,7 +317,9 @@ function buildEngineCommand(engine, engineInfo, opts = {}) {
|
|
|
255
317
|
return `${bin} --output-format json${opts.model ? ` --model ${opts.model}` : ''}`;
|
|
256
318
|
|
|
257
319
|
case 'codex':
|
|
258
|
-
|
|
320
|
+
// For codex, message is passed via --prompt flag in exec mode
|
|
321
|
+
const prompt = opts.message ? ` --prompt ${JSON.stringify(opts.message)}` : '';
|
|
322
|
+
return `${bin} exec --json${opts.model ? ` -m ${opts.model}` : ''}${opts.reasoningEffort ? ` --reasoning ${opts.reasoningEffort}` : ''}${prompt}`;
|
|
259
323
|
|
|
260
324
|
case 'gemini':
|
|
261
325
|
if (opts.resume) {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GET /api/v1/sessions — List/load sessions
|
|
2
|
+
* GET /api/v1/sessions — List/load sessions (with tmux_alive status)
|
|
3
3
|
* GET /api/v1/sessions/:id/messages — Get session messages
|
|
4
4
|
* GET /api/v1/sessions/:id/summary — Get session summary
|
|
5
5
|
* POST /api/v1/sessions/:id/rename — Rename session
|
|
6
|
+
* POST /api/v1/sessions/:id/pin — Pin/unpin session
|
|
6
7
|
* DELETE /api/v1/sessions/:id — Delete session
|
|
7
8
|
*/
|
|
8
9
|
|
|
@@ -11,15 +12,37 @@ const router = Router();
|
|
|
11
12
|
|
|
12
13
|
// List conversations (optionally grouped by date)
|
|
13
14
|
router.get('/', (req, res) => {
|
|
14
|
-
const { store } = req;
|
|
15
|
-
const { workspace, groupBy } = req.query;
|
|
15
|
+
const { store, tmux } = req;
|
|
16
|
+
const { workspace, groupBy, host } = req.query;
|
|
16
17
|
|
|
17
18
|
if (groupBy === 'date') {
|
|
18
|
-
const grouped = store.listGroupedByDate({ workspace });
|
|
19
|
+
const grouped = store.listGroupedByDate({ workspace, host });
|
|
20
|
+
|
|
21
|
+
// Enrich with tmux_alive status
|
|
22
|
+
Object.keys(grouped).forEach(dateGroup => {
|
|
23
|
+
grouped[dateGroup].forEach(conv => {
|
|
24
|
+
if (conv.tmux_window) {
|
|
25
|
+
conv.tmux_alive = tmux.hasWindow(conv.tmux_window, conv.host || 'local');
|
|
26
|
+
} else {
|
|
27
|
+
conv.tmux_alive = false;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
19
32
|
return res.json(grouped);
|
|
20
33
|
}
|
|
21
34
|
|
|
22
|
-
const convs = store.listConversations({ workspace });
|
|
35
|
+
const convs = store.listConversations({ workspace, host });
|
|
36
|
+
|
|
37
|
+
// Enrich with tmux_alive status
|
|
38
|
+
convs.forEach(conv => {
|
|
39
|
+
if (conv.tmux_window) {
|
|
40
|
+
conv.tmux_alive = tmux.hasWindow(conv.tmux_window, conv.host || 'local');
|
|
41
|
+
} else {
|
|
42
|
+
conv.tmux_alive = false;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
23
46
|
res.json({ conversations: convs });
|
|
24
47
|
});
|
|
25
48
|
|
|
@@ -33,8 +56,7 @@ router.get('/:id', (req, res) => {
|
|
|
33
56
|
}
|
|
34
57
|
|
|
35
58
|
// Check tmux window status
|
|
36
|
-
const
|
|
37
|
-
const alive = conv.tmux_window ? tmux.hasWindow(conv.tmux_window, host) : false;
|
|
59
|
+
const alive = conv.tmux_window ? tmux.hasWindow(conv.tmux_window, conv.host || 'local') : false;
|
|
38
60
|
|
|
39
61
|
res.json({
|
|
40
62
|
session: {
|
|
@@ -79,23 +101,30 @@ router.post('/:id/rename', (req, res) => {
|
|
|
79
101
|
res.json({ success: true, title });
|
|
80
102
|
});
|
|
81
103
|
|
|
104
|
+
// Pin/unpin conversation
|
|
105
|
+
router.post('/:id/pin', (req, res) => {
|
|
106
|
+
const { store } = req;
|
|
107
|
+
const result = store.pinConversation(req.params.id);
|
|
108
|
+
res.json({ pinned: result });
|
|
109
|
+
});
|
|
110
|
+
|
|
82
111
|
// Delete conversation (kills tmux window too)
|
|
83
112
|
router.delete('/:id', (req, res) => {
|
|
84
|
-
const { store, tmux, logWatcher } = req;
|
|
113
|
+
const { store, tmux, logWatcher, remotePaneWatcher } = req;
|
|
85
114
|
const conv = store.getConversation(req.params.id);
|
|
86
115
|
|
|
87
116
|
if (!conv) return res.status(404).json({ error: 'Not found' });
|
|
88
117
|
|
|
89
118
|
// Kill tmux window if active
|
|
90
119
|
if (conv.tmux_window) {
|
|
91
|
-
|
|
92
|
-
tmux.killWindow(conv.tmux_window, host);
|
|
120
|
+
tmux.killWindow(conv.tmux_window, conv.host || 'local');
|
|
93
121
|
}
|
|
94
122
|
|
|
95
123
|
// Stop log watcher
|
|
96
124
|
if (conv.log_path) {
|
|
97
125
|
logWatcher.unwatch(conv.log_path);
|
|
98
126
|
}
|
|
127
|
+
remotePaneWatcher?.unwatchByConversation?.(req.params.id);
|
|
99
128
|
|
|
100
129
|
store.deleteConversation(req.params.id);
|
|
101
130
|
res.json({ success: true });
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const router = express.Router();
|
|
3
|
+
const multer = require('multer');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const { PATHS } = require('../../utils/paths');
|
|
8
|
+
|
|
9
|
+
const upload = multer({
|
|
10
|
+
storage: multer.memoryStorage(),
|
|
11
|
+
limits: { fileSize: 25 * 1024 * 1024 }
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
router.get('/status', (req, res) => {
|
|
15
|
+
res.json(req.speech.getStatus());
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
router.post('/transcribe', upload.single('audio'), async (req, res) => {
|
|
19
|
+
let tempPath = null;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
if (!req.file) {
|
|
23
|
+
return res.status(400).json({ error: 'No audio file provided' });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
fs.mkdirSync(PATHS.DATA_DIR, { recursive: true });
|
|
27
|
+
tempPath = path.join(PATHS.DATA_DIR, `stt-${Date.now()}-${Math.random().toString(16).slice(2)}.webm`);
|
|
28
|
+
fs.writeFileSync(tempPath, req.file.buffer);
|
|
29
|
+
|
|
30
|
+
const result = await req.speech.transcribe({
|
|
31
|
+
audioPath: tempPath,
|
|
32
|
+
language: req.body.language
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
res.json(result);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
console.error('[Speech] Transcription error:', error);
|
|
38
|
+
res.status(500).json({ error: error.message });
|
|
39
|
+
} finally {
|
|
40
|
+
if (tempPath && fs.existsSync(tempPath)) {
|
|
41
|
+
fs.unlinkSync(tempPath);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
module.exports = router;
|