mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
360 lines
17 KiB
TypeScript
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();
|
|
}
|