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) {