mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-10 06:28:18 +00:00
fix(gemini): align headless session/auth flow with async CLI behavior
Why: - Gemini does not expose a new session id synchronously. It emits the canonical id in the init stream event. - Creating temporary ids in web mode introduced identity drift, extra mapping logic, and harder resume/debug behavior. - Headless server runs often miss shell-inherited auth vars, while users configure Gemini through user-level env files. - Gemini mirrors session artifacts across folders, which caused duplicate sync events and duplicate session rows. What changed: - Removed temporary Gemini ids for new sessions. - New Gemini sessions are now created only after init provides session_id. - Persisted cliSessionId from the discovered canonical id, keeping one identifier across stream, storage, and resume. - Built Gemini spawn env from process env plus user-level fallback files: ~/.gemini/.env then ~/.env, honoring GEMINI_CLI_HOME. - Added --skip-trust for headless runs, because web flows cannot answer interactive trust prompts. - Improved terminal error mapping and rejection reasons, especially for auth exit code 41 with actionable context. - Limited Gemini synchronization to tmp JSONL chat artifacts, and disabled duplicate watcher/index paths that mirror the same sessions. - Added gemini-2.5-flash-lite to shared model constants. Result: - Gemini session identity is canonical and provider-consistent. - Headless auth now matches practical Gemini CLI configuration patterns. - Duplicate Gemini session indexing is reduced at the source. - Operators get clearer, actionable failure messages.
This commit is contained in:
@@ -1,19 +1,125 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
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 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 41:
|
||||
return 'Gemini authentication failed (exit code 41). Run `gemini` in a terminal to choose an auth method, or configure a valid `GEMINI_API_KEY`.';
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 (GEMINI_AUTH_ENV_KEYS.every((key) => processEnv[key])) {
|
||||
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;
|
||||
}
|
||||
|
||||
async function spawnGemini(command, options = {}, ws) {
|
||||
const { sessionId, projectPath, cwd, toolsSettings, permissionMode, images, sessionSummary } = options;
|
||||
let capturedSessionId = sessionId; // Track session ID throughout the process
|
||||
@@ -100,6 +206,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');
|
||||
@@ -168,11 +279,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;
|
||||
@@ -276,12 +389,43 @@ 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -292,30 +436,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) {
|
||||
@@ -381,12 +501,38 @@ 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 `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' }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,7 +540,14 @@ 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}`));
|
||||
reject(
|
||||
new Error(
|
||||
terminalFailureReason
|
||||
|| (code === null
|
||||
? 'Gemini CLI process was terminated or timed out'
|
||||
: `Gemini CLI exited with code ${code}`)
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,37 @@ type GeminiCredentialsStatus = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type GeminiAuthType =
|
||||
| 'oauth-personal'
|
||||
| 'gemini-api-key'
|
||||
| 'vertex-ai'
|
||||
| 'compute-default-credentials'
|
||||
| 'gateway'
|
||||
| 'cloud-shell'
|
||||
| null;
|
||||
|
||||
/**
|
||||
* Env keys Gemini CLI accepts for headless authentication.
|
||||
* We check these from both process.env and user-level env files.
|
||||
*/
|
||||
const GEMINI_AUTH_ENV_KEYS = [
|
||||
'GEMINI_API_KEY',
|
||||
'GOOGLE_API_KEY',
|
||||
'GOOGLE_CLOUD_PROJECT',
|
||||
'GOOGLE_CLOUD_PROJECT_ID',
|
||||
'GOOGLE_CLOUD_LOCATION',
|
||||
'GOOGLE_APPLICATION_CREDENTIALS',
|
||||
] as const;
|
||||
|
||||
export class GeminiProviderAuth implements IProviderAuth {
|
||||
/**
|
||||
* Gemini CLI can override its home root via GEMINI_CLI_HOME.
|
||||
* Use the same resolution so status checks match runtime behavior.
|
||||
*/
|
||||
private getGeminiCliHome(): string {
|
||||
return process.env.GEMINI_CLI_HOME?.trim() || os.homedir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the Gemini CLI is available on this host.
|
||||
*/
|
||||
@@ -58,6 +88,88 @@ export class GeminiProviderAuth implements IProviderAuth {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses dotenv-style key/value pairs.
|
||||
*/
|
||||
private parseEnvFile(content: string): Record<string, string> {
|
||||
const parsed: Record<string, string> = {};
|
||||
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedLine = line.startsWith('export ')
|
||||
? line.slice('export '.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 quoted = (value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''));
|
||||
if (quoted) {
|
||||
value = value.slice(1, -1);
|
||||
} else {
|
||||
value = value.replace(/\s+#.*$/, '').trim();
|
||||
}
|
||||
|
||||
parsed[key] = value;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads user-level auth env in Gemini's "first file found" order.
|
||||
*/
|
||||
private async loadUserLevelAuthEnv(): Promise<Record<string, string>> {
|
||||
const geminiCliHome = this.getGeminiCliHome();
|
||||
const envCandidates = [
|
||||
path.join(geminiCliHome, '.gemini', '.env'),
|
||||
path.join(geminiCliHome, '.env'),
|
||||
];
|
||||
|
||||
for (const envPath of envCandidates) {
|
||||
try {
|
||||
const content = await readFile(envPath, 'utf8');
|
||||
return this.parseEnvFile(content);
|
||||
} catch {
|
||||
// Continue to the next fallback.
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads Gemini's selected auth type from settings.json when available.
|
||||
*/
|
||||
private async readSelectedAuthType(): Promise<GeminiAuthType> {
|
||||
try {
|
||||
const settingsPath = path.join(this.getGeminiCliHome(), '.gemini', 'settings.json');
|
||||
const content = await readFile(settingsPath, 'utf8');
|
||||
const settings = readObjectRecord(JSON.parse(content));
|
||||
const security = readObjectRecord(settings?.security);
|
||||
const auth = readObjectRecord(security?.auth);
|
||||
const selectedType = readOptionalString(auth?.selectedType);
|
||||
if (!selectedType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return selectedType as GeminiAuthType;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks Gemini credentials from API key env vars or local OAuth credential files.
|
||||
*/
|
||||
@@ -66,8 +178,46 @@ export class GeminiProviderAuth implements IProviderAuth {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||
}
|
||||
|
||||
const userEnv = await this.loadUserLevelAuthEnv();
|
||||
if (readOptionalString(userEnv.GEMINI_API_KEY)) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||
}
|
||||
|
||||
const selectedType = await this.readSelectedAuthType();
|
||||
if (selectedType === 'vertex-ai') {
|
||||
const hasGoogleApiKey = Boolean(
|
||||
process.env.GOOGLE_API_KEY?.trim()
|
||||
|| readOptionalString(userEnv.GOOGLE_API_KEY)
|
||||
);
|
||||
const hasProject = Boolean(
|
||||
process.env.GOOGLE_CLOUD_PROJECT?.trim()
|
||||
|| process.env.GOOGLE_CLOUD_PROJECT_ID?.trim()
|
||||
|| readOptionalString(userEnv.GOOGLE_CLOUD_PROJECT)
|
||||
|| readOptionalString(userEnv.GOOGLE_CLOUD_PROJECT_ID)
|
||||
);
|
||||
const hasLocation = Boolean(
|
||||
process.env.GOOGLE_CLOUD_LOCATION?.trim()
|
||||
|| readOptionalString(userEnv.GOOGLE_CLOUD_LOCATION)
|
||||
);
|
||||
const hasServiceAccount = Boolean(
|
||||
process.env.GOOGLE_APPLICATION_CREDENTIALS?.trim()
|
||||
|| readOptionalString(userEnv.GOOGLE_APPLICATION_CREDENTIALS)
|
||||
);
|
||||
|
||||
if (hasGoogleApiKey || hasServiceAccount || (hasProject && hasLocation)) {
|
||||
return { authenticated: true, email: 'Vertex AI Auth', method: 'vertex_ai' };
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: 'vertex_ai',
|
||||
error: 'Gemini is set to Vertex AI, but required env vars are missing',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
|
||||
const credsPath = path.join(this.getGeminiCliHome(), '.gemini', 'oauth_creds.json');
|
||||
const content = await readFile(credsPath, 'utf8');
|
||||
const creds = readObjectRecord(JSON.parse(content)) ?? {};
|
||||
const accessToken = readOptionalString(creds.access_token);
|
||||
@@ -106,6 +256,25 @@ export class GeminiProviderAuth implements IProviderAuth {
|
||||
method: 'credentials_file',
|
||||
};
|
||||
} catch {
|
||||
if (selectedType === 'gemini-api-key') {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: 'api_key',
|
||||
error: 'Gemini is set to "Use Gemini API key", but GEMINI_API_KEY is unavailable',
|
||||
};
|
||||
}
|
||||
|
||||
if (selectedType === 'oauth-personal') {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: 'credentials_file',
|
||||
error: 'Gemini is set to Google sign-in, but no cached OAuth credentials were found',
|
||||
};
|
||||
}
|
||||
|
||||
// If no explicit auth type was selected, surface the generic "not configured" error.
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
@@ -140,7 +309,7 @@ export class GeminiProviderAuth implements IProviderAuth {
|
||||
*/
|
||||
private async getActiveAccountEmail(): Promise<string | null> {
|
||||
try {
|
||||
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
|
||||
const accPath = path.join(this.getGeminiCliHome(), '.gemini', 'google_accounts.json');
|
||||
const accContent = await readFile(accPath, 'utf8');
|
||||
const accounts = readObjectRecord(JSON.parse(accContent));
|
||||
return readOptionalString(accounts?.active) ?? null;
|
||||
|
||||
@@ -39,33 +39,37 @@ export class GeminiSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
async synchronize(since?: Date): Promise<number> {
|
||||
const projectHashLookup = this.buildProjectHashLookup();
|
||||
|
||||
const legacySessionFiles = await findFilesRecursivelyCreatedAfter(
|
||||
path.join(this.geminiHome, 'sessions'),
|
||||
'.json',
|
||||
since ?? null
|
||||
);
|
||||
const legacyTempFiles = await findFilesRecursivelyCreatedAfter(
|
||||
path.join(this.geminiHome, 'tmp'),
|
||||
'.json',
|
||||
since ?? null
|
||||
);
|
||||
const jsonlSessionFiles = await findFilesRecursivelyCreatedAfter(
|
||||
path.join(this.geminiHome, 'sessions'),
|
||||
'.jsonl',
|
||||
since ?? null
|
||||
);
|
||||
// const legacySessionFiles = await findFilesRecursivelyCreatedAfter(
|
||||
// path.join(this.geminiHome, 'sessions'),
|
||||
// '.json',
|
||||
// since ?? null
|
||||
// );
|
||||
// Gemini creates overlapping artifacts across `sessions/` and `tmp/`.
|
||||
// We currently index only `tmp/*/chats/*.jsonl` because those files are the
|
||||
// live transcript source and avoid duplicate session rows from mirrored files.
|
||||
// const legacyTempFiles = await findFilesRecursivelyCreatedAfter(
|
||||
// path.join(this.geminiHome, 'tmp'),
|
||||
// '.json',
|
||||
// since ?? null
|
||||
// );
|
||||
// const jsonlSessionFiles = await findFilesRecursivelyCreatedAfter(
|
||||
// path.join(this.geminiHome, 'sessions'),
|
||||
// '.jsonl',
|
||||
// since ?? null
|
||||
// );
|
||||
const jsonlTempFiles = await findFilesRecursivelyCreatedAfter(
|
||||
path.join(this.geminiHome, 'tmp'),
|
||||
'.jsonl',
|
||||
since ?? null
|
||||
);
|
||||
|
||||
// Process legacy JSON first, then JSONL. If both exist for a session id,
|
||||
// the JSONL artifact becomes the canonical jsonl_path via upsert.
|
||||
// Current strategy: index only temp chat JSONL artifacts.
|
||||
const files = [
|
||||
...legacySessionFiles,
|
||||
...legacyTempFiles,
|
||||
...jsonlSessionFiles,
|
||||
// ...legacySessionFiles,
|
||||
// Intentionally disabled to avoid duplicate indexing from mirrored
|
||||
// `sessions/*.json` and `sessions/*.jsonl` artifacts.
|
||||
// ...legacyTempFiles,
|
||||
// ...jsonlSessionFiles,
|
||||
...jsonlTempFiles,
|
||||
];
|
||||
|
||||
|
||||
@@ -24,10 +24,12 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> =
|
||||
provider: 'codex',
|
||||
rootPath: path.join(os.homedir(), '.codex', 'sessions'),
|
||||
},
|
||||
{
|
||||
provider: 'gemini',
|
||||
rootPath: path.join(os.homedir(), '.gemini', 'sessions'),
|
||||
},
|
||||
// {
|
||||
// provider: 'gemini',
|
||||
// rootPath: path.join(os.homedir(), '.gemini', 'sessions'),
|
||||
// },
|
||||
// Keep `sessions/` watcher disabled: Gemini also mirrors artifacts there,
|
||||
// which causes duplicate synchronization events.
|
||||
{
|
||||
provider: 'gemini',
|
||||
rootPath: path.join(os.homedir(), '.gemini', 'tmp'),
|
||||
|
||||
Reference in New Issue
Block a user