@mmmbuto/nexuscli 0.7.1 → 0.7.2

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 CHANGED
@@ -12,7 +12,7 @@
12
12
  NexusCLI is an experimental, ultra-light terminal cockpit designed for
13
13
  AI-assisted development workflows on Termux (Android).
14
14
 
15
- **v0.6.3** - Mobile-First AI Control Plane
15
+ Mobile-First AI Control Plane
16
16
 
17
17
  Web UI wrapper for Claude Code, Codex CLI, and Gemini CLI with voice input support.
18
18
 
@@ -96,6 +96,7 @@ nexuscli start
96
96
  | `nexuscli start` | Start server (HTTP:41800 + HTTPS:41801) |
97
97
  | `nexuscli stop` | Stop server |
98
98
  | `nexuscli status` | Show status, ports, and engines |
99
+ | `nexuscli model [model-id]` | Set/get default model preference |
99
100
  | `nexuscli engines` | Manage AI engines |
100
101
  | `nexuscli workspaces` | Manage workspaces |
101
102
  | `nexuscli config` | Configuration |
package/bin/nexuscli.js CHANGED
@@ -22,7 +22,6 @@ const workspacesCommand = require('../lib/cli/workspaces');
22
22
  const usersCommand = require('../lib/cli/users');
23
23
  const uninstallCommand = require('../lib/cli/uninstall');
24
24
  const setupTermuxCommand = require('../lib/cli/setup-termux');
25
- const { modelCommand } = require('../lib/cli/model');
26
25
 
27
26
  program
28
27
  .name('nexuscli')
@@ -63,12 +62,6 @@ program
63
62
  .description('Manage configuration (get/set/list)')
64
63
  .action(configCommand);
65
64
 
66
- // nexuscli model
67
- program
68
- .command('model [model-id]')
69
- .description('Set/get default model preference')
70
- .action(modelCommand);
71
-
72
65
  // nexuscli engines
73
66
  program
74
67
  .command('engines [action]')
@@ -110,18 +103,18 @@ program
110
103
  .option('-a, --admin', 'Create as admin user')
111
104
  .action(usersCommand);
112
105
 
113
- // nexuscli setup-termux
114
- program
115
- .command('setup-termux')
116
- .description('Bootstrap Termux: install packages, setup SSH, show connection info')
117
- .action(setupTermuxCommand);
118
-
119
106
  // nexuscli uninstall
120
107
  program
121
108
  .command('uninstall')
122
109
  .description('Prepare for uninstallation (optional data removal)')
123
110
  .action(uninstallCommand);
124
111
 
112
+ // nexuscli setup-termux
113
+ program
114
+ .command('setup-termux')
115
+ .description('Bootstrap Termux for remote development (SSH, packages)')
116
+ .action(setupTermuxCommand);
117
+
125
118
  // Parse arguments
126
119
  program.parse();
127
120
 
@@ -42,9 +42,6 @@ const DEFAULT_CONFIG = {
42
42
  wake_lock: true,
43
43
  notifications: true,
44
44
  boot_start: false
45
- },
46
- preferences: {
47
- defaultModel: null // User's preferred default model (e.g., 'claude-sonnet-4-5-20250929')
48
45
  }
49
46
  };
50
47
 
@@ -14,7 +14,7 @@ PORT=41800
14
14
  NODE_ENV=production
15
15
 
16
16
  # Workspace directory for CLI execution
17
- WORKSPACE_DIR=/var/www/cli.wellanet.dev
17
+ WORKSPACE_DIR=/home/user/myproject
18
18
 
19
19
  # Timeout for CLI commands (ms)
20
20
  DEFAULT_TIMEOUT=30000
@@ -66,6 +66,8 @@ router.get('/:id/messages', async (req, res) => {
66
66
 
67
67
  const { messages, pagination } = await cliLoader.loadMessagesFromCLI({
68
68
  sessionId,
69
+ threadId: session.session_path, // native thread id (Codex/Gemini)
70
+ sessionPath: session.session_path,
69
71
  engine: session.engine || 'claude-code',
70
72
  workspacePath: session.workspace_path,
71
73
  limit,
@@ -147,6 +149,8 @@ router.post('/:id/summarize', async (req, res) => {
147
149
  // Load messages from CLI history
148
150
  const { messages } = await cliLoader.loadMessagesFromCLI({
149
151
  sessionId,
152
+ threadId: session.session_path,
153
+ sessionPath: session.session_path,
150
154
  engine: session.engine || 'claude-code',
151
155
  workspacePath: session.workspace_path,
152
156
  limit,
@@ -224,7 +228,7 @@ router.delete('/:id', async (req, res) => {
224
228
 
225
229
  // Delete the original .jsonl file (SYNC DELETE)
226
230
  let fileDeleted = false;
227
- const sessionFile = getSessionFilePath(sessionId, session.engine, session.workspace_path);
231
+ const sessionFile = getSessionFilePath(sessionId, session.engine, session.workspace_path, session.session_path);
228
232
  if (sessionFile && fs.existsSync(sessionFile)) {
229
233
  try {
230
234
  fs.unlinkSync(sessionFile);
@@ -263,7 +267,7 @@ function pathToSlug(workspacePath) {
263
267
  /**
264
268
  * Helper: Get the filesystem path for a session file
265
269
  */
266
- function getSessionFilePath(sessionId, engine, workspacePath) {
270
+ function getSessionFilePath(sessionId, engine, workspacePath, sessionPath) {
267
271
  const normalizedEngine = engine?.toLowerCase().includes('claude') ? 'claude'
268
272
  : engine?.toLowerCase().includes('codex') ? 'codex'
269
273
  : engine?.toLowerCase().includes('gemini') ? 'gemini'
@@ -274,7 +278,12 @@ function getSessionFilePath(sessionId, engine, workspacePath) {
274
278
  const slug = pathToSlug(workspacePath);
275
279
  return path.join(SESSION_DIRS.claude, slug, `${sessionId}.jsonl`);
276
280
  case 'codex':
277
- return path.join(SESSION_DIRS.codex, `${sessionId}.jsonl`);
281
+ // Try native threadId first, then legacy sessionId
282
+ const nativeId = sessionPath || sessionId;
283
+ const baseDir = SESSION_DIRS.codex;
284
+ const flatPath = path.join(baseDir, `${nativeId}.jsonl`);
285
+ if (fs.existsSync(flatPath)) return flatPath;
286
+ return findCodexSessionFile(baseDir, nativeId);
278
287
  case 'gemini':
279
288
  return path.join(SESSION_DIRS.gemini, `${sessionId}.jsonl`);
280
289
  default:
@@ -282,4 +291,34 @@ function getSessionFilePath(sessionId, engine, workspacePath) {
282
291
  }
283
292
  }
284
293
 
294
+ function findCodexSessionFile(baseDir, threadId) {
295
+ if (!threadId || !fs.existsSync(baseDir)) return null;
296
+ try {
297
+ const years = fs.readdirSync(baseDir);
298
+ for (const year of years) {
299
+ const yearPath = path.join(baseDir, year);
300
+ if (!fs.statSync(yearPath).isDirectory()) continue;
301
+ const months = fs.readdirSync(yearPath);
302
+ for (const month of months) {
303
+ const monthPath = path.join(yearPath, month);
304
+ if (!fs.statSync(monthPath).isDirectory()) continue;
305
+ const days = fs.readdirSync(monthPath);
306
+ for (const day of days) {
307
+ const dayPath = path.join(monthPath, day);
308
+ if (!fs.statSync(dayPath).isDirectory()) continue;
309
+ const files = fs.readdirSync(dayPath);
310
+ for (const file of files) {
311
+ if (file.endsWith('.jsonl') && file.includes(threadId)) {
312
+ return path.join(dayPath, file);
313
+ }
314
+ }
315
+ }
316
+ }
317
+ }
318
+ } catch (err) {
319
+ console.warn(`[Sessions] Failed to search Codex session file: ${err.message}`);
320
+ }
321
+ return null;
322
+ }
323
+
285
324
  module.exports = router;
@@ -28,7 +28,6 @@ const wakeLockRouter = require('./routes/wake-lock');
28
28
  const uploadRouter = require('./routes/upload');
29
29
  const keysRouter = require('./routes/keys');
30
30
  const speechRouter = require('./routes/speech');
31
- const configRouter = require('./routes/config');
32
31
 
33
32
  const app = express();
34
33
  const PORT = process.env.PORT || 41800;
@@ -63,7 +62,6 @@ app.use(express.static(frontendDist));
63
62
  // Public routes
64
63
  app.use('/api/v1/auth', authRouter);
65
64
  app.use('/api/v1/models', modelsRouter);
66
- app.use('/api/v1/config', configRouter);
67
65
  app.use('/api/v1/workspace', workspaceRouter);
68
66
  app.use('/api/v1', wakeLockRouter); // Wake lock endpoints (public for app visibility handling)
69
67
  app.use('/api/v1/workspaces', authMiddleware, workspacesRouter);
@@ -47,6 +47,8 @@ class CliLoader {
47
47
  */
48
48
  async loadMessagesFromCLI({
49
49
  sessionId,
50
+ threadId, // optional native thread id (e.g., Codex exec thread)
51
+ sessionPath, // alias for compatibility
50
52
  engine = 'claude',
51
53
  workspacePath,
52
54
  limit = DEFAULT_LIMIT,
@@ -59,6 +61,7 @@ class CliLoader {
59
61
 
60
62
  const startedAt = Date.now();
61
63
  const normalizedEngine = this._normalizeEngine(engine);
64
+ const nativeId = threadId || sessionPath || sessionId;
62
65
 
63
66
  let result;
64
67
  switch (normalizedEngine) {
@@ -67,7 +70,7 @@ class CliLoader {
67
70
  break;
68
71
 
69
72
  case 'codex':
70
- result = await this.loadCodexMessages({ sessionId, limit, before, mode });
73
+ result = await this.loadCodexMessages({ sessionId, nativeId, limit, before, mode });
71
74
  break;
72
75
 
73
76
  case 'gemini':
@@ -174,22 +177,27 @@ class CliLoader {
174
177
  // CODEX - Load from ~/.codex/sessions/<sessionId>.jsonl
175
178
  // ============================================================
176
179
 
177
- async loadCodexMessages({ sessionId, limit, before, mode }) {
178
- const sessionFile = path.join(this.codexPath, 'sessions', `${sessionId}.jsonl`);
180
+ async loadCodexMessages({ sessionId, nativeId, limit, before, mode }) {
181
+ const baseDir = path.join(this.codexPath, 'sessions');
182
+ let sessionFile = path.join(baseDir, `${nativeId || sessionId}.jsonl`);
179
183
 
180
- // Codex may not persist sessions locally - check if file exists
184
+ // If flat file missing, search nested rollout-* files by threadId
181
185
  if (!fs.existsSync(sessionFile)) {
182
- // This is expected - Codex exec mode doesn't save history locally
183
- console.log(`[CliLoader] Codex session file not found (expected): ${sessionFile}`);
186
+ sessionFile = this.findCodexSessionFile(baseDir, nativeId || sessionId);
187
+ }
188
+
189
+ // Codex exec may not persist sessions; handle gracefully
190
+ if (!sessionFile || !fs.existsSync(sessionFile)) {
191
+ console.log(`[CliLoader] Codex session file not found (id=${nativeId || sessionId})`);
184
192
  return this._emptyResult();
185
193
  }
186
194
 
187
195
  const rawMessages = await this._parseJsonlFile(sessionFile);
188
196
 
189
- // Filter and normalize
197
+ // Normalize then filter only chat messages
190
198
  const messages = rawMessages
191
- .filter(entry => entry.role === 'user' || entry.role === 'assistant')
192
- .map(entry => this._normalizeCodexEntry(entry));
199
+ .map(entry => this._normalizeCodexEntry(entry))
200
+ .filter(msg => msg && (msg.role === 'user' || msg.role === 'assistant'));
193
201
 
194
202
  return this._paginateMessages(messages, limit, before, mode);
195
203
  }
@@ -198,13 +206,34 @@ class CliLoader {
198
206
  * Normalize Codex session entry to message shape
199
207
  */
200
208
  _normalizeCodexEntry(entry) {
201
- const role = entry.role || 'assistant';
202
- const created_at = entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now();
203
-
204
- // Codex may store content as string or object
209
+ // Skip non-chat bookkeeping events
210
+ const skipTypes = ['session_meta', 'turn_context', 'event_msg', 'token_count'];
211
+ if (skipTypes.includes(entry.type)) return null;
212
+
213
+ const role =
214
+ entry.role ||
215
+ entry.payload?.role ||
216
+ (entry.payload?.type === 'message' ? entry.payload.role : null) ||
217
+ entry.message?.role ||
218
+ 'assistant';
219
+
220
+ const created_at = entry.timestamp
221
+ ? new Date(entry.timestamp).getTime()
222
+ : (entry.payload?.timestamp ? new Date(entry.payload.timestamp).getTime() : Date.now());
223
+
224
+ // Codex may store content in multiple shapes
205
225
  let content = '';
206
226
  if (typeof entry.content === 'string') {
207
227
  content = entry.content;
228
+ } else if (typeof entry.payload?.content === 'string') {
229
+ content = entry.payload.content;
230
+ } else if (Array.isArray(entry.payload?.content)) {
231
+ content = entry.payload.content
232
+ .map(block => block.text || block.message || block.title || '')
233
+ .filter(Boolean)
234
+ .join('\n');
235
+ } else if (entry.payload?.text) {
236
+ content = entry.payload.text;
208
237
  } else if (entry.message) {
209
238
  content = typeof entry.message === 'string' ? entry.message : JSON.stringify(entry.message);
210
239
  }
@@ -222,6 +251,39 @@ class CliLoader {
222
251
  };
223
252
  }
224
253
 
254
+ /**
255
+ * Find Codex rollout file by threadId within YYYY/MM/DD directories
256
+ */
257
+ findCodexSessionFile(baseDir, threadId) {
258
+ if (!threadId || !fs.existsSync(baseDir)) return null;
259
+ try {
260
+ const years = fs.readdirSync(baseDir);
261
+ for (const year of years) {
262
+ const yearPath = path.join(baseDir, year);
263
+ if (!fs.statSync(yearPath).isDirectory()) continue;
264
+ const months = fs.readdirSync(yearPath);
265
+ for (const month of months) {
266
+ const monthPath = path.join(yearPath, month);
267
+ if (!fs.statSync(monthPath).isDirectory()) continue;
268
+ const days = fs.readdirSync(monthPath);
269
+ for (const day of days) {
270
+ const dayPath = path.join(monthPath, day);
271
+ if (!fs.statSync(dayPath).isDirectory()) continue;
272
+ const files = fs.readdirSync(dayPath);
273
+ for (const file of files) {
274
+ if (file.endsWith('.jsonl') && file.includes(threadId)) {
275
+ return path.join(dayPath, file);
276
+ }
277
+ }
278
+ }
279
+ }
280
+ }
281
+ } catch (err) {
282
+ console.warn(`[CliLoader] Failed to search Codex session file: ${err.message}`);
283
+ }
284
+ return null;
285
+ }
286
+
225
287
  // ============================================================
226
288
  // GEMINI - Load from ~/.gemini/sessions/<sessionId>.jsonl
227
289
  // ============================================================
@@ -263,13 +263,6 @@ class CodexOutputParser {
263
263
  return this.usage;
264
264
  }
265
265
 
266
- /**
267
- * Get thread ID (native Codex session ID)
268
- */
269
- getThreadId() {
270
- return this.threadId;
271
- }
272
-
273
266
  /**
274
267
  * Reset parser state for new request
275
268
  */
@@ -277,7 +270,6 @@ class CodexOutputParser {
277
270
  this.buffer = '';
278
271
  this.finalResponse = '';
279
272
  this.usage = null;
280
- this.threadId = null;
281
273
  this.pendingCommands.clear();
282
274
  }
283
275
  }
@@ -139,13 +139,11 @@ class ContextBridge {
139
139
  for (let i = messages.length - 1; i >= 0; i--) {
140
140
  const msg = messages[i];
141
141
 
142
- // For code-focused engines, compress assistant responses to code only
143
- // BUT always keep user messages for context continuity
142
+ // For code-focused engines, filter out non-code content
144
143
  let content = msg.content;
145
- if (config.codeOnly && msg.role === 'assistant') {
146
- const codeContent = this.extractCodeContent(content);
147
- // Only use code-only if there's actual code, otherwise keep truncated original
148
- content = codeContent || (content.length > 500 ? content.substring(0, 500) + '...' : content);
144
+ if (config.codeOnly) {
145
+ content = this.extractCodeContent(content);
146
+ if (!content) continue; // Skip if no code
149
147
  }
150
148
 
151
149
  // Truncate long messages
@@ -67,15 +67,15 @@ class GeminiWrapper {
67
67
  *
68
68
  * @param {Object} params
69
69
  * @param {string} params.prompt - User message/prompt
70
- * @param {string} params.threadId - Native Gemini session ID for resume
70
+ * @param {string} params.sessionId - Session UUID (for logging)
71
71
  * @param {string} [params.model='gemini-3-pro-preview'] - Model name
72
72
  * @param {string} [params.workspacePath] - Workspace directory
73
73
  * @param {Function} [params.onStatus] - Callback for status events (SSE streaming)
74
- * @returns {Promise<{text: string, usage: Object, sessionId: string}>}
74
+ * @returns {Promise<{text: string, usage: Object}>}
75
75
  */
76
76
  async sendMessage({
77
77
  prompt,
78
- threadId,
78
+ sessionId,
79
79
  model = DEFAULT_MODEL,
80
80
  workspacePath,
81
81
  onStatus
@@ -87,23 +87,16 @@ class GeminiWrapper {
87
87
  const cwd = workspacePath || this.workspaceDir;
88
88
 
89
89
  // Build CLI arguments
90
- // If threadId exists, use --resume to continue native session
90
+ // Note: cwd is set in pty.spawn() options, no need for --include-directories
91
91
  const args = [
92
92
  '-y', // YOLO mode - auto-approve all actions
93
93
  '-m', model, // Model selection
94
94
  '-o', 'stream-json', // JSON streaming for structured events
95
+ prompt // Prompt as positional argument
95
96
  ];
96
97
 
97
- // Add resume flag if continuing existing session
98
- if (threadId) {
99
- args.push('--resume', threadId);
100
- }
101
-
102
- // Add prompt as positional argument
103
- args.push(prompt);
104
-
105
98
  console.log(`[GeminiWrapper] Model: ${model}`);
106
- console.log(`[GeminiWrapper] ThreadId: ${threadId || '(new session)'}`);
99
+ console.log(`[GeminiWrapper] Session: ${sessionId}`);
107
100
  console.log(`[GeminiWrapper] CWD: ${cwd}`);
108
101
  console.log(`[GeminiWrapper] Prompt length: ${prompt.length}`);
109
102
 
@@ -177,7 +170,6 @@ class GeminiWrapper {
177
170
 
178
171
  resolve({
179
172
  text: finalResponse,
180
- sessionId: parser.getSessionId(), // Native Gemini session ID for resume
181
173
  usage: {
182
174
  prompt_tokens: promptTokens,
183
175
  completion_tokens: completionTokens,
@@ -396,7 +396,7 @@ class WorkspaceManager {
396
396
  * @returns {string}
397
397
  */
398
398
  getSessionPath(workspacePath) {
399
- // Convert /var/www/myapp → -var-www-myapp
399
+ // Convert /home/user/myproject → -home-user-myproject
400
400
  const projectDir = workspacePath.replace(/\//g, '-').replace(/^-/, '');
401
401
  return path.join(this.claudePath, 'projects', projectDir);
402
402
  }
@@ -45,7 +45,7 @@ describe('Performance Benchmarks', () => {
45
45
 
46
46
  test('Workspace validation should be fast', async () => {
47
47
  const manager = new WorkspaceManager();
48
- const testPath = '/var/www/myapp';
48
+ const testPath = '/home/user/myproject';
49
49
 
50
50
  const start = Date.now();
51
51
  const validated = await manager.validateWorkspace(testPath);
@@ -16,7 +16,7 @@ describe('WorkspaceManager', () => {
16
16
 
17
17
  test('should validate workspace path', async () => {
18
18
  // Test with allowed path
19
- const validPath = '/var/www/myapp';
19
+ const validPath = '/home/user/myproject';
20
20
  const result = await manager.validateWorkspace(validPath);
21
21
  expect(result).toBe(validPath);
22
22
  });
@@ -147,7 +147,7 @@ describe('SummaryGenerator', () => {
147
147
  describe('Integration - Service Interactions', () => {
148
148
  test('WorkspaceManager should use consistent path resolution', async () => {
149
149
  const manager = new WorkspaceManager();
150
- const testPath = '/var/www/myapp';
150
+ const testPath = '/home/user/myproject';
151
151
  const validated = await manager.validateWorkspace(testPath);
152
152
  expect(validated).toBe(testPath);
153
153
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscli",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "NexusCLI - TRI CLI Control Plane (Claude/Codex/Gemini)",
5
5
  "main": "lib/server/server.js",
6
6
  "bin": {