Files
hapi/cli/src/claude/claudeRemote.ts
T

316 lines
13 KiB
TypeScript

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<string, any>,
claudeEnvVars?: Record<string, string>,
claudeArgs?: string[],
allowedTools: string[],
hookSettingsPath: string,
signal?: AbortSignal,
canCallTool: (toolName: string, input: unknown, mode: EnhancedMode, options: { signal: AbortSignal }) => Promise<PermissionResult>,
// 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<SDKUserMessage>();
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);
}
}