mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
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:
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @fileoverview Claude Code hooks configuration generator
|
||||
*
|
||||
* Generates .claude/settings.local.json with hook definitions that POST
|
||||
* to Claudeman's /api/hook-event endpoint when Claude Code fires
|
||||
* notification or stop hooks. Uses $CLAUDEMAN_API_URL and
|
||||
* $CLAUDEMAN_SESSION_ID env vars (set on every managed session) so the
|
||||
* config is static per case directory.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { HookEventType } from './types.js';
|
||||
|
||||
/**
|
||||
* Generates the hooks section for .claude/settings.local.json
|
||||
*
|
||||
* The curl commands reference env vars that are resolved at runtime by
|
||||
* the shell, so the same config works for any session in the case dir.
|
||||
*/
|
||||
export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
|
||||
const curlCmd = (event: HookEventType) =>
|
||||
`curl -s -X POST $CLAUDEMAN_API_URL/api/hook-event ` +
|
||||
`-H 'Content-Type: application/json' ` +
|
||||
`-d '{"event":"${event}","sessionId":"'$CLAUDEMAN_SESSION_ID'"}' 2>/dev/null || true`;
|
||||
|
||||
return {
|
||||
hooks: {
|
||||
Notification: [
|
||||
{
|
||||
matcher: 'idle_prompt',
|
||||
hooks: [{ type: 'command', command: curlCmd('idle_prompt'), timeout: 10000 }],
|
||||
},
|
||||
{
|
||||
matcher: 'permission_prompt',
|
||||
hooks: [{ type: 'command', command: curlCmd('permission_prompt'), timeout: 10000 }],
|
||||
},
|
||||
],
|
||||
Stop: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: curlCmd('stop'), timeout: 10000 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes hooks config to .claude/settings.local.json in the given case path.
|
||||
* Merges with existing file content, only touching the `hooks` key.
|
||||
*/
|
||||
export function writeHooksConfig(casePath: string): void {
|
||||
const claudeDir = join(casePath, '.claude');
|
||||
if (!existsSync(claudeDir)) {
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
let existing: Record<string, unknown> = {};
|
||||
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
existing = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
// If file is malformed, start fresh
|
||||
existing = {};
|
||||
}
|
||||
}
|
||||
|
||||
const hooksConfig = generateHooksConfig();
|
||||
const merged = { ...existing, ...hooksConfig };
|
||||
|
||||
writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n');
|
||||
}
|
||||
@@ -369,6 +369,21 @@ export interface QuickRunRequest {
|
||||
workingDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook event types triggered by Claude Code's hooks system
|
||||
*/
|
||||
export type HookEventType = 'idle_prompt' | 'permission_prompt' | 'stop';
|
||||
|
||||
/**
|
||||
* Request body for the hook-event API endpoint
|
||||
*/
|
||||
export interface HookEventRequest {
|
||||
/** Type of hook event that fired */
|
||||
event: HookEventType;
|
||||
/** Session ID from CLAUDEMAN_SESSION_ID env var */
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
// ========== API Response Types ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user