mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-28 23:15:33 +08:00
* feat: add opencode support
* fix: stabilize opencode session startup
* fix: /models
* fix: improveUI for commands
* fix: format commands.js
* feat: load models through provider adapters
Provider model selection had outgrown a single hardcoded service.
The old service mixed shared caching with provider catalogs and CLI lookup details.
That made stale model lists more likely as providers changed on separate schedules.
Move model discovery behind each provider so lookup lives next to the integration.
The shared service now focuses on provider resolution, caching, persistence, and dedupe.
Return cache metadata and add bypassCache because model availability changes outside the app.
The UI and /models command can show freshness and let users force a provider refresh.
Surface model descriptions while keeping fallback catalogs for unavailable CLIs or SDKs.
* feat(models): resolve active session models through provider adapters
The model inventory command was showing a mix of catalog defaults and
composer-local state instead of the model that is actually active for a
real provider session. That made /models, /cost, and /status
misleading once a session had already started, especially for providers
whose effective runtime model can differ from the optimistic model value
held in the UI.
Introduce an explicit getCurrentActiveModel() contract on
IProviderModels so model resolution lives next to each provider's
catalog logic and uses the provider-native source of truth:
- Claude reads the init event from a resumed stream-json run
- Codex reads model from ~/.codex/config.toml
- Cursor reads lastUsedModel from the chat store.db
- OpenCode reads the persisted session model from opencode.db
- Gemini intentionally returns its default because the CLI does not
provide a reliable active-session lookup
Keep the returned shape intentionally minimal ({ model }). The goal is
to expose only what downstream command consumers need and avoid leaking
provider-specific metadata into a shared transport shape that would
create extra UI coupling and future cleanup cost.
Also make command behavior session-aware: when there is no concrete
session id, do not spawn provider processes or inspect provider session
storage just to answer /models, /cost, or /status. In a new-session
view the correct answer is simply the provider default, and doing more
work there adds latency and unnecessary side effects for no user value.
As part of this, centralize two supporting concerns:
- add a shared helper for building the default current-model result from
a provider catalog so fallbacks stay aligned with DEFAULT
- move leaf-directory validation into shared utils so Cursor session
readers and model lookup code enforce the same path-safety rule
Tests were expanded to cover both the new service delegation path and
the sessionless command behavior, while keeping cache-sensitive tests
isolated from persisted host cache state.
Why this change:
- command output should reflect the model actually driving a session
- new-session views should stay fast and side-effect free
- provider-specific active-model lookup should not be scattered across
routes or UI code
- fallback behavior should be explicit, consistent, and limited to the
provider default when no true active model can be resolved
* feat: support session-scoped model overrides
Model selection was acting like a provider-level preference.
That made resumed sessions drift back to a default or request-time model.
Users expect /models changes made inside a conversation to affect that session.
Store explicit session choices in app-owned ~/.cloudcli state.
This avoids editing provider transcripts or native provider config.
Resolve the effective model before launching each provider runtime.
Claude, Cursor, Codex, Gemini, and OpenCode now honor stored resume choices.
Expose a backend active-model change endpoint for existing sessions.
The models modal can now distinguish default changes from session overrides.
It also shows when a selected model will apply on the next response.
For Claude, stop probing active model state by resuming with a dummy prompt.
Read the indexed JSONL transcript from the end instead.
This preserves provider history while honoring /model stdout or model fields.
Add service tests for adapter delegation and resume-model precedence.
The tests keep cache state, override state, and requested fallback separate.
* feat: make command modal more compact
* fix: preserve opencode session creation events
OpenCode emits the real session id asynchronously on its first JSON output. The runner
registered that id from a helper that could not see the spawned process because
the process reference was scoped inside the model-resolution callback. That
ReferenceError was swallowed by the generic JSON parse fallback, so the client
never received session_created. Without that event, a new OpenCode chat stayed
on / and the assistant stream was not attached to the new session view.
Keep the process reference in the outer spawn scope so registration can update
the active-process map and websocket writer as soon as OpenCode announces the
session id. Split JSON parsing from event processing so malformed non-JSON
output can still stream as raw text, while registration or adapter failures are
surfaced as real errors instead of being hidden as assistant content.
Add a fake opencode executable regression test to lock in the expected lifecycle
ordering: session_created must be sent before live assistant messages, and the
same session id must carry through stream_end and complete.
* fix: clarify model refresh and onboarding providers
OpenCode is now a supported chat provider, but first-run onboarding still only offered
Claude, Cursor, Codex, and Gemini. That made OpenCode harder to discover and
forced users to finish setup before finding the provider in settings or chat.
Adding it to onboarding keeps first-run setup aligned with the providers the
application already supports elsewhere.
The model refresh control was also doing too much visual work. In the new chat
model picker, the previous Hard Refresh label looked like the dialog heading,
which made the primary task unclear. Users open that dialog to choose a model;
refreshing catalogs is only a secondary maintenance action for stale cached
provider model lists.
Rename and reposition the refresh affordance so the model picker reads as a
model picker first. The copy now explains why catalogs are cached, when a refresh
is useful, and that the refresh checks every provider. The /models modal gets the
same clarification so both model-selection surfaces describe the cache behavior
consistently.
* fix: format opencode model catalog labels
OpenCode returns provider-prefixed ids directly from the CLI. Passing those ids through as
labels made the model picker hard to scan: users saw values like
anthropic/claude-3-5-sonnet-20241022 or lowercased, hyphen-split text instead
of readable model names.
Keep the exact OpenCode id as the option value because that is what the CLI
expects, but derive a presentation label for the frontend. The formatter is
intentionally generic rather than a catalog of known providers. It handles common
identifier structure such as provider/model, hyphen-delimited words, v-prefixed
versions, adjacent numeric version tokens, and 8-digit date suffixes.
This keeps OpenCode usable as its model list expands across many upstream
providers without requiring code changes for every new provider or model family.
The description keeps the raw provider-prefixed id visible so users can still
confirm the precise model being selected.
* feat: add more fallback models for cursor
* docs: move model catalog out of shared
The model catalog is no longer a frontend/backend runtime contract.
Keeping it under shared made ownership misleading. It implied the catalog was
application code shared by runtime consumers, even though it now only supports
README links and public API documentation.
Move the catalog into public so it lives beside the docs surfaces that need it.
This gives the API docs a stable, served module and gives README readers a
linkable source without suggesting frontend or backend runtime dependency.
Render the API docs model list from the exported provider registry instead of a
hardcoded Claude/Cursor/Codex subset. That keeps Gemini and OpenCode visible and
makes future provider documentation changes flow through one docs-specific file.
Update README links, provider maintenance notes, and package files so published
artifacts include the standalone docs page and model catalog without relying on
the old shared path.
* fix: simplify empty-state model selector
Keep the provider empty state focused on the setup action users need there:
choosing a model.
The refresh control, cache timestamp, and refresh explanation made the dialog feel
like a cache-management surface.
That extra action is out of place in the empty state, where the goal is to start
a chat with the selected provider and model.
Remove the refresh-specific UI from ProviderSelectionEmptyState and drop the
now-unused refresh/cache props from the ChatMessagesPane pass-through.
Refresh behavior remains available in the dedicated command result flow.
848 lines
27 KiB
JavaScript
848 lines
27 KiB
JavaScript
/**
|
|
* Claude SDK Integration
|
|
*
|
|
* This module provides SDK-based integration with Claude using the @anthropic-ai/claude-agent-sdk.
|
|
* It mirrors the interface of claude-cli.js but uses the SDK internally for better performance
|
|
* and maintainability.
|
|
*
|
|
* Key features:
|
|
* - Direct SDK integration without child processes
|
|
* - Session management with abort capability
|
|
* - Options mapping between CLI and SDK formats
|
|
* - WebSocket message streaming
|
|
*/
|
|
|
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
import crypto from 'crypto';
|
|
import { promises as fs } from 'fs';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import { CLAUDE_FALLBACK_MODELS } from './modules/providers/list/claude/claude-models.provider.js';
|
|
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
|
import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js';
|
|
import {
|
|
createNotificationEvent,
|
|
notifyRunFailed,
|
|
notifyRunStopped,
|
|
notifyUserIfEnabled
|
|
} from './services/notification-orchestrator.js';
|
|
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
|
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
|
import { createNormalizedMessage } from './shared/utils.js';
|
|
|
|
const activeSessions = new Map();
|
|
const pendingToolApprovals = new Map();
|
|
|
|
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
|
|
|
|
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']);
|
|
|
|
function createRequestId() {
|
|
if (typeof crypto.randomUUID === 'function') {
|
|
return crypto.randomUUID();
|
|
}
|
|
return crypto.randomBytes(16).toString('hex');
|
|
}
|
|
|
|
function waitForToolApproval(requestId, options = {}) {
|
|
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel, metadata } = options;
|
|
|
|
return new Promise(resolve => {
|
|
let settled = false;
|
|
|
|
const finalize = (decision) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
resolve(decision);
|
|
};
|
|
|
|
let timeout;
|
|
|
|
const cleanup = () => {
|
|
pendingToolApprovals.delete(requestId);
|
|
if (timeout) clearTimeout(timeout);
|
|
if (signal && abortHandler) {
|
|
signal.removeEventListener('abort', abortHandler);
|
|
}
|
|
};
|
|
|
|
// timeoutMs 0 = wait indefinitely (interactive tools)
|
|
if (timeoutMs > 0) {
|
|
timeout = setTimeout(() => {
|
|
onCancel?.('timeout');
|
|
finalize(null);
|
|
}, timeoutMs);
|
|
}
|
|
|
|
const abortHandler = () => {
|
|
onCancel?.('cancelled');
|
|
finalize({ cancelled: true });
|
|
};
|
|
|
|
if (signal) {
|
|
if (signal.aborted) {
|
|
onCancel?.('cancelled');
|
|
finalize({ cancelled: true });
|
|
return;
|
|
}
|
|
signal.addEventListener('abort', abortHandler, { once: true });
|
|
}
|
|
|
|
const resolver = (decision) => {
|
|
finalize(decision);
|
|
};
|
|
// Attach metadata for getPendingApprovalsForSession lookup
|
|
if (metadata) {
|
|
Object.assign(resolver, metadata);
|
|
}
|
|
pendingToolApprovals.set(requestId, resolver);
|
|
});
|
|
}
|
|
|
|
function resolveToolApproval(requestId, decision) {
|
|
const resolver = pendingToolApprovals.get(requestId);
|
|
if (resolver) {
|
|
resolver(decision);
|
|
}
|
|
}
|
|
|
|
// Match stored permission entries against a tool + input combo.
|
|
// This only supports exact tool names and the Bash(command:*) shorthand
|
|
// used by the UI; it intentionally does not implement full glob semantics,
|
|
// introduced to stay consistent with the UI's "Allow rule" format.
|
|
function matchesToolPermission(entry, toolName, input) {
|
|
if (!entry || !toolName) {
|
|
return false;
|
|
}
|
|
|
|
if (entry === toolName) {
|
|
return true;
|
|
}
|
|
|
|
const bashMatch = entry.match(/^Bash\((.+):\*\)$/);
|
|
if (toolName === 'Bash' && bashMatch) {
|
|
const allowedPrefix = bashMatch[1];
|
|
let command = '';
|
|
|
|
if (typeof input === 'string') {
|
|
command = input.trim();
|
|
} else if (input && typeof input === 'object' && typeof input.command === 'string') {
|
|
command = input.command.trim();
|
|
}
|
|
|
|
if (!command) {
|
|
return false;
|
|
}
|
|
|
|
return command.startsWith(allowedPrefix);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Maps CLI options to SDK-compatible options format
|
|
* @param {Object} options - CLI options
|
|
* @returns {Object} SDK-compatible options
|
|
*/
|
|
function mapCliOptionsToSDK(options = {}) {
|
|
const { sessionId, cwd, toolsSettings, permissionMode } = options;
|
|
|
|
const sdkOptions = {};
|
|
|
|
// Forward all host env vars (e.g. ANTHROPIC_BASE_URL) to the subprocess.
|
|
// Since SDK 0.2.113, options.env replaces process.env instead of overlaying it.
|
|
sdkOptions.env = { ...process.env };
|
|
|
|
// Resolve the executable eagerly on Windows because the SDK uses raw child_process.spawn,
|
|
// which does not reliably follow npm's shell wrappers like cross-spawn does.
|
|
sdkOptions.pathToClaudeCodeExecutable = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH);
|
|
|
|
// Map working directory
|
|
if (cwd) {
|
|
sdkOptions.cwd = cwd;
|
|
}
|
|
|
|
// Map permission mode
|
|
if (permissionMode && permissionMode !== 'default') {
|
|
sdkOptions.permissionMode = permissionMode;
|
|
}
|
|
|
|
// Map tool settings
|
|
const settings = toolsSettings || {
|
|
allowedTools: [],
|
|
disallowedTools: [],
|
|
skipPermissions: false
|
|
};
|
|
|
|
// Handle tool permissions
|
|
if (settings.skipPermissions && permissionMode !== 'plan') {
|
|
// When skipping permissions, use bypassPermissions mode
|
|
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;
|
|
|
|
// 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_FALLBACK_MODELS.DEFAULT;
|
|
// Model logged at query start below
|
|
|
|
// Map system prompt configuration
|
|
sdkOptions.systemPrompt = {
|
|
type: 'preset',
|
|
preset: 'claude_code' // Required to use CLAUDE.md
|
|
};
|
|
|
|
// Map setting sources for CLAUDE.md loading
|
|
// This loads CLAUDE.md from project, user (~/.config/claude/CLAUDE.md), and local directories
|
|
sdkOptions.settingSources = ['project', 'user', 'local'];
|
|
|
|
// Map resume session
|
|
if (sessionId) {
|
|
sdkOptions.resume = sessionId;
|
|
}
|
|
|
|
return sdkOptions;
|
|
}
|
|
|
|
/**
|
|
* Adds a session to the active sessions map
|
|
* @param {string} sessionId - Session identifier
|
|
* @param {Object} queryInstance - SDK query instance
|
|
* @param {Array<string>} tempImagePaths - Temp image file paths for cleanup
|
|
* @param {string} tempDir - Temp directory for cleanup
|
|
*/
|
|
function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null, writer = null) {
|
|
activeSessions.set(sessionId, {
|
|
instance: queryInstance,
|
|
startTime: Date.now(),
|
|
status: 'active',
|
|
tempImagePaths,
|
|
tempDir,
|
|
writer
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Removes a session from the active sessions map
|
|
* @param {string} sessionId - Session identifier
|
|
*/
|
|
function removeSession(sessionId) {
|
|
activeSessions.delete(sessionId);
|
|
}
|
|
|
|
/**
|
|
* Gets a session from the active sessions map
|
|
* @param {string} sessionId - Session identifier
|
|
* @returns {Object|undefined} Session data or undefined
|
|
*/
|
|
function getSession(sessionId) {
|
|
return activeSessions.get(sessionId);
|
|
}
|
|
|
|
/**
|
|
* Gets all active session IDs
|
|
* @returns {Array<string>} Array of active session IDs
|
|
*/
|
|
function getAllSessions() {
|
|
return Array.from(activeSessions.keys());
|
|
}
|
|
|
|
/**
|
|
* Transforms SDK messages to WebSocket format expected by frontend
|
|
* @param {Object} sdkMessage - SDK message object
|
|
* @returns {Object} Transformed message ready for WebSocket
|
|
*/
|
|
function transformMessage(sdkMessage) {
|
|
// Extract parent_tool_use_id for subagent tool grouping
|
|
if (sdkMessage.parent_tool_use_id) {
|
|
return {
|
|
...sdkMessage,
|
|
parentToolUseId: sdkMessage.parent_tool_use_id
|
|
};
|
|
}
|
|
return sdkMessage;
|
|
}
|
|
|
|
/**
|
|
* Extracts token usage from SDK result messages
|
|
* @param {Object} resultMessage - SDK result message
|
|
* @returns {Object|null} Token budget object or null
|
|
*/
|
|
function extractTokenBudget(resultMessage) {
|
|
if (resultMessage.type !== 'result' || !resultMessage.modelUsage) {
|
|
return null;
|
|
}
|
|
|
|
// Get the first model's usage data
|
|
const modelKey = Object.keys(resultMessage.modelUsage)[0];
|
|
const modelData = resultMessage.modelUsage[modelKey];
|
|
|
|
if (!modelData) {
|
|
return null;
|
|
}
|
|
|
|
// Use cumulative tokens if available (tracks total for the session)
|
|
// Otherwise fall back to per-request tokens
|
|
const inputTokens = modelData.cumulativeInputTokens || modelData.inputTokens || 0;
|
|
const outputTokens = modelData.cumulativeOutputTokens || modelData.outputTokens || 0;
|
|
const cacheReadTokens = modelData.cumulativeCacheReadInputTokens || modelData.cacheReadInputTokens || 0;
|
|
const cacheCreationTokens = modelData.cumulativeCacheCreationInputTokens || modelData.cacheCreationInputTokens || 0;
|
|
|
|
// Total used = input + output + cache tokens
|
|
const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
|
|
|
|
// Use configured context window budget from environment (default 160000)
|
|
// This is the user's budget limit, not the model's context window
|
|
const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
|
|
|
|
// Token calc logged via token-budget WS event
|
|
|
|
return {
|
|
used: totalUsed,
|
|
total: contextWindow
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Handles image processing for SDK queries
|
|
* Saves base64 images to temporary files and returns modified prompt with file paths
|
|
* @param {string} command - Original user prompt
|
|
* @param {Array} images - Array of image objects with base64 data
|
|
* @param {string} cwd - Working directory for temp file creation
|
|
* @returns {Promise<Object>} {modifiedCommand, tempImagePaths, tempDir}
|
|
*/
|
|
async function handleImages(command, images, cwd) {
|
|
const tempImagePaths = [];
|
|
let tempDir = null;
|
|
|
|
if (!images || images.length === 0) {
|
|
return { modifiedCommand: command, tempImagePaths, tempDir };
|
|
}
|
|
|
|
try {
|
|
// Create temp directory in the project directory
|
|
const workingDir = cwd || process.cwd();
|
|
tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString());
|
|
await fs.mkdir(tempDir, { recursive: true });
|
|
|
|
// Save each image to a temp file
|
|
for (const [index, image] of images.entries()) {
|
|
// Extract base64 data and mime type
|
|
const matches = image.data.match(/^data:([^;]+);base64,(.+)$/);
|
|
if (!matches) {
|
|
console.error('Invalid image data format');
|
|
continue;
|
|
}
|
|
|
|
const [, mimeType, base64Data] = matches;
|
|
const extension = mimeType.split('/')[1] || 'png';
|
|
const filename = `image_${index}.${extension}`;
|
|
const filepath = path.join(tempDir, filename);
|
|
|
|
// Write base64 data to file
|
|
await fs.writeFile(filepath, Buffer.from(base64Data, 'base64'));
|
|
tempImagePaths.push(filepath);
|
|
}
|
|
|
|
// Include the full image paths in the prompt
|
|
let modifiedCommand = command;
|
|
if (tempImagePaths.length > 0 && command && command.trim()) {
|
|
const imageNote = `\n\n[Images provided at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`;
|
|
modifiedCommand = command + imageNote;
|
|
}
|
|
|
|
// Images processed
|
|
return { modifiedCommand, tempImagePaths, tempDir };
|
|
} catch (error) {
|
|
console.error('Error processing images for SDK:', error);
|
|
return { modifiedCommand: command, tempImagePaths, tempDir };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleans up temporary image files
|
|
* @param {Array<string>} tempImagePaths - Array of temp file paths to delete
|
|
* @param {string} tempDir - Temp directory to remove
|
|
*/
|
|
async function cleanupTempFiles(tempImagePaths, tempDir) {
|
|
if (!tempImagePaths || tempImagePaths.length === 0) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Delete individual temp files
|
|
for (const imagePath of tempImagePaths) {
|
|
await fs.unlink(imagePath).catch(err =>
|
|
console.error(`Failed to delete temp image ${imagePath}:`, err)
|
|
);
|
|
}
|
|
|
|
// Delete temp directory
|
|
if (tempDir) {
|
|
await fs.rm(tempDir, { recursive: true, force: true }).catch(err =>
|
|
console.error(`Failed to delete temp directory ${tempDir}:`, err)
|
|
);
|
|
}
|
|
|
|
// Temp files cleaned
|
|
} catch (error) {
|
|
console.error('Error during temp file cleanup:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads MCP server configurations from ~/.claude.json
|
|
* @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) {
|
|
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
|
|
// No config file
|
|
return null;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Extract MCP servers (merge global and project-specific)
|
|
let mcpServers = {};
|
|
|
|
// Add global MCP servers
|
|
if (claudeConfig.mcpServers && typeof claudeConfig.mcpServers === 'object') {
|
|
mcpServers = { ...claudeConfig.mcpServers };
|
|
// Global MCP servers loaded
|
|
}
|
|
|
|
// 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 };
|
|
// Project MCP servers merged
|
|
}
|
|
}
|
|
|
|
// Return null if no servers found
|
|
if (Object.keys(mcpServers).length === 0) {
|
|
return null;
|
|
}
|
|
return mcpServers;
|
|
} catch (error) {
|
|
console.error('Error loading MCP config:', error.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Executes a Claude query using the SDK
|
|
* @param {string} command - User prompt/command
|
|
* @param {Object} options - Query options
|
|
* @param {Object} ws - WebSocket connection
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function queryClaudeSDK(command, options = {}, ws) {
|
|
const { sessionId, sessionSummary } = options;
|
|
let capturedSessionId = sessionId;
|
|
let sessionCreatedSent = false;
|
|
let tempImagePaths = [];
|
|
let tempDir = null;
|
|
|
|
const emitNotification = (event) => {
|
|
notifyUserIfEnabled({
|
|
userId: ws?.userId || null,
|
|
writer: ws,
|
|
event
|
|
});
|
|
};
|
|
|
|
try {
|
|
const resolvedModel = await providerModelsService.resolveResumeModel(
|
|
'claude',
|
|
sessionId,
|
|
options.model,
|
|
);
|
|
|
|
// Map CLI options to SDK format
|
|
const sdkOptions = mapCliOptionsToSDK({
|
|
...options,
|
|
model: resolvedModel || options.model,
|
|
});
|
|
|
|
// Load MCP configuration
|
|
const mcpServers = await loadMcpConfig(options.cwd);
|
|
if (mcpServers) {
|
|
sdkOptions.mcpServers = mcpServers;
|
|
}
|
|
|
|
// Handle images - save to temp files and modify prompt
|
|
const imageResult = await handleImages(command, options.images, options.cwd);
|
|
const finalCommand = imageResult.modifiedCommand;
|
|
tempImagePaths = imageResult.tempImagePaths;
|
|
tempDir = imageResult.tempDir;
|
|
|
|
sdkOptions.hooks = {
|
|
Notification: [{
|
|
matcher: '',
|
|
hooks: [async (input) => {
|
|
const message = typeof input?.message === 'string' ? input.message : 'Claude requires your attention.';
|
|
emitNotification(createNotificationEvent({
|
|
provider: 'claude',
|
|
sessionId: capturedSessionId || sessionId || null,
|
|
kind: 'action_required',
|
|
code: 'agent.notification',
|
|
meta: { message, sessionName: sessionSummary },
|
|
severity: 'warning',
|
|
requiresUserAction: true,
|
|
dedupeKey: `claude:hook:notification:${capturedSessionId || sessionId || 'none'}:${message}`
|
|
}));
|
|
return {};
|
|
}]
|
|
}]
|
|
};
|
|
|
|
// Caveat: in 'auto' and 'bypassPermissions' modes the SDK resolves approval
|
|
// at the permission-mode step and skips this callback, so interactive tools
|
|
// (AskUserQuestion, ExitPlanMode) won't reach the UI — the classifier/bypass
|
|
// auto-approves them and the model acts on a generated answer. Move these
|
|
// tools to a PreToolUse hook (runs before the mode check) if we need them
|
|
// to work in those modes.
|
|
sdkOptions.canUseTool = async (toolName, input, context) => {
|
|
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
|
|
|
|
if (!requiresInteraction) {
|
|
if (sdkOptions.permissionMode === 'bypassPermissions') {
|
|
return { behavior: 'allow', updatedInput: input };
|
|
}
|
|
|
|
const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
|
|
matchesToolPermission(entry, toolName, input)
|
|
);
|
|
if (isDisallowed) {
|
|
return { behavior: 'deny', message: 'Tool disallowed by settings' };
|
|
}
|
|
|
|
const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
|
|
matchesToolPermission(entry, toolName, input)
|
|
);
|
|
if (isAllowed) {
|
|
return { behavior: 'allow', updatedInput: input };
|
|
}
|
|
}
|
|
|
|
const requestId = createRequestId();
|
|
ws.send(createNormalizedMessage({ kind: 'permission_request', requestId, toolName, input, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
|
emitNotification(createNotificationEvent({
|
|
provider: 'claude',
|
|
sessionId: capturedSessionId || sessionId || null,
|
|
kind: 'action_required',
|
|
code: 'permission.required',
|
|
meta: { toolName, sessionName: sessionSummary },
|
|
severity: 'warning',
|
|
requiresUserAction: true,
|
|
dedupeKey: `claude:permission:${capturedSessionId || sessionId || 'none'}:${requestId}`
|
|
}));
|
|
|
|
const decision = await waitForToolApproval(requestId, {
|
|
timeoutMs: requiresInteraction ? 0 : undefined,
|
|
signal: context?.signal,
|
|
metadata: {
|
|
_sessionId: capturedSessionId || sessionId || null,
|
|
_toolName: toolName,
|
|
_input: input,
|
|
_receivedAt: new Date(),
|
|
},
|
|
onCancel: (reason) => {
|
|
ws.send(createNormalizedMessage({ kind: 'permission_cancelled', requestId, reason, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
|
}
|
|
});
|
|
if (!decision) {
|
|
return { behavior: 'deny', message: 'Permission request timed out' };
|
|
}
|
|
|
|
if (decision.cancelled) {
|
|
return { behavior: 'deny', message: 'Permission request cancelled' };
|
|
}
|
|
|
|
if (decision.allow) {
|
|
if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
|
|
if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
|
|
sdkOptions.allowedTools.push(decision.rememberEntry);
|
|
}
|
|
if (Array.isArray(sdkOptions.disallowedTools)) {
|
|
sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry);
|
|
}
|
|
}
|
|
return { behavior: 'allow', updatedInput: decision.updatedInput ?? input };
|
|
}
|
|
|
|
return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
|
|
};
|
|
|
|
// Set stream-close timeout for interactive tools (Query constructor reads it synchronously). Claude Agent SDK has a default of 5s and this overrides it
|
|
const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
|
|
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
|
|
|
|
let queryInstance;
|
|
try {
|
|
queryInstance = query({
|
|
prompt: finalCommand,
|
|
options: sdkOptions
|
|
});
|
|
} catch (hookError) {
|
|
// Older/newer SDK versions may not accept hook shapes yet.
|
|
// Keep notification behavior operational via runtime events even if hook registration fails.
|
|
console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError);
|
|
delete sdkOptions.hooks;
|
|
queryInstance = query({
|
|
prompt: finalCommand,
|
|
options: sdkOptions
|
|
});
|
|
}
|
|
|
|
// Restore immediately — Query constructor already captured the value
|
|
if (prevStreamTimeout !== undefined) {
|
|
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = prevStreamTimeout;
|
|
} else {
|
|
delete process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
|
|
}
|
|
|
|
// Track the query instance for abort capability
|
|
if (capturedSessionId) {
|
|
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
|
|
}
|
|
|
|
// Process streaming messages
|
|
console.log('Starting async generator loop for session:', capturedSessionId || 'NEW');
|
|
for await (const message of queryInstance) {
|
|
// Capture session ID from first message
|
|
if (message.session_id && !capturedSessionId) {
|
|
|
|
capturedSessionId = message.session_id;
|
|
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
|
|
|
|
// Set session ID on writer
|
|
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
|
|
ws.setSessionId(capturedSessionId);
|
|
}
|
|
|
|
// Send session-created event only once for new sessions
|
|
if (!sessionId && !sessionCreatedSent) {
|
|
sessionCreatedSent = true;
|
|
ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'claude' }));
|
|
}
|
|
} else {
|
|
// session_id already captured
|
|
}
|
|
|
|
// Transform and normalize message via adapter
|
|
const transformedMessage = transformMessage(message);
|
|
const sid = capturedSessionId || sessionId || null;
|
|
|
|
// Use adapter to normalize SDK events into NormalizedMessage[]
|
|
const normalized = sessionsService.normalizeMessage('claude', transformedMessage, sid);
|
|
for (const msg of normalized) {
|
|
// Preserve parentToolUseId from SDK wrapper for subagent tool grouping
|
|
if (transformedMessage.parentToolUseId && !msg.parentToolUseId) {
|
|
msg.parentToolUseId = transformedMessage.parentToolUseId;
|
|
}
|
|
ws.send(msg);
|
|
}
|
|
|
|
// Extract and send token budget updates from result messages
|
|
if (message.type === 'result') {
|
|
const models = Object.keys(message.modelUsage || {});
|
|
if (models.length > 0) {
|
|
// Model info available in result message
|
|
}
|
|
const tokenBudgetData = extractTokenBudget(message);
|
|
if (tokenBudgetData) {
|
|
ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up session on completion
|
|
if (capturedSessionId) {
|
|
removeSession(capturedSessionId);
|
|
}
|
|
|
|
// Clean up temporary image files
|
|
await cleanupTempFiles(tempImagePaths, tempDir);
|
|
|
|
// Send completion event
|
|
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: 0, isNewSession: !sessionId && !!command, sessionId: capturedSessionId, provider: 'claude' }));
|
|
notifyRunStopped({
|
|
userId: ws?.userId || null,
|
|
provider: 'claude',
|
|
sessionId: capturedSessionId || sessionId || null,
|
|
sessionName: sessionSummary,
|
|
stopReason: 'completed'
|
|
});
|
|
// Complete
|
|
|
|
} catch (error) {
|
|
console.error('SDK query error:', error);
|
|
|
|
// Clean up session on error
|
|
if (capturedSessionId) {
|
|
removeSession(capturedSessionId);
|
|
}
|
|
|
|
// Clean up temporary image files on error
|
|
await cleanupTempFiles(tempImagePaths, tempDir);
|
|
|
|
// Check if Claude CLI is installed for a clearer error message
|
|
const installed = await providerAuthService.isProviderInstalled('claude');
|
|
const errorContent = !installed
|
|
? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
|
|
: error.message;
|
|
|
|
// Send error to WebSocket
|
|
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
|
notifyRunFailed({
|
|
userId: ws?.userId || null,
|
|
provider: 'claude',
|
|
sessionId: capturedSessionId || sessionId || null,
|
|
sessionName: sessionSummary,
|
|
error
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Aborts an active SDK session
|
|
* @param {string} sessionId - Session identifier
|
|
* @returns {boolean} True if session was aborted, false if not found
|
|
*/
|
|
async function abortClaudeSDKSession(sessionId) {
|
|
const session = getSession(sessionId);
|
|
|
|
if (!session) {
|
|
console.log(`Session ${sessionId} not found`);
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
console.log(`Aborting SDK session: ${sessionId}`);
|
|
|
|
// Call interrupt() on the query instance
|
|
await session.instance.interrupt();
|
|
|
|
// Update session status
|
|
session.status = 'aborted';
|
|
|
|
// Clean up temporary image files
|
|
await cleanupTempFiles(session.tempImagePaths, session.tempDir);
|
|
|
|
// Clean up session
|
|
removeSession(sessionId);
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error(`Error aborting session ${sessionId}:`, error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if an SDK session is currently active
|
|
* @param {string} sessionId - Session identifier
|
|
* @returns {boolean} True if session is active
|
|
*/
|
|
function isClaudeSDKSessionActive(sessionId) {
|
|
const session = getSession(sessionId);
|
|
return session && session.status === 'active';
|
|
}
|
|
|
|
/**
|
|
* Gets all active SDK session IDs
|
|
* @returns {Array<string>} Array of active session IDs
|
|
*/
|
|
function getActiveClaudeSDKSessions() {
|
|
return getAllSessions();
|
|
}
|
|
|
|
/**
|
|
* Get pending tool approvals for a specific session.
|
|
* @param {string} sessionId - The session ID
|
|
* @returns {Array} Array of pending permission request objects
|
|
*/
|
|
function getPendingApprovalsForSession(sessionId) {
|
|
const pending = [];
|
|
for (const [requestId, resolver] of pendingToolApprovals.entries()) {
|
|
if (resolver._sessionId === sessionId) {
|
|
pending.push({
|
|
requestId,
|
|
toolName: resolver._toolName || 'UnknownTool',
|
|
input: resolver._input,
|
|
context: resolver._context,
|
|
sessionId,
|
|
receivedAt: resolver._receivedAt || new Date(),
|
|
});
|
|
}
|
|
}
|
|
return pending;
|
|
}
|
|
|
|
/**
|
|
* Reconnect a session's WebSocketWriter to a new raw WebSocket.
|
|
* Called when client reconnects (e.g. page refresh) while SDK is still running.
|
|
* @param {string} sessionId - The session ID
|
|
* @param {Object} newRawWs - The new raw WebSocket connection
|
|
* @returns {boolean} True if writer was successfully reconnected
|
|
*/
|
|
function reconnectSessionWriter(sessionId, newRawWs) {
|
|
const session = getSession(sessionId);
|
|
if (!session?.writer?.updateWebSocket) return false;
|
|
session.writer.updateWebSocket(newRawWs);
|
|
console.log(`[RECONNECT] Writer swapped for session ${sessionId}`);
|
|
return true;
|
|
}
|
|
|
|
// Export public API
|
|
export {
|
|
queryClaudeSDK,
|
|
abortClaudeSDKSession,
|
|
isClaudeSDKSessionActive,
|
|
getActiveClaudeSDKSessions,
|
|
resolveToolApproval,
|
|
getPendingApprovalsForSession,
|
|
reconnectSessionWriter
|
|
};
|