@mmmbuto/nexuscli 0.7.6 → 0.7.7

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 (33) hide show
  1. package/README.md +12 -4
  2. package/bin/nexuscli.js +6 -6
  3. package/frontend/dist/assets/{index-CikJbUR5.js → index-BAY_sRAu.js} +1704 -1704
  4. package/frontend/dist/assets/{index-Bn_l1e6e.css → index-CHOlrfA0.css} +1 -1
  5. package/frontend/dist/index.html +2 -2
  6. package/lib/server/.env.example +1 -1
  7. package/lib/server/db.js.old +225 -0
  8. package/lib/server/docs/API_WRAPPER_CONTRACT.md +682 -0
  9. package/lib/server/docs/ARCHITECTURE.md +441 -0
  10. package/lib/server/docs/DATABASE_SCHEMA.md +783 -0
  11. package/lib/server/docs/DESIGN_PRINCIPLES.md +598 -0
  12. package/lib/server/docs/NEXUSCHAT_ANALYSIS.md +488 -0
  13. package/lib/server/docs/PIPELINE_INTEGRATION.md +636 -0
  14. package/lib/server/docs/README.md +272 -0
  15. package/lib/server/docs/UI_DESIGN.md +916 -0
  16. package/lib/server/lib/pty-adapter.js +15 -1
  17. package/lib/server/routes/chat.js +70 -8
  18. package/lib/server/routes/codex.js +61 -7
  19. package/lib/server/routes/gemini.js +66 -12
  20. package/lib/server/routes/sessions.js +7 -2
  21. package/lib/server/server.js +2 -0
  22. package/lib/server/services/base-cli-wrapper.js +137 -0
  23. package/lib/server/services/claude-wrapper.js +11 -1
  24. package/lib/server/services/cli-loader.js.backup +446 -0
  25. package/lib/server/services/codex-output-parser.js +8 -0
  26. package/lib/server/services/codex-wrapper.js +13 -4
  27. package/lib/server/services/context-bridge.js +24 -20
  28. package/lib/server/services/gemini-wrapper.js +26 -8
  29. package/lib/server/services/session-manager.js +20 -0
  30. package/lib/server/services/workspace-manager.js +1 -1
  31. package/lib/server/tests/performance.test.js +1 -1
  32. package/lib/server/tests/services.test.js +2 -2
  33. package/package.json +1 -1
@@ -2,6 +2,8 @@
2
2
  * PTY Adapter for NexusCLI (Termux)
3
3
  * Provides node-pty-like interface using child_process.spawn
4
4
  * Termux-only: no native node-pty compilation needed
5
+ *
6
+ * @version 0.5.0 - Added stdin support for interrupt (ESC key)
5
7
  */
6
8
 
7
9
  const { spawn: cpSpawn } = require('child_process');
@@ -18,7 +20,7 @@ function spawn(command, args, options = {}) {
18
20
  cwd: options.cwd,
19
21
  env: options.env,
20
22
  shell: false,
21
- stdio: ['ignore', 'pipe', 'pipe'],
23
+ stdio: ['pipe', 'pipe', 'pipe'], // stdin enabled for interrupt support
22
24
  });
23
25
 
24
26
  const dataHandlers = [];
@@ -49,6 +51,18 @@ function spawn(command, args, options = {}) {
49
51
  onExit: (fn) => exitHandlers.push(fn),
50
52
  onError: (fn) => errorHandlers.push(fn),
51
53
  write: (data) => proc.stdin?.writable && proc.stdin.write(data),
54
+ /**
55
+ * Send ESC key (0x1B) to interrupt CLI
56
+ * Used to gracefully stop Claude/Gemini CLI execution
57
+ * @returns {boolean} true if ESC was sent successfully
58
+ */
59
+ sendEsc: () => {
60
+ if (proc.stdin?.writable) {
61
+ proc.stdin.write('\x1B');
62
+ return true;
63
+ }
64
+ return false;
65
+ },
52
66
  kill: (signal = 'SIGTERM') => proc.kill(signal),
53
67
  pid: proc.pid,
54
68
  };
@@ -15,6 +15,20 @@ const claudeWrapper = new ClaudeWrapper();
15
15
  const historySync = new HistorySync();
16
16
  const summaryGenerator = new SummaryGenerator();
17
17
 
18
+ function ensureConversation(conversationId, workspacePath) {
19
+ try {
20
+ const stmt = 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('[Chat] Failed to ensure conversation exists:', err.message);
29
+ }
30
+ }
31
+
18
32
  /**
19
33
  * POST /api/v1/chat
20
34
  * Send message to Claude Code CLI with SSE streaming
@@ -54,6 +68,8 @@ router.post('/', async (req, res) => {
54
68
  // Use SessionManager for session sync pattern
55
69
  // conversationId → sessionId (per engine)
56
70
  const frontendConversationId = conversationId || uuidv4();
71
+ ensureConversation(frontendConversationId, workspacePath);
72
+
57
73
  const { sessionId, isNew: isNewSession } = await sessionManager.getOrCreateSession(
58
74
  frontendConversationId,
59
75
  'claude',
@@ -69,10 +85,10 @@ router.post('/', async (req, res) => {
69
85
  res.setHeader('Connection', 'keep-alive');
70
86
 
71
87
  // Send initial event
72
- res.write(`data: ${JSON.stringify({ type: 'message_start', messageId: `user-${Date.now()}`, sessionId })}\n\n`);
88
+ res.write(`data: ${JSON.stringify({ type: 'message_start', messageId: `user-${Date.now()}`, sessionId, conversationId: frontendConversationId })}\n\n`);
73
89
 
74
90
  // Check if this is an engine switch (requires context bridging)
75
- const lastEngine = Message.getLastEngine(sessionId);
91
+ const lastEngine = Message.getLastEngine(frontendConversationId);
76
92
  const isEngineBridge = lastEngine && lastEngine !== 'claude';
77
93
 
78
94
  // IMPORTANT: Skip contextBridge for Claude native resume!
@@ -85,6 +101,7 @@ router.post('/', async (req, res) => {
85
101
  if (isEngineBridge) {
86
102
  // Engine switch: need context from previous engine
87
103
  const contextResult = await contextBridge.buildContext({
104
+ conversationId: frontendConversationId,
88
105
  sessionId,
89
106
  fromEngine: lastEngine,
90
107
  toEngine: 'claude',
@@ -110,7 +127,7 @@ router.post('/', async (req, res) => {
110
127
  // Save user message to database with engine tracking
111
128
  try {
112
129
  const userMessage = Message.create(
113
- sessionId,
130
+ frontendConversationId,
114
131
  'user',
115
132
  message,
116
133
  { workspace: workspacePath },
@@ -118,6 +135,7 @@ router.post('/', async (req, res) => {
118
135
  'claude' // Engine tracking for context bridging
119
136
  );
120
137
  console.log(`[Chat] Saved user message: ${userMessage.id} (engine: claude)`);
138
+ sessionManager.bumpSessionActivity(sessionId, 1);
121
139
 
122
140
  } catch (msgErr) {
123
141
  console.warn('[Chat] Failed to save user message:', msgErr.message);
@@ -138,7 +156,7 @@ router.post('/', async (req, res) => {
138
156
  // Save assistant response to database with engine tracking
139
157
  try {
140
158
  const assistantMessage = Message.create(
141
- sessionId,
159
+ frontendConversationId,
142
160
  'assistant',
143
161
  result.text,
144
162
  { usage: result.usage, model },
@@ -146,6 +164,7 @@ router.post('/', async (req, res) => {
146
164
  'claude' // Engine tracking for context bridging
147
165
  );
148
166
  console.log(`[Chat] Saved assistant message: ${assistantMessage.id} (engine: claude)`);
167
+ sessionManager.bumpSessionActivity(sessionId, 1);
149
168
  } catch (msgErr) {
150
169
  console.warn('[Chat] Failed to save assistant message:', msgErr.message);
151
170
  }
@@ -157,7 +176,8 @@ router.post('/', async (req, res) => {
157
176
  invalidateConversations(); // Clear cache for fresh sidebar
158
177
  res.write(`data: ${JSON.stringify({
159
178
  type: 'session_created',
160
- sessionId
179
+ sessionId,
180
+ conversationId: frontendConversationId
161
181
  })}\n\n`);
162
182
  } catch (syncErr) {
163
183
  console.warn('[Chat] History sync failed after new chat:', syncErr.message);
@@ -179,8 +199,8 @@ router.post('/', async (req, res) => {
179
199
  }
180
200
 
181
201
  // Smart auto-summary: trigger based on message count and engine bridging
182
- if (contextBridge.shouldTriggerSummary(sessionId, isEngineBridge)) {
183
- contextBridge.triggerSummaryGeneration(sessionId, '[Chat]');
202
+ if (contextBridge.shouldTriggerSummary(frontendConversationId, isEngineBridge)) {
203
+ contextBridge.triggerSummaryGeneration(frontendConversationId, '[Chat]');
184
204
  }
185
205
 
186
206
  // Send completion event
@@ -189,7 +209,8 @@ router.post('/', async (req, res) => {
189
209
  messageId: `assistant-${Date.now()}`,
190
210
  content: result.text,
191
211
  usage: result.usage,
192
- sessionId
212
+ sessionId,
213
+ conversationId: frontendConversationId
193
214
  })}\n\n`);
194
215
 
195
216
  res.end();
@@ -215,4 +236,45 @@ router.post('/', async (req, res) => {
215
236
  }
216
237
  });
217
238
 
239
+ /**
240
+ * POST /api/v1/chat/interrupt
241
+ * Interrupt running Claude CLI process
242
+ *
243
+ * Request body:
244
+ * {
245
+ * "sessionId": "uuid" - Session to interrupt
246
+ * }
247
+ *
248
+ * Response:
249
+ * {
250
+ * "success": boolean,
251
+ * "method": "esc" | "sigint" (if success),
252
+ * "reason": string (if failed)
253
+ * }
254
+ */
255
+ router.post('/interrupt', async (req, res) => {
256
+ try {
257
+ const { sessionId } = req.body;
258
+
259
+ if (!sessionId) {
260
+ return res.status(400).json({ error: 'sessionId required' });
261
+ }
262
+
263
+ console.log(`[Chat] Interrupt request for session: ${sessionId}`);
264
+
265
+ const result = claudeWrapper.interrupt(sessionId);
266
+
267
+ if (result.success) {
268
+ console.log(`[Chat] Interrupted session ${sessionId} via ${result.method}`);
269
+ } else {
270
+ console.log(`[Chat] Failed to interrupt session ${sessionId}: ${result.reason}`);
271
+ }
272
+
273
+ res.json(result);
274
+ } catch (error) {
275
+ console.error('[Chat] Interrupt error:', error);
276
+ res.status(500).json({ error: error.message });
277
+ }
278
+ });
279
+
218
280
  module.exports = router;
@@ -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
  }
@@ -152,7 +170,7 @@ router.post('/', async (req, res) => {
152
170
  // Save assistant response to database with engine tracking
153
171
  try {
154
172
  const assistantMessage = Message.create(
155
- sessionId,
173
+ frontendConversationId,
156
174
  'assistant',
157
175
  result.text,
158
176
  { usage: result.usage, model },
@@ -160,13 +178,14 @@ router.post('/', async (req, res) => {
160
178
  'codex' // Engine tracking for context bridging
161
179
  );
162
180
  console.log(`[Codex] Saved assistant message: ${assistantMessage.id} (engine: codex)`);
181
+ sessionManager.bumpSessionActivity(sessionId, 1);
163
182
  } catch (msgErr) {
164
183
  console.warn('[Codex] Failed to save assistant message:', msgErr.message);
165
184
  }
166
185
 
167
186
  // Smart auto-summary: trigger based on message count and engine bridging
168
- if (contextBridge.shouldTriggerSummary(sessionId, isEngineBridge)) {
169
- contextBridge.triggerSummaryGeneration(sessionId, '[Codex]');
187
+ if (contextBridge.shouldTriggerSummary(frontendConversationId, isEngineBridge)) {
188
+ contextBridge.triggerSummaryGeneration(frontendConversationId, '[Codex]');
170
189
  }
171
190
 
172
191
  // Send completion event
@@ -175,7 +194,8 @@ router.post('/', async (req, res) => {
175
194
  messageId: `assistant-${Date.now()}`,
176
195
  content: result.text,
177
196
  usage: result.usage,
178
- sessionId
197
+ sessionId,
198
+ conversationId: frontendConversationId
179
199
  })}\n\n`);
180
200
 
181
201
  res.end();
@@ -220,4 +240,38 @@ router.get('/status', async (req, res) => {
220
240
  }
221
241
  });
222
242
 
243
+ /**
244
+ * POST /api/v1/codex/interrupt
245
+ * Interrupt running Codex CLI process
246
+ *
247
+ * Request body:
248
+ * {
249
+ * "sessionId": "uuid" - Session to interrupt
250
+ * }
251
+ */
252
+ router.post('/interrupt', async (req, res) => {
253
+ try {
254
+ const { sessionId } = req.body;
255
+
256
+ if (!sessionId) {
257
+ return res.status(400).json({ error: 'sessionId required' });
258
+ }
259
+
260
+ console.log(`[Codex] Interrupt request for session: ${sessionId}`);
261
+
262
+ const result = codexWrapper.interrupt(sessionId);
263
+
264
+ if (result.success) {
265
+ console.log(`[Codex] Interrupted session ${sessionId} via ${result.method}`);
266
+ } else {
267
+ console.log(`[Codex] Failed to interrupt session ${sessionId}: ${result.reason}`);
268
+ }
269
+
270
+ res.json(result);
271
+ } catch (error) {
272
+ console.error('[Codex] Interrupt error:', error);
273
+ res.status(500).json({ error: error.message });
274
+ }
275
+ });
276
+
223
277
  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
  }
@@ -168,21 +187,22 @@ router.post('/', async (req, res) => {
168
187
 
169
188
  // Save assistant response to DB
170
189
  try {
171
- Message.create(
172
- sessionId,
173
- 'assistant',
174
- result.text,
175
- { model, usage: result.usage },
176
- Date.now(),
177
- 'gemini'
178
- );
190
+ Message.create(
191
+ frontendConversationId,
192
+ 'assistant',
193
+ result.text,
194
+ { model, usage: result.usage },
195
+ Date.now(),
196
+ 'gemini'
197
+ );
198
+ sessionManager.bumpSessionActivity(sessionId, 1);
179
199
  } catch (dbErr) {
180
200
  console.warn('[Gemini] Failed to save assistant message:', dbErr.message);
181
201
  }
182
202
 
183
203
  // Smart auto-summary: trigger based on message count and engine bridging
184
- if (contextBridge.shouldTriggerSummary(sessionId, isEngineBridge)) {
185
- contextBridge.triggerSummaryGeneration(sessionId, '[Gemini]');
204
+ if (contextBridge.shouldTriggerSummary(frontendConversationId, isEngineBridge)) {
205
+ contextBridge.triggerSummaryGeneration(frontendConversationId, '[Gemini]');
186
206
  }
187
207
 
188
208
  // Send final message with full content
@@ -244,4 +264,38 @@ router.get('/models', (req, res) => {
244
264
  });
245
265
  });
246
266
 
267
+ /**
268
+ * POST /api/v1/gemini/interrupt
269
+ * Interrupt running Gemini CLI process
270
+ *
271
+ * Request body:
272
+ * {
273
+ * "sessionId": "uuid" - Session to interrupt
274
+ * }
275
+ */
276
+ router.post('/interrupt', async (req, res) => {
277
+ try {
278
+ const { sessionId } = req.body;
279
+
280
+ if (!sessionId) {
281
+ return res.status(400).json({ error: 'sessionId required' });
282
+ }
283
+
284
+ console.log(`[Gemini] Interrupt request for session: ${sessionId}`);
285
+
286
+ const result = geminiWrapper.interrupt(sessionId);
287
+
288
+ if (result.success) {
289
+ console.log(`[Gemini] Interrupted session ${sessionId} via ${result.method}`);
290
+ } else {
291
+ console.log(`[Gemini] Failed to interrupt session ${sessionId}: ${result.reason}`);
292
+ }
293
+
294
+ res.json(result);
295
+ } catch (error) {
296
+ console.error('[Gemini] Interrupt error:', error);
297
+ res.status(500).json({ error: error.message });
298
+ }
299
+ });
300
+
247
301
  module.exports = router;
@@ -66,8 +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
+ threadId: session.session_path, // native Codex/Gemini thread id
70
+ sessionPath: session.session_path, // alias for compatibility
71
71
  engine: session.engine || 'claude-code',
72
72
  workspacePath: session.workspace_path,
73
73
  limit,
@@ -81,6 +81,7 @@ router.get('/:id/messages', async (req, res) => {
81
81
  workspace_path: session.workspace_path,
82
82
  title: session.title,
83
83
  engine: session.engine,
84
+ conversation_id: session.conversation_id,
84
85
  last_used_at: session.last_used_at,
85
86
  created_at: session.created_at,
86
87
  message_count: session.message_count
@@ -283,6 +284,7 @@ function getSessionFilePath(sessionId, engine, workspacePath, sessionPath) {
283
284
  const baseDir = SESSION_DIRS.codex;
284
285
  const flatPath = path.join(baseDir, `${nativeId}.jsonl`);
285
286
  if (fs.existsSync(flatPath)) return flatPath;
287
+ // Search nested rollout-*.jsonl files containing the threadId
286
288
  return findCodexSessionFile(baseDir, nativeId);
287
289
  case 'gemini':
288
290
  return path.join(SESSION_DIRS.gemini, `${sessionId}.jsonl`);
@@ -291,6 +293,9 @@ function getSessionFilePath(sessionId, engine, workspacePath, sessionPath) {
291
293
  }
292
294
  }
293
295
 
296
+ /**
297
+ * Find Codex rollout file by threadId within YYYY/MM/DD directories
298
+ */
294
299
  function findCodexSessionFile(baseDir, threadId) {
295
300
  if (!threadId || !fs.existsSync(baseDir)) return null;
296
301
  try {
@@ -28,6 +28,7 @@ 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');
31
32
 
32
33
  const app = express();
33
34
  const PORT = process.env.PORT || 41800;
@@ -62,6 +63,7 @@ app.use(express.static(frontendDist));
62
63
  // Public routes
63
64
  app.use('/api/v1/auth', authRouter);
64
65
  app.use('/api/v1/models', modelsRouter);
66
+ app.use('/api/v1/config', configRouter);
65
67
  app.use('/api/v1/workspace', workspaceRouter);
66
68
  app.use('/api/v1', wakeLockRouter); // Wake lock endpoints (public for app visibility handling)
67
69
  app.use('/api/v1/workspaces', authMiddleware, workspacesRouter);
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Base CLI Wrapper - Common interrupt logic for all CLI wrappers
3
+ *
4
+ * Provides unified process tracking and interrupt capability for:
5
+ * - ClaudeWrapper (PTY)
6
+ * - GeminiWrapper (PTY)
7
+ * - CodexWrapper (child_process.spawn)
8
+ *
9
+ * @version 0.5.0 - Interrupt Support
10
+ */
11
+
12
+ class BaseCliWrapper {
13
+ constructor() {
14
+ // Map: sessionId → { process, type, startTime }
15
+ this.activeProcesses = new Map();
16
+ }
17
+
18
+ /**
19
+ * Register active process for interrupt capability
20
+ * @param {string} sessionId - Session/conversation ID
21
+ * @param {Object} process - PTY process or child_process
22
+ * @param {string} type - 'pty' or 'spawn'
23
+ */
24
+ registerProcess(sessionId, process, type = 'pty') {
25
+ this.activeProcesses.set(sessionId, {
26
+ process,
27
+ type,
28
+ startTime: Date.now()
29
+ });
30
+ console.log(`[BaseWrapper] Registered ${type} process for session ${sessionId}`);
31
+ }
32
+
33
+ /**
34
+ * Unregister process on completion
35
+ * @param {string} sessionId
36
+ */
37
+ unregisterProcess(sessionId) {
38
+ if (this.activeProcesses.has(sessionId)) {
39
+ this.activeProcesses.delete(sessionId);
40
+ console.log(`[BaseWrapper] Unregistered process for session ${sessionId}`);
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Interrupt running process
46
+ *
47
+ * Strategy:
48
+ * - PTY: Try ESC (0x1B) first, then SIGINT
49
+ * - Spawn: SIGINT directly
50
+ *
51
+ * @param {string} sessionId
52
+ * @returns {{ success: boolean, method?: string, reason?: string }}
53
+ */
54
+ interrupt(sessionId) {
55
+ const entry = this.activeProcesses.get(sessionId);
56
+
57
+ if (!entry) {
58
+ console.log(`[BaseWrapper] No active process for session ${sessionId}`);
59
+ return { success: false, reason: 'no_active_process' };
60
+ }
61
+
62
+ const { process, type } = entry;
63
+
64
+ try {
65
+ if (type === 'pty') {
66
+ // PTY process: Try ESC first (gentler), then SIGINT
67
+ if (typeof process.sendEsc === 'function' && process.sendEsc()) {
68
+ console.log(`[BaseWrapper] Sent ESC to PTY session ${sessionId}`);
69
+ // Don't remove yet - let onExit handle cleanup
70
+ return { success: true, method: 'esc' };
71
+ }
72
+
73
+ // ESC failed or not available, use SIGINT
74
+ if (typeof process.kill === 'function') {
75
+ process.kill('SIGINT');
76
+ console.log(`[BaseWrapper] Sent SIGINT to PTY session ${sessionId}`);
77
+ return { success: true, method: 'sigint' };
78
+ }
79
+ } else if (type === 'spawn') {
80
+ // child_process.spawn: SIGINT directly
81
+ if (typeof process.kill === 'function') {
82
+ process.kill('SIGINT');
83
+ console.log(`[BaseWrapper] Sent SIGINT to spawn session ${sessionId}`);
84
+ return { success: true, method: 'sigint' };
85
+ }
86
+ }
87
+
88
+ return { success: false, reason: 'kill_not_available' };
89
+ } catch (err) {
90
+ console.error(`[BaseWrapper] Interrupt error for session ${sessionId}:`, err.message);
91
+ return { success: false, reason: err.message };
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Check if session has active process
97
+ * @param {string} sessionId
98
+ * @returns {boolean}
99
+ */
100
+ isActive(sessionId) {
101
+ return this.activeProcesses.has(sessionId);
102
+ }
103
+
104
+ /**
105
+ * Get count of active processes
106
+ * @returns {number}
107
+ */
108
+ getActiveCount() {
109
+ return this.activeProcesses.size;
110
+ }
111
+
112
+ /**
113
+ * Get all active session IDs
114
+ * @returns {string[]}
115
+ */
116
+ getActiveSessions() {
117
+ return Array.from(this.activeProcesses.keys());
118
+ }
119
+
120
+ /**
121
+ * Get process info for session
122
+ * @param {string} sessionId
123
+ * @returns {{ type: string, startTime: number, duration: number } | null}
124
+ */
125
+ getProcessInfo(sessionId) {
126
+ const entry = this.activeProcesses.get(sessionId);
127
+ if (!entry) return null;
128
+
129
+ return {
130
+ type: entry.type,
131
+ startTime: entry.startTime,
132
+ duration: Date.now() - entry.startTime
133
+ };
134
+ }
135
+ }
136
+
137
+ module.exports = BaseCliWrapper;