Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 111 additions & 36 deletions server/gemini-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ let activeGeminiProcesses = new Map(); // Track active processes by session ID

async function spawnGemini(command, options = {}, ws) {
const { sessionId, projectPath, cwd, toolsSettings, permissionMode, images, sessionSummary } = options;

// Ensure session store is loaded (Issue #3)
await sessionManager.ready;

// Abort existing process for this session to prevent orphans (Issue #1)
if (sessionId && activeGeminiProcesses.has(sessionId)) {
console.log(`[Gemini] Aborting existing process for session ${sessionId} before starting new one`);
abortGeminiSession(sessionId);
}

let capturedSessionId = sessionId; // Track session ID throughout the process
let sessionCreatedSent = false; // Track if we've already sent session-created event
let assistantBlocks = []; // Accumulate the full response blocks including tools
Expand All @@ -38,8 +48,15 @@ async function spawnGemini(command, options = {}, ws) {
// If we have a sessionId, we want to resume
if (sessionId) {
const session = sessionManager.getSession(sessionId);
if (session && session.cliSessionId) {
args.push('--resume', session.cliSessionId);
if (session) {
// Use native ID for resumption:
// 1. If it's a legacy session, it has a cliSessionId field.
// 2. In the new unified architecture, the session ID itself is the native ID.
const resumeId = session.cliSessionId || session.id || sessionId;
args.push('--resume', resumeId);
console.log(`[Gemini] Resuming native session: ${resumeId}`);
} else {
console.warn(`[Gemini] Session ${sessionId} not found in sessionManager. Resuming without --resume.`);
}
}

Expand Down Expand Up @@ -169,6 +186,15 @@ async function spawnGemini(command, options = {}, ws) {
}

return new Promise((resolve, reject) => {
let settled = false;

const settleOnce = (callback) => {
if (settled) return;
settled = true;
if (timeout) clearTimeout(timeout);
callback();
};

const geminiProcess = spawnFunction(spawnCmd, spawnArgs, {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
Expand Down Expand Up @@ -227,11 +253,12 @@ async function spawnGemini(command, options = {}, ws) {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId || processKey);
terminalFailureReason = `Gemini CLI timeout - no response received for ${timeoutMs / 1000} seconds`;
terminalFailureReason = 'Gemini CLI process timed out';
ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' }));
try {
geminiProcess.kill('SIGTERM');
} catch (e) { }
settleOnce(() => reject(new Error(terminalFailureReason)));
}, timeoutMs);
};

Expand Down Expand Up @@ -276,12 +303,40 @@ async function spawnGemini(command, options = {}, ws) {
}
},
onInit: (event) => {
if (capturedSessionId) {
const sess = sessionManager.getSession(capturedSessionId);
if (sess && !sess.cliSessionId) {
sess.cliSessionId = event.session_id;
sessionManager.saveSession(capturedSessionId);
// Match Claude's implementation: wait for the native session_id before creating the UI session
if (event.session_id && !capturedSessionId) {
capturedSessionId = event.session_id;
sessionCreatedSent = true;

// Create session in session manager using the native native ID
sessionManager.createSession(capturedSessionId, cwd || process.cwd());

// Save the user message now that we have the native session ID
if (command) {
sessionManager.addMessage(capturedSessionId, 'user', command);
}

// Re-key the active process map to use the native native ID
if (processKey !== capturedSessionId) {
activeGeminiProcesses.delete(processKey);
activeGeminiProcesses.set(capturedSessionId, geminiProcess);
geminiProcess.sessionId = capturedSessionId;
}

// Inform the writer and client about the newly established native ID
if (ws) {
if (typeof ws.setSessionId === 'function') {
ws.setSessionId(capturedSessionId);
}
ws.send(createNormalizedMessage({
kind: 'session_created',
newSessionId: capturedSessionId,
sessionId: capturedSessionId,
provider: 'gemini'
}));
}

console.log(`[Gemini] Native session established: ${capturedSessionId}`);
}
}
});
Expand All @@ -292,34 +347,43 @@ async function spawnGemini(command, options = {}, ws) {
const rawOutput = data.toString();
startTimeout(); // Re-arm the timeout

// For new sessions, create a session ID FIRST
if (!sessionId && !sessionCreatedSent && !capturedSessionId) {
capturedSessionId = `gemini_${Date.now()}`;
sessionCreatedSent = true;

// Create session in session manager
sessionManager.createSession(capturedSessionId, cwd || process.cwd());
if (responseHandler) {
responseHandler.processData(rawOutput);
} else if (rawOutput) {
// Fallback for raw CLI mode without structured JSON streaming
if (!sessionId && !sessionCreatedSent && !capturedSessionId) {
capturedSessionId = `gemini_${Date.now()}`;
sessionCreatedSent = true;

// Save the user message now that we have a session ID
if (command) {
sessionManager.addMessage(capturedSessionId, 'user', command);
}
// Create session in session manager
sessionManager.createSession(capturedSessionId, cwd || process.cwd());

// Update process key with captured session ID
if (processKey !== capturedSessionId) {
activeGeminiProcesses.delete(processKey);
activeGeminiProcesses.set(capturedSessionId, geminiProcess);
}
// Save the user message now that we have a fallback session ID
if (command) {
sessionManager.addMessage(capturedSessionId, 'user', command);
}

ws.setSessionId && typeof ws.setSessionId === 'function' && ws.setSessionId(capturedSessionId);
// Update process key
if (processKey !== capturedSessionId) {
activeGeminiProcesses.delete(processKey);
activeGeminiProcesses.set(capturedSessionId, geminiProcess);
geminiProcess.sessionId = capturedSessionId;
}

ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'gemini' }));
}
if (ws) {
if (typeof ws.setSessionId === 'function') {
ws.setSessionId(capturedSessionId);
}
ws.send(createNormalizedMessage({
kind: 'session_created',
newSessionId: capturedSessionId,
sessionId: capturedSessionId,
provider: 'gemini'
}));
}
}

if (responseHandler) {
responseHandler.processData(rawOutput);
} else if (rawOutput) {
// Fallback to direct sending for raw CLI mode without WS
// Fallback direct streaming
if (assistantBlocks.length > 0 && assistantBlocks[assistantBlocks.length - 1].type === 'text') {
assistantBlocks[assistantBlocks.length - 1].text += rawOutput;
} else {
Expand Down Expand Up @@ -379,7 +443,7 @@ async function spawnGemini(command, options = {}, ws) {

if (code === 0) {
notifyTerminalState({ code });
resolve();
settleOnce(() => resolve());
} else {
// code 127 = shell "command not found" — check installation
if (code === 127) {
Expand All @@ -394,7 +458,7 @@ async function spawnGemini(command, options = {}, ws) {
code,
error: code === null ? 'Gemini CLI process was terminated or timed out' : null
});
reject(new Error(code === null ? 'Gemini CLI process was terminated or timed out' : `Gemini CLI exited with code ${code}`));
settleOnce(() => reject(new Error(code === null ? 'Gemini CLI process was terminated or timed out' : `Gemini CLI exited with code ${code}`))));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Syntax error — extra closing parenthesis breaks the module.

There are four ) after the template literal but only three opens (settleOnce(, reject(, new Error(). Biome flagged this (Expected a semicolon or an implicit semicolon after a statement, but found none). As written, this file will fail to parse and the entire Gemini provider becomes unusable.

🐛 Proposed fix
-                settleOnce(() => reject(new Error(code === null ? 'Gemini CLI process was terminated or timed out' : `Gemini CLI exited with code ${code}`))));
+                settleOnce(() => reject(new Error(code === null ? 'Gemini CLI process was terminated or timed out' : `Gemini CLI exited with code ${code}`)));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
settleOnce(() => reject(new Error(code === null ? 'Gemini CLI process was terminated or timed out' : `Gemini CLI exited with code ${code}`))));
settleOnce(() => reject(new Error(code === null ? 'Gemini CLI process was terminated or timed out' : `Gemini CLI exited with code ${code}`)));
🧰 Tools
🪛 Biome (2.4.11)

[error] 461-461: Expected a semicolon or an implicit semicolon after a statement, but found none

(parse)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/gemini-cli.js` at line 461, The line calling settleOnce currently has
one too many closing parentheses which causes a syntax error; update the call to
settleOnce so it wraps the arrow function that calls reject(new Error(...)) with
the correct number of closing parentheses and terminate the statement with a
semicolon — i.e., ensure settleOnce( ... ) closes only once after the arrow
function and that reject(...) and new Error(...) are properly closed; adjust the
invocation around settleOnce, reject, new Error and the code variable
accordingly.

}
});

Expand All @@ -414,7 +478,7 @@ async function spawnGemini(command, options = {}, ws) {
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' }));
notifyTerminalState({ error });

reject(error);
settleOnce(() => reject(error));
});

});
Expand All @@ -437,10 +501,21 @@ function abortGeminiSession(sessionId) {
if (geminiProc) {
try {
geminiProc.kill('SIGTERM');
const targetProc = geminiProc; // Capture for closure
setTimeout(() => {
if (activeGeminiProcesses.has(processKey)) {
// Check if the exact same process is still in the map (by value, not just by key)
// This ensures we don't SIGKILL a NEW process that might have taken the same key
let isStillActive = false;
for (const proc of activeGeminiProcesses.values()) {
if (proc === targetProc) {
isStillActive = true;
break;
}
}

if (isStillActive) {
try {
geminiProc.kill('SIGKILL');
targetProc.kill('SIGKILL');
} catch (e) { }
}
}, 2000); // Wait 2 seconds before force kill
Expand Down
3 changes: 3 additions & 0 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2312,6 +2312,9 @@ async function startServer() {
// Initialize authentication database
await initializeDatabase();

// Ensure Gemini session store is loaded from disk before accepting requests
await sessionManager.ready;

// Configure Web Push (VAPID keys)
configureWebPush();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,9 @@ export class GeminiSessionsProvider implements IProviderSessions {
rawMessages = sessionManager.getSessionMessages(sessionId) as AnyRecord[];

if (rawMessages.length === 0) {
rawMessages = await getGeminiCliSessionMessages(sessionId) as AnyRecord[];
const session = sessionManager.getSession(sessionId);
const cliSessionId = (session as any)?.cliSessionId || sessionId;
rawMessages = await getGeminiCliSessionMessages(cliSessionId) as AnyRecord[];
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
Expand Down