diff --git a/src/app/api/hermes/events/route.ts b/src/app/api/hermes/events/route.ts new file mode 100644 index 000000000..fc8b47e66 --- /dev/null +++ b/src/app/api/hermes/events/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireRole } from '@/lib/auth' +import { eventBus } from '@/lib/event-bus' +import { logger } from '@/lib/logger' +import { getDatabase, db_helpers } from '@/lib/db' + +/** + * POST /api/hermes/events — Receive events from the Hermes Agent hook. + * + * The MC hook (installed at ~/.hermes/hooks/mission-control/) posts events + * here for: session:start, session:end, agent:start, agent:end. + */ +export async function POST(request: NextRequest) { + const auth = requireRole(request, 'viewer') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + try { + const body = await request.json() + const { event, session_id, source, timestamp, agent_name } = body + const workspaceId = auth.user.workspace_id ?? 1 + + if (!event) { + return NextResponse.json({ error: 'event field is required' }, { status: 400 }) + } + + logger.info({ event, session_id, source, agent_name }, 'Hermes event received') + + // Store event in activity log + db_helpers.logActivity( + `hermes.${event}`, + 'session', + 0, + agent_name || 'hermes', + `Hermes ${event}: ${session_id || 'unknown'} via ${source || 'cli'}`, + { session_id, source, timestamp, agent_name }, + workspaceId + ) + + // Broadcast to SSE clients + eventBus.broadcast('session.updated', { + source: 'hermes', + event, + session_id, + hermes_source: source, + timestamp: timestamp || new Date().toISOString(), + }) + + // Update agent status on agent lifecycle events + if (event === 'agent:start' || event === 'agent:end') { + const db = getDatabase() + const agentName = agent_name || 'hermes' + const status = event === 'agent:start' ? 'online' : 'idle' + db.prepare( + 'UPDATE agents SET status = ?, updated_at = ? WHERE name = ? AND workspace_id = ?' + ).run(status, Math.floor(Date.now() / 1000), agentName, workspaceId) + } + + return NextResponse.json({ ok: true, event }) + } catch (error) { + logger.error({ err: error }, 'POST /api/hermes/events error') + return NextResponse.json({ error: 'Failed to process event' }, { status: 500 }) + } +} + +export const dynamic = 'force-dynamic' diff --git a/src/components/panels/task-board-panel.tsx b/src/components/panels/task-board-panel.tsx index b897eee3b..9638ed839 100644 --- a/src/components/panels/task-board-panel.tsx +++ b/src/components/panels/task-board-panel.tsx @@ -1968,9 +1968,11 @@ function ClaudeCodeTasksSection() {
{tasks.map((task: any) => ( -
- {task.status} - {task.subject} +
+ + {task.stale ? 'stale' : task.status} + + {task.subject} {task.owner && {task.owner}} {task.blockedBy?.length > 0 && ( {t('blocked')} diff --git a/src/lib/claude-tasks.ts b/src/lib/claude-tasks.ts index 4c816be4c..04646f1e6 100644 --- a/src/lib/claude-tasks.ts +++ b/src/lib/claude-tasks.ts @@ -23,6 +23,7 @@ export interface ClaudeCodeTask { blocks: string[] blockedBy: string[] activeForm?: string + stale?: boolean } export interface ClaudeCodeTeam { @@ -120,9 +121,19 @@ function scanTasks(claudeHome: string): ClaudeCodeTask[] { } for (const file of files) { - const data = safeParse(join(teamDir, file)) + const filePath = join(teamDir, file) + const data = safeParse(filePath) if (!data?.id) continue + // Detect stale in_progress tasks: file not modified in 60+ minutes + let stale = false + if (data.status === 'in_progress') { + try { + const mtime = statSync(filePath).mtimeMs + stale = Date.now() - mtime > 60 * 60 * 1000 + } catch { /* ignore */ } + } + tasks.push({ id: `${teamName}/${data.id}`, teamName, @@ -133,6 +144,7 @@ function scanTasks(claudeHome: string): ClaudeCodeTask[] { blocks: Array.isArray(data.blocks) ? data.blocks : [], blockedBy: Array.isArray(data.blockedBy) ? data.blockedBy : [], activeForm: data.activeForm, + stale, }) } } diff --git a/src/lib/event-bus.ts b/src/lib/event-bus.ts index aedca9d7d..a76aefb34 100644 --- a/src/lib/event-bus.ts +++ b/src/lib/event-bus.ts @@ -37,6 +37,7 @@ export type EventType = | 'run.completed' | 'run.eval_attached' | 'task.escalated' + | 'session.updated' class ServerEventBus extends EventEmitter { private static instance: ServerEventBus | null = null diff --git a/src/lib/hermes-tasks.ts b/src/lib/hermes-tasks.ts index 352e23b3c..4c4fc173d 100644 --- a/src/lib/hermes-tasks.ts +++ b/src/lib/hermes-tasks.ts @@ -21,6 +21,7 @@ export interface HermesCronJob { lastRunAt: string | null lastOutput: string | null createdAt: string | null + runCount: number } export interface HermesTaskScanResult { @@ -31,18 +32,18 @@ function getHermesCronDir(): string { return join(config.homeDir, '.hermes', 'cron') } -function peekLatestOutput(cronDir: string, jobId: string): { lastRunAt: string | null; lastOutput: string | null } { +function peekLatestOutput(cronDir: string, jobId: string): { lastRunAt: string | null; lastOutput: string | null; runCount: number } { const outputDir = join(cronDir, 'output', jobId) try { if (!existsSync(outputDir) || !statSync(outputDir).isDirectory()) { - return { lastRunAt: null, lastOutput: null } + return { lastRunAt: null, lastOutput: null, runCount: 0 } } const files = readdirSync(outputDir) .filter(f => f.endsWith('.md')) .sort() .reverse() - if (files.length === 0) return { lastRunAt: null, lastOutput: null } + if (files.length === 0) return { lastRunAt: null, lastOutput: null, runCount: 0 } const latestFile = files[0] // Filename is typically a timestamp like 2025-01-15T10-30-00.md @@ -61,9 +62,10 @@ function peekLatestOutput(cronDir: string, jobId: string): { lastRunAt: string | return { lastRunAt: timestamp || null, lastOutput: content, + runCount: files.length, } } catch { - return { lastRunAt: null, lastOutput: null } + return { lastRunAt: null, lastOutput: null, runCount: 0 } } } @@ -81,7 +83,7 @@ function scanCronJobs(): HermesCronJob[] { return jobs.map((job: any) => { const id = job.id || job.name || 'unknown' - const { lastRunAt, lastOutput } = peekLatestOutput(cronDir, id) + const { lastRunAt, lastOutput, runCount } = peekLatestOutput(cronDir, id) return { id, @@ -91,6 +93,7 @@ function scanCronJobs(): HermesCronJob[] { lastRunAt: job.last_run_at || lastRunAt, lastOutput, createdAt: job.created_at || null, + runCount: runCount ?? 0, } }) } catch (err) {