@mmmbuto/nexuscli 0.7.6 → 0.7.8

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.
@@ -9,6 +9,20 @@ const contextBridge = require('../services/context-bridge');
9
9
  const router = express.Router();
10
10
  const codexWrapper = new CodexWrapper();
11
11
 
12
+ function ensureConversation(conversationId, workspacePath) {
13
+ try {
14
+ const stmt = prepare(`
15
+ INSERT OR IGNORE INTO conversations (id, title, created_at, updated_at, metadata)
16
+ VALUES (?, ?, ?, ?, ?)
17
+ `);
18
+ const now = Date.now();
19
+ const metadata = workspacePath ? JSON.stringify({ workspace: workspacePath }) : null;
20
+ stmt.run(conversationId, 'New Chat', now, now, metadata);
21
+ } catch (err) {
22
+ console.warn('[Codex] Failed to ensure conversation exists:', err.message);
23
+ }
24
+ }
25
+
12
26
  /**
13
27
  * POST /api/v1/codex
14
28
  * Send message to Codex CLI with SSE streaming
@@ -61,6 +75,8 @@ router.post('/', async (req, res) => {
61
75
  // Use SessionManager for session sync pattern
62
76
  // conversationId → sessionId (per engine)
63
77
  const frontendConversationId = conversationId || uuidv4();
78
+ ensureConversation(frontendConversationId, workspacePath);
79
+
64
80
  const { sessionId, isNew: isNewSession } = await sessionManager.getOrCreateSession(
65
81
  frontendConversationId,
66
82
  'codex',
@@ -80,10 +96,10 @@ router.post('/', async (req, res) => {
80
96
  res.setHeader('Connection', 'keep-alive');
81
97
 
82
98
  // Send initial event
83
- res.write(`data: ${JSON.stringify({ type: 'message_start', messageId: `user-${Date.now()}`, sessionId })}\n\n`);
99
+ res.write(`data: ${JSON.stringify({ type: 'message_start', messageId: `user-${Date.now()}`, sessionId, conversationId: frontendConversationId })}\n\n`);
84
100
 
85
101
  // Check if this is an engine switch (requires context bridging)
86
- const lastEngine = Message.getLastEngine(sessionId);
102
+ const lastEngine = Message.getLastEngine(frontendConversationId);
87
103
  const isEngineBridge = lastEngine && lastEngine !== 'codex';
88
104
 
89
105
  // IMPORTANT: Skip contextBridge for Codex native resume!
@@ -94,6 +110,7 @@ router.post('/', async (req, res) => {
94
110
  if (isEngineBridge) {
95
111
  // Engine switch: need context from previous engine
96
112
  const contextResult = await contextBridge.buildContext({
113
+ conversationId: frontendConversationId,
97
114
  sessionId,
98
115
  fromEngine: lastEngine,
99
116
  toEngine: 'codex',
@@ -119,7 +136,7 @@ router.post('/', async (req, res) => {
119
136
  // Save user message to database with engine tracking
120
137
  try {
121
138
  const userMessage = Message.create(
122
- sessionId,
139
+ frontendConversationId,
123
140
  'user',
124
141
  message,
125
142
  { workspace: workspacePath },
@@ -127,6 +144,7 @@ router.post('/', async (req, res) => {
127
144
  'codex' // Engine tracking for context bridging
128
145
  );
129
146
  console.log(`[Codex] Saved user message: ${userMessage.id} (engine: codex)`);
147
+ sessionManager.bumpSessionActivity(sessionId, 1);
130
148
  } catch (msgErr) {
131
149
  console.warn('[Codex] Failed to save user message:', msgErr.message);
132
150
  }
@@ -138,21 +156,28 @@ router.post('/', async (req, res) => {
138
156
  threadId: nativeThreadId, // Native Codex thread ID for resume
139
157
  reasoningEffort,
140
158
  workspacePath,
159
+ processId: sessionId, // Use Nexus sessionId for interrupt tracking
141
160
  onStatus: (event) => {
142
161
  // Stream status events to client
143
162
  res.write(`data: ${JSON.stringify(event)}\n\n`);
144
163
  }
145
164
  });
146
165
 
147
- // Save native threadId for future resume (if new)
148
- if (result.threadId && result.threadId !== nativeThreadId) {
166
+ // Persist native threadId for future resume (always, even if unchanged)
167
+ if (result.threadId) {
149
168
  sessionManager.setNativeThreadId(sessionId, result.threadId);
169
+ const unchanged = nativeThreadId && nativeThreadId === result.threadId ? ' (unchanged)' : '';
170
+ console.log(`[Codex] Saved native threadId: ${result.threadId}${unchanged}`);
171
+ } else if (nativeThreadId) {
172
+ console.log(`[Codex] Reused existing native threadId: ${nativeThreadId}`);
173
+ } else {
174
+ console.warn('[Codex] No threadId returned; resume may not work for next turn');
150
175
  }
151
176
 
152
177
  // Save assistant response to database with engine tracking
153
178
  try {
154
179
  const assistantMessage = Message.create(
155
- sessionId,
180
+ frontendConversationId,
156
181
  'assistant',
157
182
  result.text,
158
183
  { usage: result.usage, model },
@@ -160,13 +185,14 @@ router.post('/', async (req, res) => {
160
185
  'codex' // Engine tracking for context bridging
161
186
  );
162
187
  console.log(`[Codex] Saved assistant message: ${assistantMessage.id} (engine: codex)`);
188
+ sessionManager.bumpSessionActivity(sessionId, 1);
163
189
  } catch (msgErr) {
164
190
  console.warn('[Codex] Failed to save assistant message:', msgErr.message);
165
191
  }
166
192
 
167
193
  // Smart auto-summary: trigger based on message count and engine bridging
168
- if (contextBridge.shouldTriggerSummary(sessionId, isEngineBridge)) {
169
- contextBridge.triggerSummaryGeneration(sessionId, '[Codex]');
194
+ if (contextBridge.shouldTriggerSummary(frontendConversationId, isEngineBridge)) {
195
+ contextBridge.triggerSummaryGeneration(frontendConversationId, '[Codex]');
170
196
  }
171
197
 
172
198
  // Send completion event
@@ -175,7 +201,8 @@ router.post('/', async (req, res) => {
175
201
  messageId: `assistant-${Date.now()}`,
176
202
  content: result.text,
177
203
  usage: result.usage,
178
- sessionId
204
+ sessionId,
205
+ conversationId: frontendConversationId
179
206
  })}\n\n`);
180
207
 
181
208
  res.end();
@@ -220,4 +247,38 @@ router.get('/status', async (req, res) => {
220
247
  }
221
248
  });
222
249
 
250
+ /**
251
+ * POST /api/v1/codex/interrupt
252
+ * Interrupt running Codex CLI process
253
+ *
254
+ * Request body:
255
+ * {
256
+ * "sessionId": "uuid" - Session to interrupt
257
+ * }
258
+ */
259
+ router.post('/interrupt', async (req, res) => {
260
+ try {
261
+ const { sessionId } = req.body;
262
+
263
+ if (!sessionId) {
264
+ return res.status(400).json({ error: 'sessionId required' });
265
+ }
266
+
267
+ console.log(`[Codex] Interrupt request for session: ${sessionId}`);
268
+
269
+ const result = codexWrapper.interrupt(sessionId);
270
+
271
+ if (result.success) {
272
+ console.log(`[Codex] Interrupted session ${sessionId} via ${result.method}`);
273
+ } else {
274
+ console.log(`[Codex] Failed to interrupt session ${sessionId}: ${result.reason}`);
275
+ }
276
+
277
+ res.json(result);
278
+ } catch (error) {
279
+ console.error('[Codex] Interrupt error:', error);
280
+ res.status(500).json({ error: error.message });
281
+ }
282
+ });
283
+
223
284
  module.exports = router;
@@ -15,6 +15,20 @@ const contextBridge = require('../services/context-bridge');
15
15
  const router = express.Router();
16
16
  const geminiWrapper = new GeminiWrapper();
17
17
 
18
+ function ensureConversation(conversationId, workspacePath) {
19
+ try {
20
+ const stmt = require('../db').prepare(`
21
+ INSERT OR IGNORE INTO conversations (id, title, created_at, updated_at, metadata)
22
+ VALUES (?, ?, ?, ?, ?)
23
+ `);
24
+ const now = Date.now();
25
+ const metadata = workspacePath ? JSON.stringify({ workspace: workspacePath }) : null;
26
+ stmt.run(conversationId, 'New Chat', now, now, metadata);
27
+ } catch (err) {
28
+ console.warn('[Gemini] Failed to ensure conversation exists:', err.message);
29
+ }
30
+ }
31
+
18
32
  /**
19
33
  * POST /api/v1/gemini
20
34
  * Send message to Gemini CLI with SSE streaming
@@ -64,6 +78,8 @@ router.post('/', async (req, res) => {
64
78
 
65
79
  // Use SessionManager for session sync pattern
66
80
  const frontendConversationId = conversationId || uuidv4();
81
+ ensureConversation(frontendConversationId, workspacePath);
82
+
67
83
  const { sessionId, isNew: isNewSession } = await sessionManager.getOrCreateSession(
68
84
  frontendConversationId,
69
85
  'gemini',
@@ -87,11 +103,12 @@ router.post('/', async (req, res) => {
87
103
  type: 'message_start',
88
104
  messageId: `user-${Date.now()}`,
89
105
  sessionId,
106
+ conversationId: frontendConversationId,
90
107
  engine: 'gemini'
91
108
  })}\n\n`);
92
109
 
93
110
  // Check if this is an engine switch (requires context bridging)
94
- const lastEngine = Message.getLastEngine(sessionId);
111
+ const lastEngine = Message.getLastEngine(frontendConversationId);
95
112
  const isEngineBridge = lastEngine && lastEngine !== 'gemini';
96
113
 
97
114
  // IMPORTANT: Skip contextBridge for Gemini native resume!
@@ -102,6 +119,7 @@ router.post('/', async (req, res) => {
102
119
  if (isEngineBridge) {
103
120
  // Engine switch: need context from previous engine
104
121
  const contextResult = await contextBridge.buildContext({
122
+ conversationId: frontendConversationId,
105
123
  sessionId,
106
124
  fromEngine: lastEngine,
107
125
  toEngine: 'gemini',
@@ -127,13 +145,14 @@ router.post('/', async (req, res) => {
127
145
  // Save user message to DB
128
146
  try {
129
147
  Message.create(
130
- sessionId,
148
+ frontendConversationId,
131
149
  'user',
132
150
  message,
133
151
  { workspace: workspacePath, model },
134
152
  Date.now(),
135
153
  'gemini'
136
154
  );
155
+ sessionManager.bumpSessionActivity(sessionId, 1);
137
156
  } catch (dbErr) {
138
157
  console.warn('[Gemini] Failed to save user message:', dbErr.message);
139
158
  }
@@ -152,6 +171,7 @@ router.post('/', async (req, res) => {
152
171
  threadId: nativeSessionId, // Native Gemini session ID for resume
153
172
  model,
154
173
  workspacePath,
174
+ processId: sessionId, // Use Nexus sessionId for interrupt tracking
155
175
  onStatus: (event) => {
156
176
  // Forward all events to SSE
157
177
  res.write(`data: ${JSON.stringify(event)}\n\n`);
@@ -160,29 +180,35 @@ router.post('/', async (req, res) => {
160
180
 
161
181
  console.log(`[Gemini] Response received: ${result.text?.length || 0} chars`);
162
182
 
163
- // Save native sessionId for future resume (if new)
164
- if (result.sessionId && result.sessionId !== nativeSessionId) {
183
+ // Save native sessionId for future resume (always, even if unchanged)
184
+ if (result.sessionId) {
165
185
  sessionManager.setNativeThreadId(sessionId, result.sessionId);
166
- console.log(`[Gemini] Saved native sessionId: ${result.sessionId}`);
186
+ const unchanged = nativeSessionId && nativeSessionId === result.sessionId ? ' (unchanged)' : '';
187
+ console.log(`[Gemini] Saved native sessionId: ${result.sessionId}${unchanged}`);
188
+ } else if (nativeSessionId) {
189
+ console.log(`[Gemini] Reused existing native sessionId: ${nativeSessionId}`);
190
+ } else {
191
+ console.warn('[Gemini] No sessionId returned; resume may not work for next turn');
167
192
  }
168
193
 
169
194
  // Save assistant response to DB
170
195
  try {
171
- Message.create(
172
- sessionId,
173
- 'assistant',
174
- result.text,
175
- { model, usage: result.usage },
176
- Date.now(),
177
- 'gemini'
178
- );
196
+ Message.create(
197
+ frontendConversationId,
198
+ 'assistant',
199
+ result.text,
200
+ { model, usage: result.usage },
201
+ Date.now(),
202
+ 'gemini'
203
+ );
204
+ sessionManager.bumpSessionActivity(sessionId, 1);
179
205
  } catch (dbErr) {
180
206
  console.warn('[Gemini] Failed to save assistant message:', dbErr.message);
181
207
  }
182
208
 
183
209
  // Smart auto-summary: trigger based on message count and engine bridging
184
- if (contextBridge.shouldTriggerSummary(sessionId, isEngineBridge)) {
185
- contextBridge.triggerSummaryGeneration(sessionId, '[Gemini]');
210
+ if (contextBridge.shouldTriggerSummary(frontendConversationId, isEngineBridge)) {
211
+ contextBridge.triggerSummaryGeneration(frontendConversationId, '[Gemini]');
186
212
  }
187
213
 
188
214
  // Send final message with full content
@@ -244,4 +270,38 @@ router.get('/models', (req, res) => {
244
270
  });
245
271
  });
246
272
 
273
+ /**
274
+ * POST /api/v1/gemini/interrupt
275
+ * Interrupt running Gemini CLI process
276
+ *
277
+ * Request body:
278
+ * {
279
+ * "sessionId": "uuid" - Session to interrupt
280
+ * }
281
+ */
282
+ router.post('/interrupt', async (req, res) => {
283
+ try {
284
+ const { sessionId } = req.body;
285
+
286
+ if (!sessionId) {
287
+ return res.status(400).json({ error: 'sessionId required' });
288
+ }
289
+
290
+ console.log(`[Gemini] Interrupt request for session: ${sessionId}`);
291
+
292
+ const result = geminiWrapper.interrupt(sessionId);
293
+
294
+ if (result.success) {
295
+ console.log(`[Gemini] Interrupted session ${sessionId} via ${result.method}`);
296
+ } else {
297
+ console.log(`[Gemini] Failed to interrupt session ${sessionId}: ${result.reason}`);
298
+ }
299
+
300
+ res.json(result);
301
+ } catch (error) {
302
+ console.error('[Gemini] Interrupt error:', error);
303
+ res.status(500).json({ error: error.message });
304
+ }
305
+ });
306
+
247
307
  module.exports = router;
@@ -4,6 +4,7 @@ const path = require('path');
4
4
  const { prepare, saveDb } = require('../db');
5
5
  const CliLoader = require('../services/cli-loader');
6
6
  const SummaryGenerator = require('../services/summary-generator');
7
+ const sessionImporter = require('../services/session-importer');
7
8
 
8
9
  const router = express.Router();
9
10
  const cliLoader = new CliLoader();
@@ -16,6 +17,20 @@ const SESSION_DIRS = {
16
17
  gemini: path.join(process.env.HOME || '', '.gemini', 'sessions'),
17
18
  };
18
19
 
20
+ /**
21
+ * POST /api/v1/sessions/import
22
+ * Importa tutte le sessioni native (Claude/Codex/Gemini) nel DB
23
+ */
24
+ router.post('/import', async (_req, res) => {
25
+ try {
26
+ const imported = sessionImporter.importAll();
27
+ res.json({ success: true, imported });
28
+ } catch (error) {
29
+ console.error('[Sessions] Import error:', error);
30
+ res.status(500).json({ success: false, error: error.message });
31
+ }
32
+ });
33
+
19
34
  /**
20
35
  * GET /api/v1/sessions/:id
21
36
  * Return session metadata from DB (sessions table)
@@ -66,8 +81,8 @@ router.get('/:id/messages', async (req, res) => {
66
81
 
67
82
  const { messages, pagination } = await cliLoader.loadMessagesFromCLI({
68
83
  sessionId,
69
- threadId: session.session_path, // native thread id (Codex/Gemini)
70
- sessionPath: session.session_path,
84
+ threadId: session.session_path, // native Codex/Gemini thread id
85
+ sessionPath: session.session_path, // alias for compatibility
71
86
  engine: session.engine || 'claude-code',
72
87
  workspacePath: session.workspace_path,
73
88
  limit,
@@ -81,6 +96,7 @@ router.get('/:id/messages', async (req, res) => {
81
96
  workspace_path: session.workspace_path,
82
97
  title: session.title,
83
98
  engine: session.engine,
99
+ conversation_id: session.conversation_id,
84
100
  last_used_at: session.last_used_at,
85
101
  created_at: session.created_at,
86
102
  message_count: session.message_count
@@ -283,6 +299,7 @@ function getSessionFilePath(sessionId, engine, workspacePath, sessionPath) {
283
299
  const baseDir = SESSION_DIRS.codex;
284
300
  const flatPath = path.join(baseDir, `${nativeId}.jsonl`);
285
301
  if (fs.existsSync(flatPath)) return flatPath;
302
+ // Search nested rollout-*.jsonl files containing the threadId
286
303
  return findCodexSessionFile(baseDir, nativeId);
287
304
  case 'gemini':
288
305
  return path.join(SESSION_DIRS.gemini, `${sessionId}.jsonl`);
@@ -291,6 +308,9 @@ function getSessionFilePath(sessionId, engine, workspacePath, sessionPath) {
291
308
  }
292
309
  }
293
310
 
311
+ /**
312
+ * Find Codex rollout file by threadId within YYYY/MM/DD directories
313
+ */
294
314
  function findCodexSessionFile(baseDir, threadId) {
295
315
  if (!threadId || !fs.existsSync(baseDir)) return null;
296
316
  try {
@@ -7,6 +7,7 @@ const http = require('http');
7
7
  const { initDb, prepare } = require('./db');
8
8
  const User = require('./models/User');
9
9
  const WorkspaceManager = require('./services/workspace-manager');
10
+ const sessionImporter = require('./services/session-importer');
10
11
  const pkg = require('../../package.json');
11
12
 
12
13
  // Import middleware
@@ -28,6 +29,7 @@ const wakeLockRouter = require('./routes/wake-lock');
28
29
  const uploadRouter = require('./routes/upload');
29
30
  const keysRouter = require('./routes/keys');
30
31
  const speechRouter = require('./routes/speech');
32
+ const configRouter = require('./routes/config');
31
33
 
32
34
  const app = express();
33
35
  const PORT = process.env.PORT || 41800;
@@ -62,6 +64,7 @@ app.use(express.static(frontendDist));
62
64
  // Public routes
63
65
  app.use('/api/v1/auth', authRouter);
64
66
  app.use('/api/v1/models', modelsRouter);
67
+ app.use('/api/v1/config', configRouter);
65
68
  app.use('/api/v1/workspace', workspaceRouter);
66
69
  app.use('/api/v1', wakeLockRouter); // Wake lock endpoints (public for app visibility handling)
67
70
  app.use('/api/v1/workspaces', authMiddleware, workspacesRouter);
@@ -150,6 +153,14 @@ async function start() {
150
153
  // Continue anyway - database will sync on first workspace mount
151
154
  }
152
155
 
156
+ // Import native sessions from all engines (Claude/Codex/Gemini)
157
+ try {
158
+ const imported = sessionImporter.importAll();
159
+ console.log(`[Startup] Imported sessions → Claude:${imported.claude} Codex:${imported.codex} Gemini:${imported.gemini}`);
160
+ } catch (error) {
161
+ console.error('[Startup] ⚠️ Session import failed:', error.message);
162
+ }
163
+
153
164
  // Auto-seed dev user on demand (Termux/local) if no users exist
154
165
  const autoSeed = process.env.NEXUSCLI_AUTO_SEED === '1';
155
166
  if (autoSeed) {
@@ -4,19 +4,20 @@
4
4
  *
5
5
  * Based on NexusChat codex-cli-wrapper.js pattern
6
6
  * Requires: codex-cli 0.62.1+ with exec subcommand
7
+ *
8
+ * @version 0.5.0 - Extended BaseCliWrapper for interrupt support
7
9
  */
8
10
 
9
11
  const { spawn, exec } = require('child_process');
10
12
  const CodexOutputParser = require('./codex-output-parser');
13
+ const BaseCliWrapper = require('./base-cli-wrapper');
11
14
 
12
- class CodexWrapper {
15
+ class CodexWrapper extends BaseCliWrapper {
13
16
  constructor(options = {}) {
17
+ super(); // Initialize activeProcesses from BaseCliWrapper
14
18
  this.workspaceDir = options.workspaceDir || process.cwd();
15
19
  this.codexBin = options.codexBin || 'codex';
16
20
 
17
- // Track active sessions
18
- this.activeSessions = new Set();
19
-
20
21
  console.log('[CodexWrapper] Initialized');
21
22
  console.log('[CodexWrapper] Workspace:', this.workspaceDir);
22
23
  console.log('[CodexWrapper] Binary:', this.codexBin);
@@ -34,7 +35,7 @@ class CodexWrapper {
34
35
  * @param {Function} options.onStatus - Callback for status events
35
36
  * @returns {Promise<Object>} Response with text, usage, threadId
36
37
  */
37
- async sendMessage({ prompt, model, threadId, reasoningEffort, workspacePath, imageFiles = [], onStatus }) {
38
+ async sendMessage({ prompt, model, threadId, reasoningEffort, workspacePath, imageFiles = [], onStatus, processId: processIdOverride }) {
38
39
  return new Promise((resolve, reject) => {
39
40
  const parser = new CodexOutputParser();
40
41
  const cwd = workspacePath || this.workspaceDir;
@@ -91,6 +92,11 @@ class CodexWrapper {
91
92
  },
92
93
  });
93
94
 
95
+ // Register process for interrupt capability
96
+ // Prefer external processId (Nexus sessionId), else threadId, else temp
97
+ const processId = processIdOverride || threadId || `codex-${Date.now()}`;
98
+ this.registerProcess(processId, proc, 'spawn');
99
+
94
100
  proc.stdout.on('data', (data) => {
95
101
  const str = data.toString();
96
102
  stdout += str;
@@ -102,6 +108,9 @@ class CodexWrapper {
102
108
  });
103
109
 
104
110
  proc.on('close', (exitCode) => {
111
+ // Unregister process on exit
112
+ this.unregisterProcess(processId);
113
+
105
114
  clearTimeout(timeout);
106
115
  this.handleExit(exitCode, stdout, parser, prompt, resolve, reject);
107
116
  });