diff --git a/scripts/session_hook_forwarder.cjs b/scripts/session_hook_forwarder.cjs new file mode 100644 index 00000000..091b20ca --- /dev/null +++ b/scripts/session_hook_forwarder.cjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +/** + * Session Hook Forwarder + * + * This script is executed by Claude's SessionStart hook. + * It reads JSON data from stdin and forwards it to Happy's hook server. + * + * Usage: echo '{"session_id":"..."}' | node session_hook_forwarder.cjs + */ + +const http = require('http'); + +const port = parseInt(process.argv[2], 10); + +if (!port || isNaN(port)) { + process.exit(1); +} + +const chunks = []; + +process.stdin.on('data', (chunk) => { + chunks.push(chunk); +}); + +process.stdin.on('end', () => { + const body = Buffer.concat(chunks); + + const req = http.request({ + host: '127.0.0.1', + port: port, + method: 'POST', + path: '/hook/session-start', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': body.length + } + }, (res) => { + res.resume(); // Drain response + }); + + req.on('error', () => { + // Silently ignore errors - don't break Claude + }); + + req.end(body); +}); + +process.stdin.resume(); + diff --git a/src/claude/claudeLocal.ts b/src/claude/claudeLocal.ts index a0b9d861..7098da35 100644 --- a/src/claude/claudeLocal.ts +++ b/src/claude/claudeLocal.ts @@ -2,7 +2,6 @@ import { spawn } from "node:child_process"; import { resolve, join } from "node:path"; import { createInterface } from "node:readline"; import { mkdirSync, existsSync } from "node:fs"; -import { randomUUID } from "node:crypto"; import { logger } from "@/ui/logger"; import { claudeCheckSession } from "./utils/claudeCheckSession"; import { getProjectPath } from "./utils/path"; @@ -21,33 +20,35 @@ export async function claudeLocal(opts: { onSessionFound: (id: string) => void, onThinkingChange?: (thinking: boolean) => void, claudeEnvVars?: Record, - claudeArgs?: string[] - allowedTools?: string[] + claudeArgs?: string[], + allowedTools?: string[], + /** Path to temporary settings file with SessionStart hook (required for session tracking) */ + hookSettingsPath: string }) { // Ensure project directory exists const projectDir = getProjectPath(opts.path); mkdirSync(projectDir, { recursive: true }); - // Determine session ID strategy: - // - If resuming an existing session: use --resume (Claude keeps the same session ID) - // - If starting fresh: generate UUID and pass via --session-id + // Check if claudeArgs contains --continue or --resume (user passed these flags) + const hasContinueFlag = opts.claudeArgs?.includes('--continue'); + const hasResumeFlag = opts.claudeArgs?.includes('--resume'); + const hasUserSessionControl = hasContinueFlag || hasResumeFlag; + + // Determine if we have an existing session to resume + // Session ID will always be provided by hook (SessionStart) when Claude starts let startFrom = opts.sessionId; if (opts.sessionId && !claudeCheckSession(opts.sessionId, opts.path)) { startFrom = null; } - - // Generate new session ID if not resuming - const newSessionId = startFrom ? null : randomUUID(); - const effectiveSessionId = startFrom || newSessionId!; - // Notify about session ID immediately (we know it upfront now!) - if (newSessionId) { - logger.debug(`[ClaudeLocal] Generated new session ID: ${newSessionId}`); - opts.onSessionFound(newSessionId); + // Log session strategy + if (startFrom) { + logger.debug(`[ClaudeLocal] Will resume existing session: ${startFrom}`); + } else if (hasUserSessionControl) { + logger.debug(`[ClaudeLocal] User passed ${hasContinueFlag ? '--continue' : '--resume'} flag, session ID will be determined by hook`); } else { - logger.debug(`[ClaudeLocal] Resuming session: ${startFrom}`); - opts.onSessionFound(startFrom!); + logger.debug(`[ClaudeLocal] Fresh start, session ID will be provided by hook`); } // Thinking state @@ -70,12 +71,10 @@ export async function claudeLocal(opts: { await new Promise((r, reject) => { const args: string[] = [] - if (startFrom) { - // Resume existing session (Claude preserves the session ID) + // Only add --resume if we have an existing session and user didn't pass their own flags + // For fresh starts, let Claude create its own session ID (reported via hook) + if (!hasUserSessionControl && startFrom) { args.push('--resume', startFrom) - } else { - // New session with our generated UUID - args.push('--session-id', newSessionId!) } args.push('--append-system-prompt', systemPrompt); @@ -93,6 +92,10 @@ export async function claudeLocal(opts: { args.push(...opts.claudeArgs) } + // Add hook settings for session tracking (always passed) + args.push('--settings', opts.hookSettingsPath); + logger.debug(`[ClaudeLocal] Using hook settings: ${opts.hookSettingsPath}`); + if (!claudeCliPath || !existsSync(claudeCliPath)) { throw new Error('Claude local launcher not found. Please ensure HAPPY_PROJECT_ROOT is set correctly for development.'); } @@ -205,5 +208,5 @@ export async function claudeLocal(opts: { updateThinking(false); } - return effectiveSessionId; + return startFrom; } diff --git a/src/claude/claudeLocalLauncher.ts b/src/claude/claudeLocalLauncher.ts index f2b1d958..0a7772ed 100644 --- a/src/claude/claudeLocalLauncher.ts +++ b/src/claude/claudeLocalLauncher.ts @@ -17,6 +17,13 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' | } } }); + + // Register callback to notify scanner when session ID is found via hook + // This is important for --continue/--resume where session ID is not known upfront + const scannerSessionCallback = (sessionId: string) => { + scanner.onNewSession(sessionId); + }; + session.addSessionFoundCallback(scannerSessionCallback); // Handle abort @@ -101,6 +108,7 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' | claudeArgs: session.claudeArgs, mcpServers: session.mcpServers, allowedTools: session.allowedTools, + hookSettingsPath: session.hookSettingsPath, }); // Consume one-time Claude flags after spawn @@ -132,6 +140,9 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' | session.client.rpcHandlerManager.registerHandler('abort', async () => { }); session.client.rpcHandlerManager.registerHandler('switch', async () => { }); session.queue.setOnMessage(null); + + // Remove session found callback + session.removeSessionFoundCallback(scannerSessionCallback); // Cleanup await scanner.cleanup(); diff --git a/src/claude/claudeRemote.ts b/src/claude/claudeRemote.ts index 0f423ded..5c9698dc 100644 --- a/src/claude/claudeRemote.ts +++ b/src/claude/claudeRemote.ts @@ -22,6 +22,8 @@ export async function claudeRemote(opts: { allowedTools: string[], signal?: AbortSignal, canCallTool: (toolName: string, input: unknown, mode: EnhancedMode, options: { signal: AbortSignal }) => Promise, + /** Path to temporary settings file with SessionStart hook (required for session tracking) */ + hookSettingsPath: string, // Dynamic parameters nextMessage: () => Promise<{ message: string, mode: EnhancedMode } | null>, @@ -124,6 +126,7 @@ export async function claudeRemote(opts: { pathToClaudeCodeExecutable: (() => { return resolve(join(projectPath(), 'scripts', 'claude_remote_launcher.cjs')); })(), + settingsPath: opts.hookSettingsPath, } // Track thinking state diff --git a/src/claude/claudeRemoteLauncher.ts b/src/claude/claudeRemoteLauncher.ts index 735e4025..b1888804 100644 --- a/src/claude/claudeRemoteLauncher.ts +++ b/src/claude/claudeRemoteLauncher.ts @@ -329,6 +329,7 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | path: session.path, allowedTools: session.allowedTools ?? [], mcpServers: session.mcpServers, + hookSettingsPath: session.hookSettingsPath, canCallTool: permissionHandler.handleToolCall, isAborted: (toolCallId: string) => { return permissionHandler.isAborted(toolCallId); diff --git a/src/claude/loop.ts b/src/claude/loop.ts index 48da7800..b3d1f7e0 100644 --- a/src/claude/loop.ts +++ b/src/claude/loop.ts @@ -32,6 +32,8 @@ interface LoopOptions { messageQueue: MessageQueue2 allowedTools?: string[] onSessionReady?: (session: Session) => void + /** Path to temporary settings file with SessionStart hook (required for session tracking) */ + hookSettingsPath: string } export async function loop(opts: LoopOptions) { @@ -49,7 +51,8 @@ export async function loop(opts: LoopOptions) { logPath: logPath, messageQueue: opts.messageQueue, allowedTools: opts.allowedTools, - onModeChange: opts.onModeChange + onModeChange: opts.onModeChange, + hookSettingsPath: opts.hookSettingsPath }); // Notify that session is ready diff --git a/src/claude/runClaude.ts b/src/claude/runClaude.ts index 907e1ab3..a0adcc5c 100644 --- a/src/claude/runClaude.ts +++ b/src/claude/runClaude.ts @@ -19,9 +19,12 @@ import { configuration } from '@/configuration'; import { notifyDaemonSessionStarted } from '@/daemon/controlClient'; import { initialMachineMetadata } from '@/daemon/run'; import { startHappyServer } from '@/claude/utils/startHappyServer'; +import { startHookServer } from '@/claude/utils/startHookServer'; +import { generateHookSettingsFile, cleanupHookSettingsFile } from '@/claude/utils/generateHookSettings'; import { registerKillSessionHandler } from './registerKillSessionHandler'; import { projectPath } from '../projectPath'; import { resolve } from 'node:path'; +import { Session } from './session'; export interface StartOptions { model?: string @@ -130,6 +133,31 @@ export async function runClaude(credentials: Credentials, options: StartOptions const happyServer = await startHappyServer(session); logger.debug(`[START] Happy MCP server started at ${happyServer.url}`); + // Variable to track current session instance (updated via onSessionReady callback) + // Used by hook server to notify Session when Claude changes session ID + let currentSession: Session | null = null; + + // Start Hook server for receiving Claude session notifications + const hookServer = await startHookServer({ + onSessionHook: (sessionId, data) => { + logger.debug(`[START] Session hook received: ${sessionId}`, data); + + // Update session ID in the Session instance + 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}`); + + // Generate hook settings file for Claude + const hookSettingsPath = generateHookSettingsFile(hookServer.port); + logger.debug(`[START] Generated hook settings file: ${hookSettingsPath}`); + // Print log file path const logPath = logger.logFilePath; logger.infoDeveloper(`Session: ${response.id}`); @@ -320,6 +348,10 @@ export async function runClaude(credentials: Credentials, options: StartOptions // Stop Happy MCP server happyServer.stop(); + // Stop Hook server and cleanup settings file + hookServer.stop(); + cleanupHookSettingsFile(hookSettingsPath); + logger.debug('[START] Cleanup complete, exiting'); process.exit(0); } catch (error) { @@ -361,8 +393,9 @@ export async function runClaude(credentials: Credentials, options: StartOptions controlledByUser: newMode === 'local' })); }, - onSessionReady: (_sessionInstance) => { - // Intentionally unused + onSessionReady: (sessionInstance) => { + // Store reference for hook server callback + currentSession = sessionInstance; }, mcpServers: { 'happy': { @@ -372,7 +405,8 @@ export async function runClaude(credentials: Credentials, options: StartOptions }, session, claudeEnvVars: options.claudeEnvVars, - claudeArgs: options.claudeArgs + claudeArgs: options.claudeArgs, + hookSettingsPath }); // Send session death message @@ -394,6 +428,11 @@ export async function runClaude(credentials: Credentials, options: StartOptions happyServer.stop(); logger.debug('Stopped Happy MCP server'); + // Stop Hook server and cleanup settings file + hookServer.stop(); + cleanupHookSettingsFile(hookSettingsPath); + logger.debug('Stopped Hook server and cleaned up settings file'); + // Exit process.exit(0); } \ No newline at end of file diff --git a/src/claude/sdk/query.ts b/src/claude/sdk/query.ts index 9848f9bb..5ec76736 100644 --- a/src/claude/sdk/query.ts +++ b/src/claude/sdk/query.ts @@ -273,7 +273,8 @@ export function query(config: { model, fallbackModel, strictMcpConfig, - canCallTool + canCallTool, + settingsPath } = {} } = config @@ -304,6 +305,7 @@ export function query(config: { } if (strictMcpConfig) args.push('--strict-mcp-config') if (permissionMode) args.push('--permission-mode', permissionMode) + if (settingsPath) args.push('--settings', settingsPath) if (fallbackModel) { if (model && fallbackModel === model) { diff --git a/src/claude/sdk/types.ts b/src/claude/sdk/types.ts index f0b85c97..de4cba7c 100644 --- a/src/claude/sdk/types.ts +++ b/src/claude/sdk/types.ts @@ -173,6 +173,8 @@ export interface QueryOptions { fallbackModel?: string strictMcpConfig?: boolean canCallTool?: CanCallToolCallback + /** Path to a settings JSON file to pass to Claude via --settings */ + settingsPath?: string } /** diff --git a/src/claude/session.ts b/src/claude/session.ts index 6cda75a2..7a5b7fe7 100644 --- a/src/claude/session.ts +++ b/src/claude/session.ts @@ -14,10 +14,18 @@ export class Session { readonly mcpServers: Record; readonly allowedTools?: string[]; readonly _onModeChange: (mode: 'local' | 'remote') => void; + /** Path to temporary settings file with SessionStart hook (required for session tracking) */ + readonly hookSettingsPath: string; sessionId: string | null; mode: 'local' | 'remote' = 'local'; thinking: boolean = false; + + /** Callbacks to be notified when session ID is found/changed */ + private sessionFoundCallbacks: ((sessionId: string) => void)[] = []; + + /** Keep alive interval reference for cleanup */ + private keepAliveInterval: NodeJS.Timeout; constructor(opts: { api: ApiClient, @@ -31,6 +39,8 @@ export class Session { messageQueue: MessageQueue2, onModeChange: (mode: 'local' | 'remote') => void, allowedTools?: string[], + /** Path to temporary settings file with SessionStart hook (required for session tracking) */ + hookSettingsPath: string, }) { this.path = opts.path; this.api = opts.api; @@ -43,13 +53,23 @@ export class Session { this.mcpServers = opts.mcpServers; this.allowedTools = opts.allowedTools; this._onModeChange = opts.onModeChange; + this.hookSettingsPath = opts.hookSettingsPath; // Start keep alive this.client.keepAlive(this.thinking, this.mode); - setInterval(() => { + this.keepAliveInterval = setInterval(() => { this.client.keepAlive(this.thinking, this.mode); }, 2000); } + + /** + * Cleanup resources (call when session is no longer needed) + */ + cleanup = (): void => { + clearInterval(this.keepAliveInterval); + this.sessionFoundCallbacks = []; + logger.debug('[Session] Cleaned up resources'); + } onThinkingChange = (thinking: boolean) => { this.thinking = thinking; @@ -62,6 +82,17 @@ export class Session { this._onModeChange(mode); } + /** + * Called when Claude session ID is discovered or changed. + * + * This is triggered by the SessionStart hook when: + * - Claude starts a new session (fresh start) + * - Claude resumes a session (--continue, --resume flags) + * - Claude forks a session (/compact, double-escape fork) + * + * Updates internal state, syncs to API metadata, and notifies + * all registered callbacks (e.g., SessionScanner) about the change. + */ onSessionFound = (sessionId: string) => { this.sessionId = sessionId; @@ -71,6 +102,28 @@ export class Session { claudeSessionId: sessionId })); logger.debug(`[Session] Claude Code session ID ${sessionId} added to metadata`); + + // Notify all registered callbacks + for (const callback of this.sessionFoundCallbacks) { + callback(sessionId); + } + } + + /** + * Register a callback to be notified when session ID is found/changed + */ + addSessionFoundCallback = (callback: (sessionId: string) => void): void => { + this.sessionFoundCallbacks.push(callback); + } + + /** + * Remove a session found callback + */ + removeSessionFoundCallback = (callback: (sessionId: string) => void): void => { + const index = this.sessionFoundCallbacks.indexOf(callback); + if (index !== -1) { + this.sessionFoundCallbacks.splice(index, 1); + } } /** @@ -83,14 +136,21 @@ export class Session { /** * Consume one-time Claude flags from claudeArgs after Claude spawn - * Currently handles: --resume (with or without session ID) + * Handles: --resume (with or without session ID), --continue */ consumeOneTimeFlags = (): void => { if (!this.claudeArgs) return; const filteredArgs: string[] = []; for (let i = 0; i < this.claudeArgs.length; i++) { - if (this.claudeArgs[i] === '--resume') { + const arg = this.claudeArgs[i]; + + if (arg === '--continue') { + logger.debug('[Session] Consumed --continue flag'); + continue; + } + + if (arg === '--resume') { // Check if next arg looks like a UUID (contains dashes and alphanumeric) if (i + 1 < this.claudeArgs.length) { const nextArg = this.claudeArgs[i + 1]; @@ -107,9 +167,10 @@ export class Session { // --resume at the end of args logger.debug('[Session] Consumed --resume flag (no session ID)'); } - } else { - filteredArgs.push(this.claudeArgs[i]); + continue; } + + filteredArgs.push(arg); } this.claudeArgs = filteredArgs.length > 0 ? filteredArgs : undefined; diff --git a/src/claude/utils/generateHookSettings.ts b/src/claude/utils/generateHookSettings.ts new file mode 100644 index 00000000..bd2e60de --- /dev/null +++ b/src/claude/utils/generateHookSettings.ts @@ -0,0 +1,69 @@ +/** + * Generate temporary settings file with Claude hooks for session tracking + * + * Creates a settings.json file that configures Claude's SessionStart hook + * to notify our HTTP server when sessions change (new session, resume, compact, etc.) + */ + +import { join, resolve } from 'node:path'; +import { writeFileSync, mkdirSync, unlinkSync, existsSync } from 'node:fs'; +import { configuration } from '@/configuration'; +import { logger } from '@/ui/logger'; +import { projectPath } from '@/projectPath'; + +/** + * Generate a temporary settings file with SessionStart hook configuration + * + * @param port - The port where Happy server is listening + * @returns Path to the generated settings file + */ +export function generateHookSettingsFile(port: number): string { + const hooksDir = join(configuration.happyHomeDir, 'tmp', 'hooks'); + mkdirSync(hooksDir, { recursive: true }); + + // Unique filename per process to avoid conflicts + const filename = `session-hook-${process.pid}.json`; + const filepath = join(hooksDir, filename); + + // Path to the hook forwarder script + const forwarderScript = resolve(projectPath(), 'scripts', 'session_hook_forwarder.cjs'); + const hookCommand = `node "${forwarderScript}" ${port}`; + + const settings = { + hooks: { + SessionStart: [ + { + matcher: "*", + hooks: [ + { + type: "command", + command: hookCommand + } + ] + } + ] + } + }; + + writeFileSync(filepath, JSON.stringify(settings, null, 2)); + logger.debug(`[generateHookSettings] Created hook settings file: ${filepath}`); + + return filepath; +} + +/** + * Clean up the temporary hook settings file + * + * @param filepath - Path to the settings file to remove + */ +export function cleanupHookSettingsFile(filepath: string): void { + try { + if (existsSync(filepath)) { + unlinkSync(filepath); + logger.debug(`[generateHookSettings] Cleaned up hook settings file: ${filepath}`); + } + } catch (error) { + logger.debug(`[generateHookSettings] Failed to cleanup hook settings file: ${error}`); + } +} + diff --git a/src/claude/utils/startHookServer.ts b/src/claude/utils/startHookServer.ts new file mode 100644 index 00000000..d7ce813f --- /dev/null +++ b/src/claude/utils/startHookServer.ts @@ -0,0 +1,176 @@ +/** + * Dedicated HTTP server for receiving Claude session hooks + * + * This server receives notifications from Claude when sessions change + * (new session, resume, compact, fork, etc.) via the SessionStart hook. + * + * Separate from the MCP server to keep concerns isolated. + * + * ## Control Flow + * + * ### Startup + * ``` + * runClaude.ts + * │ + * ├─► startHookServer() ──► HTTP server on random port (e.g., 52290) + * │ + * ├─► generateHookSettingsFile(port) ──► ~/.happy/tmp/hooks/session-hook-.json + * │ (contains SessionStart hook pointing to our server) + * │ + * └─► loop() ──► claudeLocal/claudeRemote + * │ + * └─► spawn claude --settings + * ``` + * + * ### Session Notification Flow + * ``` + * Claude CLI (SessionStart event) + * │ + * ├─► Reads hooks from --settings file + * │ + * └─► Executes hook command (session_hook_forwarder.cjs) + * │ + * ├─► Receives session data on stdin + * │ + * └─► HTTP POST to http://127.0.0.1:/hook/session-start + * │ + * └─► startHookServer receives it + * │ + * └─► onSessionHook(sessionId, data) + * │ + * ├─► Updates Session.sessionId + * ├─► Updates API metadata + * └─► Notifies SessionScanner + * ``` + * + * ### Triggered By + * - `happy` (fresh start) - new session created + * - `happy --continue` - continues last session (may fork) + * - `happy --resume` - interactive picker, then resume + * - `happy --resume ` - resume specific session + * - `/compact` command - compacts and forks session + * - Double-escape fork - user forks conversation in CLI + * + * ### Why Not Use File Watching? + * File watching has race conditions when multiple Happy processes run. + * With hooks, Claude directly tells THIS specific process about its session, + * ensuring 1:1 mapping between Happy process and Claude session. + */ + +import { createServer, IncomingMessage, ServerResponse, Server } from 'node:http'; +import { logger } from '@/ui/logger'; + +/** + * Data received from Claude's SessionStart hook + */ +export interface SessionHookData { + session_id?: string; + sessionId?: string; + transcript_path?: string; + cwd?: string; + hook_event_name?: string; + source?: string; + [key: string]: unknown; +} + +export interface HookServerOptions { + /** Called when a session hook is received with a valid session ID */ + onSessionHook: (sessionId: string, data: SessionHookData) => void; +} + +export interface HookServer { + /** The port the server is listening on */ + port: number; + /** Stop the server */ + stop: () => void; +} + +/** + * Start a dedicated HTTP server for receiving Claude session hooks + * + * @param options - Server options including the session hook callback + * @returns Promise resolving to the server instance with port info + */ +export async function startHookServer(options: HookServerOptions): Promise { + const { onSessionHook } = options; + + return new Promise((resolve, reject) => { + const server: Server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + // Only handle POST to /hook/session-start + if (req.method === 'POST' && req.url === '/hook/session-start') { + // Set timeout to prevent hanging if Claude doesn't close stdin + const timeout = setTimeout(() => { + if (!res.headersSent) { + logger.debug('[hookServer] Request timeout'); + res.writeHead(408).end('timeout'); + } + }, 5000); + + try { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + clearTimeout(timeout); + + const body = Buffer.concat(chunks).toString('utf-8'); + logger.debug('[hookServer] Received session hook:', body); + + let data: SessionHookData = {}; + try { + data = JSON.parse(body); + } catch (parseError) { + logger.debug('[hookServer] Failed to parse hook data as JSON:', parseError); + } + + // Support both snake_case (from Claude) and camelCase + const sessionId = data.session_id || data.sessionId; + if (sessionId) { + logger.debug(`[hookServer] Session hook received session ID: ${sessionId}`); + onSessionHook(sessionId, data); + } else { + logger.debug('[hookServer] Session hook received but no session_id found in data'); + } + + res.writeHead(200, { 'Content-Type': 'text/plain' }).end('ok'); + } catch (error) { + clearTimeout(timeout); + logger.debug('[hookServer] Error handling session hook:', error); + if (!res.headersSent) { + res.writeHead(500).end('error'); + } + } + return; + } + + // 404 for anything else + res.writeHead(404).end('not found'); + }); + + // Listen on random available port + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to get server address')); + return; + } + + const port = address.port; + logger.debug(`[hookServer] Started on port ${port}`); + + resolve({ + port, + stop: () => { + server.close(); + logger.debug('[hookServer] Stopped'); + } + }); + }); + + server.on('error', (err) => { + logger.debug('[hookServer] Server error:', err); + reject(err); + }); + }); +} +