feat: Implement Worker Service with session management and SDK integration
- Added WorkerService to handle long-running HTTP service with session management. - Implemented endpoints for initializing, observing, finalizing, checking status, and deleting sessions. - Integrated with Claude SDK for processing observations and generating responses. - Added port allocator utility to dynamically find available ports for the service. - Configured TypeScript settings for the project.
This commit is contained in:
+24
-48
@@ -1,6 +1,4 @@
|
||||
import { existsSync, unlinkSync } from 'fs';
|
||||
import { HooksDatabase } from '../services/sqlite/HooksDatabase.js';
|
||||
import { getWorkerSocketPath } from '../shared/paths.js';
|
||||
|
||||
export interface SessionEndInput {
|
||||
session_id: string;
|
||||
@@ -12,15 +10,14 @@ export interface SessionEndInput {
|
||||
|
||||
/**
|
||||
* Cleanup Hook - SessionEnd
|
||||
* Cleans up worker process and marks session as terminated
|
||||
* Cleans up worker session via HTTP DELETE
|
||||
*
|
||||
* This hook runs when a Claude Code session ends. It:
|
||||
* 1. Finds active SDK session for this Claude session
|
||||
* 2. Terminates worker process if still running
|
||||
* 3. Removes stale socket file
|
||||
* 4. Marks session as failed (since no Stop hook completed it)
|
||||
* 2. Sends DELETE request to worker service
|
||||
* 3. Marks session as failed if not already completed
|
||||
*/
|
||||
export function cleanupHook(input?: SessionEndInput): void {
|
||||
export async function cleanupHook(input?: SessionEndInput): Promise<void> {
|
||||
try {
|
||||
// Log hook entry point
|
||||
console.error('[claude-mem cleanup] Hook fired', {
|
||||
@@ -63,57 +60,36 @@ export function cleanupHook(input?: SessionEndInput): void {
|
||||
console.error('[claude-mem cleanup] Active SDK session found', {
|
||||
session_id: session.id,
|
||||
sdk_session_id: session.sdk_session_id,
|
||||
project: session.project
|
||||
project: session.project,
|
||||
worker_port: session.worker_port
|
||||
});
|
||||
|
||||
// Get worker PID and socket path
|
||||
const socketPath = getWorkerSocketPath(session.id);
|
||||
// 1. Delete session via HTTP
|
||||
if (session.worker_port) {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${session.worker_port}/sessions/${session.id}`, {
|
||||
method: 'DELETE',
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
|
||||
// 1. Kill worker process if it exists
|
||||
try {
|
||||
// Try to read PID from socket file existence
|
||||
if (existsSync(socketPath)) {
|
||||
console.error('[claude-mem cleanup] Socket file exists, attempting cleanup', { socketPath });
|
||||
|
||||
// Remove socket file
|
||||
try {
|
||||
unlinkSync(socketPath);
|
||||
console.error('[claude-mem cleanup] Socket file removed successfully', { socketPath });
|
||||
} catch (unlinkErr: any) {
|
||||
console.error('[claude-mem cleanup] Failed to remove socket file', {
|
||||
error: unlinkErr.message,
|
||||
socketPath
|
||||
});
|
||||
if (response.ok) {
|
||||
console.error('[claude-mem cleanup] Session deleted successfully via HTTP');
|
||||
} else {
|
||||
console.error('[claude-mem cleanup] Failed to delete session:', await response.text());
|
||||
}
|
||||
} else {
|
||||
console.error('[claude-mem cleanup] Socket file does not exist', { socketPath });
|
||||
} catch (error: any) {
|
||||
console.error('[claude-mem cleanup] HTTP DELETE error:', error.message);
|
||||
}
|
||||
|
||||
// Note: We don't kill the worker process here because:
|
||||
// 1. Workers have a 2-hour watchdog timer that will kill them automatically
|
||||
// 2. Killing by PID is fragile (PID might be reused)
|
||||
// 3. The worker will exit on its own when it can't reach the socket
|
||||
// We just clean up the socket file to prevent stale socket issues
|
||||
|
||||
} catch (cleanupErr: any) {
|
||||
console.error('[claude-mem cleanup] Error during cleanup', {
|
||||
error: cleanupErr.message,
|
||||
stack: cleanupErr.stack
|
||||
});
|
||||
} else {
|
||||
console.error('[claude-mem cleanup] No worker port, cannot send DELETE request');
|
||||
}
|
||||
|
||||
// 2. Mark session as failed (since Stop hook didn't complete it)
|
||||
// 2. Mark session as failed in DB (if not already completed)
|
||||
try {
|
||||
db.markSessionFailed(session.id);
|
||||
console.error('[claude-mem cleanup] Session marked as failed', {
|
||||
session_id: session.id,
|
||||
reason: 'SessionEnd hook - session terminated without completion'
|
||||
});
|
||||
console.error('[claude-mem cleanup] Session marked as failed in database');
|
||||
} catch (markErr: any) {
|
||||
console.error('[claude-mem cleanup] Failed to mark session as failed', {
|
||||
error: markErr.message,
|
||||
session_id: session.id
|
||||
});
|
||||
console.error('[claude-mem cleanup] Failed to mark session as failed:', markErr);
|
||||
}
|
||||
|
||||
db.close();
|
||||
|
||||
+35
-1
@@ -24,8 +24,9 @@ export function contextHook(input?: SessionStartInput): void {
|
||||
|
||||
try {
|
||||
const summaries = db.getRecentSummaries(project, 5);
|
||||
const observations = db.getRecentObservations(project, 20);
|
||||
|
||||
if (summaries.length === 0) {
|
||||
if (summaries.length === 0 && observations.length === 0) {
|
||||
// Output directly to stdout for injection into context
|
||||
console.log('# Recent Session Context\n\nNo previous sessions found for this project yet.');
|
||||
return;
|
||||
@@ -34,6 +35,39 @@ export function contextHook(input?: SessionStartInput): void {
|
||||
const output: string[] = [];
|
||||
output.push('# Recent Session Context');
|
||||
output.push('');
|
||||
|
||||
// Show observations first
|
||||
if (observations.length > 0) {
|
||||
output.push(`## Recent Observations (${observations.length})`);
|
||||
output.push('');
|
||||
|
||||
// Group observations by type
|
||||
const byType: Record<string, Array<{text: string; created_at: string}>> = {};
|
||||
for (const obs of observations) {
|
||||
if (!byType[obs.type]) byType[obs.type] = [];
|
||||
byType[obs.type].push({ text: obs.text, created_at: obs.created_at });
|
||||
}
|
||||
|
||||
// Display each type
|
||||
const typeOrder = ['feature', 'bugfix', 'refactor', 'discovery', 'decision'];
|
||||
for (const type of typeOrder) {
|
||||
if (byType[type] && byType[type].length > 0) {
|
||||
output.push(`### ${type.charAt(0).toUpperCase() + type.slice(1)}s`);
|
||||
for (const obs of byType[type]) {
|
||||
output.push(`- ${obs.text}`);
|
||||
}
|
||||
output.push('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (summaries.length === 0) {
|
||||
console.log(output.join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
output.push('## Recent Sessions');
|
||||
output.push('');
|
||||
const sessionWord = summaries.length === 1 ? 'session' : 'sessions';
|
||||
output.push(`Showing last ${summaries.length} ${sessionWord} for **${project}**:`);
|
||||
output.push('');
|
||||
|
||||
+63
-15
@@ -1,4 +1,3 @@
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import { HooksDatabase } from '../services/sqlite/HooksDatabase.js';
|
||||
import { createHookResponse } from './hook-response.js';
|
||||
@@ -11,10 +10,32 @@ export interface UserPromptSubmitInput {
|
||||
}
|
||||
|
||||
/**
|
||||
* New Hook - UserPromptSubmit
|
||||
* Initializes SDK memory session in background
|
||||
* Get worker service port from file
|
||||
*/
|
||||
export function newHook(input?: UserPromptSubmitInput): void {
|
||||
async function getWorkerPort(): Promise<number | null> {
|
||||
const { readFileSync, existsSync } = await import('fs');
|
||||
const { join } = await import('path');
|
||||
const { homedir } = await import('os');
|
||||
|
||||
const portFile = join(homedir(), '.claude-mem', 'worker.port');
|
||||
|
||||
if (!existsSync(portFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const portStr = readFileSync(portFile, 'utf8').trim();
|
||||
return parseInt(portStr, 10);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* New Hook - UserPromptSubmit
|
||||
* Initializes SDK memory session via HTTP POST to worker service
|
||||
*/
|
||||
export async function newHook(input?: UserPromptSubmitInput): Promise<void> {
|
||||
if (!input) {
|
||||
throw new Error('newHook requires input');
|
||||
}
|
||||
@@ -24,30 +45,57 @@ export function newHook(input?: UserPromptSubmitInput): void {
|
||||
const db = new HooksDatabase();
|
||||
|
||||
try {
|
||||
const existing = db.findActiveSDKSession(session_id);
|
||||
// Check for any existing session (active, failed, or completed)
|
||||
let existing = db.findActiveSDKSession(session_id);
|
||||
let sessionDbId: number;
|
||||
|
||||
if (existing) {
|
||||
// Session already active, just continue
|
||||
sessionDbId = existing.id;
|
||||
console.log(createHookResponse('UserPromptSubmit', true));
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = db.createSDKSession(session_id, project, prompt);
|
||||
// Check for inactive sessions we can reuse
|
||||
const inactive = db.findAnySDKSession(session_id);
|
||||
|
||||
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
|
||||
if (!pluginRoot) {
|
||||
throw new Error('CLAUDE_PLUGIN_ROOT not set');
|
||||
if (inactive) {
|
||||
// Reactivate the existing session
|
||||
sessionDbId = inactive.id;
|
||||
db.reactivateSession(sessionDbId, prompt);
|
||||
console.error(`[new-hook] Reactivated session ${sessionDbId} for Claude session ${session_id}`);
|
||||
} else {
|
||||
// Create new session
|
||||
sessionDbId = db.createSDKSession(session_id, project, prompt);
|
||||
console.error(`[new-hook] Created new session ${sessionDbId} for Claude session ${session_id}`);
|
||||
}
|
||||
|
||||
const workerPath = path.join(pluginRoot, 'scripts', 'hooks', 'worker.js');
|
||||
const child = spawn('bun', [workerPath, sessionId.toString()], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
// Find worker service port
|
||||
const port = await getWorkerPort();
|
||||
if (!port) {
|
||||
console.error('[new-hook] Worker service not running. Start with: npm run worker:start');
|
||||
console.log(createHookResponse('UserPromptSubmit', true)); // Don't block Claude
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize session via HTTP
|
||||
const response = await fetch(`http://127.0.0.1:${port}/sessions/${sessionDbId}/init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ project, userPrompt: prompt }),
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
|
||||
child.unref();
|
||||
if (!response.ok) {
|
||||
console.error('[new-hook] Failed to init session:', await response.text());
|
||||
}
|
||||
|
||||
console.log(createHookResponse('UserPromptSubmit', true));
|
||||
} catch (error: any) {
|
||||
console.error('[new-hook] FATAL ERROR:', error.message);
|
||||
console.error('[new-hook] Stack:', error.stack);
|
||||
console.error('[new-hook] Full error:', JSON.stringify(error, Object.getOwnPropertyNames(error)));
|
||||
console.log(createHookResponse('UserPromptSubmit', true)); // Don't block Claude
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
+26
-26
@@ -1,6 +1,4 @@
|
||||
import net from 'net';
|
||||
import { HooksDatabase } from '../services/sqlite/HooksDatabase.js';
|
||||
import { getWorkerSocketPath } from '../shared/paths.js';
|
||||
import { createHookResponse } from './hook-response.js';
|
||||
|
||||
export interface PostToolUseInput {
|
||||
@@ -20,9 +18,9 @@ const SKIP_TOOLS = new Set([
|
||||
|
||||
/**
|
||||
* Save Hook - PostToolUse
|
||||
* Sends tool observations to worker via Unix socket
|
||||
* Sends tool observations to worker via HTTP POST
|
||||
*/
|
||||
export function saveHook(input?: PostToolUseInput): void {
|
||||
export async function saveHook(input?: PostToolUseInput): Promise<void> {
|
||||
if (!input) {
|
||||
throw new Error('saveHook requires input');
|
||||
}
|
||||
@@ -43,28 +41,30 @@ export function saveHook(input?: PostToolUseInput): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const socketPath = getWorkerSocketPath(session.id);
|
||||
const message = {
|
||||
type: 'observation',
|
||||
tool_name,
|
||||
tool_input: JSON.stringify(tool_input),
|
||||
tool_output: JSON.stringify(tool_output)
|
||||
};
|
||||
|
||||
const client = net.connect(socketPath, () => {
|
||||
client.write(JSON.stringify(message) + '\n');
|
||||
client.end();
|
||||
});
|
||||
|
||||
let responded = false;
|
||||
const respond = () => {
|
||||
if (responded) {
|
||||
return;
|
||||
}
|
||||
responded = true;
|
||||
if (!session.worker_port) {
|
||||
console.error('[save-hook] No worker port for session', session.id);
|
||||
console.log(createHookResponse('PostToolUse', true));
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
client.on('close', respond);
|
||||
client.on('error', respond);
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${session.worker_port}/sessions/${session.id}/observations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tool_name,
|
||||
tool_input: JSON.stringify(tool_input),
|
||||
tool_output: JSON.stringify(tool_output)
|
||||
}),
|
||||
signal: AbortSignal.timeout(2000)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[save-hook] Failed to send observation:', await response.text());
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[save-hook] Error:', error.message);
|
||||
} finally {
|
||||
console.log(createHookResponse('PostToolUse', true));
|
||||
}
|
||||
}
|
||||
|
||||
+21
-23
@@ -1,6 +1,4 @@
|
||||
import net from 'net';
|
||||
import { HooksDatabase } from '../services/sqlite/HooksDatabase.js';
|
||||
import { getWorkerSocketPath } from '../shared/paths.js';
|
||||
import { createHookResponse } from './hook-response.js';
|
||||
|
||||
export interface StopInput {
|
||||
@@ -11,9 +9,9 @@ export interface StopInput {
|
||||
|
||||
/**
|
||||
* Summary Hook - Stop
|
||||
* Sends FINALIZE message to worker via Unix socket
|
||||
* Sends FINALIZE message to worker via HTTP POST
|
||||
*/
|
||||
export function summaryHook(input?: StopInput): void {
|
||||
export async function summaryHook(input?: StopInput): Promise<void> {
|
||||
if (!input) {
|
||||
throw new Error('summaryHook requires input');
|
||||
}
|
||||
@@ -28,25 +26,25 @@ export function summaryHook(input?: StopInput): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const socketPath = getWorkerSocketPath(session.id);
|
||||
const message = {
|
||||
type: 'finalize'
|
||||
};
|
||||
|
||||
const client = net.connect(socketPath, () => {
|
||||
client.write(JSON.stringify(message) + '\n');
|
||||
client.end();
|
||||
});
|
||||
|
||||
let responded = false;
|
||||
const respond = () => {
|
||||
if (responded) {
|
||||
return;
|
||||
}
|
||||
responded = true;
|
||||
if (!session.worker_port) {
|
||||
console.error('[summary-hook] No worker port for session', session.id);
|
||||
console.log(createHookResponse('Stop', true));
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
client.on('close', respond);
|
||||
client.on('error', respond);
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${session.worker_port}/sessions/${session.id}/finalize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: AbortSignal.timeout(2000)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[summary-hook] Failed to finalize:', await response.text());
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[summary-hook] Error:', error.message);
|
||||
} finally {
|
||||
console.log(createHookResponse('Stop', true));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user