diff --git a/cli/src/agent/sessionBase.ts b/cli/src/agent/sessionBase.ts index 5cd1c701..2a5c422d 100644 --- a/cli/src/agent/sessionBase.ts +++ b/cli/src/agent/sessionBase.ts @@ -29,6 +29,7 @@ export class AgentSessionBase { mode: 'local' | 'remote' = 'local'; thinking: boolean = false; + private sessionFoundCallbacks: ((sessionId: string) => void)[] = []; private readonly applySessionIdToMetadata: (metadata: Metadata, sessionId: string) => Metadata; private readonly sessionLabel: string; private readonly sessionIdLabel: string; @@ -68,6 +69,21 @@ export class AgentSessionBase { this.sessionId = sessionId; this.client.updateMetadata((metadata) => this.applySessionIdToMetadata(metadata, sessionId)); logger.debug(`[${this.sessionLabel}] ${this.sessionIdLabel} session ID ${sessionId} added to metadata`); + + for (const callback of this.sessionFoundCallbacks) { + callback(sessionId); + } + }; + + addSessionFoundCallback = (callback: (sessionId: string) => void): void => { + this.sessionFoundCallbacks.push(callback); + }; + + removeSessionFoundCallback = (callback: (sessionId: string) => void): void => { + const index = this.sessionFoundCallbacks.indexOf(callback); + if (index !== -1) { + this.sessionFoundCallbacks.splice(index, 1); + } }; stopKeepAlive = (): void => { diff --git a/cli/src/claude/claudeLocal.ts b/cli/src/claude/claudeLocal.ts index 50703417..331f0dda 100644 --- a/cli/src/claude/claudeLocal.ts +++ b/cli/src/claude/claudeLocal.ts @@ -16,6 +16,7 @@ export async function claudeLocal(opts: { claudeEnvVars?: Record, claudeArgs?: string[] allowedTools?: string[] + hookSettingsPath: string }) { // Ensure project directory exists @@ -73,6 +74,10 @@ export async function claudeLocal(opts: { args.push(...opts.claudeArgs) } + // Add hook settings for session tracking + args.push('--settings', opts.hookSettingsPath) + logger.debug(`[ClaudeLocal] Using hook settings: ${opts.hookSettingsPath}`); + // Prepare environment variables // Note: Local mode uses global Claude installation with --session-id flag const env = { diff --git a/cli/src/claude/claudeLocalLauncher.ts b/cli/src/claude/claudeLocalLauncher.ts index f8a656f4..f3d1ae64 100644 --- a/cli/src/claude/claudeLocalLauncher.ts +++ b/cli/src/claude/claudeLocalLauncher.ts @@ -18,6 +18,11 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' | } }); + const handleSessionFound = (sessionId: string) => { + scanner.onNewSession(sessionId); + }; + session.addSessionFoundCallback(handleSessionFound); + // Handle abort let exitReason: 'switch' | 'exit' | null = null; @@ -78,7 +83,6 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' | // Handle session start const handleSessionStart = (sessionId: string) => { session.onSessionFound(sessionId); - scanner.onNewSession(sessionId); } // Run local mode @@ -100,6 +104,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 @@ -133,6 +138,7 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' | session.queue.setOnMessage(null); // Cleanup + session.removeSessionFoundCallback(handleSessionFound); await scanner.cleanup(); } diff --git a/cli/src/claude/claudeRemote.ts b/cli/src/claude/claudeRemote.ts index 23dfff74..023b4c65 100644 --- a/cli/src/claude/claudeRemote.ts +++ b/cli/src/claude/claudeRemote.ts @@ -19,6 +19,7 @@ export async function claudeRemote(opts: { claudeEnvVars?: Record, claudeArgs?: string[], allowedTools: string[], + hookSettingsPath: string, signal?: AbortSignal, canCallTool: (toolName: string, input: unknown, mode: EnhancedMode, options: { signal: AbortSignal }) => Promise, @@ -122,6 +123,7 @@ export async function claudeRemote(opts: { executable: process.execPath, abort: opts.signal, pathToClaudeCodeExecutable: 'claude', + settingsPath: opts.hookSettingsPath, } // Track thinking state diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index 2e9cc3c7..a3af3262 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -116,6 +116,11 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | version: process.env.npm_package_version }, permissionHandler.getResponses()); + const handleSessionFound = (sessionId: string) => { + sdkToLogConverter.updateSessionId(sessionId); + }; + session.addSessionFoundCallback(handleSessionFound); + // Handle messages let planModeToolCalls = new Set(); @@ -329,6 +334,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); @@ -363,8 +369,6 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | return null; }, onSessionFound: (sessionId) => { - // Update converter's session ID when new session is found - sdkToLogConverter.updateSessionId(sessionId); session.onSessionFound(sessionId); }, onThinkingChange: session.onThinkingChange, @@ -431,6 +435,8 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | } } finally { + session.removeSessionFoundCallback(handleSessionFound); + // Clean up permission handler permissionHandler.reset(); diff --git a/cli/src/claude/loop.ts b/cli/src/claude/loop.ts index 1b9b2d4d..de4c4e31 100644 --- a/cli/src/claude/loop.ts +++ b/cli/src/claude/loop.ts @@ -33,6 +33,7 @@ interface LoopOptions { messageQueue: MessageQueue2 allowedTools?: string[] onSessionReady?: (session: Session) => void + hookSettingsPath: string } export async function loop(opts: LoopOptions) { @@ -51,7 +52,8 @@ export async function loop(opts: LoopOptions) { messageQueue: opts.messageQueue, allowedTools: opts.allowedTools, onModeChange: opts.onModeChange, - mode: opts.startingMode + mode: opts.startingMode, + hookSettingsPath: opts.hookSettingsPath }); // Notify that session is ready diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index b7506474..47aa841a 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -17,9 +17,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 { runtimePath } from '../projectPath'; import { resolve } from 'node:path'; +import type { Session } from './session'; export interface StartOptions { model?: string @@ -125,6 +128,28 @@ export async function runClaude(options: StartOptions = {}): Promise { 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) + 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); + + 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); + logger.debug(`[START] Generated hook settings file: ${hookSettingsPath}`); + // Print log file path const logPath = logger.logFilePath; logger.infoDeveloper(`Session: ${response.id}`); @@ -306,6 +331,10 @@ export async function runClaude(options: StartOptions = {}): Promise { // Stop HAPI 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) { @@ -347,8 +376,8 @@ export async function runClaude(options: StartOptions = {}): Promise { controlledByUser: newMode === 'local' })); }, - onSessionReady: (_sessionInstance) => { - // Intentionally unused + onSessionReady: (sessionInstance) => { + currentSession = sessionInstance; }, mcpServers: { 'hapi': { @@ -358,7 +387,8 @@ export async function runClaude(options: StartOptions = {}): Promise { }, session, claudeEnvVars: options.claudeEnvVars, - claudeArgs: options.claudeArgs + claudeArgs: options.claudeArgs, + hookSettingsPath }); // Send session death message @@ -376,6 +406,11 @@ export async function runClaude(options: StartOptions = {}): Promise { happyServer.stop(); logger.debug('Stopped HAPI 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); } diff --git a/cli/src/claude/sdk/query.ts b/cli/src/claude/sdk/query.ts index 8ab9c28c..3aa9dc0b 100644 --- a/cli/src/claude/sdk/query.ts +++ b/cli/src/claude/sdk/query.ts @@ -273,6 +273,7 @@ export function query(config: { resume, model, fallbackModel, + settingsPath, strictMcpConfig, canCallTool } = {} @@ -298,6 +299,7 @@ export function query(config: { } if (continueConversation) args.push('--continue') if (resume) args.push('--resume', resume) + if (settingsPath) args.push('--settings', settingsPath) if (allowedTools.length > 0) args.push('--allowedTools', allowedTools.join(',')) if (disallowedTools.length > 0) args.push('--disallowedTools', disallowedTools.join(',')) if (mcpServers && Object.keys(mcpServers).length > 0) { diff --git a/cli/src/claude/sdk/types.ts b/cli/src/claude/sdk/types.ts index f0b85c97..f0730cb2 100644 --- a/cli/src/claude/sdk/types.ts +++ b/cli/src/claude/sdk/types.ts @@ -171,6 +171,7 @@ export interface QueryOptions { resume?: string model?: string fallbackModel?: string + settingsPath?: string strictMcpConfig?: boolean canCallTool?: CanCallToolCallback } @@ -193,4 +194,4 @@ export class AbortError extends Error { super(message) this.name = 'AbortError' } -} \ No newline at end of file +} diff --git a/cli/src/claude/session.ts b/cli/src/claude/session.ts index 5d7e4a9e..2d3015e9 100644 --- a/cli/src/claude/session.ts +++ b/cli/src/claude/session.ts @@ -9,6 +9,7 @@ export class Session extends AgentSessionBase { claudeArgs?: string[]; readonly mcpServers: Record; readonly allowedTools?: string[]; + readonly hookSettingsPath: string; constructor(opts: { api: ApiClient; @@ -23,6 +24,7 @@ export class Session extends AgentSessionBase { onModeChange: (mode: 'local' | 'remote') => void; allowedTools?: string[]; mode?: 'local' | 'remote'; + hookSettingsPath: string; }) { super({ api: opts.api, @@ -45,6 +47,7 @@ export class Session extends AgentSessionBase { this.claudeArgs = opts.claudeArgs; this.mcpServers = opts.mcpServers; this.allowedTools = opts.allowedTools; + this.hookSettingsPath = opts.hookSettingsPath; } /** diff --git a/cli/src/claude/utils/generateHookSettings.ts b/cli/src/claude/utils/generateHookSettings.ts new file mode 100644 index 00000000..61892ce3 --- /dev/null +++ b/cli/src/claude/utils/generateHookSettings.ts @@ -0,0 +1,77 @@ +/** + * 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 } from 'node:path'; +import { writeFileSync, mkdirSync, unlinkSync, existsSync } from 'node:fs'; +import { configuration } from '@/configuration'; +import { logger } from '@/ui/logger'; +import { getHappyCliCommand } from '@/utils/spawnHappyCLI'; + +function shellQuote(value: string): string { + if (value.length === 0) { + return '""'; + } + + if (/^[A-Za-z0-9_\/:=-]+$/.test(value)) { + return value; + } + + return '"' + value.replace(/(["\\$`])/g, '\\$1') + '"'; +} + +function shellJoin(parts: string[]): string { + return parts.map(shellQuote).join(' '); +} + +/** + * Generate a temporary settings file with SessionStart hook configuration. + */ +export function generateHookSettingsFile(port: number): string { + const hooksDir = join(configuration.happyHomeDir, 'tmp', 'hooks'); + mkdirSync(hooksDir, { recursive: true }); + + const filename = `session-hook-${process.pid}.json`; + const filepath = join(hooksDir, filename); + + const { command, args } = getHappyCliCommand(['hook-forwarder', String(port)]); + const hookCommand = shellJoin([command, ...args]); + + const settings = { + hooks: { + SessionStart: [ + { + matcher: '*', + hooks: [ + { + type: 'command', + command: hookCommand + } + ] + } + ] + } + }; + + writeFileSync(filepath, JSON.stringify(settings, null, 4)); + logger.debug(`[generateHookSettings] Created hook settings file: ${filepath}`); + + return filepath; +} + +/** + * Clean up the temporary hook settings file. + */ +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/cli/src/claude/utils/sessionHookForwarder.ts b/cli/src/claude/utils/sessionHookForwarder.ts new file mode 100644 index 00000000..a95779ac --- /dev/null +++ b/cli/src/claude/utils/sessionHookForwarder.ts @@ -0,0 +1,82 @@ +import { request } from 'node:http'; + +function logError(message: string, error?: unknown): void { + const detail = error instanceof Error ? error.message : (error ? String(error) : ''); + const suffix = detail ? `: ${detail}` : ''; + process.stderr.write(`[hook-forwarder] ${message}${suffix}\n`); +} + +function parsePort(value: string | undefined): number | null { + if (!value) { + return null; + } + + const port = Number.parseInt(value, 10); + if (!port || Number.isNaN(port)) { + return null; + } + + return port; +} + +export async function runSessionHookForwarder(args: string[]): Promise { + const port = parsePort(args[0]); + if (!port) { + logError('Invalid or missing port argument'); + process.exitCode = 1; + return; + } + + try { + const chunks: Buffer[] = []; + process.stdin.resume(); + for await (const chunk of process.stdin) { + if (typeof chunk === 'string') { + chunks.push(Buffer.from(chunk)); + } else { + chunks.push(chunk as Buffer); + } + } + + const body = Buffer.concat(chunks); + + let hadError = false; + await new Promise((resolve) => { + const req = request({ + host: '127.0.0.1', + port, + method: 'POST', + path: '/hook/session-start', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': body.length + } + }, (res) => { + if (res.statusCode && res.statusCode >= 400) { + hadError = true; + logError(`Hook server responded with status ${res.statusCode}`); + } + res.on('error', (error) => { + hadError = true; + logError('Error reading hook server response', error); + resolve(); + }); + res.on('end', () => resolve()); + res.resume(); + }); + + req.on('error', (error) => { + hadError = true; + logError('Failed to send hook request', error); + resolve(); + }); + req.end(body); + }); + if (hadError) { + process.exitCode = 1; + } + } catch (error) { + logError('Failed to forward session hook', error); + process.exitCode = 1; + } +} diff --git a/cli/src/claude/utils/startHookServer.test.ts b/cli/src/claude/utils/startHookServer.test.ts new file mode 100644 index 00000000..57fe4a0e --- /dev/null +++ b/cli/src/claude/utils/startHookServer.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest' +import { request } from 'node:http' +import { startHookServer, type SessionHookData } from './startHookServer' + +const sendHookRequest = async (port: number, body: string): Promise<{ statusCode?: number; body: string }> => { + return await new Promise((resolve, reject) => { + const req = request({ + host: '127.0.0.1', + port, + path: '/hook/session-start', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body) + } + }, (res) => { + const chunks: Buffer[] = [] + res.on('data', (chunk) => chunks.push(chunk as Buffer)) + res.on('error', reject) + res.on('end', () => { + resolve({ + statusCode: res.statusCode, + body: Buffer.concat(chunks).toString('utf-8') + }) + }) + }) + + req.on('error', reject) + req.end(body) + }) +} + +describe('startHookServer', () => { + it('forwards session hook payload to callback', async () => { + let received: { sessionId?: string; data?: SessionHookData } = {} + const server = await startHookServer({ + onSessionHook: (sessionId, data) => { + received = { sessionId, data } + } + }) + + try { + const body = JSON.stringify({ session_id: 'session-123', extra: 'ok' }) + const response = await sendHookRequest(server.port, body) + expect(response.statusCode).toBe(200) + } finally { + server.stop() + } + + expect(received.sessionId).toBe('session-123') + expect(received.data?.session_id).toBe('session-123') + }) + + it('returns 400 for invalid JSON payloads', async () => { + let hookCalled = false + const server = await startHookServer({ + onSessionHook: () => { + hookCalled = true + } + }) + + try { + const response = await sendHookRequest(server.port, '{"session_id":') + expect(response.statusCode).toBe(400) + expect(response.body).toBe('invalid json') + } finally { + server.stop() + } + + expect(hookCalled).toBe(false) + }) + + it('returns 422 when session_id is missing', async () => { + let hookCalled = false + const server = await startHookServer({ + onSessionHook: () => { + hookCalled = true + } + }) + + try { + const body = JSON.stringify({ extra: 'ok' }) + const response = await sendHookRequest(server.port, body) + expect(response.statusCode).toBe(422) + expect(response.body).toBe('missing session_id') + } finally { + server.stop() + } + + expect(hookCalled).toBe(false) + }) +}) diff --git a/cli/src/claude/utils/startHookServer.ts b/cli/src/claude/utils/startHookServer.ts new file mode 100644 index 00000000..e24842e4 --- /dev/null +++ b/cli/src/claude/utils/startHookServer.ts @@ -0,0 +1,137 @@ +/** + * 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. + */ + +import { createServer, type IncomingMessage, type ServerResponse, type 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. + */ +export async function startHookServer(options: HookServerOptions): Promise { + const { onSessionHook } = options; + + return new Promise((resolve, reject) => { + const server: Server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + if (req.method === 'POST' && req.url === '/hook/session-start') { + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + if (!res.headersSent) { + logger.debug('[hookServer] Request timeout'); + res.writeHead(408).end('timeout'); + } + req.destroy(new Error('Request timeout')); + }, 5000); + + try { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + clearTimeout(timeout); + + if (timedOut || res.headersSent || res.writableEnded) { + return; + } + + const body = Buffer.concat(chunks).toString('utf-8'); + logger.debug('[hookServer] Received session hook:', body); + + let data: SessionHookData = {}; + try { + const parsed = JSON.parse(body); + if (!parsed || typeof parsed !== 'object') { + logger.debug('[hookServer] Parsed hook data is not an object'); + res.writeHead(400, { 'Content-Type': 'text/plain' }).end('invalid json'); + return; + } + data = parsed as SessionHookData; + } catch (parseError) { + logger.debug('[hookServer] Failed to parse hook data as JSON:', parseError); + res.writeHead(400, { 'Content-Type': 'text/plain' }).end('invalid json'); + return; + } + + 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(422, { 'Content-Type': 'text/plain' }).end('missing session_id'); + return; + } + + if (!res.headersSent && !res.writableEnded) { + res.writeHead(200, { 'Content-Type': 'text/plain' }).end('ok'); + } + } catch (error) { + clearTimeout(timeout); + if (timedOut) { + return; + } + logger.debug('[hookServer] Error handling session hook:', error); + if (!res.headersSent && !res.writableEnded) { + res.writeHead(500).end('error'); + } + } + return; + } + + res.writeHead(404).end('not found'); + }); + + 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); + }); + }); +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 4def06ee..b08b1646 100755 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -82,6 +82,12 @@ import { withBunRuntimeEnv } from './utils/bunRuntime' return } + if (subcommand === 'hook-forwarder') { + const { runSessionHookForwarder } = await import('@/claude/utils/sessionHookForwarder') + await runSessionHookForwarder(args.slice(1)) + return + } + await ensureRuntimeAssets() logger.debug('Starting hapi CLI with args: ', process.argv)