diff --git a/server/claude-cli-query.js b/server/claude-cli-query.js new file mode 100644 index 000000000..cdc31f5e1 --- /dev/null +++ b/server/claude-cli-query.js @@ -0,0 +1,446 @@ +/** + * Claude CLI Query Runner (Full REPL Mode v2) + * + * Spawns the native `claude` CLI with `--output-format stream-json` + * via node-pty (pseudo-terminal) and maps the streaming JSON events + * to the same WebSocket message format the Chat UI already expects. + * + * The claude binary requires a TTY to produce output, so we must use + * node-pty instead of child_process.spawn. + */ + +import pty from 'node-pty'; +import { promises as fs, constants as fsConstants } from 'fs'; +import path from 'path'; +import os from 'os'; + +const ANSI_ESCAPE_RE = /\x1b\[[0-9;]*[a-zA-Z]/g; + +const activeCliSessions = new Map(); + +/** + * Maps projectPath → last CLI session UUID. + * Used for bidirectional session sync between Chat and Shell tabs. + */ +const projectSessionRegistry = new Map(); + +export function getProjectSessionId(projectPath) { + return projectSessionRegistry.get(projectPath) || null; +} + +export function setProjectSessionId(projectPath, sessionId) { + if (projectPath && sessionId) { + projectSessionRegistry.set(projectPath, sessionId); + console.log(`[Full REPL v2] Registry: ${projectPath} → ${sessionId}`); + } +} + +/** + * Scans ~/.claude/projects/ for the most recently modified session file + * for a given project path. Returns the session UUID or null. + */ +export async function findLatestSessionForProject(projectPath) { + try { + // Claude encodes project paths by replacing non-alphanumeric chars with - + const encoded = projectPath.replace(/[^a-zA-Z0-9-]/g, '-'); + const projectDir = path.join(os.homedir(), '.claude', 'projects', encoded); + + const entries = await fs.readdir(projectDir); + const jsonlFiles = entries.filter(e => e.endsWith('.jsonl')); + + if (jsonlFiles.length === 0) return null; + + // Find the most recently modified + let latest = null; + let latestMtime = 0; + + for (const file of jsonlFiles) { + const filePath = path.join(projectDir, file); + const stat = await fs.stat(filePath); + if (stat.mtimeMs > latestMtime) { + latestMtime = stat.mtimeMs; + latest = file.replace('.jsonl', ''); + } + } + + return latest; + } catch { + return null; + } +} + +let cachedClaudeBin = null; + +/** + * Finds the actual claude binary path, skipping shell functions/aliases. + */ +async function resolveClaudeBinary() { + if (cachedClaudeBin) return cachedClaudeBin; + + const candidates = [ + path.join(os.homedir(), '.local', 'bin', 'claude'), + '/usr/local/bin/claude', + '/opt/homebrew/bin/claude', + ]; + + for (const candidate of candidates) { + try { + await fs.access(candidate, fsConstants.X_OK); + cachedClaudeBin = candidate; + console.log(`[Full REPL v2] Resolved claude binary: ${cachedClaudeBin}`); + return cachedClaudeBin; + } catch { + // Not found, try next + } + } + + console.log('[Full REPL v2] Could not resolve claude binary, falling back to PATH'); + cachedClaudeBin = 'claude'; + return cachedClaudeBin; +} + +/** + * Spawns the native claude CLI and streams structured JSON events to the WebSocket. + */ +export async function queryClaudeCLI(command, options = {}, ws) { + const { sessionId, cwd, model, permissionMode, images } = options; + + const args = ['--output-format', 'stream-json', '--verbose']; + + // Skip MCP server loading for --print mode queries. The CLI waits for all + // MCP servers to connect/fail before processing, which adds 20-30s for servers + // that timeout. MCP tools are available in the Shell tab (persistent REPL). + // MCP_CONNECTION_NONBLOCKING only works for the interactive SDK mode, not --print. + args.push('--mcp-config', '{"mcpServers":{}}', '--strict-mcp-config'); + + // Resume: explicit session ID > registry > none + const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const resolvedCwd = cwd || process.env.HOME; + let resumeId = (sessionId && UUID_RE.test(sessionId)) ? sessionId : null; + + if (!resumeId) { + // Check registry for a session created by Shell or a previous Chat query + const registryId = getProjectSessionId(resolvedCwd); + if (registryId && UUID_RE.test(registryId)) { + resumeId = registryId; + console.log(`[Full REPL v2] Chat resuming session from registry: ${resumeId}`); + } + } + + const isResumed = Boolean(resumeId); + + if (resumeId) { + args.push('--resume', resumeId); + } + + if (model) { + args.push('--model', model); + } + + if (permissionMode === 'plan') { + args.push('--permission-mode', 'plan'); + } else if (permissionMode && permissionMode !== 'default') { + args.push('--permission-mode', permissionMode); + } + + // Handle images + let finalCommand = command; + let tempImagePaths = []; + let tempDir = null; + + if (images && images.length > 0) { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-img-')); + for (let i = 0; i < images.length; i++) { + const img = images[i]; + // Support both data URL format (data:mime;base64,...) and raw base64 + const dataUrlMatch = typeof img.data === 'string' + ? img.data.match(/^data:([^;]+);base64,(.+)$/) + : null; + const mimeType = img.mediaType || img.mimeType || dataUrlMatch?.[1]; + const base64Data = dataUrlMatch?.[2] || + (typeof img.data === 'string' && !img.data.startsWith('data:') ? img.data : null); + + if (mimeType && base64Data) { + const ext = mimeType.split('/')[1] || 'png'; + const imgPath = path.join(tempDir, `image_${i}.${ext}`); + const buffer = Buffer.from(base64Data, 'base64'); + await fs.writeFile(imgPath, buffer); + tempImagePaths.push(imgPath); + } + } + if (tempImagePaths.length > 0) { + const imageRefs = tempImagePaths.map(p => `[Image: ${p}]`).join(' '); + finalCommand = `${imageRefs}\n\n${command}`; + } + } + + args.push('--print', finalCommand); + + // Build the full command string for bash -c. + // The claude binary is a Bun executable that requires a proper shell + // environment (same way the Shell tab spawns it). + const claudeBin = await resolveClaudeBinary(); + const isWindows = os.platform() === 'win32'; + const escapedArgs = args.map(a => { + if (isWindows) { + return `'${a.replace(/'/g, "''")}'`; + } + return `'${a.replace(/'/g, "'\\''")}'`; + }).join(' '); + const shellCommand = `${claudeBin} ${escapedArgs}`; + + console.log(`[Full REPL v2] Spawning via bash: claude ${args.slice(0, 6).join(' ')}...`); + console.log(`[Full REPL v2] cwd: ${resolvedCwd}`); + + const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash'; + const shellArgs = os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand]; + + const cliProcess = pty.spawn(shell, shellArgs, { + name: 'xterm-256color', + cols: 120, + rows: 40, + cwd: resolvedCwd, + env: { + ...process.env, + NO_COLOR: '1', + }, + }); + + let capturedSessionId = sessionId || null; + let partialLine = ''; + let sessionCreatedSent = false; + let bufferedMessages = []; + let lastPlaintextLine = ''; + let structuredErrorSent = false; + + const session = { + process: cliProcess, + startTime: Date.now(), + sessionId: capturedSessionId, + }; + + const sessionKey = sessionId || `pending_${Date.now()}`; + activeCliSessions.set(sessionKey, session); + + console.log(`[Full REPL v2] Process PID: ${cliProcess.pid}`); + + // Parse PTY output as JSONL + cliProcess.onData((rawData) => { + partialLine += rawData; + const lines = partialLine.split('\n'); + partialLine = lines.pop(); // Keep incomplete line + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + // Strip any ANSI escape sequences that might leak through + const cleaned = trimmed.replace(ANSI_ESCAPE_RE, '').trim(); + if (!cleaned) continue; + if (cleaned[0] !== '{') { + lastPlaintextLine = cleaned; + continue; + } + + try { + const event = JSON.parse(cleaned); + console.log(`[Full REPL v2] Event: ${event.type}/${event.subtype || ''}`); + // Capture session ID from init event BEFORE mapping messages + if (event.type === 'system' && event.subtype === 'init' && event.session_id) { + capturedSessionId = event.session_id; + session.sessionId = capturedSessionId; + + // Store in registry for Shell tab to pick up + setProjectSessionId(resolvedCwd, capturedSessionId); + + if (sessionKey !== capturedSessionId) { + activeCliSessions.delete(sessionKey); + activeCliSessions.set(capturedSessionId, session); + } + } + + const wsMessages = mapCliEventToWsMessages(event, session); + for (const msg of wsMessages) { + if (msg.type === 'session-created') { + console.log(`[Full REPL v2] Sending WS: ${JSON.stringify(msg)}`); + ws.send(msg); + + // Flush buffered messages synchronously after session-created + for (const buffered of bufferedMessages) { + ws.send(buffered); + } + bufferedMessages = []; + sessionCreatedSent = true; + } else if (!sessionCreatedSent) { + // Buffer messages until session-created has been sent + bufferedMessages.push(msg); + } else { + ws.send(msg); + } + } + } catch { + // Not valid JSON, skip + } + } + }); + + cliProcess.onExit(async ({ exitCode }) => { + console.log(`[Full REPL v2] CLI process exited with code ${exitCode}`); + + // Process any remaining partial line + if (partialLine.trim()) { + const cleaned = partialLine.trim().replace(ANSI_ESCAPE_RE, '').trim(); + if (cleaned && cleaned[0] === '{') { + try { + const event = JSON.parse(cleaned); + const wsMessages = mapCliEventToWsMessages(event, session); + for (const msg of wsMessages) { + ws.send(msg); + } + } catch { + // ignore + } + } + } + + // Emit plaintext CLI error if process failed without a structured error + if (exitCode && exitCode !== 0 && !structuredErrorSent && lastPlaintextLine) { + ws.send({ + type: 'claude-error', + error: lastPlaintextLine, + sessionId: capturedSessionId || null, + }); + } + + ws.send({ + type: 'claude-complete', + sessionId: capturedSessionId, + exitCode: exitCode || 0, + isNewSession: !isResumed, + }); + + activeCliSessions.delete(capturedSessionId || sessionKey); + + // Clean up temp images + if (tempDir) { + try { + await Promise.all(tempImagePaths.map(p => fs.unlink(p).catch(() => {}))); + await fs.rmdir(tempDir).catch(() => {}); + } catch { + // ignore cleanup errors + } + } + }); +} + +/** + * Maps a CLI stream-json event to WebSocket messages the Chat UI expects. + */ +function mapCliEventToWsMessages(event, session) { + const sid = session.sessionId; + const messages = []; + + switch (event.type) { + case 'system': { + if (event.subtype === 'init') { + messages.push({ + type: 'session-created', + sessionId: event.session_id, + }); + } + break; + } + + case 'assistant': { + const msg = event.message; + if (msg) { + messages.push({ + type: 'claude-response', + data: { + message: msg, + parent_tool_use_id: event.parent_tool_use_id || null, + }, + sessionId: sid, + }); + } + break; + } + + case 'user': { + const msg = event.message; + if (msg) { + messages.push({ + type: 'claude-response', + data: { + message: msg, + parent_tool_use_id: event.parent_tool_use_id || null, + tool_use_result: event.tool_use_result || null, + }, + sessionId: sid, + }); + } + break; + } + + case 'result': { + if (event.modelUsage) { + const models = Object.keys(event.modelUsage); + if (models.length > 0) { + const usage = event.modelUsage[models[0]]; + messages.push({ + type: 'token-budget', + data: { + used: (usage.inputTokens || 0) + (usage.outputTokens || 0) + + (usage.cacheReadInputTokens || 0) + (usage.cacheCreationInputTokens || 0), + total: usage.contextWindow || 200000, + }, + sessionId: sid, + }); + } + } + break; + } + + case 'rate_limit_event': + break; + + default: { + if (event.message) { + messages.push({ + type: 'claude-response', + data: { message: event.message }, + sessionId: sid, + }); + } + break; + } + } + + return messages; +} + +/** + * Aborts an active CLI session. + */ +export function abortClaudeCLISession(sessionId) { + const session = activeCliSessions.get(sessionId); + if (session?.process) { + session.process.write('\x03'); // Ctrl+C + setTimeout(() => { + try { session.process.kill(); } catch { /* already dead */ } + activeCliSessions.delete(sessionId); + }, 1000); + return true; + } + return false; +} + +export function isClaudeCLISessionActive(sessionId) { + return activeCliSessions.has(sessionId); +} + +export function getActiveClaudeCLISessions() { + return Array.from(activeCliSessions.keys()); +} + +export { activeCliSessions }; diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 2bc80b0f1..4e5e306ed 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -24,6 +24,13 @@ import { notifyRunStopped, notifyUserIfEnabled } from './services/notification-orchestrator.js'; +import { + isFullReplMode, + getSettings, + getPermissions, + getMcpServersFromSettings, + persistAllowedTool +} from './utils/settings-reader.js'; const activeSessions = new Map(); const pendingToolApprovals = new Map(); @@ -141,7 +148,7 @@ function matchesToolPermission(entry, toolName, input) { * @param {Object} options - CLI options * @returns {Object} SDK-compatible options */ -function mapCliOptionsToSDK(options = {}) { +async function mapCliOptionsToSDK(options = {}) { const { sessionId, cwd, toolsSettings, permissionMode, images } = options; const sdkOptions = {}; @@ -156,40 +163,66 @@ function mapCliOptionsToSDK(options = {}) { sdkOptions.permissionMode = permissionMode; } - // Map tool settings - const settings = toolsSettings || { - allowedTools: [], - disallowedTools: [], - skipPermissions: false - }; + // Full REPL Mode: override permissions from ~/.claude/settings.json + const fullRepl = isFullReplMode(options.fullReplMode); + if (fullRepl) { + const diskPermissions = await getPermissions(); + const diskSettings = await getSettings(); - // Handle tool permissions - if (settings.skipPermissions && permissionMode !== 'plan') { - // When skipping permissions, use bypassPermissions mode - sdkOptions.permissionMode = 'bypassPermissions'; - } + let allowedTools = [...(diskPermissions.allow || [])]; + + // Add plan mode default tools + if (permissionMode === 'plan') { + const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch']; + for (const tool of planModeTools) { + if (!allowedTools.includes(tool)) { + allowedTools.push(tool); + } + } + } - let allowedTools = [...(settings.allowedTools || [])]; + sdkOptions.allowedTools = allowedTools; + sdkOptions.disallowedTools = diskPermissions.deny || []; - // Add plan mode default tools - if (permissionMode === 'plan') { - const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch']; - for (const tool of planModeTools) { - if (!allowedTools.includes(tool)) { - allowedTools.push(tool); + if (diskSettings?.skipDangerousModePermissionPrompt && permissionMode !== 'plan') { + sdkOptions.permissionMode = 'bypassPermissions'; + } + + console.log(`[Full REPL] Loaded ${allowedTools.length} allowed, ${sdkOptions.disallowedTools.length} denied tools from settings.json`); + } else { + // Default mode: use browser-provided tool settings + const settings = toolsSettings || { + allowedTools: [], + disallowedTools: [], + skipPermissions: false + }; + + // Handle tool permissions + if (settings.skipPermissions && permissionMode !== 'plan') { + sdkOptions.permissionMode = 'bypassPermissions'; + } + + let allowedTools = [...(settings.allowedTools || [])]; + + // Add plan mode default tools + if (permissionMode === 'plan') { + const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch']; + for (const tool of planModeTools) { + if (!allowedTools.includes(tool)) { + allowedTools.push(tool); + } } } - } - sdkOptions.allowedTools = allowedTools; + sdkOptions.allowedTools = allowedTools; + sdkOptions.disallowedTools = settings.disallowedTools || []; + } // Use the tools preset to make all default built-in tools available (including AskUserQuestion). // This was introduced in SDK 0.1.57. Omitting this preserves existing behavior (all tools available), // but being explicit ensures forward compatibility and clarity. sdkOptions.tools = { type: 'preset', preset: 'claude_code' }; - sdkOptions.disallowedTools = settings.disallowedTools || []; - // Map model (default to sonnet) // Valid models: sonnet, opus, haiku, opusplan, sonnet[1m] sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT; @@ -404,59 +437,79 @@ async function cleanupTempFiles(tempImagePaths, tempDir) { * @param {string} cwd - Current working directory for project-specific configs * @returns {Object|null} MCP servers object or null if none found */ -async function loadMcpConfig(cwd) { +async function loadMcpFromClaudeJson(cwd) { try { const claudeConfigPath = path.join(os.homedir(), '.claude.json'); - // Check if config file exists try { await fs.access(claudeConfigPath); } catch (error) { - // File doesn't exist, return null - console.log('No ~/.claude.json found, proceeding without MCP servers'); - return null; + return {}; } - // Read and parse config file let claudeConfig; try { const configContent = await fs.readFile(claudeConfigPath, 'utf8'); claudeConfig = JSON.parse(configContent); } catch (error) { console.error('Failed to parse ~/.claude.json:', error.message); - return null; + return {}; } - // Extract MCP servers (merge global and project-specific) let mcpServers = {}; - // Add global MCP servers if (claudeConfig.mcpServers && typeof claudeConfig.mcpServers === 'object') { mcpServers = { ...claudeConfig.mcpServers }; - console.log(`Loaded ${Object.keys(mcpServers).length} global MCP servers`); } - // Add/override with project-specific MCP servers if (claudeConfig.claudeProjects && cwd) { const projectConfig = claudeConfig.claudeProjects[cwd]; if (projectConfig && projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') { mcpServers = { ...mcpServers, ...projectConfig.mcpServers }; - console.log(`Loaded ${Object.keys(projectConfig.mcpServers).length} project-specific MCP servers`); } } - // Return null if no servers found - if (Object.keys(mcpServers).length === 0) { - console.log('No MCP servers configured'); + return mcpServers; + } catch (error) { + console.error('Error loading MCP from ~/.claude.json:', error.message); + return {}; + } +} + +async function loadMcpConfig(cwd, fullReplOverride) { + const fullRepl = isFullReplMode(fullReplOverride); + + if (fullRepl) { + // Primary: ~/.claude/settings.json + const settingsMcp = await getMcpServersFromSettings(); + + // Secondary: ~/.claude.json for project-scoped servers + const claudeJsonMcp = await loadMcpFromClaudeJson(cwd); + + // Merge: settings.json takes precedence + const merged = { ...claudeJsonMcp, ...settingsMcp }; + const count = Object.keys(merged).length; + + if (count === 0) { + console.log('[Full REPL] No MCP servers configured'); return null; } - console.log(`Total MCP servers loaded: ${Object.keys(mcpServers).length}`); - return mcpServers; - } catch (error) { - console.error('Error loading MCP config:', error.message); + console.log(`[Full REPL] Total MCP servers loaded: ${count} (${Object.keys(settingsMcp).length} from settings.json, ${Object.keys(claudeJsonMcp).length} from .claude.json)`); + return merged; + } + + // Default behavior: read from ~/.claude.json only + const mcpServers = await loadMcpFromClaudeJson(cwd); + const count = Object.keys(mcpServers).length; + + if (count === 0) { + console.log('No MCP servers configured'); return null; } + + console.log(`Total MCP servers loaded: ${count}`); + return mcpServers; } /** @@ -483,10 +536,10 @@ async function queryClaudeSDK(command, options = {}, ws) { try { // Map CLI options to SDK format - const sdkOptions = mapCliOptionsToSDK(options); + const sdkOptions = await mapCliOptionsToSDK(options); // Load MCP configuration - const mcpServers = await loadMcpConfig(options.cwd); + const mcpServers = await loadMcpConfig(options.cwd, options.fullReplMode); if (mcpServers) { sdkOptions.mcpServers = mcpServers; } @@ -593,6 +646,15 @@ async function queryClaudeSDK(command, options = {}, ws) { if (Array.isArray(sdkOptions.disallowedTools)) { sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry); } + + // Persist to disk in Full REPL Mode + if (isFullReplMode(options.fullReplMode)) { + try { + await persistAllowedTool(decision.rememberEntry); + } catch (persistError) { + console.error('[Full REPL] Failed to persist allowed tool to disk:', persistError.message); + } + } } return { behavior: 'allow', updatedInput: decision.updatedInput ?? input }; } diff --git a/server/index.js b/server/index.js index 27aae75c1..d487d2554 100755 --- a/server/index.js +++ b/server/index.js @@ -46,6 +46,8 @@ import mime from 'mime-types'; import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js'; import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js'; +import { queryClaudeCLI, abortClaudeCLISession, isClaudeCLISessionActive, getActiveClaudeCLISessions, getProjectSessionId, setProjectSessionId, findLatestSessionForProject } from './claude-cli-query.js'; +import { isFullReplMode } from './utils/settings-reader.js'; import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js'; import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js'; import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getActiveGeminiSessions } from './gemini-cli.js'; @@ -1464,8 +1466,34 @@ function handleChatConnection(ws, request) { console.log('📁 Project:', data.options?.projectPath || 'Unknown'); console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New'); - // Use Claude Agents SDK - await queryClaudeSDK(data.command, data.options, writer); + if (isFullReplMode(data.options?.fullReplMode)) { + // Full REPL Mode: spawn native claude CLI + console.log('[Full REPL v2] Using native CLI'); + + // Kill any active Claude Shell PTY for this project to release the session lock. + // Only targets Claude sessions (UUID session IDs), not plain shell/cursor/codex/gemini. + const projectPath = data.options?.cwd || data.options?.projectPath; + const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (projectPath) { + for (const [key, session] of ptySessionsMap.entries()) { + if (session.projectPath === projectPath && session.pty && session.sessionId && uuidPattern.test(session.sessionId)) { + console.log(`[Full REPL v2] Killing Claude Shell PTY for project handoff: ${key}`); + try { session.pty.kill(); } catch { /* already dead */ } + ptySessionsMap.delete(key); + } + } + } + + // Normalize cwd so the CLI runner uses the correct project path + if (!data.options.cwd && projectPath) { + data.options.cwd = projectPath; + } + + await queryClaudeCLI(data.command, data.options, writer); + } else { + // Default: use Claude Agents SDK + await queryClaudeSDK(data.command, data.options, writer); + } } else if (data.type === 'cursor-command') { console.log('[DEBUG] Cursor message:', data.command || '[Continue/Resume]'); console.log('📁 Project:', data.options?.cwd || 'Unknown'); @@ -1504,8 +1532,11 @@ function handleChatConnection(ws, request) { } else if (provider === 'gemini') { success = abortGeminiSession(data.sessionId); } else { - // Use Claude Agents SDK - success = await abortClaudeSDKSession(data.sessionId); + // Try CLI session first, then SDK + success = abortClaudeCLISession(data.sessionId); + if (!success) { + success = await abortClaudeSDKSession(data.sessionId); + } } writer.send({ @@ -1548,12 +1579,15 @@ function handleChatConnection(ws, request) { } else if (provider === 'gemini') { isActive = isGeminiSessionActive(sessionId); } else { - // Use Claude Agents SDK - isActive = isClaudeSDKSessionActive(sessionId); - if (isActive) { - // Reconnect the session's writer to the new WebSocket so - // subsequent SDK output flows to the refreshed client. - reconnectSessionWriter(sessionId, ws); + // Check CLI sessions first, then SDK + isActive = isClaudeCLISessionActive(sessionId); + if (!isActive) { + isActive = isClaudeSDKSessionActive(sessionId); + if (isActive) { + // Reconnect the session's writer to the new WebSocket so + // subsequent SDK output flows to the refreshed client. + reconnectSessionWriter(sessionId, ws); + } } } @@ -1577,7 +1611,7 @@ function handleChatConnection(ws, request) { } else if (data.type === 'get-active-sessions') { // Get all currently active sessions const activeSessions = { - claude: getActiveClaudeSDKSessions(), + claude: [...getActiveClaudeSDKSessions(), ...getActiveClaudeCLISessions()], cursor: getActiveCursorSessions(), codex: getActiveCodexSessions(), gemini: getActiveGeminiSessions() @@ -1722,6 +1756,7 @@ function handleShellConnection(ws) { // Build shell command — use cwd for project path (never interpolate into shell string) let shellCommand; + let resumeSessionId = null; if (isPlainShell) { // Plain shell mode - run the initial command in the project directory shellCommand = initialCommand; @@ -1772,11 +1807,28 @@ function handleShellConnection(ws) { } else { // Claude (default provider) const command = initialCommand || 'claude'; - if (hasSession && sessionId) { + + // Check for session ID: explicit > registry > none + resumeSessionId = (hasSession && sessionId) ? sessionId : null; + if (!resumeSessionId) { + // Full REPL Mode: check if Chat created a session for this project + const registrySessionId = getProjectSessionId(resolvedProjectPath); + if (registrySessionId && safeSessionIdPattern.test(registrySessionId)) { + resumeSessionId = registrySessionId; + console.log(`[Full REPL v2] Shell resuming Chat session from registry: ${registrySessionId}`); + } + } + + // Kill any active Chat CLI process for the session being resumed + if (resumeSessionId && abortClaudeCLISession(resumeSessionId)) { + console.log('[Full REPL v2] Killed Chat CLI process for session handoff'); + } + + if (resumeSessionId) { if (os.platform() === 'win32') { - shellCommand = `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`; + shellCommand = `claude --resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { claude }`; } else { - shellCommand = `claude --resume "${sessionId}" || claude`; + shellCommand = `claude --resume "${resumeSessionId}" || claude`; } } else { shellCommand = command; @@ -1815,9 +1867,61 @@ function handleShellConnection(ws) { buffer: [], timeoutId: null, projectPath, - sessionId + sessionId: resumeSessionId || sessionId }); + // Shell → Chat sync: detect session ID from PTY output + // by watching for UUID patterns in early output (e.g. "Resuming Claude session ") + let sessionDetected = false; + let sessionDetectionTimeout = null; + let sessionDetectionDisposable = null; + + if (provider === 'claude' && !isPlainShell) { + const uuidPattern = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; + + const sessionDetectionHandler = (outputData) => { + if (sessionDetected) return; + const match = outputData.match(uuidPattern); + if (match) { + sessionDetected = true; + const detectedSessionId = match[0]; + setProjectSessionId(resolvedProjectPath, detectedSessionId); + const ptySession = ptySessionsMap.get(ptySessionKey); + if (ptySession) { + ptySession.sessionId = detectedSessionId; + } + console.log(`[Full REPL v2] Detected Shell session from PTY output: ${detectedSessionId}`); + if (sessionDetectionTimeout) { + clearTimeout(sessionDetectionTimeout); + sessionDetectionTimeout = null; + } + } + }; + + // Listen on PTY output for session ID + sessionDetectionDisposable = shellProcess.onData(sessionDetectionHandler); + + // Stop listening after 15 seconds if no UUID found + sessionDetectionTimeout = setTimeout(() => { + if (!sessionDetected) { + sessionDetectionDisposable.dispose(); + // Fallback: scan disk for latest session file + findLatestSessionForProject(resolvedProjectPath).then(latestSession => { + if (latestSession) { + setProjectSessionId(resolvedProjectPath, latestSession); + const ptySession = ptySessionsMap.get(ptySessionKey); + if (ptySession) { + ptySession.sessionId = latestSession; + } + console.log(`[Full REPL v2] Fallback: detected Shell session from disk: ${latestSession}`); + } + }).catch(err => { + console.error('[Full REPL v2] Failed to detect Shell session:', err.message); + }); + } + }, 15000); + } + // Handle data output shellProcess.onData((data) => { const session = ptySessionsMap.get(ptySessionKey); @@ -1886,6 +1990,15 @@ function handleShellConnection(ws) { // Handle process exit shellProcess.onExit((exitCode) => { console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal); + + // Clean up session detection listener/timeout + sessionDetected = true; + sessionDetectionDisposable?.dispose(); + if (sessionDetectionTimeout) { + clearTimeout(sessionDetectionTimeout); + sessionDetectionTimeout = null; + } + const session = ptySessionsMap.get(ptySessionKey); if (session && session.ws && session.ws.readyState === WebSocket.OPEN) { session.ws.send(JSON.stringify({ diff --git a/server/routes/settings.js b/server/routes/settings.js index 7eee24549..a4a10b07c 100644 --- a/server/routes/settings.js +++ b/server/routes/settings.js @@ -1,7 +1,10 @@ import express from 'express'; +import path from 'path'; +import os from 'os'; import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js'; import { getPublicKey } from '../services/vapid-keys.js'; import { createNotificationEvent, notifyUserIfEnabled } from '../services/notification-orchestrator.js'; +import { isFullReplMode, getSettings, SETTINGS_PATH } from '../utils/settings-reader.js'; const router = express.Router(); @@ -273,4 +276,34 @@ router.post('/push/unsubscribe', async (req, res) => { } }); +// =============================== +// Full REPL Mode Configuration +// =============================== + +router.get('/repl-mode', async (req, res) => { + try { + const envMode = process.env.CLAUDE_FULL_REPL_MODE === 'true'; + + const settings = await getSettings(); + const settingsExists = !!settings; + const allowedTools = settings?.permissions?.allow || []; + const deniedTools = settings?.permissions?.deny || []; + const permissionCount = allowedTools.length; + const mcpServerCount = Object.keys(settings?.mcpServers || {}).length; + + res.json({ + fullReplMode: envMode, + settingsExists, + permissionCount, + mcpServerCount, + allowedTools, + deniedTools, + settingsPath: SETTINGS_PATH + }); + } catch (error) { + console.error('Error fetching REPL mode config:', error); + res.status(500).json({ error: 'Failed to fetch REPL mode configuration' }); + } +}); + export default router; diff --git a/server/utils/settings-reader.js b/server/utils/settings-reader.js new file mode 100644 index 000000000..98e1d6bba --- /dev/null +++ b/server/utils/settings-reader.js @@ -0,0 +1,113 @@ +/** + * Settings Reader for Full REPL Mode + * + * Reads, caches, and writes ~/.claude/settings.json to achieve parity + * with the native Claude Code CLI permissions and MCP server config. + */ + +import { promises as fs } from 'fs'; +import crypto from 'crypto'; +import path from 'path'; +import os from 'os'; + +const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json'); +let cachedSettings = null; +let cachedMtime = 0; + +/** + * Returns true when Full REPL Mode is enabled via environment variable. + * The frontend can also send a per-request override, but the env var + * is the server-wide default. + */ +export function isFullReplMode(requestOverride) { + if (typeof requestOverride === 'boolean') return requestOverride; + return process.env.CLAUDE_FULL_REPL_MODE === 'true'; +} + +/** + * Reads ~/.claude/settings.json with mtime-based caching. + * Returns null when the file is missing or unreadable. + */ +export async function getSettings() { + try { + const stat = await fs.stat(SETTINGS_PATH).catch(() => null); + if (!stat) return null; + + if (stat.mtimeMs !== cachedMtime) { + const content = await fs.readFile(SETTINGS_PATH, 'utf8'); + cachedSettings = JSON.parse(content); + cachedMtime = stat.mtimeMs; + } + + return cachedSettings; + } catch (error) { + console.error('Failed to read settings.json:', error.message); + return null; + } +} + +/** + * Returns the permissions object from settings.json. + */ +export async function getPermissions() { + const settings = await getSettings(); + return settings?.permissions || { allow: [], deny: [] }; +} + +/** + * Returns MCP servers defined in ~/.claude/settings.json. + */ +export async function getMcpServersFromSettings() { + const settings = await getSettings(); + return settings?.mcpServers || {}; +} + +/** + * Persists a newly-approved tool entry to ~/.claude/settings.json + * using an atomic read-modify-write pattern (write to temp, rename). + * Serialized via a promise chain to prevent concurrent write races. + */ +let writeQueue = Promise.resolve(); + +export async function persistAllowedTool(entry) { + writeQueue = writeQueue.then(() => _persistAllowedToolImpl(entry)).catch(() => {}); + return writeQueue; +} + +async function _persistAllowedToolImpl(entry) { + try { + // Ensure ~/.claude directory exists + await fs.mkdir(path.dirname(SETTINGS_PATH), { recursive: true }); + + let settings = {}; + try { + const content = await fs.readFile(SETTINGS_PATH, 'utf8'); + settings = JSON.parse(content); + } catch { + // File missing or corrupt, start fresh + } + + if (!settings.permissions) settings.permissions = { allow: [], deny: [] }; + if (!Array.isArray(settings.permissions.allow)) settings.permissions.allow = []; + + if (settings.permissions.allow.includes(entry)) return; + + settings.permissions.allow.push(entry); + + // Atomic write: temp file with unique suffix + rename + const suffix = process.pid + '.' + Date.now() + '.' + crypto.randomBytes(4).toString('hex'); + const tmpPath = SETTINGS_PATH + '.tmp.' + suffix; + await fs.writeFile(tmpPath, JSON.stringify(settings, null, 2), 'utf8'); + await fs.rename(tmpPath, SETTINGS_PATH); + + // Invalidate cache so the next read picks up the change + cachedMtime = 0; + cachedSettings = null; + + console.log(`[Full REPL] Persisted allowed tool: ${entry}`); + } catch (error) { + console.error('Failed to persist allowed tool:', error.message); + } +} + +export { SETTINGS_PATH }; diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 6aab9eece..802c53052 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -13,7 +13,7 @@ import { useDropzone } from 'react-dropzone'; import { authenticatedFetch } from '../../../utils/api'; import { thinkingModes } from '../constants/thinkingModes'; import { grantClaudeToolPermission } from '../utils/chatPermissions'; -import { safeLocalStorage } from '../utils/chatStorage'; +import { isFullReplModeActive, safeLocalStorage } from '../utils/chatStorage'; import type { ChatMessage, PendingPermissionRequest, @@ -671,6 +671,7 @@ export function useChatComposerState({ }, }); } else { + const replMode = isFullReplModeActive(); sendMessage({ type: 'claude-command', command: messageContent, @@ -684,6 +685,7 @@ export function useChatComposerState({ model: claudeModel, sessionSummary, images: uploadedImages, + ...(replMode !== undefined && { fullReplMode: replMode }), }, }); } diff --git a/src/components/chat/utils/chatStorage.ts b/src/components/chat/utils/chatStorage.ts index d1ae32781..a0d6e0aad 100644 --- a/src/components/chat/utils/chatStorage.ts +++ b/src/components/chat/utils/chatStorage.ts @@ -1,6 +1,25 @@ import type { ClaudeSettings } from '../types/types'; export const CLAUDE_SETTINGS_KEY = 'claude-settings'; +const FULL_REPL_MODE_KEY = 'claude-full-repl-mode'; + +export function isFullReplModeActive(): boolean | undefined { + try { + const value = localStorage.getItem(FULL_REPL_MODE_KEY); + if (value === null) return undefined; + return value === 'true'; + } catch { + return undefined; + } +} + +export function setFullReplMode(enabled: boolean): void { + try { + localStorage.setItem(FULL_REPL_MODE_KEY, String(enabled)); + } catch { + // ignore + } +} export const safeLocalStorage = { setItem: (key: string, value: string) => { @@ -75,6 +94,19 @@ export const safeLocalStorage = { }; export function getClaudeSettings(): ClaudeSettings { + // In Full REPL Mode, permissions come from ~/.claude/settings.json on the server. + // Return empty tool lists so the server-side settings take precedence. + if (isFullReplModeActive()) { + const raw = safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY); + const parsed = raw ? (() => { try { return JSON.parse(raw); } catch { return {}; } })() : {}; + return { + allowedTools: [], + disallowedTools: [], + skipPermissions: false, + projectSortOrder: parsed.projectSortOrder || 'name', + }; + } + const raw = safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY); if (!raw) { return { diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 79c99d821..d06237f1f 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { Terminal } from 'lucide-react'; import { useTasksSettings } from '../../../contexts/TasksSettingsContext'; import { QuickSettingsPanel } from '../../quick-settings-panel'; import type { ChatInterfaceProps, Provider } from '../types/types'; @@ -7,6 +8,7 @@ import { useChatProviderState } from '../hooks/useChatProviderState'; import { useChatSessionState } from '../hooks/useChatSessionState'; import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers'; import { useChatComposerState } from '../hooks/useChatComposerState'; +import { isFullReplModeActive } from '../utils/chatStorage'; import ChatMessagesPane from './subcomponents/ChatMessagesPane'; import ChatComposer from './subcomponents/ChatComposer'; @@ -339,6 +341,13 @@ function ChatInterface({ isLoading={isLoading} /> + {isFullReplModeActive() && provider === 'claude' && ( +
+ + Full REPL Mode. MCP server tools are only available in the Shell tab. +
+ )} + void; }; +type ReplModeConfig = { + fullReplMode: boolean; + settingsExists: boolean; + permissionCount: number; + mcpServerCount: number; + allowedTools: string[]; + deniedTools: string[]; + settingsPath: string; +}; + +function FullReplModeSection({ isActive, onActiveChange }: { isActive: boolean; onActiveChange: (enabled: boolean) => void }) { + const [replConfig, setReplConfig] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const initialToggle = isFullReplModeActive(); + authenticatedFetch('/api/settings/repl-mode') + .then(res => res.json()) + .then(data => { + setReplConfig(data); + // If env var enables it, sync the local toggle + if (data.fullReplMode && !initialToggle) { + onActiveChange(true); + } + }) + .catch((error) => { + console.error('Failed to fetch REPL mode config:', error); + setReplConfig(null); + }) + .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleToggle = (enabled: boolean) => { + onActiveChange(enabled); + }; + + if (loading) return null; + + return ( +
+
+ +

Full REPL Mode

+
+ +
+ + + {replConfig?.fullReplMode && ( +
+ Set via CLAUDE_FULL_REPL_MODE=true environment variable +
+ )} +
+ + {isActive && replConfig?.settingsExists && ( +
+
+ + + {replConfig.settingsPath} + +
+
+
+ Allowed tools:{' '} + {replConfig.permissionCount} +
+
+ MCP servers:{' '} + {replConfig.mcpServerCount} +
+
+ {replConfig.allowedTools.length > 0 && ( +
+ + View allowed tools from settings.json + +
+ {replConfig.allowedTools.map((tool) => ( +
+ {tool} +
+ ))} +
+
+ )} + {replConfig.deniedTools.length > 0 && ( +
+ + View denied tools from settings.json + +
+ {replConfig.deniedTools.map((tool) => ( +
+ {tool} +
+ ))} +
+
+ )} +
+ )} + + {isActive && !replConfig?.settingsExists && ( +
+

+ ~/.claude/settings.json not found. + Run claude config in your terminal to create it, + or permissions will fall back to the default (empty) settings. +

+
+ )} +
+ ); +} + function ClaudePermissions({ skipPermissions, onSkipPermissionsChange, @@ -70,6 +209,12 @@ function ClaudePermissions({ const { t } = useTranslation('settings'); const [newAllowedTool, setNewAllowedTool] = useState(''); const [newDisallowedTool, setNewDisallowedTool] = useState(''); + const [fullReplActive, setFullReplActive] = useState(isFullReplModeActive()); + + const handleReplModeChange = (enabled: boolean) => { + setFullReplMode(enabled); + setFullReplActive(enabled); + }; const handleAddAllowedTool = (tool: string) => { const updated = addUnique(allowedTools, tool); @@ -93,169 +238,188 @@ function ClaudePermissions({ return (
-
-
- -

{t('permissions.title')}

+ + + {fullReplActive && ( +
+

+ Full REPL Mode is active. Permissions are managed via{' '} + ~/.claude/settings.json. + The manual permission controls below are disabled. +

-
-