feat: add Koa2 BFF server, CLI management, sessions CLI integration, and logs page

- Add Koa2 BFF layer for API proxy, file upload, session management
- Auto-check and enable api_server in ~/.hermes/config.yaml on startup
- Integrate sessions with Hermes CLI (list, get, delete)
- Add Logs page with level filtering, log file selection, and search
- Add CLI commands: start/stop/restart/status for daemon management
- Unify package.json for frontend and server dependencies
- Default port changed to 8648

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-11 21:33:04 +08:00
parent a2f8f6aec5
commit ee9f56dfbd
25 changed files with 1613 additions and 713 deletions
+61
View File
@@ -0,0 +1,61 @@
import Router from '@koa/router'
import * as hermesCli from '../services/hermes-cli'
export const logRoutes = new Router()
// List available log files
logRoutes.get('/api/logs', async (ctx) => {
const files = await hermesCli.listLogFiles()
ctx.body = { files }
})
interface LogEntry {
timestamp: string
level: string
logger: string
message: string
raw: string
}
// Parse a single log line into structured entry
function parseLine(line: string): LogEntry | null {
// Match: 2026-04-11 20:16:16,289 INFO aiohttp.access: message
const match = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+?):\s(.*)$/)
if (match) {
return {
timestamp: match[1],
level: match[2],
logger: match[3],
message: match[4],
raw: line,
}
}
// Unparseable line (e.g. traceback continuation)
return null
}
// Read log lines (parsed)
logRoutes.get('/api/logs/:name', async (ctx) => {
const logName = ctx.params.name
const lines = ctx.query.lines ? parseInt(ctx.query.lines as string, 10) : 100
const level = (ctx.query.level as string) || undefined
const session = (ctx.query.session as string) || undefined
const since = (ctx.query.since as string) || undefined
try {
const content = await hermesCli.readLogs(logName, lines, level, session, since)
const rawLines = content.split('\n')
const entries: (LogEntry | null)[] = []
for (const line of rawLines) {
// Skip header lines like "--- ~/.hermes/logs/agent.log (last 100) ---"
if (line.startsWith('---') || line.trim() === '') continue
entries.push(parseLine(line))
}
ctx.body = { entries }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
+81
View File
@@ -0,0 +1,81 @@
import type { Context } from 'koa'
import { config } from '../config'
export async function proxy(ctx: Context) {
const upstream = config.upstream.replace(/\/$/, '')
const url = `${upstream}${ctx.path}${ctx.search || ''}`
console.log(`[PROXY] ${ctx.method} ${ctx.path} -> ${url}`)
// Build headers — forward most, strip browser-specific ones
const headers: Record<string, string> = {}
for (const [key, value] of Object.entries(ctx.headers)) {
if (value == null) continue
const lower = key.toLowerCase()
if (lower === 'host') {
headers['host'] = new URL(upstream).host
} else if (lower !== 'origin' && lower !== 'referer' && lower !== 'connection') {
const v = Array.isArray(value) ? value[0] : value
if (v) headers[key] = v
}
}
// Add SSE-friendly headers
if (ctx.path.match(/\/events$/)) {
headers['x-accel-buffering'] = 'no'
headers['cache-control'] = 'no-cache'
}
try {
// Build request body from raw body
let body: string | undefined
if (ctx.req.method !== 'GET' && ctx.req.method !== 'HEAD') {
body = (ctx as any).request.rawBody as string | undefined
}
const res = await fetch(url, {
method: ctx.req.method,
headers,
body,
})
// Set response headers
const resHeaders: Record<string, string> = {}
res.headers.forEach((value, key) => {
const lower = key.toLowerCase()
if (lower !== 'transfer-encoding' && lower !== 'connection') {
resHeaders[key] = value
}
})
if (ctx.path.match(/\/events$/)) {
resHeaders['x-accel-buffering'] = 'no'
resHeaders['cache-control'] = 'no-cache'
}
ctx.status = res.status
ctx.set(resHeaders)
// Stream response body
if (res.body) {
const reader = res.body.getReader()
const pump = async () => {
while (true) {
const { done, value } = await reader.read()
if (done) break
ctx.res.write(value)
}
ctx.res.end()
}
await pump()
} else {
ctx.res.end()
}
} catch (err: any) {
if (!ctx.res.headersSent) {
ctx.status = 502
ctx.set('Content-Type', 'application/json')
ctx.body = { error: { message: `Proxy error: ${err.message}` } }
} else {
ctx.res.end()
}
}
}
+8
View File
@@ -0,0 +1,8 @@
import Router from '@koa/router'
import { proxy } from './proxy-handler'
export const proxyRoutes = new Router()
// Proxy all /api/*, /v1/* to upstream Hermes API
proxyRoutes.all('/api/(.*)', proxy)
proxyRoutes.all('/v1/(.*)', proxy)
+34
View File
@@ -0,0 +1,34 @@
import Router from '@koa/router'
import * as hermesCli from '../services/hermes-cli'
export const sessionRoutes = new Router()
// List sessions from Hermes
sessionRoutes.get('/api/sessions', async (ctx) => {
const source = (ctx.query.source as string) || undefined
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
const sessions = await hermesCli.listSessions(source, limit)
ctx.body = { sessions }
})
// Get single session with messages
sessionRoutes.get('/api/sessions/:id', async (ctx) => {
const session = await hermesCli.getSession(ctx.params.id)
if (!session) {
ctx.status = 404
ctx.body = { error: 'Session not found' }
return
}
ctx.body = { session }
})
// Delete session from Hermes
sessionRoutes.delete('/api/sessions/:id', async (ctx) => {
const ok = await hermesCli.deleteSession(ctx.params.id)
if (!ok) {
ctx.status = 500
ctx.body = { error: 'Failed to delete session' }
return
}
ctx.body = { ok: true }
})
+52
View File
@@ -0,0 +1,52 @@
import Router from '@koa/router'
import { randomBytes } from 'crypto'
import { mkdir, writeFile } from 'fs/promises'
import { config } from '../config'
export const uploadRoutes = new Router()
uploadRoutes.post('/upload', async (ctx) => {
const contentType = ctx.get('content-type') || ''
if (!contentType.startsWith('multipart/form-data')) {
ctx.status = 400
ctx.body = { error: 'Expected multipart/form-data' }
return
}
const boundary = '--' + contentType.split('boundary=')[1]
if (!boundary || boundary === '--undefined') {
ctx.status = 400
ctx.body = { error: 'Missing boundary' }
return
}
await mkdir(config.uploadDir, { recursive: true })
// Read raw body
const chunks: Buffer[] = []
for await (const chunk of ctx.req) chunks.push(chunk)
const body = Buffer.concat(chunks).toString('latin1')
const parts = body.split(boundary).slice(1, -1)
const results: { name: string; path: string }[] = []
for (const part of parts) {
const headerEnd = part.indexOf('\r\n\r\n')
if (headerEnd === -1) continue
const header = part.substring(0, headerEnd)
const data = part.substring(headerEnd + 4, part.length - 2)
const filenameMatch = header.match(/filename="([^"]+)"/)
if (!filenameMatch) continue
const filename = filenameMatch[1]
const ext = filename.includes('.') ? '.' + filename.split('.').pop() : ''
const savedName = randomBytes(8).toString('hex') + ext
const savedPath = `${config.uploadDir}/${savedName}`
await writeFile(savedPath, Buffer.from(data, 'binary'))
results.push({ name: filename, path: savedPath })
}
ctx.body = { files: results }
})
+33
View File
@@ -0,0 +1,33 @@
import Router from '@koa/router'
import { emitWebhook } from '../services/hermes'
export const webhookRoutes = new Router()
/**
* POST /webhook — receive callbacks from Hermes Agent
*
* Expected body:
* {
* "event": "run.completed" | "job.completed" | ...,
* "run_id": "...",
* "data": { ... }
* }
*
* TODO: Add signature verification when Hermes supports webhook signing
*/
webhookRoutes.post('/webhook', async (ctx) => {
const payload = ctx.request.body
if (!payload || !payload.event) {
ctx.status = 400
ctx.body = { error: 'Missing event field' }
return
}
console.log(`[Webhook] Received event: ${payload.event}`)
// Emit to registered callbacks
emitWebhook(payload)
ctx.body = { ok: true }
})