Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/app/api/hermes/events/route.ts
Original file line number Diff line number Diff line change
@@ -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'
8 changes: 5 additions & 3 deletions src/components/panels/task-board-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1968,9 +1968,11 @@ function ClaudeCodeTasksSection() {
</div>
<div className="space-y-1">
{tasks.map((task: any) => (
<div key={task.id} className="flex items-center gap-3 px-3 py-2 rounded bg-surface-1 border border-border text-sm">
<span className={`text-[10px] font-mono ${statusColor(task.status)}`}>{task.status}</span>
<span className="text-foreground flex-1 truncate">{task.subject}</span>
<div key={task.id} className={`flex items-center gap-3 px-3 py-2 rounded bg-surface-1 border border-border text-sm ${task.stale ? 'opacity-50' : ''}`}>
<span className={`text-[10px] font-mono ${task.stale ? 'text-muted-foreground/50' : statusColor(task.status)}`}>
{task.stale ? 'stale' : task.status}
</span>
<span className={`flex-1 truncate ${task.stale ? 'text-muted-foreground' : 'text-foreground'}`}>{task.subject}</span>
{task.owner && <span className="text-[10px] text-muted-foreground">{task.owner}</span>}
{task.blockedBy?.length > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/15 text-red-400">{t('blocked')}</span>
Expand Down
14 changes: 13 additions & 1 deletion src/lib/claude-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface ClaudeCodeTask {
blocks: string[]
blockedBy: string[]
activeForm?: string
stale?: boolean
}

export interface ClaudeCodeTeam {
Expand Down Expand Up @@ -120,9 +121,19 @@ function scanTasks(claudeHome: string): ClaudeCodeTask[] {
}

for (const file of files) {
const data = safeParse<any>(join(teamDir, file))
const filePath = join(teamDir, file)
const data = safeParse<any>(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,
Expand All @@ -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,
})
}
}
Expand Down
1 change: 1 addition & 0 deletions src/lib/event-bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions src/lib/hermes-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface HermesCronJob {
lastRunAt: string | null
lastOutput: string | null
createdAt: string | null
runCount: number
}

export interface HermesTaskScanResult {
Expand All @@ -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
Expand All @@ -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 }
}
}

Expand All @@ -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,
Expand All @@ -91,6 +93,7 @@ function scanCronJobs(): HermesCronJob[] {
lastRunAt: job.last_run_at || lastRunAt,
lastOutput,
createdAt: job.created_at || null,
runCount: runCount ?? 0,
}
})
} catch (err) {
Expand Down
Loading