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

360 lines
17 KiB
TypeScript

import { logger } from '@/ui/logger';
import { loop } from '@/claude/loop';
import { AgentState, SessionModelMode } from '@/api/types';
import { EnhancedMode, PermissionMode } from './loop';
import { MessageQueue2 } from '@/utils/MessageQueue2';
import { hashObject } from '@/utils/deterministicJson';
import { extractSDKMetadataAsync } from '@/claude/sdk/metadataExtractor';
import { parseSpecialCommand } from '@/parsers/specialCommands';
import { getEnvironmentInfo } from '@/ui/doctor';
import { startHappyServer } from '@/claude/utils/startHappyServer';
import { startHookServer } from '@/claude/utils/startHookServer';
import { generateHookSettingsFile, cleanupHookSettingsFile } from '@/modules/common/hooks/generateHookSettings';
import { registerKillSessionHandler } from './registerKillSessionHandler';
import type { Session } from './session';
import { bootstrapSession } from '@/agent/sessionFactory';
import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle';
import { isModelModeAllowedForFlavor, isPermissionModeAllowedForFlavor } from '@hapi/protocol';
import { ModelModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas';
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
import { resolveClaudeSessionModelMode } from './modelMode';
export interface StartOptions {
model?: string
permissionMode?: PermissionMode
startingMode?: 'local' | 'remote'
shouldStartRunner?: boolean
claudeEnvVars?: Record<string, string>
claudeArgs?: string[]
startedBy?: 'runner' | 'terminal'
}
export async function runClaude(options: StartOptions = {}): Promise<void> {
const workingDirectory = process.cwd();
const startedBy = options.startedBy ?? 'terminal';
// Log environment info at startup
logger.debugLargeJson('[START] HAPI process started', getEnvironmentInfo());
logger.debug(`[START] Options: startedBy=${startedBy}, startingMode=${options.startingMode}`);
// Validate runner spawn requirements
if (startedBy === 'runner' && options.startingMode === 'local') {
logger.debug('Runner spawn requested with local mode - forcing remote mode');
options.startingMode = 'remote';
// TODO: Eventually we should error here instead of silently switching
// throw new Error('Runner-spawned sessions cannot use local/interactive mode');
}
const initialState: AgentState = {};
const { api, session, sessionInfo } = await bootstrapSession({
flavor: 'claude',
startedBy,
workingDirectory,
agentState: initialState
});
logger.debug(`Session created: ${sessionInfo.id}`);
// Extract SDK metadata in background and update session when ready
extractSDKMetadataAsync(async (sdkMetadata) => {
logger.debug('[start] SDK metadata extracted, updating session:', sdkMetadata);
try {
// Update session metadata with tools and slash commands
session.updateMetadata((currentMetadata) => ({
...currentMetadata,
tools: sdkMetadata.tools,
slashCommands: sdkMetadata.slashCommands
}));
logger.debug('[start] Session metadata updated with SDK capabilities');
} catch (error) {
logger.debug('[start] Failed to update session metadata:', error);
}
});
// Start HAPI MCP server
const happyServer = await startHappyServer(session);
logger.debug(`[START] HAPI MCP server started at ${happyServer.url}`);
// Variable to track current session instance (updated via onSessionReady callback)
const currentSessionRef: { current: Session | null } = { current: null };
const formatFailureReason = (message: string): string => {
const maxLength = 200;
if (message.length <= maxLength) {
return message;
}
return `${message.slice(0, maxLength)}...`;
};
// Start Hook server for receiving Claude session notifications
const hookServer = await startHookServer({
onSessionHook: (sessionId, data) => {
logger.debug(`[START] Session hook received: ${sessionId}`, data);
const currentSession = currentSessionRef.current;
if (currentSession) {
const previousSessionId = currentSession.sessionId;
if (previousSessionId !== sessionId) {
logger.debug(`[START] Claude session ID changed: ${previousSessionId} -> ${sessionId}`);
currentSession.onSessionFound(sessionId);
}
}
}
});
logger.debug(`[START] Hook server started on port ${hookServer.port}`);
const hookSettingsPath = generateHookSettingsFile(hookServer.port, hookServer.token, {
filenamePrefix: 'session-hook',
logLabel: 'generateHookSettings'
});
logger.debug(`[START] Generated hook settings file: ${hookSettingsPath}`);
// Print log file path
const logPath = logger.logFilePath;
logger.infoDeveloper(`Session: ${sessionInfo.id}`);
logger.infoDeveloper(`Logs: ${logPath}`);
const lifecycle = createRunnerLifecycle({
session,
logTag: 'claude',
stopKeepAlive: () => currentSessionRef.current?.stopKeepAlive(),
onAfterClose: () => {
happyServer.stop();
hookServer.stop();
cleanupHookSettingsFile(hookSettingsPath, 'generateHookSettings');
}
});
lifecycle.registerProcessHandlers();
registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit);
// Set initial agent state
const startingMode = options.startingMode ?? (startedBy === 'runner' ? 'remote' : 'local');
setControlledByUser(session, startingMode);
// Import MessageQueue2 and create message queue
const messageQueue = new MessageQueue2<EnhancedMode>(mode => hashObject({
isPlan: mode.permissionMode === 'plan',
model: mode.model,
fallbackModel: mode.fallbackModel,
customSystemPrompt: mode.customSystemPrompt,
appendSystemPrompt: mode.appendSystemPrompt,
allowedTools: mode.allowedTools,
disallowedTools: mode.disallowedTools
}));
// Forward messages to the queue
let currentPermissionMode: PermissionMode = options.permissionMode ?? 'default';
let currentModelMode: SessionModelMode = resolveClaudeSessionModelMode(options.model);
let currentFallbackModel: string | undefined = undefined; // Track current fallback model
let currentCustomSystemPrompt: string | undefined = undefined; // Track current custom system prompt
let currentAppendSystemPrompt: string | undefined = undefined; // Track current append system prompt
let currentAllowedTools: string[] | undefined = undefined; // Track current allowed tools
let currentDisallowedTools: string[] | undefined = undefined; // Track current disallowed tools
const syncSessionModes = () => {
const sessionInstance = currentSessionRef.current;
if (!sessionInstance) {
return;
}
sessionInstance.setPermissionMode(currentPermissionMode);
sessionInstance.setModelMode(currentModelMode);
logger.debug(`[loop] Synced session modes for keepalive: permissionMode=${currentPermissionMode}, modelMode=${currentModelMode}`);
};
session.onUserMessage((message) => {
const sessionPermissionMode = currentSessionRef.current?.getPermissionMode();
if (sessionPermissionMode && isPermissionModeAllowedForFlavor(sessionPermissionMode, 'claude')) {
currentPermissionMode = sessionPermissionMode as PermissionMode;
}
const messagePermissionMode = currentPermissionMode;
const messageModel = currentModelMode === 'default' ? undefined : currentModelMode;
logger.debug(`[loop] User message received with permission mode: ${currentPermissionMode}, model: ${currentModelMode}`);
// Resolve custom system prompt - use message.meta.customSystemPrompt if provided, otherwise use current
let messageCustomSystemPrompt = currentCustomSystemPrompt;
if (message.meta?.hasOwnProperty('customSystemPrompt')) {
messageCustomSystemPrompt = message.meta.customSystemPrompt || undefined; // null becomes undefined
currentCustomSystemPrompt = messageCustomSystemPrompt;
logger.debug(`[loop] Custom system prompt updated from user message: ${messageCustomSystemPrompt ? 'set' : 'reset to none'}`);
} else {
logger.debug(`[loop] User message received with no custom system prompt override, using current: ${currentCustomSystemPrompt ? 'set' : 'none'}`);
}
// Resolve fallback model - use message.meta.fallbackModel if provided, otherwise use current fallback model
let messageFallbackModel = currentFallbackModel;
if (message.meta?.hasOwnProperty('fallbackModel')) {
messageFallbackModel = message.meta.fallbackModel || undefined; // null becomes undefined
currentFallbackModel = messageFallbackModel;
logger.debug(`[loop] Fallback model updated from user message: ${messageFallbackModel || 'reset to none'}`);
} else {
logger.debug(`[loop] User message received with no fallback model override, using current: ${currentFallbackModel || 'none'}`);
}
// Resolve append system prompt - use message.meta.appendSystemPrompt if provided, otherwise use current
let messageAppendSystemPrompt = currentAppendSystemPrompt;
if (message.meta?.hasOwnProperty('appendSystemPrompt')) {
messageAppendSystemPrompt = message.meta.appendSystemPrompt || undefined; // null becomes undefined
currentAppendSystemPrompt = messageAppendSystemPrompt;
logger.debug(`[loop] Append system prompt updated from user message: ${messageAppendSystemPrompt ? 'set' : 'reset to none'}`);
} else {
logger.debug(`[loop] User message received with no append system prompt override, using current: ${currentAppendSystemPrompt ? 'set' : 'none'}`);
}
// Resolve allowed tools - use message.meta.allowedTools if provided, otherwise use current
let messageAllowedTools = currentAllowedTools;
if (message.meta?.hasOwnProperty('allowedTools')) {
messageAllowedTools = message.meta.allowedTools || undefined; // null becomes undefined
currentAllowedTools = messageAllowedTools;
logger.debug(`[loop] Allowed tools updated from user message: ${messageAllowedTools ? messageAllowedTools.join(', ') : 'reset to none'}`);
} else {
logger.debug(`[loop] User message received with no allowed tools override, using current: ${currentAllowedTools ? currentAllowedTools.join(', ') : 'none'}`);
}
// Resolve disallowed tools - use message.meta.disallowedTools if provided, otherwise use current
let messageDisallowedTools = currentDisallowedTools;
if (message.meta?.hasOwnProperty('disallowedTools')) {
messageDisallowedTools = message.meta.disallowedTools || undefined; // null becomes undefined
currentDisallowedTools = messageDisallowedTools;
logger.debug(`[loop] Disallowed tools updated from user message: ${messageDisallowedTools ? messageDisallowedTools.join(', ') : 'reset to none'}`);
} else {
logger.debug(`[loop] User message received with no disallowed tools override, using current: ${currentDisallowedTools ? currentDisallowedTools.join(', ') : 'none'}`);
}
// Check for special commands before processing
const specialCommand = parseSpecialCommand(message.content.text);
// Format message text with attachments for Claude
const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments);
if (specialCommand.type === 'compact') {
logger.debug('[start] Detected /compact command');
const enhancedMode: EnhancedMode = {
permissionMode: messagePermissionMode ?? 'default',
model: messageModel,
fallbackModel: messageFallbackModel,
customSystemPrompt: messageCustomSystemPrompt,
appendSystemPrompt: messageAppendSystemPrompt,
allowedTools: messageAllowedTools,
disallowedTools: messageDisallowedTools
};
// Use raw text only, ignore attachments for special commands
const commandText = specialCommand.originalMessage || message.content.text;
messageQueue.pushIsolateAndClear(commandText, enhancedMode);
logger.debugLargeJson('[start] /compact command pushed to queue:', message);
return;
}
if (specialCommand.type === 'clear') {
logger.debug('[start] Detected /clear command');
const enhancedMode: EnhancedMode = {
permissionMode: messagePermissionMode ?? 'default',
model: messageModel,
fallbackModel: messageFallbackModel,
customSystemPrompt: messageCustomSystemPrompt,
appendSystemPrompt: messageAppendSystemPrompt,
allowedTools: messageAllowedTools,
disallowedTools: messageDisallowedTools
};
// Use raw text only, ignore attachments for special commands
const commandText = specialCommand.originalMessage || message.content.text;
messageQueue.pushIsolateAndClear(commandText, enhancedMode);
logger.debugLargeJson('[start] /clear command pushed to queue:', message);
return;
}
// Push with resolved permission mode, model, system prompts, and tools
const enhancedMode: EnhancedMode = {
permissionMode: messagePermissionMode ?? 'default',
model: messageModel,
fallbackModel: messageFallbackModel,
customSystemPrompt: messageCustomSystemPrompt,
appendSystemPrompt: messageAppendSystemPrompt,
allowedTools: messageAllowedTools,
disallowedTools: messageDisallowedTools
};
messageQueue.push(formattedText, enhancedMode);
logger.debugLargeJson('User message pushed to queue:', message)
});
const resolvePermissionMode = (value: unknown): PermissionMode => {
const parsed = PermissionModeSchema.safeParse(value);
if (!parsed.success || !isPermissionModeAllowedForFlavor(parsed.data, 'claude')) {
throw new Error('Invalid permission mode');
}
return parsed.data as PermissionMode;
};
const resolveModelMode = (value: unknown): SessionModelMode => {
const parsed = ModelModeSchema.safeParse(value);
if (!parsed.success || !isModelModeAllowedForFlavor(parsed.data, 'claude')) {
throw new Error('Invalid model mode');
}
return parsed.data;
};
session.rpcHandlerManager.registerHandler('set-session-config', async (payload: unknown) => {
if (!payload || typeof payload !== 'object') {
throw new Error('Invalid session config payload');
}
const config = payload as { permissionMode?: unknown; modelMode?: unknown };
if (config.permissionMode !== undefined) {
currentPermissionMode = resolvePermissionMode(config.permissionMode);
}
if (config.modelMode !== undefined) {
const resolvedModelMode = resolveModelMode(config.modelMode);
currentModelMode = resolvedModelMode;
}
syncSessionModes();
return { applied: { permissionMode: currentPermissionMode, modelMode: currentModelMode } };
});
let loopError: unknown = null;
let loopFailed = false;
try {
await loop({
path: workingDirectory,
model: options.model,
permissionMode: options.permissionMode,
startingMode,
messageQueue,
api,
allowedTools: happyServer.toolNames.map(toolName => `mcp__hapi__${toolName}`),
onModeChange: createModeChangeHandler(session),
onSessionReady: (sessionInstance) => {
currentSessionRef.current = sessionInstance;
syncSessionModes();
},
mcpServers: {
'hapi': {
type: 'http' as const,
url: happyServer.url,
}
},
session,
claudeEnvVars: options.claudeEnvVars,
claudeArgs: options.claudeArgs,
startedBy,
hookSettingsPath
});
} catch (error) {
loopError = error;
loopFailed = true;
lifecycle.markCrash(error);
}
const localFailure = currentSessionRef.current?.localLaunchFailure;
if (localFailure?.exitReason === 'exit') {
lifecycle.setExitCode(1);
lifecycle.setArchiveReason(`Local launch failed: ${formatFailureReason(localFailure.message)}`);
}
if (loopFailed) {
await lifecycle.cleanup();
throw loopError;
}
await lifecycle.cleanupAndExit();
}