feat: implement session tracking via Claude hooks

Adds a dedicated hook server infrastructure to receive Claude SessionStart
notifications. This allows the CLI to track session ID changes (new sessions,
resume, compact, fork, etc.) without relying on file watchers.

Changes:
- Add SessionFound callback system to AgentSessionBase for session tracking
- Create hook server that listens for POST requests from Claude's hooks
- Generate temporary settings files with hook command configuration
- Implement session hook forwarder CLI command to relay hook data
- Integrate hook server into runClaude with proper cleanup
- Pass hookSettingsPath through local and remote execution paths
- Update SDK query builder to support --settings flag
This commit is contained in:
weishu
2025-12-23 16:02:04 +08:00
parent e637f2bb27
commit 61d3a1bed1
15 changed files with 480 additions and 8 deletions
+16
View File
@@ -29,6 +29,7 @@ export class AgentSessionBase<Mode> {
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<Mode> {
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 => {
+5
View File
@@ -16,6 +16,7 @@ export async function claudeLocal(opts: {
claudeEnvVars?: Record<string, string>,
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 = {
+7 -1
View File
@@ -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();
}
+2
View File
@@ -19,6 +19,7 @@ export async function claudeRemote(opts: {
claudeEnvVars?: Record<string, string>,
claudeArgs?: string[],
allowedTools: string[],
hookSettingsPath: string,
signal?: AbortSignal,
canCallTool: (toolName: string, input: unknown, mode: EnhancedMode, options: { signal: AbortSignal }) => Promise<PermissionResult>,
@@ -122,6 +123,7 @@ export async function claudeRemote(opts: {
executable: process.execPath,
abort: opts.signal,
pathToClaudeCodeExecutable: 'claude',
settingsPath: opts.hookSettingsPath,
}
// Track thinking state
+8 -2
View File
@@ -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<string>();
@@ -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();
+3 -1
View File
@@ -33,6 +33,7 @@ interface LoopOptions {
messageQueue: MessageQueue2<EnhancedMode>
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
+38 -3
View File
@@ -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<void> {
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<void> {
// 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<void> {
controlledByUser: newMode === 'local'
}));
},
onSessionReady: (_sessionInstance) => {
// Intentionally unused
onSessionReady: (sessionInstance) => {
currentSession = sessionInstance;
},
mcpServers: {
'hapi': {
@@ -358,7 +387,8 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
},
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<void> {
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);
}
+2
View File
@@ -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) {
+2 -1
View File
@@ -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'
}
}
}
+3
View File
@@ -9,6 +9,7 @@ export class Session extends AgentSessionBase<EnhancedMode> {
claudeArgs?: string[];
readonly mcpServers: Record<string, any>;
readonly allowedTools?: string[];
readonly hookSettingsPath: string;
constructor(opts: {
api: ApiClient;
@@ -23,6 +24,7 @@ export class Session extends AgentSessionBase<EnhancedMode> {
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<EnhancedMode> {
this.claudeArgs = opts.claudeArgs;
this.mcpServers = opts.mcpServers;
this.allowedTools = opts.allowedTools;
this.hookSettingsPath = opts.hookSettingsPath;
}
/**
@@ -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}`);
}
}
@@ -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<void> {
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<void>((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;
}
}
@@ -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)
})
})
+137
View File
@@ -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<HookServer> {
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);
});
});
}
+6
View File
@@ -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)