@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.
- package/README.md +12 -4
- package/bin/nexuscli.js +6 -6
- package/frontend/dist/assets/{index-CikJbUR5.js → index-BAY_sRAu.js} +1704 -1704
- package/frontend/dist/assets/{index-Bn_l1e6e.css → index-CHOlrfA0.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/lib/server/.env.example +1 -1
- package/lib/server/db.js.old +225 -0
- package/lib/server/docs/API_WRAPPER_CONTRACT.md +682 -0
- package/lib/server/docs/ARCHITECTURE.md +441 -0
- package/lib/server/docs/DATABASE_SCHEMA.md +783 -0
- package/lib/server/docs/DESIGN_PRINCIPLES.md +598 -0
- package/lib/server/docs/NEXUSCHAT_ANALYSIS.md +488 -0
- package/lib/server/docs/PIPELINE_INTEGRATION.md +636 -0
- package/lib/server/docs/README.md +272 -0
- package/lib/server/docs/UI_DESIGN.md +916 -0
- package/lib/server/lib/pty-adapter.js +15 -1
- package/lib/server/routes/chat.js +70 -8
- package/lib/server/routes/codex.js +61 -7
- package/lib/server/routes/gemini.js +66 -12
- package/lib/server/routes/sessions.js +7 -2
- package/lib/server/server.js +2 -0
- package/lib/server/services/base-cli-wrapper.js +137 -0
- package/lib/server/services/claude-wrapper.js +11 -1
- package/lib/server/services/cli-loader.js.backup +446 -0
- package/lib/server/services/codex-output-parser.js +8 -0
- package/lib/server/services/codex-wrapper.js +13 -4
- package/lib/server/services/context-bridge.js +24 -20
- package/lib/server/services/gemini-wrapper.js +26 -8
- package/lib/server/services/session-manager.js +20 -0
- package/lib/server/services/workspace-manager.js +1 -1
- package/lib/server/tests/performance.test.js +1 -1
- package/lib/server/tests/services.test.js +2 -2
- 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: ['
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
183
|
-
contextBridge.triggerSummaryGeneration(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
169
|
-
contextBridge.triggerSummaryGeneration(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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(
|
|
185
|
-
contextBridge.triggerSummaryGeneration(
|
|
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
|
|
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 {
|
package/lib/server/server.js
CHANGED
|
@@ -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;
|