@siteboon/claude-code-ui 1.20.1 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ <svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>
package/dist/index.html CHANGED
@@ -25,11 +25,11 @@
25
25
 
26
26
  <!-- Prevent zoom on iOS -->
27
27
  <meta name="format-detection" content="telephone=no" />
28
- <script type="module" crossorigin src="/assets/index-C88hdQje.js"></script>
28
+ <script type="module" crossorigin src="/assets/index-Br2fwqOq.js"></script>
29
29
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-DIN4KjD2.js">
30
- <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-l-lAmaJ1.js">
31
- <link rel="modulepreload" crossorigin href="/assets/vendor-xterm-DfaPXD3y.js">
32
- <link rel="stylesheet" crossorigin href="/assets/index-BPHfv_yU.css">
30
+ <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-BMLq5tLB.js">
31
+ <link rel="modulepreload" crossorigin href="/assets/vendor-xterm-CJZjLICi.js">
32
+ <link rel="stylesheet" crossorigin href="/assets/index-B6iL1dXV.css">
33
33
  </head>
34
34
  <body>
35
35
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siteboon/claude-code-ui",
3
- "version": "1.20.1",
3
+ "version": "1.22.0",
4
4
  "description": "A web-based UI for Claude Code CLI",
5
5
  "type": "module",
6
6
  "main": "server/index.js",
@@ -45,7 +45,7 @@
45
45
  "author": "CloudCLI UI Contributors",
46
46
  "license": "GPL-3.0",
47
47
  "dependencies": {
48
- "@anthropic-ai/claude-agent-sdk": "^0.1.71",
48
+ "@anthropic-ai/claude-agent-sdk": "^0.2.59",
49
49
  "@codemirror/lang-css": "^6.3.1",
50
50
  "@codemirror/lang-html": "^6.4.9",
51
51
  "@codemirror/lang-javascript": "^6.2.4",
@@ -66,7 +66,7 @@
66
66
  "@xterm/addon-webgl": "^0.18.0",
67
67
  "@xterm/xterm": "^5.5.0",
68
68
  "bcrypt": "^6.0.0",
69
- "better-sqlite3": "^12.2.0",
69
+ "better-sqlite3": "^12.6.2",
70
70
  "chokidar": "^4.0.3",
71
71
  "class-variance-authority": "^0.7.1",
72
72
  "clsx": "^2.1.1",
@@ -78,6 +78,7 @@
78
78
  "i18next": "^25.7.4",
79
79
  "i18next-browser-languagedetector": "^8.2.0",
80
80
  "jsonwebtoken": "^9.0.2",
81
+ "jszip": "^3.10.1",
81
82
  "katex": "^0.16.25",
82
83
  "lucide-react": "^0.515.0",
83
84
  "mime-types": "^3.0.1",
@@ -87,6 +88,7 @@
87
88
  "react": "^18.2.0",
88
89
  "react-dom": "^18.2.0",
89
90
  "react-dropzone": "^14.2.3",
91
+ "react-error-boundary": "^4.1.2",
90
92
  "react-i18next": "^16.5.3",
91
93
  "react-markdown": "^10.1.0",
92
94
  "react-router-dom": "^6.8.1",
@@ -603,6 +603,10 @@ async function queryClaudeSDK(command, options = {}, ws) {
603
603
 
604
604
  // Extract and send token budget updates from result messages
605
605
  if (message.type === 'result') {
606
+ const models = Object.keys(message.modelUsage || {});
607
+ if (models.length > 0) {
608
+ console.log("---> Model was sent using:", models);
609
+ }
606
610
  const tokenBudget = extractTokenBudget(message);
607
611
  if (tokenBudget) {
608
612
  console.log('Token budget from modelUsage:', tokenBudget);
@@ -0,0 +1,455 @@
1
+ import { spawn } from 'child_process';
2
+ import crossSpawn from 'cross-spawn';
3
+
4
+ // Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
5
+ const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
6
+ import { promises as fs } from 'fs';
7
+ import path from 'path';
8
+ import os from 'os';
9
+ import { getSessions, getSessionMessages } from './projects.js';
10
+ import sessionManager from './sessionManager.js';
11
+ import GeminiResponseHandler from './gemini-response-handler.js';
12
+
13
+ let activeGeminiProcesses = new Map(); // Track active processes by session ID
14
+
15
+ async function spawnGemini(command, options = {}, ws) {
16
+ const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode, images } = options;
17
+ let capturedSessionId = sessionId; // Track session ID throughout the process
18
+ let sessionCreatedSent = false; // Track if we've already sent session-created event
19
+ let assistantBlocks = []; // Accumulate the full response blocks including tools
20
+
21
+ // Use tools settings passed from frontend, or defaults
22
+ const settings = toolsSettings || {
23
+ allowedTools: [],
24
+ disallowedTools: [],
25
+ skipPermissions: false
26
+ };
27
+
28
+ // Build Gemini CLI command - start with print/resume flags first
29
+ const args = [];
30
+
31
+ // Add prompt flag with command if we have a command
32
+ if (command && command.trim()) {
33
+ args.push('--prompt', command);
34
+ }
35
+
36
+ // If we have a sessionId, we want to resume
37
+ if (sessionId) {
38
+ const session = sessionManager.getSession(sessionId);
39
+ if (session && session.cliSessionId) {
40
+ args.push('--resume', session.cliSessionId);
41
+ }
42
+ }
43
+
44
+ // Use cwd (actual project directory) instead of projectPath (Gemini's metadata directory)
45
+ // Clean the path by removing any non-printable characters
46
+ const cleanPath = (cwd || projectPath || process.cwd()).replace(/[^\x20-\x7E]/g, '').trim();
47
+ const workingDir = cleanPath;
48
+
49
+ // Handle images by saving them to temporary files and passing paths to Gemini
50
+ const tempImagePaths = [];
51
+ let tempDir = null;
52
+ if (images && images.length > 0) {
53
+ try {
54
+ // Create temp directory in the project directory so Gemini can access it
55
+ tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString());
56
+ await fs.mkdir(tempDir, { recursive: true });
57
+
58
+ // Save each image to a temp file
59
+ for (const [index, image] of images.entries()) {
60
+ // Extract base64 data and mime type
61
+ const matches = image.data.match(/^data:([^;]+);base64,(.+)$/);
62
+ if (!matches) {
63
+ continue;
64
+ }
65
+
66
+ const [, mimeType, base64Data] = matches;
67
+ const extension = mimeType.split('/')[1] || 'png';
68
+ const filename = `image_${index}.${extension}`;
69
+ const filepath = path.join(tempDir, filename);
70
+
71
+ // Write base64 data to file
72
+ await fs.writeFile(filepath, Buffer.from(base64Data, 'base64'));
73
+ tempImagePaths.push(filepath);
74
+ }
75
+
76
+ // Include the full image paths in the prompt for Gemini to reference
77
+ // Gemini CLI can read images from file paths in the prompt
78
+ if (tempImagePaths.length > 0 && command && command.trim()) {
79
+ const imageNote = `\n\n[Images given: ${tempImagePaths.length} images are located at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`;
80
+ const modifiedCommand = command + imageNote;
81
+
82
+ // Update the command in args
83
+ const promptIndex = args.indexOf('--prompt');
84
+ if (promptIndex !== -1 && args[promptIndex + 1] === command) {
85
+ args[promptIndex + 1] = modifiedCommand;
86
+ } else if (promptIndex !== -1) {
87
+ // If we're using context, update the full prompt
88
+ args[promptIndex + 1] = args[promptIndex + 1] + imageNote;
89
+ }
90
+ }
91
+ } catch (error) {
92
+ console.error('Error processing images for Gemini:', error);
93
+ }
94
+ }
95
+
96
+ // Add basic flags for Gemini
97
+ if (options.debug) {
98
+ args.push('--debug');
99
+ }
100
+
101
+ // Add MCP config flag only if MCP servers are configured
102
+ try {
103
+ const geminiConfigPath = path.join(os.homedir(), '.gemini.json');
104
+ let hasMcpServers = false;
105
+
106
+ try {
107
+ await fs.access(geminiConfigPath);
108
+ const geminiConfigRaw = await fs.readFile(geminiConfigPath, 'utf8');
109
+ const geminiConfig = JSON.parse(geminiConfigRaw);
110
+
111
+ // Check global MCP servers
112
+ if (geminiConfig.mcpServers && Object.keys(geminiConfig.mcpServers).length > 0) {
113
+ hasMcpServers = true;
114
+ }
115
+
116
+ // Check project-specific MCP servers
117
+ if (!hasMcpServers && geminiConfig.geminiProjects) {
118
+ const currentProjectPath = process.cwd();
119
+ const projectConfig = geminiConfig.geminiProjects[currentProjectPath];
120
+ if (projectConfig && projectConfig.mcpServers && Object.keys(projectConfig.mcpServers).length > 0) {
121
+ hasMcpServers = true;
122
+ }
123
+ }
124
+ } catch (e) {
125
+ // Ignore if file doesn't exist or isn't parsable
126
+ }
127
+
128
+ if (hasMcpServers) {
129
+ args.push('--mcp-config', geminiConfigPath);
130
+ }
131
+ } catch (error) {
132
+ // Ignore outer errors
133
+ }
134
+
135
+ // Add model for all sessions (both new and resumed)
136
+ let modelToUse = options.model || 'gemini-2.5-flash';
137
+ args.push('--model', modelToUse);
138
+ args.push('--output-format', 'stream-json');
139
+
140
+ // Handle approval modes and allowed tools
141
+ if (settings.skipPermissions || options.skipPermissions || permissionMode === 'yolo') {
142
+ args.push('--yolo');
143
+ } else if (permissionMode === 'auto_edit') {
144
+ args.push('--approval-mode', 'auto_edit');
145
+ } else if (permissionMode === 'plan') {
146
+ args.push('--approval-mode', 'plan');
147
+ }
148
+
149
+ if (settings.allowedTools && settings.allowedTools.length > 0) {
150
+ args.push('--allowed-tools', settings.allowedTools.join(','));
151
+ }
152
+
153
+ // Try to find gemini in PATH first, then fall back to environment variable
154
+ const geminiPath = process.env.GEMINI_PATH || 'gemini';
155
+ console.log('Spawning Gemini CLI:', geminiPath, args.join(' '));
156
+ console.log('Working directory:', workingDir);
157
+
158
+ let spawnCmd = geminiPath;
159
+ let spawnArgs = args;
160
+
161
+ // On non-Windows platforms, wrap the execution in a shell to avoid ENOEXEC
162
+ // which happens when the target is a script lacking a shebang.
163
+ if (os.platform() !== 'win32') {
164
+ spawnCmd = 'sh';
165
+ // Use exec to replace the shell process, ensuring signals hit gemini directly
166
+ spawnArgs = ['-c', 'exec "$0" "$@"', geminiPath, ...args];
167
+ }
168
+
169
+ return new Promise((resolve, reject) => {
170
+ const geminiProcess = spawnFunction(spawnCmd, spawnArgs, {
171
+ cwd: workingDir,
172
+ stdio: ['pipe', 'pipe', 'pipe'],
173
+ env: { ...process.env } // Inherit all environment variables
174
+ });
175
+
176
+ // Attach temp file info to process for cleanup later
177
+ geminiProcess.tempImagePaths = tempImagePaths;
178
+ geminiProcess.tempDir = tempDir;
179
+
180
+ // Store process reference for potential abort
181
+ const processKey = capturedSessionId || sessionId || Date.now().toString();
182
+ activeGeminiProcesses.set(processKey, geminiProcess);
183
+
184
+ // Store sessionId on the process object for debugging
185
+ geminiProcess.sessionId = processKey;
186
+
187
+ // Close stdin to signal we're done sending input
188
+ geminiProcess.stdin.end();
189
+
190
+ // Add timeout handler
191
+ let hasReceivedOutput = false;
192
+ const timeoutMs = 120000; // 120 seconds for slower models
193
+ let timeout;
194
+
195
+ const startTimeout = () => {
196
+ if (timeout) clearTimeout(timeout);
197
+ timeout = setTimeout(() => {
198
+ const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId || processKey);
199
+ ws.send({
200
+ type: 'gemini-error',
201
+ sessionId: socketSessionId,
202
+ error: `Gemini CLI timeout - no response received for ${timeoutMs / 1000} seconds`
203
+ });
204
+ try {
205
+ geminiProcess.kill('SIGTERM');
206
+ } catch (e) { }
207
+ }, timeoutMs);
208
+ };
209
+
210
+ startTimeout();
211
+
212
+ // Save user message to session when starting
213
+ if (command && capturedSessionId) {
214
+ sessionManager.addMessage(capturedSessionId, 'user', command);
215
+ }
216
+
217
+ // Create response handler for NDJSON buffering
218
+ let responseHandler;
219
+ if (ws) {
220
+ responseHandler = new GeminiResponseHandler(ws, {
221
+ onContentFragment: (content) => {
222
+ if (assistantBlocks.length > 0 && assistantBlocks[assistantBlocks.length - 1].type === 'text') {
223
+ assistantBlocks[assistantBlocks.length - 1].text += content;
224
+ } else {
225
+ assistantBlocks.push({ type: 'text', text: content });
226
+ }
227
+ },
228
+ onToolUse: (event) => {
229
+ assistantBlocks.push({
230
+ type: 'tool_use',
231
+ id: event.tool_id,
232
+ name: event.tool_name,
233
+ input: event.parameters
234
+ });
235
+ },
236
+ onToolResult: (event) => {
237
+ if (capturedSessionId) {
238
+ if (assistantBlocks.length > 0) {
239
+ sessionManager.addMessage(capturedSessionId, 'assistant', [...assistantBlocks]);
240
+ assistantBlocks = [];
241
+ }
242
+ sessionManager.addMessage(capturedSessionId, 'user', [{
243
+ type: 'tool_result',
244
+ tool_use_id: event.tool_id,
245
+ content: event.output === undefined ? null : event.output,
246
+ is_error: event.status === 'error'
247
+ }]);
248
+ }
249
+ },
250
+ onInit: (event) => {
251
+ if (capturedSessionId) {
252
+ const sess = sessionManager.getSession(capturedSessionId);
253
+ if (sess && !sess.cliSessionId) {
254
+ sess.cliSessionId = event.session_id;
255
+ sessionManager.saveSession(capturedSessionId);
256
+ }
257
+ }
258
+ }
259
+ });
260
+ }
261
+
262
+ // Handle stdout
263
+ geminiProcess.stdout.on('data', (data) => {
264
+ const rawOutput = data.toString();
265
+ hasReceivedOutput = true;
266
+ startTimeout(); // Re-arm the timeout
267
+
268
+ // For new sessions, create a session ID FIRST
269
+ if (!sessionId && !sessionCreatedSent && !capturedSessionId) {
270
+ capturedSessionId = `gemini_${Date.now()}`;
271
+ sessionCreatedSent = true;
272
+
273
+ // Create session in session manager
274
+ sessionManager.createSession(capturedSessionId, cwd || process.cwd());
275
+
276
+ // Save the user message now that we have a session ID
277
+ if (command) {
278
+ sessionManager.addMessage(capturedSessionId, 'user', command);
279
+ }
280
+
281
+ // Update process key with captured session ID
282
+ if (processKey !== capturedSessionId) {
283
+ activeGeminiProcesses.delete(processKey);
284
+ activeGeminiProcesses.set(capturedSessionId, geminiProcess);
285
+ }
286
+
287
+ ws.setSessionId && typeof ws.setSessionId === 'function' && ws.setSessionId(capturedSessionId);
288
+
289
+ ws.send({
290
+ type: 'session-created',
291
+ sessionId: capturedSessionId
292
+ });
293
+
294
+ // Emit fake system init so the frontend immediately navigates and saves the session
295
+ ws.send({
296
+ type: 'claude-response',
297
+ sessionId: capturedSessionId,
298
+ data: {
299
+ type: 'system',
300
+ subtype: 'init',
301
+ session_id: capturedSessionId
302
+ }
303
+ });
304
+ }
305
+
306
+ if (responseHandler) {
307
+ responseHandler.processData(rawOutput);
308
+ } else if (rawOutput) {
309
+ // Fallback to direct sending for raw CLI mode without WS
310
+ if (assistantBlocks.length > 0 && assistantBlocks[assistantBlocks.length - 1].type === 'text') {
311
+ assistantBlocks[assistantBlocks.length - 1].text += rawOutput;
312
+ } else {
313
+ assistantBlocks.push({ type: 'text', text: rawOutput });
314
+ }
315
+ const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId);
316
+ ws.send({
317
+ type: 'gemini-response',
318
+ sessionId: socketSessionId,
319
+ data: {
320
+ type: 'message',
321
+ content: rawOutput
322
+ }
323
+ });
324
+ }
325
+ });
326
+
327
+ // Handle stderr
328
+ geminiProcess.stderr.on('data', (data) => {
329
+ const errorMsg = data.toString();
330
+
331
+ // Filter out deprecation warnings and "Loaded cached credentials" message
332
+ if (errorMsg.includes('[DEP0040]') ||
333
+ errorMsg.includes('DeprecationWarning') ||
334
+ errorMsg.includes('--trace-deprecation') ||
335
+ errorMsg.includes('Loaded cached credentials')) {
336
+ return;
337
+ }
338
+
339
+ const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId);
340
+ ws.send({
341
+ type: 'gemini-error',
342
+ sessionId: socketSessionId,
343
+ error: errorMsg
344
+ });
345
+ });
346
+
347
+ // Handle process completion
348
+ geminiProcess.on('close', async (code) => {
349
+ clearTimeout(timeout);
350
+
351
+ // Flush any remaining buffered content
352
+ if (responseHandler) {
353
+ responseHandler.forceFlush();
354
+ responseHandler.destroy();
355
+ }
356
+
357
+ // Clean up process reference
358
+ const finalSessionId = capturedSessionId || sessionId || processKey;
359
+ activeGeminiProcesses.delete(finalSessionId);
360
+
361
+ // Save assistant response to session if we have one
362
+ if (finalSessionId && assistantBlocks.length > 0) {
363
+ sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks);
364
+ }
365
+
366
+ ws.send({
367
+ type: 'claude-complete', // Use claude-complete for compatibility with UI
368
+ sessionId: finalSessionId,
369
+ exitCode: code,
370
+ isNewSession: !sessionId && !!command // Flag to indicate this was a new session
371
+ });
372
+
373
+ // Clean up temporary image files if any
374
+ if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {
375
+ for (const imagePath of geminiProcess.tempImagePaths) {
376
+ await fs.unlink(imagePath).catch(err => { });
377
+ }
378
+ if (geminiProcess.tempDir) {
379
+ await fs.rm(geminiProcess.tempDir, { recursive: true, force: true }).catch(err => { });
380
+ }
381
+ }
382
+
383
+ if (code === 0) {
384
+ resolve();
385
+ } else {
386
+ reject(new Error(code === null ? 'Gemini CLI process was terminated or timed out' : `Gemini CLI exited with code ${code}`));
387
+ }
388
+ });
389
+
390
+ // Handle process errors
391
+ geminiProcess.on('error', (error) => {
392
+ // Clean up process reference on error
393
+ const finalSessionId = capturedSessionId || sessionId || processKey;
394
+ activeGeminiProcesses.delete(finalSessionId);
395
+
396
+ const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
397
+ ws.send({
398
+ type: 'gemini-error',
399
+ sessionId: errorSessionId,
400
+ error: error.message
401
+ });
402
+
403
+ reject(error);
404
+ });
405
+
406
+ });
407
+ }
408
+
409
+ function abortGeminiSession(sessionId) {
410
+ let geminiProc = activeGeminiProcesses.get(sessionId);
411
+ let processKey = sessionId;
412
+
413
+ if (!geminiProc) {
414
+ for (const [key, proc] of activeGeminiProcesses.entries()) {
415
+ if (proc.sessionId === sessionId) {
416
+ geminiProc = proc;
417
+ processKey = key;
418
+ break;
419
+ }
420
+ }
421
+ }
422
+
423
+ if (geminiProc) {
424
+ try {
425
+ geminiProc.kill('SIGTERM');
426
+ setTimeout(() => {
427
+ if (activeGeminiProcesses.has(processKey)) {
428
+ try {
429
+ geminiProc.kill('SIGKILL');
430
+ } catch (e) { }
431
+ }
432
+ }, 2000); // Wait 2 seconds before force kill
433
+
434
+ return true;
435
+ } catch (error) {
436
+ return false;
437
+ }
438
+ }
439
+ return false;
440
+ }
441
+
442
+ function isGeminiSessionActive(sessionId) {
443
+ return activeGeminiProcesses.has(sessionId);
444
+ }
445
+
446
+ function getActiveGeminiSessions() {
447
+ return Array.from(activeGeminiProcesses.keys());
448
+ }
449
+
450
+ export {
451
+ spawnGemini,
452
+ abortGeminiSession,
453
+ isGeminiSessionActive,
454
+ getActiveGeminiSessions
455
+ };
@@ -0,0 +1,140 @@
1
+ // Gemini Response Handler - JSON Stream processing
2
+ class GeminiResponseHandler {
3
+ constructor(ws, options = {}) {
4
+ this.ws = ws;
5
+ this.buffer = '';
6
+ this.onContentFragment = options.onContentFragment || null;
7
+ this.onInit = options.onInit || null;
8
+ this.onToolUse = options.onToolUse || null;
9
+ this.onToolResult = options.onToolResult || null;
10
+ }
11
+
12
+ // Process incoming raw data from Gemini stream-json
13
+ processData(data) {
14
+ this.buffer += data;
15
+
16
+ // Split by newline
17
+ const lines = this.buffer.split('\n');
18
+
19
+ // Keep the last incomplete line in the buffer
20
+ this.buffer = lines.pop() || '';
21
+
22
+ for (const line of lines) {
23
+ if (!line.trim()) continue;
24
+
25
+ try {
26
+ const event = JSON.parse(line);
27
+ this.handleEvent(event);
28
+ } catch (err) {
29
+ // Not a JSON line, probably debug output or CLI warnings
30
+ // console.error('[Gemini Handler] Non-JSON line ignored:', line);
31
+ }
32
+ }
33
+ }
34
+
35
+ handleEvent(event) {
36
+ const socketSessionId = typeof this.ws.getSessionId === 'function' ? this.ws.getSessionId() : null;
37
+
38
+ if (event.type === 'init') {
39
+ if (this.onInit) {
40
+ this.onInit(event);
41
+ }
42
+ return;
43
+ }
44
+
45
+ if (event.type === 'message' && event.role === 'assistant') {
46
+ const content = event.content || '';
47
+
48
+ // Notify the parent CLI handler of accumulated text
49
+ if (this.onContentFragment && content) {
50
+ this.onContentFragment(content);
51
+ }
52
+
53
+ let payload = {
54
+ type: 'gemini-response',
55
+ data: {
56
+ type: 'message',
57
+ content: content,
58
+ isPartial: event.delta === true
59
+ }
60
+ };
61
+ if (socketSessionId) payload.sessionId = socketSessionId;
62
+ this.ws.send(payload);
63
+ }
64
+ else if (event.type === 'tool_use') {
65
+ if (this.onToolUse) {
66
+ this.onToolUse(event);
67
+ }
68
+ let payload = {
69
+ type: 'gemini-tool-use',
70
+ toolName: event.tool_name,
71
+ toolId: event.tool_id,
72
+ parameters: event.parameters || {}
73
+ };
74
+ if (socketSessionId) payload.sessionId = socketSessionId;
75
+ this.ws.send(payload);
76
+ }
77
+ else if (event.type === 'tool_result') {
78
+ if (this.onToolResult) {
79
+ this.onToolResult(event);
80
+ }
81
+ let payload = {
82
+ type: 'gemini-tool-result',
83
+ toolId: event.tool_id,
84
+ status: event.status,
85
+ output: event.output || ''
86
+ };
87
+ if (socketSessionId) payload.sessionId = socketSessionId;
88
+ this.ws.send(payload);
89
+ }
90
+ else if (event.type === 'result') {
91
+ // Send a finalize message string
92
+ let payload = {
93
+ type: 'gemini-response',
94
+ data: {
95
+ type: 'message',
96
+ content: '',
97
+ isPartial: false
98
+ }
99
+ };
100
+ if (socketSessionId) payload.sessionId = socketSessionId;
101
+ this.ws.send(payload);
102
+
103
+ if (event.stats && event.stats.total_tokens) {
104
+ let statsPayload = {
105
+ type: 'claude-status',
106
+ data: {
107
+ status: 'Complete',
108
+ tokens: event.stats.total_tokens
109
+ }
110
+ };
111
+ if (socketSessionId) statsPayload.sessionId = socketSessionId;
112
+ this.ws.send(statsPayload);
113
+ }
114
+ }
115
+ else if (event.type === 'error') {
116
+ let payload = {
117
+ type: 'gemini-error',
118
+ error: event.error || event.message || 'Unknown Gemini streaming error'
119
+ };
120
+ if (socketSessionId) payload.sessionId = socketSessionId;
121
+ this.ws.send(payload);
122
+ }
123
+ }
124
+
125
+ forceFlush() {
126
+ // If the buffer has content, try to parse it one last time
127
+ if (this.buffer.trim()) {
128
+ try {
129
+ const event = JSON.parse(this.buffer);
130
+ this.handleEvent(event);
131
+ } catch (err) { }
132
+ }
133
+ }
134
+
135
+ destroy() {
136
+ this.buffer = '';
137
+ }
138
+ }
139
+
140
+ export default GeminiResponseHandler;