@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +89 -152
  3. package/bin/nexuscli.js +12 -0
  4. package/frontend/dist/assets/{index-D8XkscmI.js → index-Bztt9hew.js} +1704 -1704
  5. package/frontend/dist/assets/{index-CoLEGBO4.css → index-Dj7jz2fy.css} +1 -1
  6. package/frontend/dist/index.html +2 -2
  7. package/frontend/dist/sw.js +1 -1
  8. package/lib/cli/api.js +19 -1
  9. package/lib/cli/config.js +27 -5
  10. package/lib/cli/engines.js +84 -202
  11. package/lib/cli/init.js +56 -2
  12. package/lib/cli/model.js +17 -7
  13. package/lib/cli/start.js +37 -24
  14. package/lib/cli/stop.js +12 -41
  15. package/lib/cli/update.js +28 -0
  16. package/lib/cli/workspaces.js +4 -0
  17. package/lib/config/manager.js +112 -8
  18. package/lib/config/models.js +388 -192
  19. package/lib/server/db/migrations/001_ultra_light_schema.sql +1 -1
  20. package/lib/server/db/migrations/006_runtime_lane_tracking.sql +79 -0
  21. package/lib/server/lib/getPty.js +51 -0
  22. package/lib/server/lib/pty-adapter.js +101 -57
  23. package/lib/server/lib/pty-provider.js +63 -0
  24. package/lib/server/lib/pty-utils-loader.js +136 -0
  25. package/lib/server/middleware/auth.js +27 -4
  26. package/lib/server/models/Conversation.js +7 -3
  27. package/lib/server/models/Message.js +29 -5
  28. package/lib/server/routes/chat.js +27 -4
  29. package/lib/server/routes/codex.js +35 -8
  30. package/lib/server/routes/config.js +9 -1
  31. package/lib/server/routes/gemini.js +24 -5
  32. package/lib/server/routes/jobs.js +15 -156
  33. package/lib/server/routes/models.js +12 -10
  34. package/lib/server/routes/qwen.js +26 -7
  35. package/lib/server/routes/runtimes.js +68 -0
  36. package/lib/server/server.js +3 -0
  37. package/lib/server/services/claude-wrapper.js +60 -62
  38. package/lib/server/services/codex-wrapper.js +79 -10
  39. package/lib/server/services/gemini-wrapper.js +9 -4
  40. package/lib/server/services/job-runner.js +156 -0
  41. package/lib/server/services/qwen-wrapper.js +26 -11
  42. package/lib/server/services/runtime-manager.js +467 -0
  43. package/lib/server/services/session-manager.js +56 -14
  44. package/lib/server/tests/integration.test.js +12 -0
  45. package/lib/server/tests/runtime-manager.test.js +46 -0
  46. package/lib/server/tests/runtime-persistence.test.js +97 -0
  47. package/lib/setup/postinstall-pty-check.js +183 -0
  48. package/lib/setup/postinstall.js +60 -41
  49. package/lib/utils/restart-warning.js +18 -0
  50. package/lib/utils/server.js +88 -0
  51. package/lib/utils/termux.js +1 -1
  52. package/lib/utils/update-check.js +153 -0
  53. package/lib/utils/update-runner.js +62 -0
  54. 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;
@@ -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 (/home/dag/.claude/local/claude)
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({ prompt, conversationId, model = 'sonnet', workspacePath, onStatus }) {
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 isDeepSeek = model.startsWith('deepseek-');
168
- const isGLM = model === 'glm-4-6';
169
- const isAlternativeModel = isDeepSeek || isGLM;
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
- // Alternative models (DeepSeek, GLM) use ANTHROPIC_MODEL env var
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
- // Build environment - configure API for alternative models
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
- if (!deepseekKey) {
208
- const errorMsg = `DeepSeek API key not configured!\n\n` +
209
- `Run this command to add your API key:\n` +
210
- ` nexuscli api set deepseek YOUR_API_KEY\n\n` +
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
- return reject(new Error(errorMsg));
235
+ if (!spawnEnv.ANTHROPIC_AUTH_TOKEN) {
236
+ spawnEnv.ANTHROPIC_AUTH_TOKEN = this.resolveProviderToken(providerAuth);
224
237
  }
225
238
 
226
- // DeepSeek uses Anthropic-compatible API at different endpoint
227
- spawnEnv.ANTHROPIC_BASE_URL = 'https://api.deepseek.com/anthropic';
228
- spawnEnv.ANTHROPIC_AUTH_TOKEN = deepseekKey;
229
- spawnEnv.ANTHROPIC_MODEL = model; // Pass model name to API
230
- console.log(`[ClaudeWrapper] DeepSeek detected - using api.deepseek.com/anthropic`);
231
- } else if (isGLM) {
232
- // Get API key from database (priority) or fallback to env var
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
- ` nexuscli api set zai YOUR_API_KEY\n\n` +
239
- `Get your key at: https://z.ai`;
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}${isDeepSeek ? ' (DeepSeek API)' : isGLM ? ' (Z.ai API)' : ''}`);
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() && this.claudePath.endsWith('/claude')) {
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(this.claudePath);
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
- let timeoutMs = 600000; // 10 minutes default
361
- let timeoutLabel = '10 minutes';
362
- if (isGLM) {
363
- timeoutMs = 3600000; // 60 minutes for GLM-4.6 (slow responses)
364
- timeoutLabel = '60 minutes';
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({ prompt, model, threadId, reasoningEffort, workspacePath, imageFiles = [], onStatus, processId: processIdOverride }) {
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 proc = spawn(this.codexBin, args, {
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
- exec(`${this.codexBin} --version`, (error, stdout) => {
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
- exec(`${this.codexBin} exec --help`, (error, stdout) => {
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
- ptyProcess = pty.spawn(this.geminiPath, args, {
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
- exec(`${this.geminiPath} --version`, { timeout: 5000 }, (error, stdout) => {
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
+ };