Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b3fe1b4
fix(codex): align session id lifecycle with async thread.start events
blackmammoth May 5, 2026
c064aff
refactor(chat): trim non-rendered realtime state from session flow
blackmammoth May 5, 2026
1325f18
test-commit
blackmammoth May 5, 2026
59fbdde
fix: cursor projects fetching
unsystemizer May 5, 2026
41e221f
refactor(session): simplify session date and time retrieval logic
unsystemizer May 5, 2026
6f3e48f
refactor(sidebar): rely on canonical session lastActivity timestamps
blackmammoth May 5, 2026
f289ce8
fix(gemini): align headless session/auth flow with async CLI behavior
blackmammoth May 7, 2026
ce36fb8
Potential fix for pull request finding 'Unused variable, import, func…
blackmammoth May 7, 2026
5bd179a
fix(cursor): remove user_info, system_reminder, and user_query tags
unsystemizer May 8, 2026
c941381
fix(cursor): attach write tool outputs correctly
unsystemizer May 8, 2026
29f6d94
fix(cursor): remove debug console logs from message normalization
unsystemizer May 8, 2026
684e127
fix(chat): make provider message totals reflect what the user actuall…
blackmammoth May 8, 2026
8273dc5
Merge branch 'fix/websocket-streaming-issues' of https://github.com/s…
blackmammoth May 8, 2026
13d1d43
Merge branch 'fix/websocket-streaming-issues' of https://github.com/s…
unsystemizer May 8, 2026
5554e4e
fix(cursor-chat): count totals as rendered rows, not normalized trans…
unsystemizer May 8, 2026
e57ce42
fix(session-runtime): make Gemini auth handling and Codex resume stat…
unsystemizer May 8, 2026
de25f6d
fix(logging): remove unnecessary console logs from CodexSessionsProvi…
blackmammoth May 8, 2026
54130e8
fix(cursor-history): align pagination with visible rows
unsystemizer May 8, 2026
ded6308
fix(claude): add support for custom titles by claude
blackmammoth May 8, 2026
116f91b
fix(claude): exclude meta messages from user content in normalization
blackmammoth May 8, 2026
17db71c
fix(claude): preserve local command artifacts in session history
blackmammoth May 8, 2026
b2e3a61
feat: add archiving flows for sessions and workspaces
blackmammoth May 8, 2026
10528a2
fix: exclude archived content from conversation search
blackmammoth May 8, 2026
ca83453
fix: remove performance warning for loaded messages in ChatMessagesPane
blackmammoth May 8, 2026
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
8 changes: 0 additions & 8 deletions server/cursor-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,6 @@ async function spawnCursor(command, options = {}, ws) {

try {
const response = JSON.parse(line);
console.log('Parsed JSON response:', response);

// Handle different message types
switch (response.type) {
Expand All @@ -159,7 +158,6 @@ async function spawnCursor(command, options = {}, ws) {
// Capture session ID
if (response.session_id && !capturedSessionId) {
capturedSessionId = response.session_id;
console.log('Captured session ID:', capturedSessionId);

// Update process key with captured session ID
if (processKey !== capturedSessionId) {
Expand Down Expand Up @@ -197,7 +195,6 @@ async function spawnCursor(command, options = {}, ws) {

case 'result': {
// Session complete — send stream end + lifecycle complete with result payload
console.log('Cursor session result:', response);
const resultText = typeof response.result === 'string' ? response.result : '';
ws.send(createNormalizedMessage({
kind: 'complete',
Expand All @@ -213,8 +210,6 @@ async function spawnCursor(command, options = {}, ws) {
// Unknown message types — ignore.
}
} catch (parseError) {
console.log('Non-JSON response:', line);

if (shouldSuppressForTrustRetry(line)) {
return;
}
Expand All @@ -228,7 +223,6 @@ async function spawnCursor(command, options = {}, ws) {
// Handle stdout (streaming JSON responses)
cursorProcess.stdout.on('data', (data) => {
const rawOutput = data.toString();
console.log('Cursor CLI stdout:', rawOutput);

// Stream chunks can split JSON objects across packets; keep trailing partial line.
stdoutLineBuffer += rawOutput;
Expand All @@ -254,8 +248,6 @@ async function spawnCursor(command, options = {}, ws) {

// Handle process completion
cursorProcess.on('close', async (code) => {
console.log(`Cursor CLI process exited with code ${code}`);

const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);

Expand Down
232 changes: 190 additions & 42 deletions server/gemini-cli.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,123 @@
import { spawn } from 'child_process';
import crossSpawn from 'cross-spawn';

// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import path from 'path';

import crossSpawn from 'cross-spawn';

import sessionManager from './sessionManager.js';
import GeminiResponseHandler from './gemini-response-handler.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { createNormalizedMessage } from './shared/utils.js';

// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;

let activeGeminiProcesses = new Map(); // Track active processes by session ID

function mapGeminiExitCodeToMessage(exitCode) {
switch (exitCode) {
case 42:
return 'Gemini rejected the request input (exit code 42).';
case 44:
return 'Gemini sandbox error (exit code 44). Check local sandbox/container settings.';
case 52:
return 'Gemini configuration error (exit code 52). Check your Gemini settings files for invalid JSON/config.';
case 53:
return 'Gemini conversation turn limit reached (exit code 53). Start a new Gemini session.';
default:
return null;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const GEMINI_AUTH_ENV_KEYS = [
'GEMINI_API_KEY',
'GOOGLE_API_KEY',
'GOOGLE_CLOUD_PROJECT',
'GOOGLE_CLOUD_PROJECT_ID',
'GOOGLE_CLOUD_LOCATION',
'GOOGLE_APPLICATION_CREDENTIALS'
];

function parseEnvFileContent(content) {
const parsed = {};

for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}

const exportPrefix = 'export ';
const normalizedLine = line.startsWith(exportPrefix) ? line.slice(exportPrefix.length).trim() : line;
const separatorIndex = normalizedLine.indexOf('=');

if (separatorIndex <= 0) {
continue;
}

const key = normalizedLine.slice(0, separatorIndex).trim();
if (!key) {
continue;
}

let value = normalizedLine.slice(separatorIndex + 1).trim();
const hasDoubleQuotes = value.startsWith('"') && value.endsWith('"');
const hasSingleQuotes = value.startsWith('\'') && value.endsWith('\'');

if (hasDoubleQuotes || hasSingleQuotes) {
value = value.slice(1, -1);
} else {
// Support inline comments in unquoted values: KEY=value # comment
value = value.replace(/\s+#.*$/, '').trim();
}

parsed[key] = value;
}

return parsed;
}

async function loadGeminiUserLevelEnv() {
const geminiCliHome = (process.env.GEMINI_CLI_HOME || '').trim() || os.homedir();
const envCandidates = [
path.join(geminiCliHome, '.gemini', '.env'),
path.join(geminiCliHome, '.env')
];

for (const envPath of envCandidates) {
try {
await fs.access(envPath);
const content = await fs.readFile(envPath, 'utf8');
return parseEnvFileContent(content);
} catch {
// Keep scanning for the next candidate.
}
}

return {};
}

async function buildGeminiProcessEnv() {
const processEnv = { ...process.env };
if (processEnv.GEMINI_API_KEY || processEnv.GOOGLE_API_KEY || processEnv.GOOGLE_APPLICATION_CREDENTIALS) {
return processEnv;
}

// Gemini CLI docs recommend ~/.gemini/.env for persistent headless auth settings.
// When the server process was launched without shell profile variables, we still
// want the spawned CLI process to inherit those user-level credentials.
const userEnv = await loadGeminiUserLevelEnv();
for (const key of GEMINI_AUTH_ENV_KEYS) {
if (!processEnv[key] && userEnv[key]) {
processEnv[key] = userEnv[key];
}
}

return processEnv;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async function spawnGemini(command, options = {}, ws) {
const { sessionId, projectPath, cwd, toolsSettings, permissionMode, images, sessionSummary } = options;
let capturedSessionId = sessionId; // Track session ID throughout the process
Expand Down Expand Up @@ -100,6 +204,11 @@ async function spawnGemini(command, options = {}, ws) {
args.push('--debug');
}

// This integration runs Gemini in headless mode and cannot answer trust prompts.
// Skip folder-trust interactivity so authenticated runs don't fail with
// FatalUntrustedWorkspaceError in previously unseen directories.
args.push('--skip-trust');

// Add MCP config flag only if MCP servers are configured
try {
const geminiConfigPath = path.join(os.homedir(), '.gemini.json');
Expand Down Expand Up @@ -154,9 +263,6 @@ async function spawnGemini(command, options = {}, ws) {

// Try to find gemini in PATH first, then fall back to environment variable
const geminiPath = process.env.GEMINI_PATH || 'gemini';
console.log('Spawning Gemini CLI:', geminiPath, args.join(' '));
console.log('Working directory:', workingDir);

let spawnCmd = geminiPath;
let spawnArgs = args;

Expand All @@ -168,11 +274,13 @@ async function spawnGemini(command, options = {}, ws) {
spawnArgs = ['-c', 'exec "$0" "$@"', geminiPath, ...args];
}

const spawnEnv = await buildGeminiProcessEnv();

return new Promise((resolve, reject) => {
const geminiProcess = spawnFunction(spawnCmd, spawnArgs, {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env } // Inherit all environment variables
env: spawnEnv
});
let terminalNotificationSent = false;
let terminalFailureReason = null;
Expand Down Expand Up @@ -276,13 +384,44 @@ 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);
const discoveredSessionId = event?.session_id;
if (!discoveredSessionId) {
return;
}

// New Gemini sessions announce their canonical ID asynchronously via the
// initial `init` stream event. Avoid synthetic IDs and only register
// the session once that real ID is known (same model used by Claude/Codex).
if (!capturedSessionId) {
capturedSessionId = discoveredSessionId;

sessionManager.createSession(capturedSessionId, cwd || process.cwd());
if (command) {
sessionManager.addMessage(capturedSessionId, 'user', command);
}

if (processKey !== capturedSessionId) {
activeGeminiProcesses.delete(processKey);
activeGeminiProcesses.set(capturedSessionId, geminiProcess);
}

geminiProcess.sessionId = capturedSessionId;

if (ws.setSessionId && typeof ws.setSessionId === 'function') {
ws.setSessionId(capturedSessionId);
}

if (!sessionId && !sessionCreatedSent) {
sessionCreatedSent = true;
ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'gemini' }));
}
}

const sess = sessionManager.getSession(capturedSessionId);
if (sess && !sess.cliSessionId) {
sess.cliSessionId = discoveredSessionId;
sessionManager.saveSession(capturedSessionId);
}
}
});
}
Expand All @@ -292,30 +431,6 @@ 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());

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

// Update process key with captured session ID
if (processKey !== capturedSessionId) {
activeGeminiProcesses.delete(processKey);
activeGeminiProcesses.set(capturedSessionId, geminiProcess);
}

ws.setSessionId && 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) {
Expand Down Expand Up @@ -381,20 +496,53 @@ async function spawnGemini(command, options = {}, ws) {
notifyTerminalState({ code });
resolve();
} else {
// code 127 = shell "command not found" — check installation
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;

// code 127 = shell "command not found" - check installation
if (code === 127) {
const installed = await providerAuthService.isProviderInstalled('gemini');
if (!installed) {
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
ws.send(createNormalizedMessage({ kind: 'error', content: 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli', sessionId: socketSessionId, provider: 'gemini' }));
terminalFailureReason = 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli';
ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' }));
}
} else if (code === 41) {
// Gemini CLI documents exit code 41 as FatalAuthenticationError.
// Surface an actionable auth error instead of a generic exit-code message.
let authErrorSuffix = '';
try {
const authStatus = await providerAuthService.getProviderAuthStatus('gemini');
if (!authStatus?.authenticated && authStatus?.error) {
authErrorSuffix = ` Details: ${authStatus.error}`;
}
} catch {
// Keep base remediation text when auth status lookup fails.
}

terminalFailureReason =
'Gemini authentication failed (exit code 41). '
+ 'Run `gemini` in a terminal to choose an auth method, or configure a valid `GEMINI_API_KEY`.'
+ authErrorSuffix;
ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' }));
} else {
const mappedError = mapGeminiExitCodeToMessage(code);
if (mappedError) {
terminalFailureReason = mappedError;
ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' }));
}
}

notifyTerminalState({
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}`));
reject(
new Error(
terminalFailureReason
|| (code === null
? 'Gemini CLI process was terminated or timed out'
: `Gemini CLI exited with code ${code}`)
)
);
}
});

Expand Down
Loading
Loading