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
8 changes: 7 additions & 1 deletion server/gemini-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,17 @@ async function persistGeminiSessionMetadata(sessionId, projectPath, sessionMode)
sessionId,
encodeProjectPath(projectPath),
'gemini',
'Untitled Session',
null,
new Date().toISOString(),
0,
{ sessionMode: sessionMode || 'research' },
);
// upsertSession preserves existing display_name when incoming is null, so
// stale "Untitled Session" rows from older builds need an explicit clear.
const existing = sessionDb.getSessionById(sessionId);
if (existing && existing.display_name === 'Untitled Session') {
sessionDb.updateSessionName(sessionId, null);
}
} catch (error) {
console.warn('[Gemini] Failed to persist session metadata:', error.message);
}
Expand Down
86 changes: 76 additions & 10 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,14 @@ import os from 'os';
import http from 'http';
import cors from 'cors';
import { promises as fsPromises } from 'fs';
import { spawn } from 'child_process';
import { spawn, execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
import pty from 'node-pty';
import fetch from 'node-fetch';
import mime from 'mime-types';

import { getProjects, getTrashedProjects, getSessions, getSessionMessages, renameProject, renameSession, deleteSession, deleteProject, restoreProject, deleteTrashedProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
import { getProjects, getTrashedProjects, getSessions, getSessionMessages, renameProject, renameSession, deleteSession, deleteProject, restoreProject, deleteTrashedProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, readGeminiTmpChatSessionId } from './projects.js';
import { getProjectTokenUsageSummary } from './project-token-usage.js';
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getClaudeSDKSessionStartTime, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
import { spawnCursor, abortCursorSession, isCursorSessionActive, getCursorSessionStartTime, getActiveCursorSessions } from './cursor-cli.js';
Expand Down Expand Up @@ -89,12 +91,45 @@ import {
import { buildCodexTokenUsageFromJsonl } from './utils/sessionTokenUsage.js';
import { getNanoDrClawSessionsRoot } from './nanoSessionPaths.js';

// Gemini CLI's --resume flag only accepts "latest" or a numeric index from
// --list-sessions, not the session UUID. This helper maps UUID → index so the
// shell can actually resume the session the chat page is pointing at.
const GEMINI_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
async function resolveGeminiSessionIndex(sessionUuid, cwd) {
if (!GEMINI_UUID_PATTERN.test(sessionUuid || '')) {
return null;
}
try {
const { stdout } = await execFileAsync('gemini', ['--list-sessions'], {
cwd,
timeout: 10_000,
env: process.env,
});
const targetSuffix = `[${sessionUuid}]`;
for (const line of stdout.split('\n')) {
const trimmed = line.trim();
if (trimmed.endsWith(targetSuffix)) {
const match = trimmed.match(/^(\d+)\./);
if (match) return match[1];
}
}
return null;
} catch (err) {
console.warn('[Gemini] Failed to resolve session index:', err.message);
return null;
}
}

// File system watchers for provider project/session folders
const PROVIDER_WATCH_PATHS = [
{ provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
{ provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') },
{ provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') },
{ provider: 'gemini', rootPath: path.join(os.homedir(), '.gemini', 'sessions') },
// Gemini TUI rewrites live conversation state under ~/.gemini/tmp/{hash}/chats/
// on every turn. Watching it lets the chat page mirror in-shell activity
// before the committed .jsonl flush happens.
{ provider: 'gemini-tmp', rootPath: path.join(os.homedir(), '.gemini', 'tmp') },
{ provider: 'nano', rootPath: getNanoDrClawSessionsRoot() },
];
const WATCHER_IGNORED_PATTERNS = [
Expand Down Expand Up @@ -125,6 +160,11 @@ function shouldProcessProjectsWatcherEvent(eventType, filePath, provider) {
return normalized.endsWith('.jsonl');
}

if (provider === 'gemini-tmp') {
const base = path.basename(normalized);
return base.startsWith('session-') && base.endsWith('.json');
}

if (provider === 'cursor') {
return (
normalized.endsWith('.db') ||
Expand Down Expand Up @@ -205,8 +245,28 @@ async function setupProjectsWatcher() {
const updatedProjects = await getProjects();
const updateSignature = JSON.stringify(updatedProjects);

// Skip broadcasting identical snapshots
if (updateSignature === lastProjectsUpdateSignature) {
// For gemini-tmp we need to resolve the filePath to a sessionId so
// the frontend's existing "{uuid}.jsonl" gate can fire. We also skip
// the identical-snapshot shortcut below because tmp/chats churn
// does not change the project index, yet still needs to reach chat.
let broadcastChangedFile = path.relative(rootPath, filePath);
let broadcastProvider = provider;
let forceBroadcast = false;
if (provider === 'gemini-tmp') {
const resolvedSessionId = await readGeminiTmpChatSessionId(filePath);
if (resolvedSessionId) {
broadcastChangedFile = `${resolvedSessionId}.jsonl`;
broadcastProvider = 'gemini';
forceBroadcast = true;
} else {
// Can't resolve — nothing actionable for the client.
return;
}
}

// Skip broadcasting identical snapshots unless we explicitly need
// to push a tmp/chats notification through.
if (!forceBroadcast && updateSignature === lastProjectsUpdateSignature) {
return;
}
lastProjectsUpdateSignature = updateSignature;
Expand All @@ -217,8 +277,8 @@ async function setupProjectsWatcher() {
projects: updatedProjects,
timestamp: new Date().toISOString(),
changeType: eventType,
changedFile: path.relative(rootPath, filePath),
watchProvider: provider
changedFile: broadcastChangedFile,
watchProvider: broadcastProvider
});

connectedClients.forEach(client => {
Expand Down Expand Up @@ -2045,15 +2105,21 @@ function handleShellConnection(ws) {
} else if (provider === 'gemini') {
// Use gemini command
const command = initialCommand || 'gemini';
const resumeIndex = hasSession && sessionId
? await resolveGeminiSessionIndex(sessionId, projectPath)
: null;
if (hasSession && sessionId && !resumeIndex) {
console.warn(`[Gemini] Could not resolve session ${sessionId} to a --list-sessions index; falling back to a fresh session.`);
}
if (os.platform() === 'win32') {
if (hasSession && sessionId) {
shellCommand = `Set-Location -Path "${projectPath}"; gemini --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { gemini }`;
if (resumeIndex) {
shellCommand = `Set-Location -Path "${projectPath}"; gemini --resume ${resumeIndex}; if ($LASTEXITCODE -ne 0) { gemini }`;
} else {
shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
}
} else {
if (hasSession && sessionId) {
shellCommand = `cd "${projectPath}" && gemini --resume ${sessionId} || gemini`;
if (resumeIndex) {
shellCommand = `cd "${projectPath}" && gemini --resume ${resumeIndex} || gemini`;
} else {
shellCommand = `cd "${projectPath}" && ${command}`;
}
Expand Down
8 changes: 7 additions & 1 deletion server/nano-claude-code.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,17 @@ async function persistNanoSessionMetadata(sessionId, projectPath, sessionMode) {
sessionId,
encodeProjectPath(projectPath),
'nano',
'Nano Claude Code Session',
null,
new Date().toISOString(),
0,
{ sessionMode: sessionMode || 'research', projectPath },
);
// upsertSession preserves existing display_name when incoming is null, so
// stale placeholder rows from older builds need an explicit clear.
const existing = sessionDb.getSessionById(sessionId);
if (existing && existing.display_name === 'Nano Claude Code Session') {
sessionDb.updateSessionName(sessionId, null);
}
} catch (error) {
console.warn('[Nano] Failed to persist session metadata:', error.message);
}
Expand Down
150 changes: 149 additions & 1 deletion server/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -1990,6 +1990,117 @@ async function unlinkNanoSessionFilesEverywhere(projectName, sessionId) {
return deleted;
}

// Gemini TUI writes live conversation state to
// ~/.gemini/tmp/{projectHash}/chats/session-*.json
// on every turn, but only flushes ~/.gemini/sessions/{uuid}.jsonl periodically.
// These helpers ingest the tmp/chats blob so chat views can mirror what the TUI
// is actually showing the user.
const GEMINI_TMP_CHATS_ROOT = path.join(os.homedir(), '.gemini', 'tmp');

/** Extract sessionId from a tmp/chats JSON file without loading the full parse into callers. */
async function readGeminiTmpChatSessionId(filePath) {
try {
const raw = await fs.readFile(filePath, 'utf-8');
const parsed = JSON.parse(raw);
const sid = parsed?.sessionId;
return typeof sid === 'string' && sid.length > 0 ? sid : null;
} catch {
return null;
}
}

/** Locate the tmp/chats JSON blob that matches the given sessionId (scans all project subdirs). */
async function findGeminiTmpChatFile(sessionId) {
if (!sessionId || typeof sessionId !== 'string') return null;
let projectDirs;
try {
projectDirs = await fs.readdir(GEMINI_TMP_CHATS_ROOT, { withFileTypes: true });
} catch {
return null;
}
// Filename suffix "session-YYYY-MM-DD...-{first8}.json" narrows the scan.
const shortId = sessionId.slice(0, 8);
for (const entry of projectDirs) {
if (!entry.isDirectory()) continue;
const chatsDir = path.join(GEMINI_TMP_CHATS_ROOT, entry.name, 'chats');
let chatFiles;
try {
chatFiles = await fs.readdir(chatsDir);
} catch {
continue;
}
for (const fname of chatFiles) {
if (!fname.startsWith('session-') || !fname.endsWith('.json')) continue;
if (shortId && !fname.includes(shortId)) continue;
const full = path.join(chatsDir, fname);
const sid = await readGeminiTmpChatSessionId(full);
if (sid === sessionId) {
let mtime = 0;
try {
const stat = await fs.stat(full);
mtime = stat.mtimeMs;
} catch {}
return { filePath: full, mtime };
}
}
}
return null;
}

/** Convert tmp/chats message list into the same shape produced by the jsonl branch. */
function convertGeminiTmpChatsToMessages(data) {
const rawMessages = Array.isArray(data?.messages) ? data.messages : [];
const out = [];
for (const m of rawMessages) {
const timestamp = m.timestamp || '';
const kind = m.type;
if (kind === 'user') {
const parts = Array.isArray(m.content) ? m.content : [];
const text = parts.map((p) => (typeof p?.text === 'string' ? p.text : '')).join('');
out.push({ type: 'message', role: 'user', content: text, timestamp });
continue;
}
if (kind === 'gemini') {
const content = typeof m.content === 'string' ? m.content : '';
if (content) {
out.push({ type: 'message', role: 'assistant', content, timestamp });
}
if (Array.isArray(m.toolCalls)) {
for (const tc of m.toolCalls) {
let toolInput = '{}';
try {
toolInput = typeof tc.args === 'string' ? tc.args : JSON.stringify(tc.args ?? {});
} catch {
toolInput = '{}';
}
out.push({
type: 'tool_use',
timestamp: tc.timestamp || timestamp,
toolName: tc.name || tc.displayName || 'unknown',
toolInput,
toolCallId: tc.id,
});
// Tool results are nested inside toolCalls[].result[].functionResponse.response
const resultEntries = Array.isArray(tc.result) ? tc.result : [];
for (const r of resultEntries) {
const output = r?.functionResponse?.response?.output;
if (typeof output === 'string' && output.length) {
out.push({
type: 'tool_result',
output,
tool_call_id: tc.id,
toolCallId: tc.id,
timestamp: tc.timestamp || timestamp,
});
}
}
}
}
}
}
return out;
}

/** Maps nano-claude-code session.json (title, messages[]) to Dr. Claw chat message shape. */
function nanoSessionJsonToMessages(data) {
const rawMessages = Array.isArray(data.messages) ? data.messages : [];
Expand Down Expand Up @@ -2017,6 +2128,38 @@ async function getSessionMessages(projectName, sessionId, limit = null, offset =
if (provider === 'gemini') {
const geminiSessionFile = path.join(os.homedir(), '.gemini', 'sessions', `${sessionId}.jsonl`);
console.log(`[DEBUG] Reading Gemini session file: ${geminiSessionFile}`);

// Check if the TUI's live tmp/chats blob is more recent than the committed jsonl.
// When it is, prefer the tmp/chats content so chat reflects what the user sees
// in the shell. Otherwise fall through to the jsonl reader below.
const tmpChat = await findGeminiTmpChatFile(sessionId);
let jsonlMtime = 0;
try {
const stat = await fs.stat(geminiSessionFile);
jsonlMtime = stat.mtimeMs;
} catch {}
if (tmpChat && tmpChat.mtime >= jsonlMtime) {
try {
const raw = await fs.readFile(tmpChat.filePath, 'utf-8');
const data = JSON.parse(raw);
const messages = convertGeminiTmpChatsToMessages(data);
console.log(`[DEBUG] Loaded ${messages.length} messages from Gemini tmp/chats: ${tmpChat.filePath}`);
const total = messages.length;
if (limit === null) return messages;
const startIndex = Math.max(0, total - offset - limit);
const endIndex = total - offset;
return {
messages: messages.slice(startIndex, endIndex),
total,
hasMore: startIndex > 0,
offset,
limit,
};
} catch (err) {
console.warn(`[Gemini] Failed to read tmp/chats ${tmpChat.filePath}:`, err.message);
}
}

try {
await fs.access(geminiSessionFile);
const messages = [];
Expand Down Expand Up @@ -3451,7 +3594,10 @@ async function buildGeminiSessionsIndex() {
const indexedMessageCount = Number(indexedSession?.message_count ?? indexedSession?.messageCount ?? 0);
const matchedProjectPaths = new Set();

let explicitTitle = indexedSession?.display_name || null;
const storedDisplayName = indexedSession?.display_name || null;
// Treat the legacy "Untitled Session" placeholder as empty so firstMessageText
// fallback can run for rows seeded by older builds.
let explicitTitle = storedDisplayName === 'Untitled Session' ? null : storedDisplayName;
let firstMessageText = null;
let messageCount = 0;
// Recovery order: DB metadata -> JSONL metadata -> context marker -> high-confidence heuristic -> default.
Expand Down Expand Up @@ -4371,6 +4517,8 @@ export {
reconcileClaudeSessionIndex,
reconcileCodexSessionIndex,
reconcileGeminiSessionIndex,
readGeminiTmpChatSessionId,
GEMINI_TMP_CHATS_ROOT,
reconcileOpenRouterSessionIndex,
reconcileLocalGPUSessionIndex,
ensureProjectSkillLinks,
Expand Down
13 changes: 13 additions & 0 deletions src/components/Shell.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,19 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
if (fitAddon.current && terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
fitAddon.current.fit();

// Gemini TUI keeps conversation state in memory and never re-reads its
// session file, so messages sent from the chat page won't appear in
// this running TUI. Surface that asymmetry up-front.
if (
!isPlainShellRef.current &&
selectedSessionRef.current &&
getPreferredProvider(selectedSessionRef.current) === 'gemini'
) {
terminal.current.write(
'\x1b[33m[\u26A0 Gemini TUI won\'t see messages sent from the chat page until you exit and re-resume this session]\x1b[0m\r\n'
);
}

ws.current.send(JSON.stringify({
type: 'init',
projectPath: selectedProjectRef.current.fullPath || selectedProjectRef.current.path,
Expand Down
Loading
Loading