@mmmbuto/nexuscli 0.9.7005-termux → 0.10.0
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/CHANGELOG.md +84 -0
- package/README.md +89 -152
- package/bin/nexuscli.js +12 -0
- package/frontend/dist/assets/{index-D8XkscmI.js → index-Bztt9hew.js} +1704 -1704
- package/frontend/dist/assets/{index-CoLEGBO4.css → index-Dj7jz2fy.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +1 -1
- package/lib/cli/api.js +19 -1
- package/lib/cli/config.js +27 -5
- package/lib/cli/engines.js +84 -202
- package/lib/cli/init.js +56 -2
- package/lib/cli/model.js +17 -7
- package/lib/cli/start.js +37 -24
- package/lib/cli/stop.js +12 -41
- package/lib/cli/update.js +28 -0
- package/lib/cli/workspaces.js +4 -0
- package/lib/config/manager.js +112 -8
- package/lib/config/models.js +388 -192
- package/lib/server/db/migrations/001_ultra_light_schema.sql +1 -1
- package/lib/server/db/migrations/006_runtime_lane_tracking.sql +79 -0
- package/lib/server/lib/getPty.js +51 -0
- package/lib/server/lib/pty-adapter.js +101 -57
- package/lib/server/lib/pty-provider.js +63 -0
- package/lib/server/lib/pty-utils-loader.js +136 -0
- package/lib/server/middleware/auth.js +27 -4
- package/lib/server/models/Conversation.js +7 -3
- package/lib/server/models/Message.js +29 -5
- package/lib/server/routes/chat.js +27 -4
- package/lib/server/routes/codex.js +35 -8
- package/lib/server/routes/config.js +9 -1
- package/lib/server/routes/gemini.js +24 -5
- package/lib/server/routes/jobs.js +15 -156
- package/lib/server/routes/models.js +12 -10
- package/lib/server/routes/qwen.js +26 -7
- package/lib/server/routes/runtimes.js +68 -0
- package/lib/server/server.js +3 -0
- package/lib/server/services/claude-wrapper.js +60 -62
- package/lib/server/services/codex-wrapper.js +79 -10
- package/lib/server/services/gemini-wrapper.js +9 -4
- package/lib/server/services/job-runner.js +156 -0
- package/lib/server/services/qwen-wrapper.js +26 -11
- package/lib/server/services/runtime-manager.js +467 -0
- package/lib/server/services/session-manager.js +56 -14
- package/lib/server/tests/integration.test.js +12 -0
- package/lib/server/tests/runtime-manager.test.js +46 -0
- package/lib/server/tests/runtime-persistence.test.js +97 -0
- package/lib/setup/postinstall-pty-check.js +183 -0
- package/lib/setup/postinstall.js +60 -41
- package/lib/utils/restart-warning.js +18 -0
- package/lib/utils/server.js +88 -0
- package/lib/utils/termux.js +1 -1
- package/lib/utils/update-check.js +153 -0
- package/lib/utils/update-runner.js +62 -0
- package/package.json +6 -5
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const RuntimeManager = require('../services/runtime-manager');
|
|
3
|
+
const { createJob } = require('../services/job-runner');
|
|
4
|
+
|
|
5
|
+
const router = express.Router();
|
|
6
|
+
const runtimeManager = new RuntimeManager();
|
|
7
|
+
|
|
8
|
+
router.get('/', async (_req, res) => {
|
|
9
|
+
try {
|
|
10
|
+
const inventory = await runtimeManager.getRuntimeInventory();
|
|
11
|
+
res.json({
|
|
12
|
+
platform: runtimeManager.platformId,
|
|
13
|
+
runtimes: inventory,
|
|
14
|
+
});
|
|
15
|
+
} catch (error) {
|
|
16
|
+
console.error('[Runtimes] Inventory error:', error);
|
|
17
|
+
res.status(500).json({ error: 'Failed to fetch runtime inventory' });
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
router.post('/check', async (_req, res) => {
|
|
22
|
+
try {
|
|
23
|
+
const inventory = await runtimeManager.getRuntimeInventory();
|
|
24
|
+
res.json({
|
|
25
|
+
platform: runtimeManager.platformId,
|
|
26
|
+
runtimes: inventory,
|
|
27
|
+
checkedAt: new Date().toISOString(),
|
|
28
|
+
});
|
|
29
|
+
} catch (error) {
|
|
30
|
+
console.error('[Runtimes] Check error:', error);
|
|
31
|
+
res.status(500).json({ error: 'Failed to check runtimes' });
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function queueRuntimeAction(req, res, action) {
|
|
36
|
+
const { runtimeId } = req.body || {};
|
|
37
|
+
|
|
38
|
+
if (!runtimeId) {
|
|
39
|
+
return res.status(400).json({ error: 'runtimeId required' });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const command = runtimeManager.resolveAction(runtimeId, action);
|
|
43
|
+
if (!command) {
|
|
44
|
+
return res.status(400).json({ error: `No ${action} command configured for ${runtimeId}` });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const job = createJob({
|
|
48
|
+
tool: 'bash',
|
|
49
|
+
command,
|
|
50
|
+
timeout: action === 'check' ? 15000 : 180000,
|
|
51
|
+
metadata: {
|
|
52
|
+
runtimeId,
|
|
53
|
+
action,
|
|
54
|
+
platform: runtimeManager.platformId,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return res.status(202).json({
|
|
59
|
+
...job,
|
|
60
|
+
runtimeId,
|
|
61
|
+
action,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
router.post('/install', (req, res) => queueRuntimeAction(req, res, 'install'));
|
|
66
|
+
router.post('/update', (req, res) => queueRuntimeAction(req, res, 'update'));
|
|
67
|
+
|
|
68
|
+
module.exports = router;
|
package/lib/server/server.js
CHANGED
|
@@ -24,6 +24,7 @@ const codexRouter = require('./routes/codex');
|
|
|
24
24
|
const geminiRouter = require('./routes/gemini');
|
|
25
25
|
const qwenRouter = require('./routes/qwen');
|
|
26
26
|
const modelsRouter = require('./routes/models');
|
|
27
|
+
const runtimesRouter = require('./routes/runtimes');
|
|
27
28
|
const workspaceRouter = require('./routes/workspace');
|
|
28
29
|
const workspacesRouter = require('./routes/workspaces');
|
|
29
30
|
const sessionsRouter = require('./routes/sessions');
|
|
@@ -67,6 +68,7 @@ app.use(express.static(frontendDist));
|
|
|
67
68
|
app.use('/api/v1/auth', authRouter);
|
|
68
69
|
app.use('/api/v1/models', modelsRouter);
|
|
69
70
|
app.use('/api/v1/config', configRouter);
|
|
71
|
+
app.use('/api/v1/runtimes', authMiddleware, runtimesRouter);
|
|
70
72
|
app.use('/api/v1/workspace', workspaceRouter);
|
|
71
73
|
app.use('/api/v1', wakeLockRouter); // Wake lock endpoints (public for app visibility handling)
|
|
72
74
|
app.use('/api/v1/workspaces', authMiddleware, workspacesRouter);
|
|
@@ -95,6 +97,7 @@ app.get('/', (req, res) => {
|
|
|
95
97
|
endpoints: {
|
|
96
98
|
health: '/health',
|
|
97
99
|
models: '/api/v1/models',
|
|
100
|
+
runtimes: '/api/v1/runtimes',
|
|
98
101
|
chat: '/api/v1/chat (Claude)',
|
|
99
102
|
codex: '/api/v1/codex (OpenAI)',
|
|
100
103
|
gemini: '/api/v1/gemini (Google)',
|
|
@@ -9,7 +9,7 @@ const { getApiKey } = require('../db');
|
|
|
9
9
|
* Wrapper for Claude Code CLI (local installation)
|
|
10
10
|
*
|
|
11
11
|
* Features:
|
|
12
|
-
* - Uses local Claude Code installation
|
|
12
|
+
* - Uses a local Claude Code installation discovered from PATH/common locations
|
|
13
13
|
* - OAuth authentication (handled by CLI)
|
|
14
14
|
* - Real-time status streaming via onStatus callback
|
|
15
15
|
* - Session management with conversation ID
|
|
@@ -134,6 +134,23 @@ class ClaudeWrapper extends BaseCliWrapper {
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
resolveProviderToken(providerAuth) {
|
|
138
|
+
if (!providerAuth) return null;
|
|
139
|
+
|
|
140
|
+
const envVars = providerAuth.envVars || [];
|
|
141
|
+
for (const envVar of envVars) {
|
|
142
|
+
if (process.env[envVar]) {
|
|
143
|
+
return process.env[envVar];
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (providerAuth.dbKey) {
|
|
148
|
+
return getApiKey(providerAuth.dbKey);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
137
154
|
/**
|
|
138
155
|
* Send message to Claude Code CLI
|
|
139
156
|
*
|
|
@@ -145,7 +162,16 @@ class ClaudeWrapper extends BaseCliWrapper {
|
|
|
145
162
|
* @param {Function} params.onStatus - Callback for status events (tool use, thinking)
|
|
146
163
|
* @returns {Promise<{text: string, usage: Object}>}
|
|
147
164
|
*/
|
|
148
|
-
async sendMessage({
|
|
165
|
+
async sendMessage({
|
|
166
|
+
prompt,
|
|
167
|
+
conversationId,
|
|
168
|
+
model = 'sonnet',
|
|
169
|
+
workspacePath,
|
|
170
|
+
onStatus,
|
|
171
|
+
runtimeCommand,
|
|
172
|
+
envOverrides = {},
|
|
173
|
+
providerAuth = null
|
|
174
|
+
}) {
|
|
149
175
|
return new Promise((resolve, reject) => {
|
|
150
176
|
const parser = new OutputParser();
|
|
151
177
|
// Prevent double-settling when PTY fires both error and exit events
|
|
@@ -164,9 +190,11 @@ class ClaudeWrapper extends BaseCliWrapper {
|
|
|
164
190
|
const isExistingSession = this.isExistingSession(conversationId);
|
|
165
191
|
|
|
166
192
|
// Detect alternative models early (needed for args construction)
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
193
|
+
const isAlternativeModel = Boolean(
|
|
194
|
+
providerAuth ||
|
|
195
|
+
envOverrides.ANTHROPIC_BASE_URL ||
|
|
196
|
+
envOverrides.ANTHROPIC_MODEL
|
|
197
|
+
);
|
|
170
198
|
|
|
171
199
|
// Build Claude Code CLI args
|
|
172
200
|
const args = [
|
|
@@ -176,8 +204,8 @@ class ClaudeWrapper extends BaseCliWrapper {
|
|
|
176
204
|
'--output-format', 'stream-json', // JSON streaming events
|
|
177
205
|
];
|
|
178
206
|
|
|
179
|
-
// Only pass --model for native Claude models
|
|
180
|
-
//
|
|
207
|
+
// Only pass --model for native Claude models.
|
|
208
|
+
// Custom lanes route through ANTHROPIC_* env overrides instead.
|
|
181
209
|
if (!isAlternativeModel) {
|
|
182
210
|
args.push('--model', model);
|
|
183
211
|
}
|
|
@@ -197,46 +225,27 @@ class ClaudeWrapper extends BaseCliWrapper {
|
|
|
197
225
|
// Termux compatibility: make sure ripgrep path exists before spawn
|
|
198
226
|
this.ensureRipgrepForTermux();
|
|
199
227
|
|
|
200
|
-
|
|
201
|
-
const spawnEnv = { ...process.env };
|
|
202
|
-
|
|
203
|
-
if (isDeepSeek) {
|
|
204
|
-
// Get API key from database (priority) or fallback to env var
|
|
205
|
-
const deepseekKey = getApiKey('deepseek') || process.env.DEEPSEEK_API_KEY;
|
|
228
|
+
const spawnEnv = { ...process.env, ...envOverrides };
|
|
206
229
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
`Get your key at: https://platform.deepseek.com/api_keys`;
|
|
212
|
-
|
|
213
|
-
console.error(`[ClaudeWrapper] ❌ ${errorMsg}`);
|
|
214
|
-
|
|
215
|
-
if (onStatus) {
|
|
216
|
-
onStatus({
|
|
217
|
-
type: 'error',
|
|
218
|
-
category: 'config',
|
|
219
|
-
message: errorMsg
|
|
220
|
-
});
|
|
221
|
-
}
|
|
230
|
+
if (isAlternativeModel) {
|
|
231
|
+
if (!spawnEnv.ANTHROPIC_MODEL) {
|
|
232
|
+
spawnEnv.ANTHROPIC_MODEL = model;
|
|
233
|
+
}
|
|
222
234
|
|
|
223
|
-
|
|
235
|
+
if (!spawnEnv.ANTHROPIC_AUTH_TOKEN) {
|
|
236
|
+
spawnEnv.ANTHROPIC_AUTH_TOKEN = this.resolveProviderToken(providerAuth);
|
|
224
237
|
}
|
|
225
238
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const glmKey = getApiKey('zai') || process.env.ZAI_API_KEY;
|
|
234
|
-
|
|
235
|
-
if (!glmKey) {
|
|
236
|
-
const errorMsg = `Z.ai API key not configured for GLM-4.6!\n\n` +
|
|
239
|
+
if (!spawnEnv.ANTHROPIC_AUTH_TOKEN) {
|
|
240
|
+
const providerLabel = providerAuth?.displayName || 'custom provider';
|
|
241
|
+
const providerKeyHint = providerAuth?.dbKey
|
|
242
|
+
? ` nexuscli api set ${providerAuth.dbKey} YOUR_API_KEY\n\n`
|
|
243
|
+
: '';
|
|
244
|
+
const helpUrl = providerAuth?.helpUrl || 'your provider dashboard';
|
|
245
|
+
const errorMsg = `${providerLabel} API key not configured!\n\n` +
|
|
237
246
|
`Run this command to add your API key:\n` +
|
|
238
|
-
|
|
239
|
-
`Get your key at:
|
|
247
|
+
providerKeyHint +
|
|
248
|
+
`Get your key at: ${helpUrl}`;
|
|
240
249
|
|
|
241
250
|
console.error(`[ClaudeWrapper] ❌ ${errorMsg}`);
|
|
242
251
|
|
|
@@ -250,29 +259,22 @@ class ClaudeWrapper extends BaseCliWrapper {
|
|
|
250
259
|
|
|
251
260
|
return reject(new Error(errorMsg));
|
|
252
261
|
}
|
|
253
|
-
|
|
254
|
-
// GLM-4.6 uses Z.ai Anthropic-compatible API
|
|
255
|
-
spawnEnv.ANTHROPIC_BASE_URL = 'https://api.z.ai/api/anthropic';
|
|
256
|
-
spawnEnv.ANTHROPIC_AUTH_TOKEN = glmKey;
|
|
257
|
-
spawnEnv.ANTHROPIC_MODEL = 'GLM-4.6'; // Z.ai model name
|
|
258
|
-
spawnEnv.API_TIMEOUT_MS = '3000000'; // 50 minutes timeout
|
|
259
|
-
console.log(`[ClaudeWrapper] GLM-4.6 detected - using Z.ai API with extended timeout`);
|
|
260
262
|
}
|
|
261
263
|
|
|
262
|
-
console.log(`[ClaudeWrapper] Model: ${model}${
|
|
264
|
+
console.log(`[ClaudeWrapper] Model: ${model}${isAlternativeModel ? ' (custom provider)' : ''}`);
|
|
263
265
|
console.log(`[ClaudeWrapper] Session: ${conversationId} (${isExistingSession ? 'RESUME' : 'NEW'})`);
|
|
264
266
|
console.log(`[ClaudeWrapper] Working dir: ${cwd}`);
|
|
265
267
|
|
|
266
268
|
// Spawn Claude Code CLI with PTY
|
|
267
269
|
// On Termux, invoke node directly with the cli.js script for better compatibility
|
|
268
|
-
let command = this.claudePath;
|
|
270
|
+
let command = runtimeCommand || this.claudePath;
|
|
269
271
|
let spawnArgs = args;
|
|
270
272
|
|
|
271
|
-
if (!pty.isPtyAvailable() &&
|
|
273
|
+
if (!pty.isPtyAvailable() && command && command.endsWith('/claude')) {
|
|
272
274
|
// Resolve symlink to actual cli.js and invoke with node
|
|
273
275
|
const fs = require('fs');
|
|
274
276
|
try {
|
|
275
|
-
const realPath = fs.realpathSync(
|
|
277
|
+
const realPath = fs.realpathSync(command);
|
|
276
278
|
if (realPath.endsWith('.js')) {
|
|
277
279
|
command = process.execPath; // node binary
|
|
278
280
|
spawnArgs = [realPath, ...args];
|
|
@@ -357,15 +359,11 @@ class ClaudeWrapper extends BaseCliWrapper {
|
|
|
357
359
|
let stdout = '';
|
|
358
360
|
|
|
359
361
|
// Dynamic timeout based on model
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
} else if (isDeepSeek) {
|
|
366
|
-
timeoutMs = 900000; // 15 minutes for DeepSeek
|
|
367
|
-
timeoutLabel = '15 minutes';
|
|
368
|
-
}
|
|
362
|
+
const configuredTimeout = Number.parseInt(spawnEnv.API_TIMEOUT_MS || '', 10);
|
|
363
|
+
const timeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout > 0
|
|
364
|
+
? configuredTimeout
|
|
365
|
+
: 600000;
|
|
366
|
+
const timeoutLabel = `${Math.ceil(timeoutMs / 60000)} minutes`;
|
|
369
367
|
|
|
370
368
|
const timeout = setTimeout(() => {
|
|
371
369
|
console.error(`[ClaudeWrapper] Timeout after ${timeoutLabel}`);
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
const { spawn, exec } = require('child_process');
|
|
12
12
|
const CodexOutputParser = require('./codex-output-parser');
|
|
13
13
|
const BaseCliWrapper = require('./base-cli-wrapper');
|
|
14
|
+
const { getApiKey } = require('../db');
|
|
14
15
|
|
|
15
16
|
class CodexWrapper extends BaseCliWrapper {
|
|
16
17
|
constructor(options = {}) {
|
|
@@ -23,6 +24,23 @@ class CodexWrapper extends BaseCliWrapper {
|
|
|
23
24
|
console.log('[CodexWrapper] Binary:', this.codexBin);
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
resolveProviderToken(providerAuth) {
|
|
28
|
+
if (!providerAuth) return null;
|
|
29
|
+
|
|
30
|
+
const envVars = providerAuth.envVars || [];
|
|
31
|
+
for (const envVar of envVars) {
|
|
32
|
+
if (process.env[envVar]) {
|
|
33
|
+
return process.env[envVar];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (providerAuth.dbKey) {
|
|
38
|
+
return getApiKey(providerAuth.dbKey);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
26
44
|
/**
|
|
27
45
|
* Send message and get response with streaming events
|
|
28
46
|
* @param {Object} options - Message options
|
|
@@ -35,7 +53,20 @@ class CodexWrapper extends BaseCliWrapper {
|
|
|
35
53
|
* @param {Function} options.onStatus - Callback for status events
|
|
36
54
|
* @returns {Promise<Object>} Response with text, usage, threadId
|
|
37
55
|
*/
|
|
38
|
-
async sendMessage({
|
|
56
|
+
async sendMessage({
|
|
57
|
+
prompt,
|
|
58
|
+
model,
|
|
59
|
+
threadId,
|
|
60
|
+
reasoningEffort,
|
|
61
|
+
workspacePath,
|
|
62
|
+
imageFiles = [],
|
|
63
|
+
onStatus,
|
|
64
|
+
processId: processIdOverride,
|
|
65
|
+
runtimeCommand,
|
|
66
|
+
envOverrides = {},
|
|
67
|
+
configOverrides = [],
|
|
68
|
+
providerAuth = null
|
|
69
|
+
}) {
|
|
39
70
|
return new Promise((resolve, reject) => {
|
|
40
71
|
const parser = new CodexOutputParser();
|
|
41
72
|
const cwd = workspacePath || this.workspaceDir;
|
|
@@ -59,6 +90,14 @@ class CodexWrapper extends BaseCliWrapper {
|
|
|
59
90
|
args.push('-c', `model_reasoning_effort="${reasoningEffort}"`);
|
|
60
91
|
}
|
|
61
92
|
|
|
93
|
+
if (!threadId && Array.isArray(configOverrides)) {
|
|
94
|
+
for (const override of configOverrides) {
|
|
95
|
+
if (override) {
|
|
96
|
+
args.push('-c', override);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
62
101
|
// Add image files for multimodal support (only for new sessions)
|
|
63
102
|
if (imageFiles && imageFiles.length > 0 && !threadId) {
|
|
64
103
|
for (const imagePath of imageFiles) {
|
|
@@ -84,12 +123,40 @@ class CodexWrapper extends BaseCliWrapper {
|
|
|
84
123
|
|
|
85
124
|
let stdout = '';
|
|
86
125
|
|
|
87
|
-
const
|
|
126
|
+
const command = runtimeCommand || this.codexBin;
|
|
127
|
+
const spawnEnv = {
|
|
128
|
+
...global.process.env,
|
|
129
|
+
TERM: 'xterm-256color',
|
|
130
|
+
...envOverrides,
|
|
131
|
+
};
|
|
132
|
+
const providerToken = this.resolveProviderToken(providerAuth);
|
|
133
|
+
if (providerAuth && !providerToken) {
|
|
134
|
+
const providerLabel = providerAuth.displayName || 'custom provider';
|
|
135
|
+
const providerKeyHint = providerAuth.dbKey
|
|
136
|
+
? ` nexuscli api set ${providerAuth.dbKey} YOUR_API_KEY\n\n`
|
|
137
|
+
: '';
|
|
138
|
+
const helpUrl = providerAuth.helpUrl || 'your provider dashboard';
|
|
139
|
+
return reject(new Error(
|
|
140
|
+
`${providerLabel} API key not configured!\n\n` +
|
|
141
|
+
`Run this command to add your API key:\n` +
|
|
142
|
+
providerKeyHint +
|
|
143
|
+
`Get your key at: ${helpUrl}`
|
|
144
|
+
));
|
|
145
|
+
}
|
|
146
|
+
if (providerToken) {
|
|
147
|
+
if (providerAuth?.assignOpenAiKey) {
|
|
148
|
+
spawnEnv.OPENAI_API_KEY = providerToken;
|
|
149
|
+
}
|
|
150
|
+
for (const envVar of providerAuth?.envVars || []) {
|
|
151
|
+
if (!spawnEnv[envVar]) {
|
|
152
|
+
spawnEnv[envVar] = providerToken;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const proc = spawn(command, args, {
|
|
88
158
|
cwd: cwd,
|
|
89
|
-
env:
|
|
90
|
-
...global.process.env,
|
|
91
|
-
TERM: 'xterm-256color',
|
|
92
|
-
},
|
|
159
|
+
env: spawnEnv,
|
|
93
160
|
});
|
|
94
161
|
|
|
95
162
|
// Register process for interrupt capability
|
|
@@ -208,9 +275,10 @@ class CodexWrapper extends BaseCliWrapper {
|
|
|
208
275
|
/**
|
|
209
276
|
* Check if Codex CLI is available
|
|
210
277
|
*/
|
|
211
|
-
async isAvailable() {
|
|
278
|
+
async isAvailable(runtimeCommand) {
|
|
212
279
|
return new Promise((resolve) => {
|
|
213
|
-
|
|
280
|
+
const command = runtimeCommand || this.codexBin;
|
|
281
|
+
exec(`${command} --version`, (error, stdout) => {
|
|
214
282
|
if (error) {
|
|
215
283
|
console.log('[CodexWrapper] Codex CLI not available:', error.message);
|
|
216
284
|
resolve(false);
|
|
@@ -225,9 +293,10 @@ class CodexWrapper extends BaseCliWrapper {
|
|
|
225
293
|
/**
|
|
226
294
|
* Check if exec subcommand is available
|
|
227
295
|
*/
|
|
228
|
-
async hasExecSupport() {
|
|
296
|
+
async hasExecSupport(runtimeCommand) {
|
|
229
297
|
return new Promise((resolve) => {
|
|
230
|
-
|
|
298
|
+
const command = runtimeCommand || this.codexBin;
|
|
299
|
+
exec(`${command} exec --help`, (error, stdout) => {
|
|
231
300
|
if (error) {
|
|
232
301
|
console.log('[CodexWrapper] exec subcommand not available');
|
|
233
302
|
resolve(false);
|
|
@@ -111,7 +111,9 @@ class GeminiWrapper extends BaseCliWrapper {
|
|
|
111
111
|
model = DEFAULT_MODEL,
|
|
112
112
|
workspacePath,
|
|
113
113
|
onStatus,
|
|
114
|
-
processId: processIdOverride
|
|
114
|
+
processId: processIdOverride,
|
|
115
|
+
runtimeCommand,
|
|
116
|
+
envOverrides = {}
|
|
115
117
|
}) {
|
|
116
118
|
return new Promise((resolve, reject) => {
|
|
117
119
|
const parser = new GeminiOutputParser();
|
|
@@ -143,7 +145,8 @@ class GeminiWrapper extends BaseCliWrapper {
|
|
|
143
145
|
// Spawn Gemini CLI with PTY
|
|
144
146
|
let ptyProcess;
|
|
145
147
|
try {
|
|
146
|
-
|
|
148
|
+
const command = runtimeCommand || this.geminiPath;
|
|
149
|
+
ptyProcess = pty.spawn(command, args, {
|
|
147
150
|
name: 'xterm-color',
|
|
148
151
|
cols: 120,
|
|
149
152
|
rows: 40,
|
|
@@ -151,6 +154,7 @@ class GeminiWrapper extends BaseCliWrapper {
|
|
|
151
154
|
env: {
|
|
152
155
|
...process.env,
|
|
153
156
|
TERM: 'xterm-256color',
|
|
157
|
+
...envOverrides,
|
|
154
158
|
}
|
|
155
159
|
});
|
|
156
160
|
} catch (spawnError) {
|
|
@@ -281,10 +285,11 @@ class GeminiWrapper extends BaseCliWrapper {
|
|
|
281
285
|
* Check if Gemini CLI is available
|
|
282
286
|
* @returns {Promise<boolean>}
|
|
283
287
|
*/
|
|
284
|
-
async isAvailable() {
|
|
288
|
+
async isAvailable(runtimeCommand) {
|
|
285
289
|
return new Promise((resolve) => {
|
|
286
290
|
const { exec } = require('child_process');
|
|
287
|
-
|
|
291
|
+
const command = runtimeCommand || this.geminiPath;
|
|
292
|
+
exec(`${command} --version`, { timeout: 5000 }, (error, stdout) => {
|
|
288
293
|
if (error) {
|
|
289
294
|
console.log('[GeminiWrapper] CLI not available:', error.message);
|
|
290
295
|
resolve(false);
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
const { v4: uuidv4 } = require('uuid');
|
|
2
|
+
const CliWrapper = require('../lib/cli-wrapper');
|
|
3
|
+
const db = require('../db');
|
|
4
|
+
|
|
5
|
+
const cliWrapper = new CliWrapper({
|
|
6
|
+
workspaceDir: process.cwd()
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
function ensureSseMap() {
|
|
10
|
+
if (!global.sseConnections) {
|
|
11
|
+
global.sseConnections = new Map();
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function emitSSE(jobId, event) {
|
|
16
|
+
ensureSseMap();
|
|
17
|
+
const res = global.sseConnections.get(jobId);
|
|
18
|
+
if (!res) return;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
console.error(`[Jobs] SSE write error for job ${jobId}:`, error);
|
|
24
|
+
global.sseConnections.delete(jobId);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function executeJob(jobId, { tool, command, workingDir, timeout }) {
|
|
29
|
+
console.log(`[Jobs] Executing job ${jobId}`);
|
|
30
|
+
|
|
31
|
+
let stmt = db.prepare(`
|
|
32
|
+
UPDATE jobs
|
|
33
|
+
SET status = ?, started_at = ?
|
|
34
|
+
WHERE id = ?
|
|
35
|
+
`);
|
|
36
|
+
stmt.run('executing', Date.now(), jobId);
|
|
37
|
+
|
|
38
|
+
emitSSE(jobId, {
|
|
39
|
+
type: 'status',
|
|
40
|
+
category: 'executing',
|
|
41
|
+
message: 'Executing on localhost...',
|
|
42
|
+
icon: '▶',
|
|
43
|
+
timestamp: new Date().toISOString()
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const result = await cliWrapper.execute({
|
|
48
|
+
jobId,
|
|
49
|
+
tool,
|
|
50
|
+
command,
|
|
51
|
+
workingDir,
|
|
52
|
+
timeout,
|
|
53
|
+
onStatus: (event) => emitSSE(jobId, event)
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
stmt = db.prepare(`
|
|
57
|
+
UPDATE jobs
|
|
58
|
+
SET status = ?, exit_code = ?, stdout = ?, stderr = ?,
|
|
59
|
+
duration = ?, completed_at = ?
|
|
60
|
+
WHERE id = ?
|
|
61
|
+
`);
|
|
62
|
+
|
|
63
|
+
const status = result.exitCode === 0 ? 'completed' : 'failed';
|
|
64
|
+
stmt.run(
|
|
65
|
+
status,
|
|
66
|
+
result.exitCode,
|
|
67
|
+
result.stdout,
|
|
68
|
+
result.stderr,
|
|
69
|
+
result.duration,
|
|
70
|
+
Date.now(),
|
|
71
|
+
jobId
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
emitSSE(jobId, {
|
|
75
|
+
type: 'response_done',
|
|
76
|
+
exitCode: result.exitCode,
|
|
77
|
+
duration: result.duration
|
|
78
|
+
});
|
|
79
|
+
emitSSE(jobId, { type: 'done' });
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error(`[Jobs] Job ${jobId} error:`, error);
|
|
82
|
+
|
|
83
|
+
stmt = db.prepare(`
|
|
84
|
+
UPDATE jobs
|
|
85
|
+
SET status = ?, stderr = ?, completed_at = ?
|
|
86
|
+
WHERE id = ?
|
|
87
|
+
`);
|
|
88
|
+
stmt.run('failed', error.message, Date.now(), jobId);
|
|
89
|
+
|
|
90
|
+
emitSSE(jobId, {
|
|
91
|
+
type: 'error',
|
|
92
|
+
error: error.message
|
|
93
|
+
});
|
|
94
|
+
emitSSE(jobId, { type: 'done' });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function createJob({
|
|
99
|
+
conversationId = null,
|
|
100
|
+
messageId = null,
|
|
101
|
+
nodeId = 'localhost',
|
|
102
|
+
tool = 'bash',
|
|
103
|
+
command,
|
|
104
|
+
workingDir,
|
|
105
|
+
timeout = 30000,
|
|
106
|
+
metadata = null,
|
|
107
|
+
}) {
|
|
108
|
+
const jobId = uuidv4();
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
|
|
111
|
+
const decoratedCommand = metadata ? `${command}\n# ${JSON.stringify(metadata)}` : command;
|
|
112
|
+
|
|
113
|
+
const stmt = db.prepare(`
|
|
114
|
+
INSERT INTO jobs (
|
|
115
|
+
id, conversation_id, message_id, node_id, tool, command,
|
|
116
|
+
status, created_at
|
|
117
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
118
|
+
`);
|
|
119
|
+
|
|
120
|
+
stmt.run(
|
|
121
|
+
jobId,
|
|
122
|
+
conversationId,
|
|
123
|
+
messageId,
|
|
124
|
+
nodeId,
|
|
125
|
+
tool,
|
|
126
|
+
decoratedCommand,
|
|
127
|
+
'queued',
|
|
128
|
+
now
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
setImmediate(() => {
|
|
132
|
+
executeJob(jobId, { tool, command, workingDir, timeout });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
jobId,
|
|
137
|
+
nodeId,
|
|
138
|
+
tool,
|
|
139
|
+
command,
|
|
140
|
+
status: 'queued',
|
|
141
|
+
createdAt: now,
|
|
142
|
+
streamEndpoint: `/api/v1/jobs/${jobId}/stream`
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function killJob(jobId) {
|
|
147
|
+
return cliWrapper.kill(jobId);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = {
|
|
151
|
+
createJob,
|
|
152
|
+
executeJob,
|
|
153
|
+
emitSSE,
|
|
154
|
+
ensureSseMap,
|
|
155
|
+
killJob,
|
|
156
|
+
};
|