2026-05-27 22:45:26 -04:00
/ * *
* 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 _MODELS } from '../shared/modelConstants.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 _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 ~ / . c l a u d e . j s o n
* @ 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 {
// Map CLI options to SDK format
const sdkOptions = mapCliOptionsToSDK ( options ) ;
// 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 ) ;
2026-05-30 10:25:01 -04:00
// Dangerously-skip / bypass mode: auto-allow EVERY tool, including interactive
// ones (AskUserQuestion, ExitPlanMode). This guarantees zero prompts.
if ( sdkOptions . permissionMode === 'bypassPermissions' ) {
return { behavior : 'allow' , updatedInput : input } ;
}
2026-05-27 22:45:26 -04:00
2026-05-30 10:25:01 -04:00
if ( ! requiresInteraction ) {
2026-05-27 22:45:26 -04:00
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
} ;