refactor: restructure project for multi-agent extensibility
- Migrate source to packages/client and packages/server directories - Namespace all Hermes-specific code under hermes/ subdirectories (api/hermes/, components/hermes/, views/hermes/, stores/hermes/) - Add hermes.* route names and /hermes/* path prefixes - Upgrade @koa/router to v15, adapt path-to-regexp v8 syntax - Fix proxy path rewriting: /api/hermes/v1/* → /v1/*, /api/hermes/* → /api/* - Fix frontend API paths to match backend /api/hermes/* routes - Fix WebSocket terminal path to /api/hermes/terminal - Add proxyMiddleware for reliable unmatched route proxying - Add profiles route module and hermes-cli profile commands - Update CLAUDE.md development guide with new architecture - Add Chinese README (README_zh.md) - Add Web Terminal feature to README Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
import { resolve } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
export const config = {
|
||||
port: parseInt(process.env.PORT || '8648', 10),
|
||||
upstream: process.env.UPSTREAM || 'http://127.0.0.1:8642',
|
||||
uploadDir: process.env.UPLOAD_DIR || resolve(tmpdir(), 'hermes-uploads'),
|
||||
dataDir: resolve(__dirname, '..', 'data'),
|
||||
corsOrigins: process.env.CORS_ORIGINS || '*',
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import Koa from 'koa'
|
||||
import cors from '@koa/cors'
|
||||
import bodyParser from '@koa/bodyparser'
|
||||
import serve from 'koa-static'
|
||||
import send from 'koa-send'
|
||||
import { resolve } from 'path'
|
||||
import { mkdir } from 'fs/promises'
|
||||
import { config } from './config'
|
||||
import { hermesRoutes, setupTerminalWebSocket, proxyMiddleware } from './routes/hermes'
|
||||
import { uploadRoutes } from './routes/upload'
|
||||
import { webhookRoutes } from './routes/webhook'
|
||||
import * as hermesCli from './services/hermes-cli'
|
||||
import { getToken, authMiddleware } from './services/auth'
|
||||
|
||||
const app = new Koa()
|
||||
const { restartGateway, startGateway, startGatewayBackground, getVersion } = hermesCli
|
||||
|
||||
let server: any = null
|
||||
let isShuttingDown = false
|
||||
|
||||
// 👉 如果你有子进程,一定要存
|
||||
let gatewayPid: number | null = null
|
||||
|
||||
export async function bootstrap() {
|
||||
await mkdir(config.uploadDir, { recursive: true })
|
||||
await mkdir(config.dataDir, { recursive: true })
|
||||
|
||||
// Auth (after mkdir so data dir exists)
|
||||
const authToken = await getToken()
|
||||
if (authToken) {
|
||||
app.use(await authMiddleware(authToken))
|
||||
console.log(`🔐 Auth enabled — token: ${authToken}`)
|
||||
}
|
||||
|
||||
await ensureApiServerConfig()
|
||||
await ensureGatewayRunning()
|
||||
|
||||
app.use(cors({ origin: config.corsOrigins }))
|
||||
app.use(bodyParser())
|
||||
|
||||
app.use(webhookRoutes.routes())
|
||||
app.use(uploadRoutes.routes())
|
||||
app.use(hermesRoutes.routes())
|
||||
app.use(proxyMiddleware)
|
||||
|
||||
// health
|
||||
app.use(async (ctx, next) => {
|
||||
if (ctx.path === '/health') {
|
||||
const raw = await getVersion()
|
||||
const version = raw.split('\n')[0].replace('Hermes Agent ', '') || ''
|
||||
|
||||
let gatewayOk = false
|
||||
try {
|
||||
const res = await fetch(`${config.upstream.replace(/\/$/, '')}/health`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
gatewayOk = res.ok
|
||||
} catch { }
|
||||
|
||||
ctx.body = {
|
||||
status: gatewayOk ? 'ok' : 'error',
|
||||
platform: 'hermes-agent',
|
||||
version,
|
||||
gateway: gatewayOk ? 'running' : 'stopped',
|
||||
}
|
||||
return
|
||||
}
|
||||
await next()
|
||||
})
|
||||
|
||||
// SPA
|
||||
const distDir = resolve(__dirname, '..', 'client')
|
||||
app.use(serve(distDir))
|
||||
app.use(async (ctx) => {
|
||||
if (!ctx.path.startsWith('/api') &&
|
||||
ctx.path !== '/health' &&
|
||||
ctx.path !== '/upload' &&
|
||||
ctx.path !== '/webhook') {
|
||||
await send(ctx, 'index.html', { root: distDir })
|
||||
}
|
||||
})
|
||||
|
||||
// 🚀 启动服务
|
||||
server = app.listen(config.port, '0.0.0.0')
|
||||
|
||||
// Terminal WebSocket (must be after server is created)
|
||||
setupTerminalWebSocket(server)
|
||||
|
||||
server.on('listening', () => {
|
||||
console.log(`➜ Server: http://localhost:${config.port}`)
|
||||
console.log(`➜ Upstream: ${config.upstream}`)
|
||||
})
|
||||
|
||||
server.on('error', (err: any) => {
|
||||
console.error('Server error:', err.message)
|
||||
})
|
||||
|
||||
// 👇 绑定退出信号
|
||||
bindShutdown()
|
||||
}
|
||||
|
||||
// ============================
|
||||
// ✅ 统一关闭逻辑(核心)
|
||||
// ============================
|
||||
function bindShutdown() {
|
||||
const shutdown = async (signal: string) => {
|
||||
if (isShuttingDown) return
|
||||
isShuttingDown = true
|
||||
|
||||
console.log(`\n[${signal}] shutting down...`)
|
||||
|
||||
try {
|
||||
// ✅ 1. 关闭 HTTP server
|
||||
if (server) {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => {
|
||||
console.log('✓ http server closed')
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ✅ 2. 关闭子进程(如果有)
|
||||
if (gatewayPid) {
|
||||
try {
|
||||
process.kill(gatewayPid)
|
||||
console.log(`✓ gateway process killed: ${gatewayPid}`)
|
||||
} catch { }
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('shutdown error:', err)
|
||||
}
|
||||
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// 👉 nodemon 专用(必须 once)
|
||||
process.once('SIGUSR2', shutdown)
|
||||
|
||||
// 👉 正常退出
|
||||
process.on('SIGINT', shutdown)
|
||||
process.on('SIGTERM', shutdown)
|
||||
|
||||
// 👉 防止异常退出没处理
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('uncaughtException:', err)
|
||||
shutdown('uncaughtException')
|
||||
})
|
||||
|
||||
process.on('unhandledRejection', (err) => {
|
||||
console.error('unhandledRejection:', err)
|
||||
shutdown('unhandledRejection')
|
||||
})
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 你的原逻辑(基本不动)
|
||||
// ============================
|
||||
|
||||
async function ensureApiServerConfig() {
|
||||
const { homedir } = await import('os')
|
||||
const { readFileSync, writeFileSync, existsSync, copyFileSync } = await import('fs')
|
||||
const yaml = (await import('js-yaml')).default
|
||||
const configPath = resolve(homedir(), '.hermes/config.yaml')
|
||||
|
||||
const defaults: Record<string, any> = {
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: 8642,
|
||||
key: '',
|
||||
cors_origins: '*',
|
||||
}
|
||||
|
||||
try {
|
||||
if (!existsSync(configPath)) {
|
||||
console.log('✗ config.yaml not found')
|
||||
return
|
||||
}
|
||||
|
||||
const content = readFileSync(configPath, 'utf-8')
|
||||
const cfg = yaml.load(content) as any || {}
|
||||
|
||||
if (!cfg.platforms) cfg.platforms = {}
|
||||
if (!cfg.platforms.api_server) cfg.platforms.api_server = {}
|
||||
|
||||
const api = cfg.platforms.api_server
|
||||
let changed = false
|
||||
|
||||
for (const [k, v] of Object.entries(defaults)) {
|
||||
if (api[k] != null && api[k] !== v) {
|
||||
api[k] = v
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return
|
||||
|
||||
copyFileSync(configPath, configPath + '.bak')
|
||||
writeFileSync(configPath, yaml.dump(cfg), 'utf-8')
|
||||
|
||||
await restartGateway()
|
||||
} catch (err: any) {
|
||||
console.error('config error:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureGatewayRunning() {
|
||||
const upstream = config.upstream.replace(/\/$/, '')
|
||||
|
||||
try {
|
||||
const res = await fetch(`${upstream}/health`, { signal: AbortSignal.timeout(5000) })
|
||||
if (res.ok) return
|
||||
} catch { }
|
||||
|
||||
console.log('⚠ Gateway not running, starting...')
|
||||
|
||||
try {
|
||||
// 👉 关键:保存 PID
|
||||
gatewayPid = await startGatewayBackground()
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
const res = await fetch(`${upstream}/health`, { signal: AbortSignal.timeout(5000) })
|
||||
if (res.ok) {
|
||||
console.log(`✓ Gateway started (PID: ${gatewayPid})`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('gateway start failed:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
@@ -0,0 +1,315 @@
|
||||
import Router from '@koa/router'
|
||||
import { readFile, writeFile, copyFile } from 'fs/promises'
|
||||
import { chmod } from 'fs/promises'
|
||||
import { resolve } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import YAML from 'js-yaml'
|
||||
import { restartGateway } from '../../services/hermes-cli'
|
||||
|
||||
// Platform sections that require gateway restart after config change
|
||||
const PLATFORM_SECTIONS = new Set([
|
||||
'telegram', 'discord', 'slack', 'whatsapp', 'matrix',
|
||||
'weixin', 'wecom', 'feishu', 'dingtalk',
|
||||
])
|
||||
|
||||
const configPath = resolve(homedir(), '.hermes/config.yaml')
|
||||
const envPath = resolve(homedir(), '.hermes/.env')
|
||||
|
||||
// Env var → (platform, configPath in PlatformConfig) mapping
|
||||
// Matches hermes _apply_env_overrides() in gateway/config.py
|
||||
const envPlatformMap: Record<string, [string, string]> = {
|
||||
TELEGRAM_BOT_TOKEN: ['telegram', 'token'],
|
||||
DISCORD_BOT_TOKEN: ['discord', 'token'],
|
||||
SLACK_BOT_TOKEN: ['slack', 'token'],
|
||||
MATRIX_ACCESS_TOKEN: ['matrix', 'token'],
|
||||
MATRIX_HOMESERVER: ['matrix', 'extra.homeserver'],
|
||||
FEISHU_APP_ID: ['feishu', 'extra.app_id'],
|
||||
FEISHU_APP_SECRET: ['feishu', 'extra.app_secret'],
|
||||
DINGTALK_CLIENT_ID: ['dingtalk', 'extra.client_id'],
|
||||
DINGTALK_CLIENT_SECRET: ['dingtalk', 'extra.client_secret'],
|
||||
// DingTalk has no _apply_env_overrides entry in hermes;
|
||||
// the adapter reads these env vars directly at runtime.
|
||||
DINGTALK_APP_KEY: ['dingtalk', 'extra.app_key'],
|
||||
WECOM_BOT_ID: ['wecom', 'extra.bot_id'],
|
||||
WECOM_SECRET: ['wecom', 'extra.secret'],
|
||||
WEIXIN_TOKEN: ['weixin', 'token'],
|
||||
WEIXIN_ACCOUNT_ID: ['weixin', 'extra.account_id'],
|
||||
WEIXIN_BASE_URL: ['weixin', 'extra.base_url'],
|
||||
WHATSAPP_ENABLED: ['whatsapp', 'enabled'],
|
||||
}
|
||||
|
||||
// Reverse map: (platform, configPath) → env var
|
||||
const platformEnvMap: Record<string, Record<string, string>> = {}
|
||||
for (const [envVar, [platform, configPath]] of Object.entries(envPlatformMap)) {
|
||||
if (!platformEnvMap[platform]) platformEnvMap[platform] = {}
|
||||
platformEnvMap[platform][configPath] = envVar
|
||||
}
|
||||
|
||||
function parseEnv(raw: string): Record<string, string> {
|
||||
const env: Record<string, string> = {}
|
||||
for (const line of raw.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
const eqIdx = trimmed.indexOf('=')
|
||||
if (eqIdx === -1) continue
|
||||
const key = trimmed.slice(0, eqIdx).trim()
|
||||
const val = trimmed.slice(eqIdx + 1).trim()
|
||||
if (val) env[key] = val
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
function setNested(obj: Record<string, any>, path: string, value: any) {
|
||||
const parts = path.split('.')
|
||||
let cur = obj
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!cur[parts[i]]) cur[parts[i]] = {}
|
||||
cur = cur[parts[i]]
|
||||
}
|
||||
cur[parts[parts.length - 1]] = value
|
||||
}
|
||||
|
||||
function getNested(obj: Record<string, any>, path: string): any {
|
||||
const parts = path.split('.')
|
||||
let cur = obj
|
||||
for (const p of parts) {
|
||||
if (!cur || typeof cur !== 'object') return undefined
|
||||
cur = cur[p]
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
async function readEnvPlatforms(): Promise<Record<string, any>> {
|
||||
try {
|
||||
const raw = await readFile(envPath, 'utf-8')
|
||||
const env = parseEnv(raw)
|
||||
const platforms: Record<string, any> = {}
|
||||
for (const [envKey, [platform, cfgPath]] of Object.entries(envPlatformMap)) {
|
||||
const val = env[envKey]
|
||||
if (val === undefined || val === '') continue
|
||||
if (!platforms[platform]) platforms[platform] = {}
|
||||
let finalVal: any = val
|
||||
if (cfgPath === 'enabled') finalVal = val === 'true'
|
||||
setNested(platforms[platform], cfgPath, finalVal)
|
||||
}
|
||||
return platforms
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// Write a KEY=value to .env (matching hermes save_env_value behavior)
|
||||
// If value is empty, remove the line instead
|
||||
async function saveEnvValue(key: string, value: string): Promise<void> {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(envPath, 'utf-8')
|
||||
} catch {
|
||||
raw = ''
|
||||
}
|
||||
|
||||
const remove = !value
|
||||
const lines = raw.split('\n')
|
||||
let found = false
|
||||
const result: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith('#')) {
|
||||
// Check if there's a commented-out version of this key
|
||||
if (trimmed.startsWith(`# ${key}=`)) {
|
||||
if (!remove) {
|
||||
result.push(`${key}=${value}`)
|
||||
}
|
||||
found = true
|
||||
} else {
|
||||
result.push(line)
|
||||
}
|
||||
} else {
|
||||
const eqIdx = trimmed.indexOf('=')
|
||||
if (eqIdx !== -1 && trimmed.slice(0, eqIdx).trim() === key) {
|
||||
if (!remove) {
|
||||
result.push(`${key}=${value}`)
|
||||
}
|
||||
found = true
|
||||
} else {
|
||||
result.push(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found && !remove) {
|
||||
result.push(`${key}=${value}`)
|
||||
}
|
||||
|
||||
// Remove trailing empty lines, keep exactly one trailing newline
|
||||
let output = result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n'
|
||||
await writeFile(envPath, output, 'utf-8')
|
||||
// Set permissions to 0600 (owner only), matching hermes behavior
|
||||
try { await chmod(envPath, 0o600) } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function readConfig(): Promise<Record<string, any>> {
|
||||
const raw = await readFile(configPath, 'utf-8')
|
||||
return (YAML.load(raw) as Record<string, any>) || {}
|
||||
}
|
||||
|
||||
async function writeConfig(data: Record<string, any>): Promise<void> {
|
||||
await copyFile(configPath, configPath + '.bak')
|
||||
const yamlStr = YAML.dump(data, {
|
||||
lineWidth: -1,
|
||||
noRefs: true,
|
||||
quotingType: '"',
|
||||
forceQuotes: false,
|
||||
})
|
||||
await writeFile(configPath, yamlStr, 'utf-8')
|
||||
}
|
||||
|
||||
export const configRoutes = new Router()
|
||||
|
||||
// GET /api/config — read config sections
|
||||
configRoutes.get('/api/hermes/config', async (ctx) => {
|
||||
try {
|
||||
const config = await readConfig()
|
||||
// Merge .env platform credentials into platforms section
|
||||
const envPlatforms = await readEnvPlatforms()
|
||||
if (Object.keys(envPlatforms).length > 0) {
|
||||
// Deep-merge: env values fill in missing, don't overwrite config.yaml
|
||||
const existing = config.platforms || {}
|
||||
for (const [platform, vals] of Object.entries(envPlatforms)) {
|
||||
existing[platform] = { ...(existing[platform] || {}), ...(vals as Record<string, any>) }
|
||||
}
|
||||
config.platforms = existing
|
||||
}
|
||||
const { section, sections } = ctx.query
|
||||
|
||||
if (section) {
|
||||
ctx.body = { [section as string]: config[section as string] || {} }
|
||||
} else if (sections) {
|
||||
const keys = (sections as string).split(',')
|
||||
const result: Record<string, any> = {}
|
||||
for (const key of keys) {
|
||||
result[key.trim()] = config[key.trim()] || {}
|
||||
}
|
||||
ctx.body = result
|
||||
} else {
|
||||
ctx.body = config
|
||||
}
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/config — update a config section (writes to config.yaml)
|
||||
configRoutes.put('/api/hermes/config', async (ctx) => {
|
||||
const { section, values } = ctx.request.body as {
|
||||
section: string
|
||||
values: Record<string, any>
|
||||
}
|
||||
|
||||
if (!section || !values) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing section or values' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await readConfig()
|
||||
config[section] = { ...(config[section] || {}), ...values }
|
||||
await writeConfig(config)
|
||||
// Restart gateway for platform/channel config changes
|
||||
if (PLATFORM_SECTIONS.has(section)) {
|
||||
await restartGateway()
|
||||
}
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/config/credentials — save platform credentials to .env
|
||||
// Body: { platform: string, values: Record<string, any> }
|
||||
// values keys match PlatformConfig paths: 'token', 'extra.app_id', 'extra.app_secret', etc.
|
||||
configRoutes.put('/api/hermes/config/credentials', async (ctx) => {
|
||||
const { platform, values } = ctx.request.body as {
|
||||
platform: string
|
||||
values: Record<string, any>
|
||||
}
|
||||
|
||||
if (!platform || !values) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing platform or values' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const envMap = platformEnvMap[platform]
|
||||
if (!envMap) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: `Unknown platform: ${platform}` }
|
||||
return
|
||||
}
|
||||
|
||||
// Also clean up config.yaml platforms.<platform> to keep in sync
|
||||
const config = await readConfig()
|
||||
let configChanged = false
|
||||
|
||||
// Flatten nested values: { extra: { app_id: '' } } → { 'extra.app_id': '' }
|
||||
const flatValues: Record<string, any> = {}
|
||||
for (const [key, val] of Object.entries(values)) {
|
||||
if (key === 'extra' && val && typeof val === 'object') {
|
||||
for (const [subKey, subVal] of Object.entries(val as Record<string, any>)) {
|
||||
flatValues[`extra.${subKey}`] = subVal
|
||||
}
|
||||
} else {
|
||||
flatValues[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
for (const [cfgPath, val] of Object.entries(flatValues)) {
|
||||
const envVar = envMap[cfgPath]
|
||||
if (!envVar) continue
|
||||
if (val === undefined || val === null || val === '') {
|
||||
await saveEnvValue(envVar, '')
|
||||
// Remove from config.yaml too
|
||||
const parts = cfgPath.split('.')
|
||||
let obj: any = config.platforms?.[platform]
|
||||
if (obj) {
|
||||
if (parts.length === 1) {
|
||||
delete obj[parts[0]]
|
||||
} else {
|
||||
let cur = obj
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!cur[parts[i]]) break
|
||||
cur = cur[parts[i]]
|
||||
}
|
||||
delete cur[parts[parts.length - 1]]
|
||||
// Clean up empty extra
|
||||
if (obj.extra && Object.keys(obj.extra).length === 0) delete obj.extra
|
||||
}
|
||||
if (Object.keys(obj).length === 0) {
|
||||
if (!config.platforms) config.platforms = {}
|
||||
delete config.platforms[platform]
|
||||
}
|
||||
configChanged = true
|
||||
}
|
||||
} else {
|
||||
await saveEnvValue(envVar, String(val))
|
||||
}
|
||||
}
|
||||
|
||||
if (configChanged) {
|
||||
await writeConfig(config)
|
||||
}
|
||||
|
||||
// Restart gateway for platform credential changes
|
||||
await restartGateway()
|
||||
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,651 @@
|
||||
import Router from '@koa/router'
|
||||
import { readdir, readFile, stat, writeFile, mkdir, copyFile } from 'fs/promises'
|
||||
import { join, resolve } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import YAML from 'js-yaml'
|
||||
|
||||
// --- Auth / Credential Pool ---
|
||||
|
||||
interface CredentialPoolEntry {
|
||||
id: string
|
||||
label: string
|
||||
base_url: string
|
||||
access_token: string
|
||||
last_status?: string | null
|
||||
}
|
||||
|
||||
interface AuthJson {
|
||||
credential_pool?: Record<string, CredentialPoolEntry[]>
|
||||
}
|
||||
|
||||
const authPath = resolve(homedir(), '.hermes', 'auth.json')
|
||||
|
||||
async function loadAuthJson(): Promise<AuthJson | null> {
|
||||
try {
|
||||
const raw = await readFile(authPath, 'utf-8')
|
||||
return JSON.parse(raw) as AuthJson
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAuthJson(auth: AuthJson): Promise<void> {
|
||||
await writeFile(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8')
|
||||
}
|
||||
|
||||
async function fetchProviderModels(baseUrl: string, apiKey: string): Promise<string[]> {
|
||||
try {
|
||||
const url = baseUrl.replace(/\/+$/, '') + '/models'
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
})
|
||||
if (!res.ok) {
|
||||
console.error(`[available-models] ${baseUrl} returned ${res.status}`)
|
||||
return []
|
||||
}
|
||||
const data = await res.json() as { data?: Array<{ id: string }> }
|
||||
if (!Array.isArray(data.data)) {
|
||||
console.error(`[available-models] ${baseUrl} returned unexpected format`)
|
||||
return []
|
||||
}
|
||||
return data.data.map(m => m.id).sort()
|
||||
} catch (err: any) {
|
||||
console.error(`[available-models] ${baseUrl} failed: ${err.message}`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hardcoded model catalogs (single source: src/shared/providers.ts) ---
|
||||
import { buildProviderModelMap } from '../../shared/providers'
|
||||
const PROVIDER_MODEL_CATALOG = buildProviderModelMap()
|
||||
|
||||
export const fsRoutes = new Router()
|
||||
|
||||
const hermesDir = resolve(homedir(), '.hermes')
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface SkillInfo {
|
||||
name: string
|
||||
description: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface SkillCategory {
|
||||
name: string
|
||||
description: string
|
||||
skills: SkillInfo[]
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function extractDescription(content: string): string {
|
||||
const lines = content.split('\n')
|
||||
let inFrontmatter = false
|
||||
let bodyStarted = false
|
||||
|
||||
for (const line of lines) {
|
||||
if (!bodyStarted && line.trim() === '---') {
|
||||
if (!inFrontmatter) {
|
||||
inFrontmatter = true
|
||||
continue
|
||||
} else {
|
||||
inFrontmatter = false
|
||||
bodyStarted = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (inFrontmatter) continue
|
||||
if (line.trim() === '') continue
|
||||
if (line.startsWith('#')) continue
|
||||
return line.trim().slice(0, 80)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function safeReadFile(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(filePath, 'utf-8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function safeStat(filePath: string): Promise<{ mtime: number } | null> {
|
||||
try {
|
||||
const s = await stat(filePath)
|
||||
return { mtime: Math.round(s.mtimeMs) }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// --- Config YAML helpers ---
|
||||
|
||||
const configPath = resolve(homedir(), '.hermes/config.yaml')
|
||||
|
||||
async function readConfigYaml(): Promise<Record<string, any>> {
|
||||
const raw = await safeReadFile(configPath)
|
||||
if (!raw) return {}
|
||||
return (YAML.load(raw) as Record<string, any>) || {}
|
||||
}
|
||||
|
||||
async function writeConfigYaml(config: Record<string, any>): Promise<void> {
|
||||
await copyFile(configPath, configPath + '.bak')
|
||||
const yamlStr = YAML.dump(config, {
|
||||
lineWidth: -1,
|
||||
noRefs: true,
|
||||
quotingType: '"',
|
||||
})
|
||||
await writeFile(configPath, yamlStr, 'utf-8')
|
||||
}
|
||||
|
||||
// --- Skills Routes ---
|
||||
|
||||
// List all skills grouped by category
|
||||
fsRoutes.get('/api/hermes/skills', async (ctx) => {
|
||||
const skillsDir = join(hermesDir, 'skills')
|
||||
|
||||
try {
|
||||
// Read disabled skills list from config.yaml
|
||||
const config = await readConfigYaml()
|
||||
const disabledList: string[] = config.skills?.disabled || []
|
||||
|
||||
const entries = await readdir(skillsDir, { withFileTypes: true })
|
||||
const categories: SkillCategory[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
||||
|
||||
const catDir = join(skillsDir, entry.name)
|
||||
const catDesc = await safeReadFile(join(catDir, 'DESCRIPTION.md'))
|
||||
const catDescription = catDesc ? catDesc.trim().split('\n')[0].replace(/^#+\s*/, '').slice(0, 100) : ''
|
||||
|
||||
const skillEntries = await readdir(catDir, { withFileTypes: true })
|
||||
const skills: SkillInfo[] = []
|
||||
|
||||
for (const se of skillEntries) {
|
||||
if (!se.isDirectory()) continue
|
||||
const skillMd = await safeReadFile(join(catDir, se.name, 'SKILL.md'))
|
||||
if (skillMd) {
|
||||
skills.push({
|
||||
name: se.name,
|
||||
description: extractDescription(skillMd),
|
||||
enabled: !disabledList.includes(se.name),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (skills.length > 0) {
|
||||
categories.push({ name: entry.name, description: catDescription, skills })
|
||||
}
|
||||
}
|
||||
|
||||
categories.sort((a, b) => a.name.localeCompare(b.name))
|
||||
for (const cat of categories) {
|
||||
cat.skills.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
ctx.body = { categories }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: `Failed to read skills directory: ${err.message}` }
|
||||
}
|
||||
})
|
||||
|
||||
// Toggle skill enabled/disabled via config.yaml skills.disabled
|
||||
fsRoutes.put('/api/hermes/skills/toggle', async (ctx) => {
|
||||
const { name, enabled } = ctx.request.body as { name?: string; enabled?: boolean }
|
||||
|
||||
if (!name || typeof enabled !== 'boolean') {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing name or enabled flag' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await readConfigYaml()
|
||||
if (!config.skills) config.skills = {}
|
||||
if (!Array.isArray(config.skills.disabled)) config.skills.disabled = []
|
||||
|
||||
const disabled = config.skills.disabled as string[]
|
||||
const idx = disabled.indexOf(name)
|
||||
|
||||
if (enabled) {
|
||||
// Enable: remove from disabled list
|
||||
if (idx !== -1) disabled.splice(idx, 1)
|
||||
} else {
|
||||
// Disable: add to disabled list
|
||||
if (idx === -1) disabled.push(name)
|
||||
}
|
||||
|
||||
await writeConfigYaml(config)
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// List files in a skill directory
|
||||
async function listFilesRecursive(dir: string, prefix: string): Promise<{ path: string; name: string }[]> {
|
||||
const result: { path: string; name: string }[] = []
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return result
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name
|
||||
if (entry.isDirectory()) {
|
||||
result.push(...await listFilesRecursive(join(dir, entry.name), relPath))
|
||||
} else {
|
||||
result.push({ path: relPath, name: entry.name })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fsRoutes.get('/api/hermes/skills/:category/:skill/files', async (ctx) => {
|
||||
const { category, skill } = ctx.params
|
||||
const skillDir = join(hermesDir, 'skills', category, skill)
|
||||
|
||||
try {
|
||||
const allFiles = await listFilesRecursive(skillDir, '')
|
||||
const files = allFiles.filter(f => f.path !== 'SKILL.md')
|
||||
ctx.body = { files }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Read a specific file under skills/ (must be registered after the /files route)
|
||||
fsRoutes.get('/api/hermes/skills/{*path}', async (ctx) => {
|
||||
const filePath = (ctx.params as any).path
|
||||
const fullPath = resolve(join(hermesDir, 'skills', filePath))
|
||||
|
||||
if (!fullPath.startsWith(join(hermesDir, 'skills'))) {
|
||||
ctx.status = 403
|
||||
ctx.body = { error: 'Access denied' }
|
||||
return
|
||||
}
|
||||
|
||||
const content = await safeReadFile(fullPath)
|
||||
if (content === null) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'File not found' }
|
||||
return
|
||||
}
|
||||
|
||||
ctx.body = { content }
|
||||
})
|
||||
|
||||
// --- Memory Routes ---
|
||||
|
||||
fsRoutes.get('/api/hermes/memory', async (ctx) => {
|
||||
const memoryPath = join(hermesDir, 'memories', 'MEMORY.md')
|
||||
const userPath = join(hermesDir, 'memories', 'USER.md')
|
||||
|
||||
const [memory, user, memoryStat, userStat] = await Promise.all([
|
||||
safeReadFile(memoryPath),
|
||||
safeReadFile(userPath),
|
||||
safeStat(memoryPath),
|
||||
safeStat(userPath),
|
||||
])
|
||||
|
||||
ctx.body = {
|
||||
memory: memory || '',
|
||||
user: user || '',
|
||||
memory_mtime: memoryStat?.mtime || null,
|
||||
user_mtime: userStat?.mtime || null,
|
||||
}
|
||||
})
|
||||
|
||||
fsRoutes.post('/api/hermes/memory', async (ctx) => {
|
||||
const { section, content } = ctx.request.body as { section: string; content: string }
|
||||
|
||||
if (!section || !content) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing section or content' }
|
||||
return
|
||||
}
|
||||
|
||||
if (section !== 'memory' && section !== 'user') {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Section must be "memory" or "user"' }
|
||||
return
|
||||
}
|
||||
|
||||
const fileName = section === 'memory' ? 'MEMORY.md' : 'USER.md'
|
||||
const filePath = join(hermesDir, 'memories', fileName)
|
||||
|
||||
try {
|
||||
await writeFile(filePath, content, 'utf-8')
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// --- Config Model Routes ---
|
||||
|
||||
interface ModelInfo {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface ModelGroup {
|
||||
provider: string
|
||||
models: ModelInfo[]
|
||||
}
|
||||
|
||||
// Build model list from user's actual config.yaml using js-yaml
|
||||
function buildModelGroups(config: Record<string, any>): { default: string; groups: ModelGroup[] } {
|
||||
let defaultModel = ''
|
||||
let defaultProvider = ''
|
||||
const groups: ModelGroup[] = []
|
||||
const allModelIds = new Set<string>()
|
||||
|
||||
// 1. Extract current model
|
||||
const modelSection = config.model
|
||||
if (typeof modelSection === 'object' && modelSection !== null) {
|
||||
defaultModel = String(modelSection.default || '').trim()
|
||||
defaultProvider = String(modelSection.provider || '').trim()
|
||||
} else if (typeof modelSection === 'string') {
|
||||
defaultModel = modelSection.trim()
|
||||
}
|
||||
|
||||
// 2. Extract custom_providers section
|
||||
const customProviders = config.custom_providers
|
||||
if (Array.isArray(customProviders)) {
|
||||
const customModels: ModelInfo[] = []
|
||||
for (const entry of customProviders) {
|
||||
if (entry && typeof entry === 'object') {
|
||||
const cName = String(entry.name || '').trim()
|
||||
const cModel = String(entry.model || '').trim()
|
||||
if (cName && cModel) {
|
||||
customModels.push({ id: cModel, label: `${cName}: ${cModel}` })
|
||||
allModelIds.add(cModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (customModels.length > 0) {
|
||||
groups.push({ provider: 'Custom', models: customModels })
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Add current default model (if not already in custom_providers)
|
||||
if (defaultModel && !allModelIds.has(defaultModel)) {
|
||||
groups.unshift({ provider: 'Current', models: [{ id: defaultModel, label: defaultModel }] })
|
||||
}
|
||||
|
||||
return { default: defaultModel, groups }
|
||||
}
|
||||
|
||||
// GET /api/available-models — fetch models from all credential pool endpoints
|
||||
fsRoutes.get('/api/hermes/available-models', async (ctx) => {
|
||||
try {
|
||||
const auth = await loadAuthJson()
|
||||
const pool = auth?.credential_pool || {}
|
||||
|
||||
const config = await readConfigYaml()
|
||||
const modelSection = config.model
|
||||
let currentDefault = ''
|
||||
if (typeof modelSection === 'object' && modelSection !== null) {
|
||||
currentDefault = String(modelSection.default || '').trim()
|
||||
} else if (typeof modelSection === 'string') {
|
||||
currentDefault = modelSection.trim()
|
||||
}
|
||||
|
||||
// Collect unique endpoints from credential pool
|
||||
const endpoints: Array<{ key: string; label: string; base_url: string; token: string }> = []
|
||||
const seenUrls = new Set<string>()
|
||||
|
||||
for (const [providerKey, entries] of Object.entries(pool)) {
|
||||
if (!Array.isArray(entries) || entries.length === 0) continue
|
||||
const entry = entries.find(e => e.last_status !== 'exhausted') || entries[0]
|
||||
if (!entry?.base_url || !entry?.access_token) continue
|
||||
const baseUrl = entry.base_url.replace(/\/+$/, '')
|
||||
if (seenUrls.has(baseUrl)) continue
|
||||
seenUrls.add(baseUrl)
|
||||
endpoints.push({
|
||||
key: providerKey,
|
||||
label: providerKey.replace(/^custom:/, '') || entry.label || baseUrl,
|
||||
base_url: baseUrl,
|
||||
token: entry.access_token,
|
||||
})
|
||||
}
|
||||
|
||||
// Resolve models: hardcoded catalog first, live probe as fallback
|
||||
const groups: Array<{ provider: string; label: string; base_url: string; models: string[] }> = []
|
||||
const liveEndpoints: typeof endpoints = []
|
||||
|
||||
for (const ep of endpoints) {
|
||||
const catalogModels = PROVIDER_MODEL_CATALOG[ep.key]
|
||||
if (catalogModels && catalogModels.length > 0) {
|
||||
groups.push({ provider: ep.key, label: ep.label, base_url: ep.base_url, models: catalogModels })
|
||||
} else {
|
||||
liveEndpoints.push(ep)
|
||||
}
|
||||
}
|
||||
|
||||
if (liveEndpoints.length > 0) {
|
||||
const results = await Promise.allSettled(
|
||||
liveEndpoints.map(async ep => {
|
||||
const models = await fetchProviderModels(ep.base_url, ep.token)
|
||||
return { ...ep, models }
|
||||
}),
|
||||
)
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value.models.length > 0) {
|
||||
const { key, label, base_url, models } = result.value
|
||||
groups.push({ provider: key, label, base_url, models })
|
||||
} else if (result.status === 'rejected') {
|
||||
console.error(`[available-models] Failed: ${result.reason?.message || result.reason}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if no providers returned models, fall back to config.yaml parsing
|
||||
if (groups.length === 0) {
|
||||
const fallback = buildModelGroups(config)
|
||||
ctx.body = fallback
|
||||
return
|
||||
}
|
||||
|
||||
ctx.body = { default: currentDefault, groups }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/config/models
|
||||
fsRoutes.get('/api/hermes/config/models', async (ctx) => {
|
||||
try {
|
||||
const config = await readConfigYaml()
|
||||
ctx.body = buildModelGroups(config)
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/config/model
|
||||
fsRoutes.put('/api/hermes/config/model', async (ctx) => {
|
||||
const { default: defaultModel, provider: reqProvider } = ctx.request.body as {
|
||||
default: string
|
||||
provider?: string
|
||||
}
|
||||
|
||||
if (!defaultModel) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing default model' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await readConfigYaml()
|
||||
|
||||
if (typeof config.model !== 'object' || config.model === null) {
|
||||
config.model = {}
|
||||
}
|
||||
|
||||
config.model.default = defaultModel
|
||||
if (reqProvider) {
|
||||
config.model.provider = reqProvider
|
||||
}
|
||||
|
||||
await writeConfigYaml(config)
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/config/providers
|
||||
fsRoutes.post('/api/hermes/config/providers', async (ctx) => {
|
||||
const { name, base_url, api_key, model, providerKey } = ctx.request.body as {
|
||||
name: string
|
||||
base_url: string
|
||||
api_key: string
|
||||
model: string
|
||||
providerKey?: string | null
|
||||
}
|
||||
|
||||
if (!name || !base_url || !model) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing name, base_url, or model' }
|
||||
return
|
||||
}
|
||||
|
||||
if (!api_key) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing API key' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Write to config.yaml custom_providers
|
||||
const config = await readConfigYaml()
|
||||
|
||||
if (!Array.isArray(config.custom_providers)) {
|
||||
config.custom_providers = []
|
||||
}
|
||||
|
||||
config.custom_providers.push({ name, base_url, api_key, model })
|
||||
await writeConfigYaml(config)
|
||||
|
||||
// 2. Write to auth.json credential_pool
|
||||
const poolKey = providerKey
|
||||
|| `custom:${name.trim().toLowerCase().replace(/ /g, '-')}`
|
||||
const auth = await loadAuthJson() || { credential_pool: {} }
|
||||
if (!auth.credential_pool) auth.credential_pool = {}
|
||||
|
||||
if (!auth.credential_pool[poolKey]) {
|
||||
auth.credential_pool[poolKey] = []
|
||||
}
|
||||
|
||||
auth.credential_pool[poolKey].push({
|
||||
id: `${poolKey}-${Date.now()}`,
|
||||
label: name,
|
||||
base_url,
|
||||
access_token: api_key,
|
||||
last_status: null,
|
||||
})
|
||||
|
||||
await saveAuthJson(auth)
|
||||
|
||||
// 3. Auto-switch model to the newly added provider
|
||||
const config2 = await readConfigYaml()
|
||||
if (typeof config2.model !== 'object' || config2.model === null) {
|
||||
config2.model = {}
|
||||
}
|
||||
config2.model.default = model
|
||||
config2.model.provider = poolKey
|
||||
await writeConfigYaml(config2)
|
||||
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/config/providers/:poolKey
|
||||
fsRoutes.delete('/api/hermes/config/providers/:poolKey', async (ctx) => {
|
||||
const poolKey = decodeURIComponent(ctx.params.poolKey)
|
||||
|
||||
try {
|
||||
const auth = await loadAuthJson()
|
||||
if (!auth?.credential_pool) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'No credential pool found' }
|
||||
return
|
||||
}
|
||||
|
||||
const keys = Object.keys(auth.credential_pool)
|
||||
|
||||
if (keys.length <= 1) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Cannot delete the last provider' }
|
||||
return
|
||||
}
|
||||
|
||||
if (!(poolKey in auth.credential_pool)) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: `Provider "${poolKey}" not found` }
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is the current active provider
|
||||
const config = await readConfigYaml()
|
||||
const currentProvider = config.model?.provider
|
||||
const isCurrent = currentProvider === poolKey
|
||||
|
||||
// Save base_url before deleting
|
||||
const deletedBaseUrl = auth.credential_pool[poolKey]?.[0]?.base_url
|
||||
|
||||
// 1. Delete from auth.json
|
||||
delete auth.credential_pool[poolKey]
|
||||
await saveAuthJson(auth)
|
||||
|
||||
// 2. Remove matching entry from config.yaml custom_providers
|
||||
if (deletedBaseUrl && Array.isArray(config.custom_providers)) {
|
||||
config.custom_providers = (config.custom_providers as any[]).filter(
|
||||
(entry: any) => entry.base_url !== deletedBaseUrl,
|
||||
)
|
||||
await writeConfigYaml(config)
|
||||
}
|
||||
|
||||
// 3. If was the current provider, switch to first remaining
|
||||
if (isCurrent) {
|
||||
const remainingKeys = Object.keys(auth.credential_pool)
|
||||
if (remainingKeys.length > 0) {
|
||||
const fallback = remainingKeys[0]
|
||||
const fallbackEntry = auth.credential_pool[fallback]?.[0]
|
||||
const catalogModels = PROVIDER_MODEL_CATALOG[fallback] || []
|
||||
const fallbackModel = catalogModels[0] || fallbackEntry?.label || fallback
|
||||
|
||||
const config2 = await readConfigYaml()
|
||||
if (typeof config2.model !== 'object' || config2.model === null) {
|
||||
config2.model = {}
|
||||
}
|
||||
config2.model.default = fallbackModel
|
||||
config2.model.provider = fallback
|
||||
await writeConfigYaml(config2)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import Router from '@koa/router'
|
||||
import { sessionRoutes } from './sessions'
|
||||
import { profileRoutes } from './profiles'
|
||||
import { configRoutes } from './config'
|
||||
import { fsRoutes } from './filesystem'
|
||||
import { logRoutes } from './logs'
|
||||
import { weixinRoutes } from './weixin'
|
||||
import { proxyRoutes, proxyMiddleware } from './proxy'
|
||||
import { setupTerminalWebSocket } from './terminal'
|
||||
|
||||
export const hermesRoutes = new Router()
|
||||
|
||||
hermesRoutes.use(sessionRoutes.routes())
|
||||
hermesRoutes.use(profileRoutes.routes())
|
||||
hermesRoutes.use(configRoutes.routes())
|
||||
hermesRoutes.use(fsRoutes.routes())
|
||||
hermesRoutes.use(logRoutes.routes())
|
||||
hermesRoutes.use(weixinRoutes.routes())
|
||||
hermesRoutes.use(proxyRoutes.routes())
|
||||
|
||||
export { setupTerminalWebSocket, proxyMiddleware }
|
||||
@@ -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/hermes/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/hermes/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 }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
import Router from '@koa/router'
|
||||
import * as hermesCli from '../../services/hermes-cli'
|
||||
|
||||
export const profileRoutes = new Router()
|
||||
|
||||
// GET /api/profiles - List all profiles
|
||||
profileRoutes.get('/api/hermes/profiles', async (ctx) => {
|
||||
try {
|
||||
const profiles = await hermesCli.listProfiles()
|
||||
ctx.body = { profiles }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/profiles - Create a new profile
|
||||
profileRoutes.post('/api/hermes/profiles', async (ctx) => {
|
||||
const { name, clone } = ctx.request.body as { name?: string; clone?: boolean }
|
||||
|
||||
if (!name) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing profile name' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const output = await hermesCli.createProfile(name, clone)
|
||||
ctx.body = { success: true, message: output.trim() }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/profiles/:name - Get profile details
|
||||
profileRoutes.get('/api/hermes/profiles/:name', async (ctx) => {
|
||||
const { name } = ctx.params
|
||||
|
||||
try {
|
||||
const profile = await hermesCli.getProfile(name)
|
||||
ctx.body = { profile }
|
||||
} catch (err: any) {
|
||||
ctx.status = err.message.includes('not found') ? 404 : 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/profiles/:name - Delete a profile
|
||||
profileRoutes.delete('/api/hermes/profiles/:name', async (ctx) => {
|
||||
const { name } = ctx.params
|
||||
|
||||
if (name === 'default') {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Cannot delete the default profile' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const ok = await hermesCli.deleteProfile(name)
|
||||
if (ok) {
|
||||
ctx.body = { success: true }
|
||||
} else {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: 'Failed to delete profile' }
|
||||
}
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/profiles/:name/rename - Rename a profile
|
||||
profileRoutes.post('/api/hermes/profiles/:name/rename', async (ctx) => {
|
||||
const { name } = ctx.params
|
||||
const { new_name } = ctx.request.body as { new_name?: string }
|
||||
|
||||
if (!new_name) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing new_name' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const ok = await hermesCli.renameProfile(name, new_name)
|
||||
if (ok) {
|
||||
ctx.body = { success: true }
|
||||
} else {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: 'Failed to rename profile' }
|
||||
}
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/profiles/active - Switch active profile
|
||||
profileRoutes.put('/api/hermes/profiles/active', async (ctx) => {
|
||||
const { name } = ctx.request.body as { name?: string }
|
||||
|
||||
if (!name) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing profile name' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Stop gateway (try launchd/systemd first, ignore if unavailable e.g. WSL)
|
||||
try { await hermesCli.stopGateway() } catch { }
|
||||
|
||||
// 2. Kill gateway by port if still running (for WSL / background mode)
|
||||
try {
|
||||
const { execSync } = await import('child_process')
|
||||
const isWin = process.platform === 'win32'
|
||||
let pids = ''
|
||||
if (isWin) {
|
||||
const out = execSync('netstat -aon | findstr :8642', { encoding: 'utf-8', timeout: 5000 }).trim()
|
||||
const lines = out.split('\n').filter(l => l.includes('LISTENING'))
|
||||
pids = Array.from(new Set(lines.map(l => l.trim().split(/\s+/).pop()).filter(Boolean))).join(' ')
|
||||
} else {
|
||||
pids = execSync('lsof -ti:8642', { encoding: 'utf-8', timeout: 5000 }).trim()
|
||||
}
|
||||
if (pids) {
|
||||
if (isWin) {
|
||||
execSync(`taskkill /F /PID ${pids.split(' ').join(' /PID ')}`, { timeout: 5000 })
|
||||
} else {
|
||||
execSync(`kill -9 ${pids}`, { timeout: 5000 })
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
}
|
||||
} catch { }
|
||||
|
||||
// 3. Switch profile
|
||||
const output = await hermesCli.useProfile(name)
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
|
||||
// 4. Start gateway — try launchd/systemd first, fall back to background mode
|
||||
try {
|
||||
await hermesCli.restartGateway()
|
||||
} catch {
|
||||
// Fallback for WSL / environments without launchd/systemd
|
||||
try {
|
||||
const pid = await hermesCli.startGatewayBackground()
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
console.log(`[Profile] Gateway started in background mode (PID: ${pid})`)
|
||||
} catch (err: any) {
|
||||
console.error('[Profile] Gateway start failed:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.body = { success: true, message: output.trim() }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/profiles/:name/export - Export profile to archive
|
||||
profileRoutes.post('/api/hermes/profiles/:name/export', async (ctx) => {
|
||||
const { name } = ctx.params
|
||||
const { output } = ctx.request.body as { output?: string }
|
||||
|
||||
try {
|
||||
const result = await hermesCli.exportProfile(name, output)
|
||||
ctx.body = { success: true, message: result.trim() }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/profiles/import - Import profile from archive
|
||||
profileRoutes.post('/api/hermes/profiles/import', async (ctx) => {
|
||||
const { archive, name } = ctx.request.body as { archive?: string; name?: string }
|
||||
|
||||
if (!archive) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing archive path' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await hermesCli.importProfile(archive, name)
|
||||
ctx.body = { success: true, message: result.trim() }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Context } from 'koa'
|
||||
import { config } from '../../config'
|
||||
|
||||
export async function proxy(ctx: Context) {
|
||||
const upstream = config.upstream.replace(/\/$/, '')
|
||||
// Rewrite path for upstream gateway:
|
||||
// /api/hermes/v1/* -> /v1/* (upstream uses /v1/ prefix)
|
||||
// /api/hermes/* -> /api/* (upstream uses /api/ prefix)
|
||||
const upstreamPath = ctx.path.replace(/^\/api\/hermes\/v1/, '/v1').replace(/^\/api\/hermes/, '/api')
|
||||
const url = `${upstream}${upstreamPath}${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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import Router from '@koa/router'
|
||||
import type { Context, Next } from 'koa'
|
||||
import { proxy } from './proxy-handler'
|
||||
|
||||
export const proxyRoutes = new Router()
|
||||
|
||||
// Proxy unmatched /api/hermes/* and /v1/* to upstream Hermes API
|
||||
proxyRoutes.all('/api/hermes/{*any}', proxy)
|
||||
proxyRoutes.all('/v1/{*any}', proxy)
|
||||
|
||||
// Also register as middleware so it works reliably with nested .use()
|
||||
export async function proxyMiddleware(ctx: Context, next: Next) {
|
||||
if (ctx.path.startsWith('/api/hermes/') || ctx.path.startsWith('/v1/')) {
|
||||
return proxy(ctx)
|
||||
}
|
||||
await next()
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Router from '@koa/router'
|
||||
import * as hermesCli from '../../services/hermes-cli'
|
||||
|
||||
export const sessionRoutes = new Router()
|
||||
|
||||
// List sessions from Hermes
|
||||
sessionRoutes.get('/api/hermes/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/hermes/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/hermes/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 }
|
||||
})
|
||||
|
||||
// Rename session
|
||||
sessionRoutes.post('/api/hermes/sessions/:id/rename', async (ctx) => {
|
||||
const { title } = ctx.request.body as { title?: string }
|
||||
if (!title || typeof title !== 'string') {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'title is required' }
|
||||
return
|
||||
}
|
||||
const ok = await hermesCli.renameSession(ctx.params.id, title.trim())
|
||||
if (!ok) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: 'Failed to rename session' }
|
||||
return
|
||||
}
|
||||
ctx.body = { ok: true }
|
||||
})
|
||||
@@ -0,0 +1,287 @@
|
||||
import { WebSocketServer } from 'ws'
|
||||
import type { Server as HttpServer } from 'http'
|
||||
import { existsSync } from 'fs'
|
||||
import * as pty from 'node-pty'
|
||||
import { getToken } from '../../services/auth'
|
||||
|
||||
// ─── Shell detection ────────────────────────────────────────────
|
||||
|
||||
function findShell(): string {
|
||||
const candidates = [
|
||||
process.env.SHELL,
|
||||
'/bin/zsh',
|
||||
'/bin/bash',
|
||||
process.platform === 'win32' ? 'powershell.exe' : null,
|
||||
process.platform === 'win32' ? 'cmd.exe' : null,
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
for (const shell of candidates) {
|
||||
if (existsSync(shell)) return shell
|
||||
}
|
||||
return '/bin/bash'
|
||||
}
|
||||
|
||||
function shellName(shell: string): string {
|
||||
return shell.split('/').pop() || 'shell'
|
||||
}
|
||||
|
||||
// ─── Session types ──────────────────────────────────────────────
|
||||
|
||||
interface PtySession {
|
||||
id: string
|
||||
pty: pty.IPty
|
||||
shell: string
|
||||
pid: number
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
interface Connection {
|
||||
sessions: Map<string, PtySession>
|
||||
activeSessionId: string | null
|
||||
outputBuffers: Map<string, string[]>
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
function generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
|
||||
}
|
||||
|
||||
function createSession(shell: string): PtySession {
|
||||
const id = generateId()
|
||||
let ptyProcess: pty.IPty
|
||||
try {
|
||||
ptyProcess = pty.spawn(shell, [], {
|
||||
name: 'xterm-color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: process.env.HOME || undefined,
|
||||
})
|
||||
} catch (err: any) {
|
||||
throw new Error(`Failed to spawn shell "${shell}": ${err.message}. Run "npm rebuild node-pty" to fix.`)
|
||||
}
|
||||
|
||||
const session: PtySession = {
|
||||
id,
|
||||
pty: ptyProcess,
|
||||
shell,
|
||||
pid: ptyProcess.pid,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
// ─── WebSocket server setup ─────────────────────────────────────
|
||||
|
||||
export function setupTerminalWebSocket(httpServer: HttpServer) {
|
||||
const wss = new WebSocketServer({ noServer: true })
|
||||
const defaultShell = findShell()
|
||||
|
||||
httpServer.on('upgrade', async (req, socket, head) => {
|
||||
const url = new URL(req.url || '', `http://${req.headers.host}`)
|
||||
if (url.pathname !== '/api/hermes/terminal') {
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
|
||||
// Auth check
|
||||
const authToken = await getToken()
|
||||
if (authToken) {
|
||||
const token = url.searchParams.get('token') || ''
|
||||
if (token !== authToken) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, req)
|
||||
})
|
||||
})
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
const conn: Connection = {
|
||||
sessions: new Map(),
|
||||
activeSessionId: null,
|
||||
outputBuffers: new Map(),
|
||||
}
|
||||
|
||||
// ─── PTY output → WebSocket ──────────────────────────────────
|
||||
|
||||
function attachPtyOutput(session: PtySession) {
|
||||
session.pty.onData((data) => {
|
||||
if (ws.readyState !== ws.OPEN) return
|
||||
if (conn.activeSessionId === session.id) {
|
||||
ws.send(data)
|
||||
} else {
|
||||
// Buffer output for inactive sessions
|
||||
let buf = conn.outputBuffers.get(session.id)
|
||||
if (!buf) {
|
||||
buf = []
|
||||
conn.outputBuffers.set(session.id, buf)
|
||||
}
|
||||
buf.push(data)
|
||||
// Cap buffer at 1MB to prevent memory issues
|
||||
if (buf.length > 5000) {
|
||||
buf.splice(0, buf.length - 5000)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
session.pty.onExit(({ exitCode }) => {
|
||||
conn.outputBuffers.delete(session.id)
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'exited', id: session.id, exitCode }))
|
||||
}
|
||||
conn.sessions.delete(session.id)
|
||||
console.log(`[Terminal] Session ${session.id} exited (pid ${session.pid}, code ${exitCode})`)
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Message handler ────────────────────────────────────────
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
const msg = Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw)
|
||||
|
||||
// JSON control message
|
||||
if (msg.charCodeAt(0) === 0x7B) {
|
||||
try {
|
||||
const parsed = JSON.parse(msg)
|
||||
handleControl(parsed)
|
||||
} catch {
|
||||
// Not valid JSON, fall through to raw input
|
||||
writeRaw(msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeRaw(msg)
|
||||
})
|
||||
|
||||
function writeRaw(data: string) {
|
||||
const session = conn.activeSessionId ? conn.sessions.get(conn.activeSessionId) : null
|
||||
if (session) {
|
||||
session.pty.write(data)
|
||||
}
|
||||
}
|
||||
|
||||
function handleControl(parsed: any) {
|
||||
switch (parsed.type) {
|
||||
case 'create': {
|
||||
const shell = parsed.shell || defaultShell
|
||||
let session: PtySession
|
||||
try {
|
||||
session = createSession(shell)
|
||||
} catch (err: any) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: err.message }))
|
||||
return
|
||||
}
|
||||
conn.sessions.set(session.id, session)
|
||||
conn.activeSessionId = session.id
|
||||
attachPtyOutput(session)
|
||||
ws.send(JSON.stringify({
|
||||
type: 'created',
|
||||
id: session.id,
|
||||
pid: session.pid,
|
||||
shell: shellName(shell),
|
||||
}))
|
||||
console.log(`[Terminal] Session created: ${session.id} (${shellName(shell)}, pid ${session.pid})`)
|
||||
break
|
||||
}
|
||||
|
||||
case 'switch': {
|
||||
const { sessionId } = parsed
|
||||
const session = conn.sessions.get(sessionId)
|
||||
if (!session) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Session not found' }))
|
||||
return
|
||||
}
|
||||
conn.activeSessionId = sessionId
|
||||
|
||||
// Send switched first so frontend mounts the correct terminal
|
||||
ws.send(JSON.stringify({ type: 'switched', id: sessionId }))
|
||||
|
||||
// Then flush buffered output for this session
|
||||
const buf = conn.outputBuffers.get(sessionId)
|
||||
if (buf && buf.length > 0) {
|
||||
for (const chunk of buf) {
|
||||
ws.send(chunk)
|
||||
}
|
||||
conn.outputBuffers.delete(sessionId)
|
||||
}
|
||||
|
||||
console.log(`[Terminal] Switched to session ${sessionId}`)
|
||||
break
|
||||
}
|
||||
|
||||
case 'close': {
|
||||
const { sessionId } = parsed
|
||||
const session = conn.sessions.get(sessionId)
|
||||
if (!session) return
|
||||
session.pty.kill()
|
||||
conn.sessions.delete(sessionId)
|
||||
conn.outputBuffers.delete(sessionId)
|
||||
if (conn.activeSessionId === sessionId) {
|
||||
// Auto-switch to the first remaining session
|
||||
const remaining = Array.from(conn.sessions.keys())
|
||||
conn.activeSessionId = remaining.length > 0 ? remaining[0] : null
|
||||
}
|
||||
console.log(`[Terminal] Session closed: ${sessionId}`)
|
||||
break
|
||||
}
|
||||
|
||||
case 'resize': {
|
||||
const session = conn.activeSessionId ? conn.sessions.get(conn.activeSessionId) : null
|
||||
if (!session) return
|
||||
const cols = Math.max(1, parsed.cols || 0)
|
||||
const rows = Math.max(1, parsed.rows || 0)
|
||||
try { session.pty.resize(cols, rows) } catch { }
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cleanup ────────────────────────────────────────────────
|
||||
|
||||
ws.on('close', () => {
|
||||
for (const session of Array.from(conn.sessions.values())) {
|
||||
try { session.pty.kill() } catch { }
|
||||
}
|
||||
conn.sessions.clear()
|
||||
console.log(`[Terminal] Connection closed, all sessions killed`)
|
||||
})
|
||||
|
||||
ws.on('error', () => {
|
||||
for (const session of Array.from(conn.sessions.values())) {
|
||||
try { session.pty.kill() } catch { }
|
||||
}
|
||||
conn.sessions.clear()
|
||||
})
|
||||
|
||||
// ─── Auto-create first session ──────────────────────────────
|
||||
|
||||
let firstSession: PtySession
|
||||
try {
|
||||
firstSession = createSession(defaultShell)
|
||||
} catch (err: any) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: err.message }))
|
||||
console.error(`[Terminal] Failed to create session: ${err.message}`)
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
conn.sessions.set(firstSession.id, firstSession)
|
||||
conn.activeSessionId = firstSession.id
|
||||
attachPtyOutput(firstSession)
|
||||
ws.send(JSON.stringify({
|
||||
type: 'created',
|
||||
id: firstSession.id,
|
||||
pid: firstSession.pid,
|
||||
shell: shellName(defaultShell),
|
||||
}))
|
||||
console.log(`[Terminal] First session created: ${firstSession.id} (${shellName(defaultShell)}, pid ${firstSession.pid})`)
|
||||
})
|
||||
|
||||
console.log(`[Terminal] WebSocket ready at /terminal (shell: ${defaultShell})`)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import Router from '@koa/router'
|
||||
import axios from 'axios'
|
||||
import { readFile, writeFile } from 'fs/promises'
|
||||
import { chmod } from 'fs/promises'
|
||||
import { resolve } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import { restartGateway } from '../../services/hermes-cli'
|
||||
|
||||
const envPath = resolve(homedir(), '.hermes/.env')
|
||||
const ILINK_BASE = 'https://ilinkai.weixin.qq.com'
|
||||
|
||||
export const weixinRoutes = new Router()
|
||||
|
||||
// GET /api/weixin/qrcode — fetch QR code from Tencent iLink API
|
||||
weixinRoutes.get('/api/hermes/weixin/qrcode', async (ctx) => {
|
||||
try {
|
||||
const res = await axios.get(`${ILINK_BASE}/ilink/bot/get_bot_qrcode`, {
|
||||
params: { bot_type: 3 },
|
||||
timeout: 15000,
|
||||
})
|
||||
const data = res.data
|
||||
if (!data || !data.qrcode) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: 'Failed to get QR code' }
|
||||
return
|
||||
}
|
||||
ctx.body = {
|
||||
qrcode: data.qrcode,
|
||||
qrcode_url: data.qrcode_img_content,
|
||||
}
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message || 'Failed to connect to iLink API' }
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/weixin/qrcode/status — poll QR scan status
|
||||
weixinRoutes.get('/api/hermes/weixin/qrcode/status', async (ctx) => {
|
||||
const qrcode = ctx.query.qrcode as string
|
||||
if (!qrcode) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing qrcode parameter' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.get(`${ILINK_BASE}/ilink/bot/get_qrcode_status`, {
|
||||
params: { qrcode },
|
||||
timeout: 35000,
|
||||
})
|
||||
const data = res.data
|
||||
const status = data?.status || 'wait'
|
||||
ctx.body = { status }
|
||||
|
||||
// If confirmed, return credentials so frontend can save them
|
||||
if (status === 'confirmed') {
|
||||
ctx.body = {
|
||||
status: 'confirmed',
|
||||
account_id: data.ilink_bot_id,
|
||||
token: data.bot_token,
|
||||
base_url: data.baseurl,
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message || 'Failed to poll QR status' }
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/weixin/save — save weixin credentials to .env
|
||||
weixinRoutes.post('/api/hermes/weixin/save', async (ctx) => {
|
||||
const { account_id, token, base_url } = ctx.request.body as {
|
||||
account_id: string
|
||||
token: string
|
||||
base_url?: string
|
||||
}
|
||||
|
||||
if (!account_id || !token) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing account_id or token' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(envPath, 'utf-8')
|
||||
} catch {
|
||||
raw = ''
|
||||
}
|
||||
|
||||
const entries: Record<string, string> = {
|
||||
WEIXIN_ACCOUNT_ID: account_id,
|
||||
WEIXIN_TOKEN: token,
|
||||
}
|
||||
if (base_url) entries.WEIXIN_BASE_URL = base_url
|
||||
|
||||
const lines = raw.split('\n')
|
||||
const existingKeys = new Set<string>()
|
||||
|
||||
const result: string[] = []
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith('#')) {
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
const eqIdx = trimmed.indexOf('=')
|
||||
if (eqIdx !== -1) {
|
||||
const key = trimmed.slice(0, eqIdx).trim()
|
||||
if (key in entries) {
|
||||
result.push(`${key}=${entries[key]}`)
|
||||
existingKeys.add(key)
|
||||
continue
|
||||
}
|
||||
}
|
||||
result.push(line)
|
||||
}
|
||||
|
||||
for (const [key, val] of Object.entries(entries)) {
|
||||
if (!existingKeys.has(key)) {
|
||||
result.push(`${key}=${val}`)
|
||||
}
|
||||
}
|
||||
|
||||
let output = result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n'
|
||||
await writeFile(envPath, output, 'utf-8')
|
||||
try { await chmod(envPath, 0o600) } catch { /* ignore */ }
|
||||
await restartGateway()
|
||||
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { readFile, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { config } from '../config'
|
||||
|
||||
// Token stored in project data directory
|
||||
const TOKEN_FILE = join(config.dataDir, '.token')
|
||||
|
||||
function generateToken(): string {
|
||||
return randomBytes(32).toString('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the auth token. Returns null if auth is disabled.
|
||||
*/
|
||||
export async function getToken(): Promise<string | null> {
|
||||
// Auth can be disabled via env var
|
||||
if (process.env.AUTH_DISABLED === '1' || process.env.AUTH_DISABLED === 'true') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Custom token via env var
|
||||
if (process.env.AUTH_TOKEN) {
|
||||
return process.env.AUTH_TOKEN
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await readFile(TOKEN_FILE, 'utf-8')
|
||||
return token.trim()
|
||||
} catch {
|
||||
// Generate a new token
|
||||
const token = generateToken()
|
||||
await writeFile(TOKEN_FILE, token + '\n', { mode: 0o600 })
|
||||
return token
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Koa middleware: check Authorization header for API routes.
|
||||
* Skips /health, /webhook, and static file requests.
|
||||
*/
|
||||
export async function authMiddleware(token: string | null) {
|
||||
return async (ctx: any, next: () => Promise<void>) => {
|
||||
// If auth is disabled, skip
|
||||
if (!token) {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
// Skip non-API paths (static files, health check, SPA)
|
||||
const path = ctx.path
|
||||
if (
|
||||
path === '/health' ||
|
||||
(!path.startsWith('/api') && !path.startsWith('/v1') && path !== '/webhook')
|
||||
) {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
const auth = ctx.headers.authorization || ''
|
||||
const provided = auth.startsWith('Bearer ')
|
||||
? auth.slice(7)
|
||||
: (ctx.query.token as string) || ''
|
||||
|
||||
if (!provided || provided !== token) {
|
||||
ctx.status = 401
|
||||
ctx.set('Content-Type', 'application/json')
|
||||
ctx.body = { error: 'Unauthorized' }
|
||||
return
|
||||
}
|
||||
|
||||
await next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const execOpts = { windowsHide: true }
|
||||
|
||||
export interface HermesSession {
|
||||
id: string
|
||||
source: string
|
||||
user_id: string | null
|
||||
model: string
|
||||
title: string | null
|
||||
started_at: number
|
||||
ended_at: number | null
|
||||
end_reason: string | null
|
||||
message_count: number
|
||||
tool_call_count: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_read_tokens: number
|
||||
cache_write_tokens: number
|
||||
reasoning_tokens: number
|
||||
billing_provider: string | null
|
||||
estimated_cost_usd: number
|
||||
actual_cost_usd: number | null
|
||||
cost_status: string
|
||||
messages?: any[]
|
||||
}
|
||||
|
||||
interface HermesSessionFull {
|
||||
id: string
|
||||
source: string
|
||||
user_id: string | null
|
||||
model: string
|
||||
title: string | null
|
||||
started_at: number
|
||||
ended_at: number | null
|
||||
end_reason: string | null
|
||||
message_count: number
|
||||
tool_call_count: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_read_tokens?: number
|
||||
cache_write_tokens?: number
|
||||
reasoning_tokens?: number
|
||||
billing_provider: string | null
|
||||
estimated_cost_usd: number
|
||||
actual_cost_usd?: number | null
|
||||
cost_status?: string
|
||||
messages?: any[]
|
||||
system_prompt?: string
|
||||
model_config?: string
|
||||
cost_source?: string
|
||||
pricing_version?: string | null
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* List sessions from Hermes CLI (without messages)
|
||||
*/
|
||||
export async function listSessions(source?: string, limit?: number): Promise<HermesSession[]> {
|
||||
const args = ['sessions', 'export', '-']
|
||||
if (source) args.push('--source', source)
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('hermes', args, {
|
||||
maxBuffer: 50 * 1024 * 1024, // 50MB
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
|
||||
const lines = stdout.trim().split('\n').filter(Boolean)
|
||||
const sessions: HermesSession[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const raw: HermesSessionFull = JSON.parse(line)
|
||||
let title = raw.title
|
||||
if (!title && raw.messages) {
|
||||
const firstUser = raw.messages.find((m: any) => m.role === 'user')
|
||||
if (firstUser?.content) {
|
||||
const t = String(firstUser.content).slice(0, 40)
|
||||
title = t + (String(firstUser.content).length > 40 ? '...' : '')
|
||||
}
|
||||
}
|
||||
sessions.push({
|
||||
id: raw.id,
|
||||
source: raw.source,
|
||||
user_id: raw.user_id,
|
||||
model: raw.model,
|
||||
title,
|
||||
started_at: raw.started_at,
|
||||
ended_at: raw.ended_at,
|
||||
end_reason: raw.end_reason,
|
||||
message_count: raw.message_count,
|
||||
tool_call_count: raw.tool_call_count,
|
||||
input_tokens: raw.input_tokens,
|
||||
output_tokens: raw.output_tokens,
|
||||
cache_read_tokens: raw.cache_read_tokens || 0,
|
||||
cache_write_tokens: raw.cache_write_tokens || 0,
|
||||
reasoning_tokens: raw.reasoning_tokens || 0,
|
||||
billing_provider: raw.billing_provider,
|
||||
estimated_cost_usd: raw.estimated_cost_usd,
|
||||
actual_cost_usd: raw.actual_cost_usd ?? null,
|
||||
cost_status: raw.cost_status || '',
|
||||
})
|
||||
} catch { /* skip malformed lines */ }
|
||||
}
|
||||
|
||||
// Sort by started_at descending
|
||||
sessions.sort((a, b) => b.started_at - a.started_at)
|
||||
|
||||
if (limit && limit > 0) {
|
||||
return sessions.slice(0, limit)
|
||||
}
|
||||
return sessions
|
||||
} catch (err: any) {
|
||||
console.error('[Hermes CLI] sessions export failed:', err.message)
|
||||
throw new Error(`Failed to list sessions: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single session with messages from Hermes CLI
|
||||
*/
|
||||
export async function getSession(id: string): Promise<HermesSession | null> {
|
||||
const args = ['sessions', 'export', '-', '--session-id', id]
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('hermes', args, {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
|
||||
const lines = stdout.trim().split('\n').filter(Boolean)
|
||||
if (lines.length === 0) return null
|
||||
|
||||
if (!lines[0].startsWith('{')) return null
|
||||
|
||||
const raw: HermesSessionFull = JSON.parse(lines[0])
|
||||
return {
|
||||
id: raw.id,
|
||||
source: raw.source,
|
||||
user_id: raw.user_id,
|
||||
model: raw.model,
|
||||
title: raw.title,
|
||||
started_at: raw.started_at,
|
||||
ended_at: raw.ended_at,
|
||||
end_reason: raw.end_reason,
|
||||
message_count: raw.message_count,
|
||||
tool_call_count: raw.tool_call_count,
|
||||
input_tokens: raw.input_tokens,
|
||||
output_tokens: raw.output_tokens,
|
||||
cache_read_tokens: raw.cache_read_tokens || 0,
|
||||
cache_write_tokens: raw.cache_write_tokens || 0,
|
||||
reasoning_tokens: raw.reasoning_tokens || 0,
|
||||
billing_provider: raw.billing_provider,
|
||||
estimated_cost_usd: raw.estimated_cost_usd,
|
||||
actual_cost_usd: raw.actual_cost_usd ?? null,
|
||||
cost_status: raw.cost_status || '',
|
||||
messages: raw.messages,
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code === 1 || err.status === 1) return null
|
||||
console.error('[Hermes CLI] session export failed:', err.message)
|
||||
throw new Error(`Failed to get session: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session from Hermes CLI
|
||||
*/
|
||||
export async function deleteSession(id: string): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync('hermes', ['sessions', 'delete', id, '--yes'], {
|
||||
timeout: 10000,
|
||||
...execOpts,
|
||||
})
|
||||
return true
|
||||
} catch (err: any) {
|
||||
console.error('[Hermes CLI] session delete failed:', err.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a session title via Hermes CLI
|
||||
*/
|
||||
export async function renameSession(id: string, title: string): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync('hermes', ['sessions', 'rename', id, title], {
|
||||
timeout: 10000,
|
||||
...execOpts,
|
||||
})
|
||||
return true
|
||||
} catch (err: any) {
|
||||
console.error('[Hermes CLI] session rename failed:', err.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export interface LogFileInfo {
|
||||
name: string
|
||||
size: string
|
||||
modified: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Hermes version
|
||||
*/
|
||||
export async function getVersion(): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('hermes', ['--version'], { timeout: 5000, ...execOpts })
|
||||
return stdout.trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Hermes gateway (uses launchd/systemd)
|
||||
*/
|
||||
export async function startGateway(): Promise<string> {
|
||||
const { stdout, stderr } = await execFileAsync('hermes', ['gateway', 'start'], {
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
return stdout || stderr
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Hermes gateway in background (for WSL where launchd/systemd is unavailable)
|
||||
* Uses "hermes gateway run" as a detached background process
|
||||
*/
|
||||
export async function startGatewayBackground(): Promise<number | null> {
|
||||
const { spawn } = require('child_process') as typeof import('child_process')
|
||||
const child = spawn('hermes', ['gateway', 'run'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
})
|
||||
child.unref()
|
||||
return child.pid ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart Hermes gateway
|
||||
*/
|
||||
export async function restartGateway(): Promise<string> {
|
||||
const { stdout, stderr } = await execFileAsync('hermes', ['gateway', 'restart'], {
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
return stdout || stderr
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop Hermes gateway
|
||||
*/
|
||||
export async function stopGateway(): Promise<string> {
|
||||
const { stdout, stderr } = await execFileAsync('hermes', ['gateway', 'stop'], {
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
return stdout || stderr
|
||||
}
|
||||
|
||||
/**
|
||||
* List available log files
|
||||
*/
|
||||
export async function listLogFiles(): Promise<LogFileInfo[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('hermes', ['logs', 'list'], {
|
||||
timeout: 10000,
|
||||
...execOpts,
|
||||
})
|
||||
const files: LogFileInfo[] = []
|
||||
const lines = stdout.trim().split('\n').filter(l => l.includes('.log'))
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\s+(\S+)\s+([\d.]+\w+)\s+(.+)$/)
|
||||
if (match) {
|
||||
const rawName = match[1]
|
||||
const name = rawName.replace(/\.log$/, '')
|
||||
if (['agent', 'errors', 'gateway'].includes(name)) {
|
||||
files.push({ name, size: match[2], modified: match[3].trim() })
|
||||
}
|
||||
}
|
||||
}
|
||||
return files
|
||||
} catch (err: any) {
|
||||
console.error('[Hermes CLI] logs list failed:', err.message)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read log lines
|
||||
*/
|
||||
export async function readLogs(
|
||||
logName: string = 'agent',
|
||||
lines: number = 100,
|
||||
level?: string,
|
||||
session?: string,
|
||||
since?: string,
|
||||
): Promise<string> {
|
||||
const args = ['logs', logName, '-n', String(lines)]
|
||||
if (level) args.push('--level', level)
|
||||
if (session) args.push('--session', session)
|
||||
if (since) args.push('--since', since)
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('hermes', args, {
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
timeout: 15000,
|
||||
...execOpts,
|
||||
})
|
||||
return stdout
|
||||
} catch (err: any) {
|
||||
console.error('[Hermes CLI] logs read failed:', err.message)
|
||||
throw new Error(`Failed to read logs: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Profile management ──────────────────────────────────────
|
||||
|
||||
export interface HermesProfile {
|
||||
name: string
|
||||
active: boolean
|
||||
model: string
|
||||
gateway: string
|
||||
alias: string
|
||||
}
|
||||
|
||||
export interface HermesProfileDetail {
|
||||
name: string
|
||||
path: string
|
||||
model: string
|
||||
provider: string
|
||||
gateway: string
|
||||
skills: number
|
||||
hasEnv: boolean
|
||||
hasSoulMd: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* List all profiles
|
||||
*/
|
||||
export async function listProfiles(): Promise<HermesProfile[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('hermes', ['profile', 'list'], {
|
||||
timeout: 10000,
|
||||
...execOpts,
|
||||
})
|
||||
|
||||
const lines = stdout.trim().split('\n').filter(Boolean)
|
||||
const profiles: HermesProfile[] = []
|
||||
|
||||
// Skip header lines (starts with " Profile" or " ─")
|
||||
for (const line of lines) {
|
||||
if (line.startsWith(' Profile') || line.match(/^ ─/)) continue
|
||||
|
||||
const match = line.match(/^\s+(◆)?(\S+)\s{2,}(\S+)\s{2,}(\S+)\s{2,}(.*)$/)
|
||||
if (match) {
|
||||
profiles.push({
|
||||
name: match[2],
|
||||
active: !!match[1],
|
||||
model: match[3],
|
||||
gateway: match[4],
|
||||
alias: match[5].trim() === '—' ? '' : match[5].trim(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return profiles
|
||||
} catch (err: any) {
|
||||
console.error('[Hermes CLI] profile list failed:', err.message)
|
||||
throw new Error(`Failed to list profiles: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get profile details
|
||||
*/
|
||||
export async function getProfile(name: string): Promise<HermesProfileDetail> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('hermes', ['profile', 'show', name], {
|
||||
timeout: 10000,
|
||||
...execOpts,
|
||||
})
|
||||
|
||||
const result: Record<string, string> = {}
|
||||
for (const line of stdout.trim().split('\n')) {
|
||||
const match = line.match(/^(\w[\w\s]*?):\s+(.+)$/)
|
||||
if (match) {
|
||||
result[match[1].trim().toLowerCase().replace(/\s+/g, '_')] = match[2].trim()
|
||||
}
|
||||
}
|
||||
|
||||
const modelFull = result.model || ''
|
||||
const providerMatch = modelFull.match(/\((.+)\)/)
|
||||
const model = providerMatch ? modelFull.replace(/\s*\(.+\)/, '').trim() : modelFull
|
||||
|
||||
return {
|
||||
name: result.profile || name,
|
||||
path: result.path || '',
|
||||
model,
|
||||
provider: providerMatch ? providerMatch[1] : '',
|
||||
gateway: result.gateway || '',
|
||||
skills: parseInt(result.skills || '0', 10),
|
||||
hasEnv: result['.env'] === 'exists',
|
||||
hasSoulMd: result.soul_md === 'exists',
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code === 1 || err.status === 1) {
|
||||
throw new Error(`Profile "${name}" not found`)
|
||||
}
|
||||
console.error('[Hermes CLI] profile show failed:', err.message)
|
||||
throw new Error(`Failed to get profile: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new profile
|
||||
*/
|
||||
export async function createProfile(name: string, clone?: boolean): Promise<string> {
|
||||
const args = ['profile', 'create', name]
|
||||
if (clone) args.push('--clone')
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('hermes', args, {
|
||||
timeout: 15000,
|
||||
...execOpts,
|
||||
})
|
||||
return stdout || stderr
|
||||
} catch (err: any) {
|
||||
console.error('[Hermes CLI] profile create failed:', err.message)
|
||||
throw new Error(`Failed to create profile: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a profile
|
||||
*/
|
||||
export async function deleteProfile(name: string): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync('hermes', ['profile', 'delete', name, '--yes'], {
|
||||
timeout: 10000,
|
||||
...execOpts,
|
||||
})
|
||||
return true
|
||||
} catch (err: any) {
|
||||
console.error('[Hermes CLI] profile delete failed:', err.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a profile
|
||||
*/
|
||||
export async function renameProfile(oldName: string, newName: string): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync('hermes', ['profile', 'rename', oldName, newName], {
|
||||
timeout: 10000,
|
||||
...execOpts,
|
||||
})
|
||||
return true
|
||||
} catch (err: any) {
|
||||
console.error('[Hermes CLI] profile rename failed:', err.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch active profile
|
||||
*/
|
||||
export async function useProfile(name: string): Promise<string> {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('hermes', ['profile', 'use', name], {
|
||||
timeout: 10000,
|
||||
...execOpts,
|
||||
})
|
||||
return stdout || stderr
|
||||
} catch (err: any) {
|
||||
console.error('[Hermes CLI] profile use failed:', err.message)
|
||||
throw new Error(`Failed to switch profile: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export profile to archive
|
||||
*/
|
||||
export async function exportProfile(name: string, outputPath?: string): Promise<string> {
|
||||
const args = ['profile', 'export', name]
|
||||
if (outputPath) args.push('--output', outputPath)
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('hermes', args, {
|
||||
timeout: 60000,
|
||||
...execOpts,
|
||||
})
|
||||
return stdout || stderr
|
||||
} catch (err: any) {
|
||||
console.error('[Hermes CLI] profile export failed:', err.message)
|
||||
throw new Error(`Failed to export profile: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import profile from archive
|
||||
*/
|
||||
export async function importProfile(archivePath: string, name?: string): Promise<string> {
|
||||
const args = ['profile', 'import', archivePath]
|
||||
if (name) args.push('--name', name)
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('hermes', args, {
|
||||
timeout: 60000,
|
||||
...execOpts,
|
||||
})
|
||||
return stdout || stderr
|
||||
} catch (err: any) {
|
||||
console.error('[Hermes CLI] profile import failed:', err.message)
|
||||
throw new Error(`Failed to import profile: ${err.message}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { config } from '../config'
|
||||
|
||||
const UPSTREAM = config.upstream.replace(/\/$/, '')
|
||||
|
||||
/**
|
||||
* Send an instruction to Hermes Agent via /v1/runs
|
||||
*/
|
||||
export async function sendInstruction(params: {
|
||||
input: string | any[]
|
||||
instructions?: string
|
||||
conversationHistory?: any[]
|
||||
sessionId?: string
|
||||
authToken?: string
|
||||
}): Promise<{ run_id: string; status: string }> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (params.authToken) {
|
||||
headers['Authorization'] = `Bearer ${params.authToken}`
|
||||
}
|
||||
|
||||
const body: any = { input: params.input }
|
||||
if (params.instructions) body.instructions = params.instructions
|
||||
if (params.conversationHistory) body.conversation_history = params.conversationHistory
|
||||
if (params.sessionId) body.session_id = params.sessionId
|
||||
|
||||
const res = await fetch(`${UPSTREAM}/v1/runs`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(`Hermes API error ${res.status}: ${text}`)
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get run status (poll /v1/runs/:id if supported)
|
||||
*/
|
||||
export async function getRunStatus(runId: string): Promise<any> {
|
||||
const res = await fetch(`${UPSTREAM}/v1/runs/${runId}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to get run status: ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to SSE events for a run
|
||||
*/
|
||||
export async function* streamRunEvents(runId: string, authToken?: string): AsyncGenerator<any> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`
|
||||
}
|
||||
|
||||
const res = await fetch(`${UPSTREAM}/v1/runs/${runId}/events`, { headers })
|
||||
if (!res.ok || !res.body) {
|
||||
throw new Error(`Failed to stream run events: ${res.status}`)
|
||||
}
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6).trim()
|
||||
if (data === '[DONE]') return
|
||||
try {
|
||||
const event = JSON.parse(data)
|
||||
yield event
|
||||
if (event.event === 'run.completed' || event.event === 'run.failed') return
|
||||
} catch { /* skip malformed lines */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
export async function healthCheck(): Promise<{ status: string; version?: string }> {
|
||||
const res = await fetch(`${UPSTREAM}/health`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available models
|
||||
*/
|
||||
export async function fetchModels(): Promise<{ data: Array<{ id: string }> }> {
|
||||
const res = await fetch(`${UPSTREAM}/v1/models`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Webhook callback registry
|
||||
type WebhookCallback = (payload: any) => void | Promise<void>
|
||||
const webhookCallbacks: WebhookCallback[] = []
|
||||
|
||||
export function onWebhook(callback: WebhookCallback) {
|
||||
webhookCallbacks.push(callback)
|
||||
}
|
||||
|
||||
export function emitWebhook(payload: any) {
|
||||
for (const cb of webhookCallbacks) {
|
||||
const result = cb(payload)
|
||||
if (result && typeof result.catch === 'function') {
|
||||
result.catch((err: Error) => console.error('Webhook callback error:', err))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Provider registry — single source of truth for both frontend and backend.
|
||||
* Synced from hermes-agent hermes_cli/models.py _PROVIDER_MODELS.
|
||||
*/
|
||||
|
||||
export interface ProviderPreset {
|
||||
label: string
|
||||
value: string
|
||||
base_url: string
|
||||
models: string[]
|
||||
}
|
||||
|
||||
export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
{
|
||||
label: 'Anthropic',
|
||||
value: 'anthropic',
|
||||
base_url: 'https://api.anthropic.com',
|
||||
models: [
|
||||
'claude-opus-4-6',
|
||||
'claude-sonnet-4-6',
|
||||
'claude-opus-4-5-20251101',
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'claude-opus-4-20250514',
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-haiku-4-5-20251001',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Google AI Studio',
|
||||
value: 'gemini',
|
||||
base_url: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
||||
models: [
|
||||
'gemini-3.1-pro-preview',
|
||||
'gemini-3-flash-preview',
|
||||
'gemini-3.1-flash-lite-preview',
|
||||
'gemini-2.5-pro',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-flash-lite',
|
||||
'gemma-4-31b-it',
|
||||
'gemma-4-26b-it',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'DeepSeek',
|
||||
value: 'deepseek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
models: ['deepseek-chat', 'deepseek-reasoner'],
|
||||
},
|
||||
{
|
||||
label: 'Z.AI / GLM',
|
||||
value: 'zai',
|
||||
base_url: 'https://api.z.ai/api/paas/v4',
|
||||
models: ['glm-5', 'glm-5-turbo', 'glm-4.7', 'glm-4.5', 'glm-4.5-flash'],
|
||||
},
|
||||
{
|
||||
label: 'Kimi Coding Plan',
|
||||
value: 'kimi-coding',
|
||||
base_url: 'https://api.kimi.com/coding/v1',
|
||||
models: [
|
||||
'kimi-for-coding',
|
||||
'kimi-k2.5',
|
||||
'kimi-k2-thinking',
|
||||
'kimi-k2-thinking-turbo',
|
||||
'kimi-k2-turbo-preview',
|
||||
'kimi-k2-0905-preview',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Moonshot (Pay-as-you-go)',
|
||||
value: 'moonshot',
|
||||
base_url: 'https://api.moonshot.ai/v1',
|
||||
models: ['kimi-k2.5', 'kimi-k2-thinking', 'kimi-k2-turbo-preview', 'kimi-k2-0905-preview'],
|
||||
},
|
||||
{
|
||||
label: 'xAI',
|
||||
value: 'xai',
|
||||
base_url: 'https://api.x.ai/v1',
|
||||
models: [
|
||||
'grok-4.20-0309-reasoning',
|
||||
'grok-4.20-0309-non-reasoning',
|
||||
'grok-4-1-fast-reasoning',
|
||||
'grok-4-1-fast-non-reasoning',
|
||||
'grok-4-fast-reasoning',
|
||||
'grok-4-fast-non-reasoning',
|
||||
'grok-4-0709',
|
||||
'grok-code-fast-1',
|
||||
'grok-3',
|
||||
'grok-3-mini',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'MiniMax',
|
||||
value: 'minimax',
|
||||
base_url: 'https://api.minimax.io/anthropic',
|
||||
models: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.1', 'MiniMax-M2'],
|
||||
},
|
||||
{
|
||||
label: 'MiniMax (China)',
|
||||
value: 'minimax-cn',
|
||||
base_url: 'https://api.minimaxi.com/v1',
|
||||
models: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.1', 'MiniMax-M2'],
|
||||
},
|
||||
{
|
||||
label: 'Alibaba Cloud',
|
||||
value: 'alibaba',
|
||||
base_url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
||||
models: [
|
||||
'qwen3.5-plus',
|
||||
'qwen3-coder-plus',
|
||||
'qwen3-coder-next',
|
||||
'glm-5',
|
||||
'glm-4.7',
|
||||
'kimi-k2.5',
|
||||
'MiniMax-M2.5',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Hugging Face',
|
||||
value: 'huggingface',
|
||||
base_url: 'https://router.huggingface.co/v1',
|
||||
models: [
|
||||
'Qwen/Qwen3.5-397B-A17B',
|
||||
'Qwen/Qwen3.5-35B-A3B',
|
||||
'deepseek-ai/DeepSeek-V3.2',
|
||||
'moonshotai/Kimi-K2.5',
|
||||
'MiniMaxAI/MiniMax-M2.5',
|
||||
'zai-org/GLM-5',
|
||||
'XiaomiMiMo/MiMo-V2-Flash',
|
||||
'moonshotai/Kimi-K2-Thinking',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Xiaomi MiMo',
|
||||
value: 'xiaomi',
|
||||
base_url: 'https://api.xiaomimimo.com/v1',
|
||||
models: ['mimo-v2-pro', 'mimo-v2-omni', 'mimo-v2-flash'],
|
||||
},
|
||||
{
|
||||
label: 'Kilo Code',
|
||||
value: 'kilocode',
|
||||
base_url: 'https://api.kilo.ai/api/gateway',
|
||||
models: [
|
||||
'anthropic/claude-opus-4.6',
|
||||
'anthropic/claude-sonnet-4.6',
|
||||
'openai/gpt-5.4',
|
||||
'google/gemini-3-pro-preview',
|
||||
'google/gemini-3-flash-preview',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'AI Gateway',
|
||||
value: 'ai-gateway',
|
||||
base_url: 'https://ai-gateway.vercel.sh/v1',
|
||||
models: [
|
||||
'anthropic/claude-opus-4.6',
|
||||
'anthropic/claude-sonnet-4.6',
|
||||
'anthropic/claude-sonnet-4.5',
|
||||
'anthropic/claude-haiku-4.5',
|
||||
'openai/gpt-5',
|
||||
'openai/gpt-4.1',
|
||||
'openai/gpt-4.1-mini',
|
||||
'google/gemini-3-pro-preview',
|
||||
'google/gemini-3-flash',
|
||||
'google/gemini-2.5-pro',
|
||||
'google/gemini-2.5-flash',
|
||||
'deepseek/deepseek-v3.2',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'OpenCode Zen',
|
||||
value: 'opencode-zen',
|
||||
base_url: 'https://opencode.ai/zen/v1',
|
||||
models: [
|
||||
'gpt-5.4-pro',
|
||||
'gpt-5.4',
|
||||
'gpt-5.3-codex',
|
||||
'gpt-5.2',
|
||||
'gpt-5.1',
|
||||
'claude-opus-4-6',
|
||||
'claude-sonnet-4-6',
|
||||
'claude-haiku-4-5',
|
||||
'gemini-3.1-pro',
|
||||
'gemini-3-pro',
|
||||
'gemini-3-flash',
|
||||
'minimax-m2.7',
|
||||
'minimax-m2.5',
|
||||
'glm-5',
|
||||
'glm-4.7',
|
||||
'kimi-k2.5',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'OpenCode Go',
|
||||
value: 'opencode-go',
|
||||
base_url: 'https://opencode.ai/zen/go/v1',
|
||||
models: ['glm-5', 'kimi-k2.5', 'mimo-v2-pro', 'mimo-v2-omni', 'minimax-m2.7', 'minimax-m2.5'],
|
||||
},
|
||||
{
|
||||
label: 'OpenRouter',
|
||||
value: 'openrouter',
|
||||
base_url: 'https://openrouter.ai/api/v1',
|
||||
models: [],
|
||||
},
|
||||
]
|
||||
|
||||
/** Build a Record<providerKey, models[]> for backend lookup */
|
||||
export function buildProviderModelMap(): Record<string, string[]> {
|
||||
const map: Record<string, string[]> = {}
|
||||
for (const p of PROVIDER_PRESETS) {
|
||||
if (p.models.length > 0) {
|
||||
map[p.value] = p.models
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"outDir": "../../dist/server",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user