import { EnhancedMode, PermissionMode } from "./loop"; import { query, type QueryOptions as Options, type SDKMessage, type SDKSystemMessage, AbortError, SDKUserMessage } from '@/claude/sdk' import { claudeCheckSession } from "./utils/claudeCheckSession"; import { join } from 'node:path'; import { parseSpecialCommand } from "@/parsers/specialCommands"; import { logger } from "@/lib"; import { PushableAsyncIterable } from "@/utils/PushableAsyncIterable"; import { getProjectPath } from "./utils/path"; import { awaitFileExist } from "@/modules/watcher/awaitFileExist"; import { systemPrompt } from "./utils/systemPrompt"; import { PermissionResult } from "./sdk/types"; import { getHapiBlobsDir } from "@/constants/uploadPaths"; import { getDefaultClaudeCodePath } from "./sdk/utils"; export async function claudeRemote(opts: { // Fixed parameters sessionId: string | null, path: string, mcpServers?: Record, claudeEnvVars?: Record, claudeArgs?: string[], allowedTools: string[], hookSettingsPath: string, signal?: AbortSignal, canCallTool: (toolName: string, input: unknown, mode: EnhancedMode, options: { signal: AbortSignal }) => Promise, // Dynamic parameters nextMessage: () => Promise<{ message: string, mode: EnhancedMode } | null>, onReady: () => void, isAborted: (toolCallId: string) => boolean, // Callbacks onSessionFound: (id: string) => void, onThinkingChange?: (thinking: boolean) => void, onMessage: (message: SDKMessage) => void, onCompletionEvent?: (message: string) => void, onSessionReset?: () => void }) { const debugPrefix = '[claudeRemote][async-debug]'; // Check if session is valid let startFrom = opts.sessionId; if (opts.sessionId && !claudeCheckSession(opts.sessionId, opts.path)) { startFrom = null; } // Extract --resume from claudeArgs if present (for first spawn) if (!startFrom && opts.claudeArgs) { for (let i = 0; i < opts.claudeArgs.length; i++) { if (opts.claudeArgs[i] === '--resume') { // Check if next arg exists and looks like a session ID if (i + 1 < opts.claudeArgs.length) { const nextArg = opts.claudeArgs[i + 1]; // If next arg doesn't start with dash and contains dashes, it's likely a UUID if (!nextArg.startsWith('-') && nextArg.includes('-')) { startFrom = nextArg; logger.debug(`[claudeRemote] Found --resume with session ID: ${startFrom}`); break; } else { // Just --resume without UUID - SDK doesn't support this logger.debug('[claudeRemote] Found --resume without session ID - not supported in remote mode'); break; } } else { // --resume at end of args - SDK doesn't support this logger.debug('[claudeRemote] Found --resume without session ID - not supported in remote mode'); break; } } } } // Set environment variables for Claude Code SDK if (opts.claudeEnvVars) { Object.entries(opts.claudeEnvVars).forEach(([key, value]) => { process.env[key] = value; }); } process.env.DISABLE_AUTOUPDATER = '1'; // Get initial message let initial; try { initial = await opts.nextMessage(); } catch (e) { if (e instanceof AbortError) { logger.debug(`[claudeRemote] Aborted during initial message`); return; } throw e; } if (!initial) { // No initial message - exit logger.debug(`${debugPrefix} initial nextMessage returned null; exiting`); return; } logger.debug(`${debugPrefix} initial message acquired`); // Handle special commands const specialCommand = parseSpecialCommand(initial.message); // Handle /clear command if (specialCommand.type === 'clear') { if (opts.onCompletionEvent) { opts.onCompletionEvent('Context was reset'); } if (opts.onSessionReset) { opts.onSessionReset(); } return; } // Handle /compact command let isCompactCommand = false; if (specialCommand.type === 'compact') { logger.debug('[claudeRemote] /compact command detected - will process as normal but with compaction behavior'); isCompactCommand = true; if (opts.onCompletionEvent) { opts.onCompletionEvent('Compaction started'); } } // Prepare SDK options let mode = initial.mode; const sdkOptions: Options = { cwd: opts.path, resume: startFrom ?? undefined, mcpServers: opts.mcpServers, permissionMode: initial.mode.permissionMode, model: initial.mode.model, effort: initial.mode.effort, fallbackModel: initial.mode.fallbackModel, customSystemPrompt: initial.mode.customSystemPrompt ? initial.mode.customSystemPrompt + '\n\n' + systemPrompt : undefined, appendSystemPrompt: initial.mode.appendSystemPrompt ? initial.mode.appendSystemPrompt + '\n\n' + systemPrompt : systemPrompt, allowedTools: initial.mode.allowedTools ? initial.mode.allowedTools.concat(opts.allowedTools) : opts.allowedTools, disallowedTools: initial.mode.disallowedTools, canCallTool: (toolName: string, input: unknown, options: { signal: AbortSignal }) => opts.canCallTool(toolName, input, mode, options), abort: opts.signal, pathToClaudeCodeExecutable: getDefaultClaudeCodePath(), settingsPath: opts.hookSettingsPath, additionalDirectories: [getHapiBlobsDir()], } // Track thinking state let thinking = false; const updateThinking = (newThinking: boolean) => { if (thinking !== newThinking) { thinking = newThinking; logger.debug(`[claudeRemote] Thinking state changed to: ${thinking}`); if (opts.onThinkingChange) { opts.onThinkingChange(thinking); } } }; // Push initial message let messages = new PushableAsyncIterable(); messages.push({ type: 'user', message: { role: 'user', content: initial.message, }, }); // Start the loop const response = query({ prompt: messages, options: sdkOptions, }); let nextMessageFetchInFlight = false; let inputEnded = false; let nextMessageFetchSeq = 0; let streamMessageSeq = 0; let resultSeq = 0; const scheduleNextMessage = () => { if (nextMessageFetchInFlight || inputEnded) { logger.debug( `${debugPrefix} scheduleNextMessage skipped ` + `(inFlight=${nextMessageFetchInFlight}, inputEnded=${inputEnded})` ); return; } const fetchId = ++nextMessageFetchSeq; const startedAt = Date.now(); nextMessageFetchInFlight = true; logger.debug(`${debugPrefix} scheduleNextMessage start fetchId=${fetchId}`); void (async () => { try { const next = await opts.nextMessage(); if (!next) { inputEnded = true; messages.end(); logger.debug( `${debugPrefix} nextMessage resolved null fetchId=${fetchId} elapsedMs=${Date.now() - startedAt}; input ended` ); return; } mode = next.mode; messages.push({ type: 'user', message: { role: 'user', content: next.message } }); logger.debug( `${debugPrefix} nextMessage resolved fetchId=${fetchId} elapsedMs=${Date.now() - startedAt} ` + `messageLength=${next.message.length} permissionMode=${next.mode.permissionMode}` ); } catch (e) { inputEnded = true; if (e instanceof AbortError) { messages.end(); logger.debug(`${debugPrefix} nextMessage aborted fetchId=${fetchId}`); return; } messages.setError(e instanceof Error ? e : new Error(String(e))); logger.debug(`${debugPrefix} nextMessage error fetchId=${fetchId}`, e); } finally { nextMessageFetchInFlight = false; logger.debug(`${debugPrefix} scheduleNextMessage done fetchId=${fetchId}`); } })(); }; updateThinking(true); try { logger.debug(`[claudeRemote] Starting to iterate over response`); for await (const message of response) { streamMessageSeq += 1; logger.debug( `${debugPrefix} stream message #${streamMessageSeq} type=${message.type} ` + `subtype=${'subtype' in message ? String((message as any).subtype) : 'n/a'}` ); logger.debugLargeJson(`[claudeRemote] Message ${message.type}`, message); // Handle messages opts.onMessage(message); // Handle special system messages if (message.type === 'system' && message.subtype === 'init') { // Start thinking when session initializes updateThinking(true); const systemInit = message as SDKSystemMessage; // Session id is still in memory, wait until session file is written to disk // Start a watcher for to detect the session id if (systemInit.session_id) { logger.debug(`[claudeRemote] Waiting for session file to be written to disk: ${systemInit.session_id}`); const projectDir = getProjectPath(opts.path); const found = await awaitFileExist(join(projectDir, `${systemInit.session_id}.jsonl`)); logger.debug(`[claudeRemote] Session file found: ${systemInit.session_id} ${found}`); opts.onSessionFound(systemInit.session_id); } } // Handle result messages if (message.type === 'result') { resultSeq += 1; updateThinking(false); logger.debug( `${debugPrefix} result #${resultSeq} received; scheduling next user message ` + `(nextInFlight=${nextMessageFetchInFlight}, inputEnded=${inputEnded})` ); // Send completion messages if (isCompactCommand) { logger.debug('[claudeRemote] Compaction completed'); if (opts.onCompletionEvent) { opts.onCompletionEvent('Compaction completed'); } isCompactCommand = false; } // Send ready event opts.onReady(); logger.debug(`${debugPrefix} onReady emitted for result #${resultSeq}`); // Pull next user message without blocking response stream processing. // Claude may emit autonomous async messages (e.g. scheduled tasks) after a result, // and we must keep consuming those messages immediately. scheduleNextMessage(); } // Handle tool result if (message.type === 'user') { const msg = message as SDKUserMessage; if (msg.message.role === 'user' && Array.isArray(msg.message.content)) { for (let c of msg.message.content) { if (c.type === 'tool_result' && c.tool_use_id && opts.isAborted(c.tool_use_id)) { logger.debug('[claudeRemote] Tool aborted, exiting claudeRemote'); logger.debug(`${debugPrefix} tool aborted via tool_result; exiting stream loop`); return; } } } } } logger.debug(`${debugPrefix} response stream exhausted`); } catch (e) { if (e instanceof AbortError) { logger.debug(`[claudeRemote] Aborted`); // Ignore } else { logger.debug(`${debugPrefix} response stream error`, e); throw e; } } finally { logger.debug( `${debugPrefix} finally ` + `(streamMessages=${streamMessageSeq}, results=${resultSeq}, nextFetches=${nextMessageFetchSeq}, inputEnded=${inputEnded})` ); updateThinking(false); } }