feat: add Claude Code hooks for desktop notifications

Wire Claude Code's official hooks system (Notification, Stop) to POST
back to Claudeman's new /api/hook-event endpoint, which broadcasts SSE
events consumed by the existing NotificationManager for desktop alerts.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-24 02:46:24 +01:00
co-authored by Claude Opus 4.5
parent f818fbb234
commit b4aea57d9c
6 changed files with 378 additions and 12 deletions
+40
View File
@@ -1092,6 +1092,46 @@ class ClaudemanApp {
message: `Agent "${data.agentId}" finished successfully`,
});
});
// Hook events (from Claude Code hooks system)
this.eventSource.addEventListener('hook:idle_prompt', (e) => {
const data = JSON.parse(e.data);
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'warning',
category: 'hook-idle',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId,
title: 'Waiting for Input',
message: 'Claude is idle and waiting for a prompt',
});
});
this.eventSource.addEventListener('hook:permission_prompt', (e) => {
const data = JSON.parse(e.data);
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'critical',
category: 'hook-permission',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId,
title: 'Permission Required',
message: 'Claude needs tool approval to continue',
});
});
this.eventSource.addEventListener('hook:stop', (e) => {
const data = JSON.parse(e.data);
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'info',
category: 'hook-stop',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId,
title: 'Response Complete',
message: 'Claude has finished responding',
});
});
}
setConnectionStatus(status) {
+23
View File
@@ -26,6 +26,7 @@ import { ScreenManager } from '../screen-manager.js';
import { getStore } from '../state-store.js';
import { generateClaudeMd } from '../templates/claude-md.js';
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
import { writeHooksConfig } from '../hooks-config.js';
import { v4 as uuidv4 } from 'uuid';
import {
getErrorMessage,
@@ -39,6 +40,7 @@ import {
type QuickStartRequest,
type CreateScheduledRunRequest,
type QuickRunRequest,
type HookEventRequest,
type ApiResponse,
type SessionResponse,
type QuickStartResponse,
@@ -1081,6 +1083,9 @@ export class WebServer extends EventEmitter {
// Write .mcp.json for Claude Code to discover spawn tools
this.writeMcpConfig(casePath);
// Write .claude/settings.local.json with hooks for desktop notifications
writeHooksConfig(casePath);
this.broadcast('case:created', { name, path: casePath });
return { success: true, case: { name, path: casePath } };
@@ -1221,6 +1226,9 @@ export class WebServer extends EventEmitter {
// Write .mcp.json for Claude Code to discover spawn tools
this.writeMcpConfig(casePath);
// Write .claude/settings.local.json with hooks for desktop notifications
writeHooksConfig(casePath);
this.broadcast('case:created', { name: caseName, path: casePath });
} catch (err) {
return { success: false, error: `Failed to create case: ${getErrorMessage(err)}` };
@@ -1447,6 +1455,21 @@ export class WebServer extends EventEmitter {
}
return { success: true, data: { agentId } };
});
// ========== Hook Events ==========
this.app.post('/api/hook-event', async (req) => {
const { event, sessionId } = req.body as HookEventRequest;
const validEvents = ['idle_prompt', 'permission_prompt', 'stop'] as const;
if (!event || !validEvents.includes(event as typeof validEvents[number])) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid event type');
}
if (!sessionId || !this.sessions.has(sessionId)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
this.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now() });
return { success: true };
});
}
/** Persists full session state including respawn config to state.json */