From 76a1495dc9388d9808bbb5a2c20dfde7913959bb Mon Sep 17 00:00:00 2001 From: Scoteezy Date: Mon, 22 Dec 2025 14:38:28 +0300 Subject: [PATCH 1/3] feat: add session hook tracking for --continue/--resume support - Add dedicated HTTP server (startHookServer.ts) for receiving Claude session hooks - Generate temporary settings file with SessionStart hook configuration - Pass --settings flag to Claude CLI for hook integration - Update Session class with callback mechanism for session ID changes - Connect scanner to session callbacks for real-time session file tracking --- src/claude/claudeLocal.ts | 50 +++++++--- src/claude/claudeLocalLauncher.ts | 11 +++ src/claude/claudeRemote.ts | 3 + src/claude/claudeRemoteLauncher.ts | 1 + src/claude/loop.ts | 5 +- src/claude/runClaude.ts | 43 ++++++++- src/claude/sdk/query.ts | 4 +- src/claude/sdk/types.ts | 2 + src/claude/session.ts | 30 ++++++ src/claude/utils/generateHookSettings.ts | 68 ++++++++++++++ src/claude/utils/startHookServer.ts | 115 +++++++++++++++++++++++ 11 files changed, 313 insertions(+), 19 deletions(-) create mode 100644 src/claude/utils/generateHookSettings.ts create mode 100644 src/claude/utils/startHookServer.ts diff --git a/src/claude/claudeLocal.ts b/src/claude/claudeLocal.ts index a0b9d861..6f99b292 100644 --- a/src/claude/claudeLocal.ts +++ b/src/claude/claudeLocal.ts @@ -21,15 +21,23 @@ 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 */ + hookSettingsPath?: string }) { // Ensure project directory exists const projectDir = getProjectPath(opts.path); mkdirSync(projectDir, { recursive: true }); + // 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 session ID strategy: + // - If user passed --continue/--resume: let Claude handle session, don't add --session-id // - If resuming an existing session: use --resume (Claude keeps the same session ID) // - If starting fresh: generate UUID and pass via --session-id let startFrom = opts.sessionId; @@ -37,17 +45,20 @@ export async function claudeLocal(opts: { startFrom = null; } - // Generate new session ID if not resuming - const newSessionId = startFrom ? null : randomUUID(); - const effectiveSessionId = startFrom || newSessionId!; + // Generate new session ID only if not using user's --continue/--resume flags + const newSessionId = (startFrom || hasUserSessionControl) ? null : randomUUID(); + const effectiveSessionId = startFrom || newSessionId || null; - // Notify about session ID immediately (we know it upfront now!) + // Notify about session ID immediately (only if we know it upfront) if (newSessionId) { logger.debug(`[ClaudeLocal] Generated new session ID: ${newSessionId}`); opts.onSessionFound(newSessionId); - } else { + } else if (startFrom) { logger.debug(`[ClaudeLocal] Resuming session: ${startFrom}`); - opts.onSessionFound(startFrom!); + opts.onSessionFound(startFrom); + } else if (hasUserSessionControl) { + // Session ID will be provided by hook when Claude starts + logger.debug(`[ClaudeLocal] User passed ${hasContinueFlag ? '--continue' : '--resume'} flag, session ID will be determined by hook`); } // Thinking state @@ -70,12 +81,15 @@ export async function claudeLocal(opts: { await new Promise((r, reject) => { const args: string[] = [] - if (startFrom) { - // Resume existing session (Claude preserves the session ID) - args.push('--resume', startFrom) - } else { - // New session with our generated UUID - args.push('--session-id', newSessionId!) + // Only add session control args if user didn't pass --continue/--resume + if (!hasUserSessionControl) { + if (startFrom) { + // Resume existing session (Claude preserves the session ID) + args.push('--resume', startFrom) + } else { + // New session with our generated UUID + args.push('--session-id', newSessionId!) + } } args.push('--append-system-prompt', systemPrompt); @@ -93,6 +107,12 @@ export async function claudeLocal(opts: { args.push(...opts.claudeArgs) } + // Add hook settings for session tracking + if (opts.hookSettingsPath) { + 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.'); } @@ -207,3 +227,5 @@ export async function claudeLocal(opts: { return effectiveSessionId; } + +export type ClaudeLocalResult = string | null; 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..3ba9216a 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 */ + 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..f1a24cf2 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 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..7ff5716b 100644 --- a/src/claude/runClaude.ts +++ b/src/claude/runClaude.ts @@ -19,6 +19,8 @@ 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'; @@ -130,6 +132,30 @@ 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) + let currentSession: import('./session').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 +346,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 +391,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 +403,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 +426,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..8ecf7a88 100644 --- a/src/claude/session.ts +++ b/src/claude/session.ts @@ -14,10 +14,15 @@ export class Session { readonly mcpServers: Record; readonly allowedTools?: string[]; readonly _onModeChange: (mode: 'local' | 'remote') => void; + /** Path to temporary settings file with SessionStart hook */ + 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)[] = []; constructor(opts: { api: ApiClient, @@ -31,6 +36,8 @@ export class Session { messageQueue: MessageQueue2, onModeChange: (mode: 'local' | 'remote') => void, allowedTools?: string[], + /** Path to temporary settings file with SessionStart hook */ + hookSettingsPath?: string, }) { this.path = opts.path; this.api = opts.api; @@ -43,6 +50,7 @@ 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); @@ -71,6 +79,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); + } } /** diff --git a/src/claude/utils/generateHookSettings.ts b/src/claude/utils/generateHookSettings.ts new file mode 100644 index 00000000..d21bb2e7 --- /dev/null +++ b/src/claude/utils/generateHookSettings.ts @@ -0,0 +1,68 @@ +/** + * 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'; + +/** + * 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); + + // Node one-liner that reads stdin and POSTs it to our server + // This command is executed by Claude when SessionStart hook fires + const hookCommand = `node -e 'const http=require("http");const chunks=[];process.stdin.on("data",c=>chunks.push(c));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()});req.on("error",()=>{});req.end(body)});process.stdin.resume()'`; + + 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..3ee9a964 --- /dev/null +++ b/src/claude/utils/startHookServer.ts @@ -0,0 +1,115 @@ +/** + * 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. + */ + +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') { + try { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + 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) { + 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); + }); + }); +} + From 71f76c3b3baa9b568d38eb7ba9466d352979093a Mon Sep 17 00:00:00 2001 From: Scoteezy Date: Mon, 22 Dec 2025 14:47:43 +0300 Subject: [PATCH 2/3] fix: session hook cleanup and timeout --- scripts/session_hook_forwarder.cjs | 49 ++++++++++++++++++++++++ src/claude/claudeLocal.ts | 2 - src/claude/session.ts | 30 ++++++++++++--- src/claude/utils/generateHookSettings.ts | 9 +++-- src/claude/utils/startHookServer.ts | 11 ++++++ 5 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 scripts/session_hook_forwarder.cjs 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 6f99b292..4b436bca 100644 --- a/src/claude/claudeLocal.ts +++ b/src/claude/claudeLocal.ts @@ -227,5 +227,3 @@ export async function claudeLocal(opts: { return effectiveSessionId; } - -export type ClaudeLocalResult = string | null; diff --git a/src/claude/session.ts b/src/claude/session.ts index 8ecf7a88..078fafcf 100644 --- a/src/claude/session.ts +++ b/src/claude/session.ts @@ -23,6 +23,9 @@ export class Session { /** 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, @@ -54,10 +57,19 @@ export class Session { // 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; @@ -113,14 +125,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]; @@ -137,9 +156,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 index d21bb2e7..bd2e60de 100644 --- a/src/claude/utils/generateHookSettings.ts +++ b/src/claude/utils/generateHookSettings.ts @@ -5,10 +5,11 @@ * to notify our HTTP server when sessions change (new session, resume, compact, etc.) */ -import { join } from 'node:path'; +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 @@ -24,9 +25,9 @@ export function generateHookSettingsFile(port: number): string { const filename = `session-hook-${process.pid}.json`; const filepath = join(hooksDir, filename); - // Node one-liner that reads stdin and POSTs it to our server - // This command is executed by Claude when SessionStart hook fires - const hookCommand = `node -e 'const http=require("http");const chunks=[];process.stdin.on("data",c=>chunks.push(c));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()});req.on("error",()=>{});req.end(body)});process.stdin.resume()'`; + // Path to the hook forwarder script + const forwarderScript = resolve(projectPath(), 'scripts', 'session_hook_forwarder.cjs'); + const hookCommand = `node "${forwarderScript}" ${port}`; const settings = { hooks: { diff --git a/src/claude/utils/startHookServer.ts b/src/claude/utils/startHookServer.ts index 3ee9a964..3a101680 100644 --- a/src/claude/utils/startHookServer.ts +++ b/src/claude/utils/startHookServer.ts @@ -48,11 +48,21 @@ export async function startHookServer(options: HookServerOptions): Promise { // 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); @@ -74,6 +84,7 @@ export async function startHookServer(options: HookServerOptions): Promise Date: Mon, 22 Dec 2025 16:15:20 +0300 Subject: [PATCH 3/3] refactor: PR review fixes for Session Hook Tracking - hookSettingsPath now required (less branching) - Static import instead of dynamic for Session type - JSDoc for onSessionFound callback - Remove fake session ID generation - Add control flow docs in startHookServer.ts --- src/claude/claudeLocal.ts | 51 ++++++++++------------------- src/claude/claudeRemote.ts | 4 +-- src/claude/loop.ts | 4 +-- src/claude/runClaude.ts | 4 ++- src/claude/session.ts | 19 ++++++++--- src/claude/utils/startHookServer.ts | 50 ++++++++++++++++++++++++++++ 6 files changed, 89 insertions(+), 43 deletions(-) diff --git a/src/claude/claudeLocal.ts b/src/claude/claudeLocal.ts index 4b436bca..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"; @@ -23,8 +22,8 @@ export async function claudeLocal(opts: { claudeEnvVars?: Record, claudeArgs?: string[], allowedTools?: string[], - /** Path to temporary settings file with SessionStart hook */ - hookSettingsPath?: string + /** Path to temporary settings file with SessionStart hook (required for session tracking) */ + hookSettingsPath: string }) { // Ensure project directory exists @@ -36,29 +35,20 @@ export async function claudeLocal(opts: { const hasResumeFlag = opts.claudeArgs?.includes('--resume'); const hasUserSessionControl = hasContinueFlag || hasResumeFlag; - // Determine session ID strategy: - // - If user passed --continue/--resume: let Claude handle session, don't add --session-id - // - If resuming an existing session: use --resume (Claude keeps the same session ID) - // - If starting fresh: generate UUID and pass via --session-id + // 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 only if not using user's --continue/--resume flags - const newSessionId = (startFrom || hasUserSessionControl) ? null : randomUUID(); - const effectiveSessionId = startFrom || newSessionId || null; - // Notify about session ID immediately (only if we know it upfront) - if (newSessionId) { - logger.debug(`[ClaudeLocal] Generated new session ID: ${newSessionId}`); - opts.onSessionFound(newSessionId); - } else if (startFrom) { - logger.debug(`[ClaudeLocal] Resuming session: ${startFrom}`); - opts.onSessionFound(startFrom); + // Log session strategy + if (startFrom) { + logger.debug(`[ClaudeLocal] Will resume existing session: ${startFrom}`); } else if (hasUserSessionControl) { - // Session ID will be provided by hook when Claude starts logger.debug(`[ClaudeLocal] User passed ${hasContinueFlag ? '--continue' : '--resume'} flag, session ID will be determined by hook`); + } else { + logger.debug(`[ClaudeLocal] Fresh start, session ID will be provided by hook`); } // Thinking state @@ -81,15 +71,10 @@ export async function claudeLocal(opts: { await new Promise((r, reject) => { const args: string[] = [] - // Only add session control args if user didn't pass --continue/--resume - if (!hasUserSessionControl) { - if (startFrom) { - // Resume existing session (Claude preserves the session ID) - args.push('--resume', startFrom) - } else { - // New session with our generated UUID - args.push('--session-id', newSessionId!) - } + // 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) } args.push('--append-system-prompt', systemPrompt); @@ -107,11 +92,9 @@ export async function claudeLocal(opts: { args.push(...opts.claudeArgs) } - // Add hook settings for session tracking - if (opts.hookSettingsPath) { - args.push('--settings', opts.hookSettingsPath); - logger.debug(`[ClaudeLocal] Using hook settings: ${opts.hookSettingsPath}`); - } + // 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.'); @@ -225,5 +208,5 @@ export async function claudeLocal(opts: { updateThinking(false); } - return effectiveSessionId; + return startFrom; } diff --git a/src/claude/claudeRemote.ts b/src/claude/claudeRemote.ts index 3ba9216a..5c9698dc 100644 --- a/src/claude/claudeRemote.ts +++ b/src/claude/claudeRemote.ts @@ -22,8 +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 */ - hookSettingsPath?: string, + /** Path to temporary settings file with SessionStart hook (required for session tracking) */ + hookSettingsPath: string, // Dynamic parameters nextMessage: () => Promise<{ message: string, mode: EnhancedMode } | null>, diff --git a/src/claude/loop.ts b/src/claude/loop.ts index f1a24cf2..b3d1f7e0 100644 --- a/src/claude/loop.ts +++ b/src/claude/loop.ts @@ -32,8 +32,8 @@ interface LoopOptions { messageQueue: MessageQueue2 allowedTools?: string[] onSessionReady?: (session: Session) => void - /** Path to temporary settings file with SessionStart hook for session tracking */ - hookSettingsPath?: string + /** Path to temporary settings file with SessionStart hook (required for session tracking) */ + hookSettingsPath: string } export async function loop(opts: LoopOptions) { diff --git a/src/claude/runClaude.ts b/src/claude/runClaude.ts index 7ff5716b..a0adcc5c 100644 --- a/src/claude/runClaude.ts +++ b/src/claude/runClaude.ts @@ -24,6 +24,7 @@ import { generateHookSettingsFile, cleanupHookSettingsFile } from '@/claude/util import { registerKillSessionHandler } from './registerKillSessionHandler'; import { projectPath } from '../projectPath'; import { resolve } from 'node:path'; +import { Session } from './session'; export interface StartOptions { model?: string @@ -133,7 +134,8 @@ export async function runClaude(credentials: Credentials, options: StartOptions logger.debug(`[START] Happy MCP server started at ${happyServer.url}`); // Variable to track current session instance (updated via onSessionReady callback) - let currentSession: import('./session').Session | null = null; + // 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({ diff --git a/src/claude/session.ts b/src/claude/session.ts index 078fafcf..7a5b7fe7 100644 --- a/src/claude/session.ts +++ b/src/claude/session.ts @@ -14,8 +14,8 @@ export class Session { readonly mcpServers: Record; readonly allowedTools?: string[]; readonly _onModeChange: (mode: 'local' | 'remote') => void; - /** Path to temporary settings file with SessionStart hook */ - readonly hookSettingsPath?: string; + /** Path to temporary settings file with SessionStart hook (required for session tracking) */ + readonly hookSettingsPath: string; sessionId: string | null; mode: 'local' | 'remote' = 'local'; @@ -39,8 +39,8 @@ export class Session { messageQueue: MessageQueue2, onModeChange: (mode: 'local' | 'remote') => void, allowedTools?: string[], - /** Path to temporary settings file with SessionStart hook */ - hookSettingsPath?: string, + /** Path to temporary settings file with SessionStart hook (required for session tracking) */ + hookSettingsPath: string, }) { this.path = opts.path; this.api = opts.api; @@ -82,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; diff --git a/src/claude/utils/startHookServer.ts b/src/claude/utils/startHookServer.ts index 3a101680..d7ce813f 100644 --- a/src/claude/utils/startHookServer.ts +++ b/src/claude/utils/startHookServer.ts @@ -5,6 +5,56 @@ * (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';