mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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 => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user