mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
Interactive Claude CLI never emits session_id on stdout, so the Session's _claudeSessionId stayed pinned to the pre-/clear jsonl and the last-response viewer kept showing the old conversation. Two complementary update paths: - Session.adoptClaudeSessionId() — public setter mirroring the existing no-op-if-same guard. Called from POST /api/hook-event when Claude Code hooks carry data.session_id (works once hooks are configured). - /api/sessions/:id/last-response now resolves the active id from ~/.claude/history.jsonl before reading the transcript. This is the only source-of-truth that does not require hooks, and we intentionally don't write hooks into arbitrary user repos. History scan filters out sessionIds held by other Codeman sessions in the same cwd, and validates via jsonl mtime to avoid inheriting a dead prior session's id. Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
72 lines
2.9 KiB
TypeScript
72 lines
2.9 KiB
TypeScript
/**
|
|
* @fileoverview Hook event route.
|
|
* Receives Claude Code hook events and broadcasts to SSE clients.
|
|
* This endpoint bypasses auth (Claude Code hooks curl from localhost).
|
|
*/
|
|
|
|
import { FastifyInstance } from 'fastify';
|
|
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
|
import { HookEventSchema, isValidWorkingDir } from '../schemas.js';
|
|
import { sanitizeHookData, parseBody } from '../route-helpers.js';
|
|
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
|
|
|
export function registerHookEventRoutes(
|
|
app: FastifyInstance,
|
|
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
|
|
): void {
|
|
app.post('/api/hook-event', async (req) => {
|
|
const { event, sessionId, data } = parseBody(HookEventSchema, req.body);
|
|
if (!ctx.sessions.has(sessionId)) {
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
}
|
|
|
|
// Signal the respawn controller based on hook event type
|
|
const controller = ctx.respawnControllers.get(sessionId);
|
|
if (controller) {
|
|
if (event === 'elicitation_dialog') {
|
|
// Block auto-accept for question prompts
|
|
controller.signalElicitation();
|
|
} else if (event === 'stop') {
|
|
// DEFINITIVE idle signal - Claude finished responding
|
|
controller.signalStopHook();
|
|
} else if (event === 'idle_prompt') {
|
|
// DEFINITIVE idle signal - Claude has been idle for 60+ seconds
|
|
controller.signalIdlePrompt();
|
|
}
|
|
}
|
|
|
|
// Start transcript watching if transcript_path is provided and safe
|
|
if (data && 'transcript_path' in data) {
|
|
const transcriptPath = String(data.transcript_path);
|
|
if (transcriptPath && isValidWorkingDir(transcriptPath)) {
|
|
ctx.startTranscriptWatcher(sessionId, transcriptPath);
|
|
}
|
|
}
|
|
|
|
// Sync Claude's current conversation id. Interactive PTY mode never emits
|
|
// `session_id` on stdout, so hooks are the only reliable way to learn that
|
|
// the user ran `/clear` (which spins up a new conversation jsonl).
|
|
if (data && typeof data.session_id === 'string' && data.session_id) {
|
|
const session = ctx.sessions.get(sessionId);
|
|
session?.adoptClaudeSessionId(data.session_id);
|
|
}
|
|
|
|
// Sanitize forwarded data: only include known safe fields, limit size
|
|
const safeData = sanitizeHookData(data);
|
|
ctx.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), ...safeData });
|
|
|
|
// Send push notifications for hook events
|
|
const session = ctx.sessions.get(sessionId);
|
|
const sessionName = session?.name ?? sessionId.slice(0, 8);
|
|
ctx.sendPushNotifications(`hook:${event}`, { sessionId, sessionName, ...safeData });
|
|
|
|
// Track in run summary
|
|
const summaryTracker = ctx.runSummaryTrackers.get(sessionId);
|
|
if (summaryTracker) {
|
|
summaryTracker.recordHookEvent(event, safeData);
|
|
}
|
|
|
|
return { success: true };
|
|
});
|
|
}
|