fix: Windows/Termux compatibility, comic theme fonts, and UI fixes (#630)

* fix: comprehensive Windows compatibility and gateway management improvements

This commit addresses multiple Windows compatibility issues and improves
gateway management across all platforms.

## Windows Compatibility Fixes
- Add hermes-path.ts with cross-platform Hermes home/bin detection
- Fix Windows native installation paths (%LOCALAPPDATA%\hermes)
- Update terminal.ts to use PowerShell instead of /bin/bash on Windows
- Fix upload.ts path construction to use path.join() for cross-platform paths
- Fix download.ts to use isAbsolute() for Windows absolute path detection
- Update auth.ts to skip file mode 0o600 on Windows (unsupported)
- Add nodemon.json for cross-platform environment variable handling

## Gateway Management Improvements
- Simplify gateway startup: all platforms use 'run' mode uniformly
- Remove complex init system detection and platform-specific code paths
- Improve PID file validation: use health check instead of port detection
- Remove getPortByPid() method (too complex and error-prone)
- Remove checkPortAvailable() TCP bind test (TIME_WAIT false positives)
- Trust gateway --replace flag to handle real port conflicts
- Add smart PID validation: check if stale process via health check
- Fix port allocation to avoid incrementing when gateway restarts
- Add allocatedPorts.clear() on each startAll() call
- Add clearPidFile() method to clean up stale PID files

## Process Management
- Remove detached:true and unref() from gateway spawn
- Gateway processes now follow parent process lifecycle
- Add process reference storage in ManagedGateway interface
- Improve shutdown logic: call gatewayManager.stopAll() before exit
- Fix Windows process killing: use process.kill(pid) for Windows
- Remove PowerShell command for lock file cleanup (use Node.js fs.unlinkSync)

## Frontend Theme Fixes
- Fix main.ts localStorage key mismatch (hermes_theme → hermes_brightness)
- Add inline script in index.html to prevent FOUC (Flash of Unstyled Content)
- Apply theme classes before Vue mount to avoid visual glitches

## Developer Experience
- Fix nodemon windows-kill popup on Windows by removing signal config
- Add delay and environment variables to nodemon.json
- Add windowsHide: true to all child process spawns

## Breaking Changes
- Gateway management now exclusively uses 'run' mode on all platforms
- systemd/launchd integration removed (use --replace flag instead)

This fix ensures hermes-web-ui works correctly on Windows native
installations while maintaining compatibility with Linux/macOS/WSL2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix gateway lifecycle port handling

* fix: comprehensive Windows compatibility and gateway management improvements

- Simplified hermes CLI binary resolution logic
- Fixed Windows line ending compatibility in profile list parsing
- Migrated gateway restart logic from CLI to GatewayManager
- Added gateway restart to updateCredentials method
- Removed unnecessary gateway restarts from provider operations
- Fixed configuration preservation when switching profiles
- Added nodemon quiet mode and legacy watch to reduce Windows popups

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* revert: change back to nodemon due to tsx compatibility issues

- tsx has compatibility issues with Koa generator functions
- Restored nodemon with simplified configuration
- Added cross-env package for future Windows environment variable needs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: replace nodemon with ts-node-dev to eliminate Windows popup windows

- Installed ts-node-dev as nodemon replacement
- ts-node-dev has better Windows compatibility without console popups
- Supports respawning, inspector debugging, and TypeScript compilation
- Uses cross-env for Windows environment variable support
- Removed nodemon.json configuration file (no longer needed)

Benefits:
- No more Windows console popup windows during development
- Faster restart times compared to nodemon
- Built-in TypeScript compilation without ts-node overhead

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: improve log parsing and Windows compatibility for agent/error logs

- Fixed Pino JSON log parsing bug where logger field incorrectly used obj.msg
- Changed logger field to use obj.name to properly display log source
- Added Windows line ending support (\r\n) for log file listing
- Added support for 'error' log type in addition to 'errors'
- Improved error message extraction from obj.err when available

This fixes the missing agent and error logs issue on Windows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix gateway health checks and shutdown ownership

* Refine auth lock window and dev shutdown

* fix: improve Hermes plugin discovery on Windows by fixing Python path resolution

- Added support for Windows venv Scripts directory structure
- Fixed Python executable path detection for hermes.exe in venv/Scripts/
- Added Windows LOCALAPPDATA hermes-agent directory to search paths
- Improved cross-platform compatibility for plugin discovery

This fixes the "No module named 'hermes_cli'" error on Windows by correctly
locating the Python virtual environment that contains the Hermes modules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: improve cross-platform compatibility for Hermes plugin discovery

- Added platform detection to only add Windows-specific paths on Windows
- Prevents potential issues on Unix/Linux/macOS systems
- Ensures LOCALAPPDATA path is only used when available on Windows
- Maintains existing behavior for all platforms

This makes the Windows plugin discovery fix safer for cross-platform usage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: remove unused development dependencies

- Removed nodemon (replaced by ts-node-dev)
- Removed tsx (had compatibility issues with Koa)
- Removed nodemon.json configuration file
- Cleaned up development tools to only what's actually used

This reduces dependency size and eliminates the windows-kill popup
source that was part of nodemon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: remove memory system files

- Removed MEMORY.md index file
- Removed memory/ directory and windows-compatibility.md
- Cleaned up unused memory persistence system

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: resolve TypeScript compilation error in plugins.ts

- Added type assertion 'as string[]' after filter(Boolean)
- Fixes TS2769 error: No overload matches this call
- Ensures type compatibility with hasHermesPluginModule function

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: comprehensive Windows compatibility and gateway management improvements

- Fix gateway detection after nodemon restart by adding health check-based detection
- Prevent port conflicts by detecting already-running gateways without PID files
- Switch to serial gateway startup to avoid lock file race conditions
- Return to nodemon from ts-node-dev for development stability
- Always stop gateways on shutdown to prevent orphan processes
- Prevent project root config files from being committed to git
- Fix syntax issues in plugins.ts

Resolves issues where default profile gateway failed to start after
nodemon restart and gateways were incorrectly marked as stopped
despite running on correct ports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: comic theme multilingual fonts, sidebar collapse fix, plugin discovery for Termux, and cron history

- Add Chinese (ZCOOL KuaiLe), Japanese (Zen Maru Gothic), Korean (Gaegu) handwritten fonts for Comic theme
- Fix collapsed sidebar: hide language switch, stack theme icons vertically
- Add hermes shebang parsing as fallback Python discovery for Termux
- Remove cron source filter from history sessions
- Add 0.5.17 changelog entries for all 8 locales

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: tolerate duplicate YAML keys in config parsing (closes #628)

Add `{ json: true }` to all 7 `yaml.load()` calls so duplicated mapping
keys (e.g. multiple `mcp_servers:` blocks) no longer crash the API.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: gateway ownership check requires PID file to prevent cross-profile port hijacking

Remove fallback that assumed ownership of healthy gateways without PID
verification. Now only claims a gateway if PID file exists and process
is alive, preventing one profile from hijacking another's port.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-05-11 20:08:13 +08:00
committed by GitHub
parent 0d14afe9b4
commit b4a80aceeb
46 changed files with 784 additions and 229 deletions
+4
View File
@@ -18,6 +18,10 @@ ROADMAP.md
packages/server/data/
packages/server/node_modules/
.hermes-web-ui/
# Hermes config files (should be in user data directory, not project root)
config.yaml
.env
hermes_data/
hermes-dependencies.md
# Editor directories and files
+13
View File
@@ -0,0 +1,13 @@
{
"watch": ["packages/server/src"],
"ext": "ts,tsx",
"execMap": {
"ts": "node -r ts-node/register"
},
"env": {
"TS_NODE_PROJECT": "packages/server/tsconfig.json"
},
"exec": "node -r ts-node/register packages/server/src/index.ts",
"nodeArgs": ["--no-warnings"],
"delay": "1000"
}
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "hermes-web-ui",
"version": "0.5.16",
"version": "0.5.17",
"description": "Self-hosted AI chat dashboard for Hermes Agent — multi-model web UI with multi-platform integration",
"repository": {
"type": "git",
@@ -36,7 +36,7 @@
"start": "vite --host --port 8648",
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"dev:client": "vite --host",
"dev:server": "nodemon --signal SIGTERM --watch packages/server/src -e ts,tsx --exec TS_NODE_PROJECT=packages/server/tsconfig.json node -r ts-node/register packages/server/src/index.ts",
"dev:server": "nodemon",
"build": "vue-tsc -b && vite build && tsc --noEmit -p packages/server/tsconfig.json && node scripts/build-server.mjs",
"prepare": "[ -d dist ] || npm run build",
"preview": "NODE_ENV=production vite preview",
@@ -86,6 +86,7 @@
"@xterm/xterm": "^6.0.0",
"axios": "^1.9.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"esbuild": "^0.27.0",
"highlight.js": "^11.11.1",
"js-yaml": "^4.1.1",
@@ -104,6 +105,7 @@
"qrcode": "^1.5.4",
"sass": "^1.99.0",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"tsoa": "^7.0.0-alpha.0",
"typescript": "~6.0.2",
"vite": "^8.0.4",
@@ -114,4 +116,4 @@
"vue-tsc": "^3.2.8",
"ws": "^8.20.0"
}
}
}
+27
View File
@@ -6,6 +6,33 @@
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hermes</title>
<!-- Prevent FOUC by applying theme classes immediately -->
<script>
(function() {
try {
const brightness = localStorage.getItem('hermes_brightness') || 'system';
const style = localStorage.getItem('hermes_style') || 'ink';
// Resolve dark mode
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = brightness === 'dark' || (brightness === 'system' && prefersDark);
// Resolve comic style
const isComic = style === 'comic';
// Apply classes immediately
if (isDark) {
document.documentElement.classList.add('dark');
}
if (isComic) {
document.documentElement.classList.add('comic');
}
} catch (e) {
console.warn('Failed to apply theme:', e);
}
})();
</script>
</head>
<body>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -688,6 +688,19 @@ function openChangelog() {
.status-row {
justify-content: center;
:deep(.input-sm) {
display: none;
}
}
.version-info {
justify-content: center;
padding: 4px 0;
:deep(.theme-switch-container) {
flex-direction: column;
}
}
}
}
@@ -5,7 +5,7 @@ const { isDark, isComic, toggleBrightness, toggleStyle } = useTheme()
</script>
<template>
<div style="display: flex; gap: 4px; align-items: center;">
<div class="theme-switch-container" style="display: flex; gap: 4px; align-items: center;">
<button class="theme-switch" :title="isComic ? 'Ink style' : 'Comic style'" @click="toggleStyle">
<!-- Palette icon for comic toggle -->
<svg v-if="isComic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+11
View File
@@ -5,6 +5,17 @@ export interface ChangelogEntry {
}
export const changelog: ChangelogEntry[] = [
{
version: '0.5.17',
date: '2026-05-11',
changes: [
'changelog.new_0_5_17_1',
'changelog.new_0_5_17_2',
'changelog.new_0_5_17_3',
'changelog.new_0_5_17_4',
'changelog.new_0_5_17_5',
],
},
{
version: '0.5.16',
date: '2026-05-10',
+5
View File
@@ -789,6 +789,11 @@ jobTriggered: 'Job ausgelost',
new_0_5_16_2: 'Echte API-Nutzung (Tokens, Cache, Reasoning) in Nutzungsstatistik-Tabelle speichern',
new_0_5_16_3: 'QQ-Gruppen-QR-Code zur Website-Navigationsleiste hinzugefügt',
new_0_5_16_4: 'Unbenutztes codex_reasoning_items-Feld aus dem Nachrichtenschema entfernt',
new_0_5_17_1: 'Vollständige Windows-Kompatibilität: Pfadverarbeitung, Prozessverwaltung, Terminal, Log-Parsing',
new_0_5_17_2: 'Gateway-Prozessverwaltung refaktoriert mit plattformübergreifendem Start/Stop/Health-Check',
new_0_5_17_3: 'Plugin-Erkennung auf Termux repariert durch Parsen des Hermes-Shebangs zur Python-Lokalisierung',
new_0_5_17_5: 'Auth-Sperrfenster und Dev-Shutdown-Ablauf verbessert',
new_0_5_17_6: 'Comic-Theme: Handschriften-Fonts für Chinesisch (ZCOOL KuaiLe), Japanisch (Zen Maru Gothic), Koreanisch (Gaegu) hinzugefügt',
new_0_5_13_1: 'Nachrichtenwarteschlange für sequenzielle Run-Verarbeitung hinzugefügt, um gleichzeitige Konflikte zu vermeiden',
new_0_5_13_2: 'Zwei-Ebenen-Skills-Verzeichnisstruktur mit Sonstige-Kategorie für flache Skills unterstützt',
new_0_5_13_3: 'Temporäre Sitzungen (eph_*) beim Start-Sync filtern, um interne Sitzungen nicht zu importieren',
+5
View File
@@ -1045,6 +1045,11 @@ export default {
new_0_5_16_2: 'Persist real API usage (tokens, cache, reasoning) to usage table',
new_0_5_16_3: 'Add QQ group QR code to website navigation bar',
new_0_5_16_4: 'Remove unused codex_reasoning_items field from message schema',
new_0_5_17_1: 'Full Windows compatibility: path handling, process management, terminal, log parsing',
new_0_5_17_2: 'Refactor Gateway process management with cross-platform start/stop/health-check',
new_0_5_17_3: 'Fix plugin discovery on Termux by parsing hermes shebang to locate Python',
new_0_5_17_5: 'Improve auth lock window and dev shutdown flow',
new_0_5_17_6: 'Add Chinese (ZCOOL KuaiLe), Japanese (Zen Maru Gothic), Korean (Gaegu) handwritten fonts for Comic theme',
new_0_5_13_1: 'Add message queue for sequential run processing to prevent concurrent request conflicts',
new_0_5_13_2: 'Support two-level skills directory structure with misc category for flat skills',
new_0_5_13_3: 'Filter out ephemeral sessions during startup sync to avoid importing internal sessions',
+5
View File
@@ -785,6 +785,11 @@ jobTriggered: 'Job ejecutado',
new_0_5_16_2: 'Persistir uso real de API (tokens, caché, razonamiento) en tabla de estadísticas',
new_0_5_16_3: 'Añadir código QR del grupo QQ a la barra de navegación del sitio web',
new_0_5_16_4: 'Eliminar campo codex_reasoning_items no utilizado del esquema de mensajes',
new_0_5_17_1: 'Compatibilidad completa con Windows: manejo de rutas, gestión de procesos, terminal, análisis de logs',
new_0_5_17_2: 'Refactorizada la gestión de procesos de Gateway con inicio/parada/health-check multiplataforma',
new_0_5_17_3: 'Corregido el descubrimiento de plugins en Termux analizando el shebang de hermes para localizar Python',
new_0_5_17_5: 'Mejorada la ventana de bloqueo de autenticación y el flujo de cierre del entorno de desarrollo',
new_0_5_17_6: 'Tema Comic: fuentes manuales para chino (ZCOOL KuaiLe), japonés (Zen Maru Gothic), coreano (Gaegu)',
new_0_5_13_1: 'Cola de mensajes para procesamiento secuencial de ejecuciones, evitando conflictos concurrentes',
new_0_5_13_2: 'Soporte para estructura de directorios de skills de dos niveles con categoría misc',
new_0_5_13_3: 'Filtrar sesiones efímeras (eph_*) durante la sincronización de inicio',
+5
View File
@@ -784,6 +784,11 @@ jobTriggered: 'Job declenche',
new_0_5_16_2: 'Persistance de l\'utilisation réelle de l\'API (tokens, cache, raisonnement) dans la table des statistiques',
new_0_5_16_3: 'Ajout du code QR du groupe QQ dans la barre de navigation du site',
new_0_5_16_4: 'Suppression du champ codex_reasoning_items inutilisé du schéma de messages',
new_0_5_17_1: 'Compatibilité Windows complète : gestion des chemins, des processus, terminal, analyse des logs',
new_0_5_17_2: 'Refonte de la gestion des processus Gateway avec démarrage/arrêt/health-check multiplateforme',
new_0_5_17_3: 'Correction de la découverte des plugins sur Termux en analysant le shebang hermes pour localiser Python',
new_0_5_17_5: 'Amélioration de la fenêtre de verrouillage d\'authentification et du processus d\'arrêt en dev',
new_0_5_17_6: 'Thème Comic : polices manuscrites pour chinois (ZCOOL KuaiLe), japonais (Zen Maru Gothic), coréen (Gaegu)',
new_0_5_13_1: 'File d\'attente de messages pour le traitement séquentiel des exécutions, évitant les conflits concurrents',
new_0_5_13_2: 'Prise en charge de la structure de répertoire de skills à deux niveaux avec catégorie divers',
new_0_5_13_3: 'Filtrer les sessions éphémères (eph_*) lors de la synchronisation au démarrage',
+5
View File
@@ -785,6 +785,11 @@ export default {
new_0_5_16_2: '実際の API 使用量(トークン、キャッシュ、推論)を統計テーブルに保存',
new_0_5_16_3: 'ウェブサイトのナビゲーションバーにQQグループのQRコードを追加',
new_0_5_16_4: 'メッセージスキーマから未使用の codex_reasoning_items フィールドを削除',
new_0_5_17_1: 'Windows完全対応:パス処理、プロセス管理、ターミナル、ログ解析',
new_0_5_17_2: 'Gatewayプロセス管理をリファクタリング、クロスプラットフォームの起動/停止/ヘルスチェックに対応',
new_0_5_17_3: 'Termuxでhermesのshebangを解析してPythonを見つけ、プラグイン検出を修正',
new_0_5_17_5: '認証ロックウィンドウと開発環境のシャットダウンフローを改善',
new_0_5_17_6: 'Comicテーマに中国語(ZCOOL KuaiLe)、日本語(Zen Maru Gothic)、韓国語(Gaegu)の手書きフォントを追加',
new_0_5_13_1: 'メッセージキューによる順次実行処理で同時リクエストの競合を防止',
new_0_5_13_2: '2階層スキルディレクトリ構造をサポート、フラットスキルは「その他」カテゴリに分類',
new_0_5_13_3: '起動同期時に一時セッション(eph_*)をフィルタリング',
+5
View File
@@ -785,6 +785,11 @@ export default {
new_0_5_16_2: '실제 API 사용량(토큰, 캐시, 추론)을 사용량 통계 테이블에 저장',
new_0_5_16_3: '웹사이트 내비게이션 바에 QQ 그룹 QR 코드 추가',
new_0_5_16_4: '메시지 스키마에서 사용하지 않는 codex_reasoning_items 필드 제거',
new_0_5_17_1: 'Windows 완전 호환: 경로 처리, 프로세스 관리, 터미널, 로그 파싱',
new_0_5_17_2: 'Gateway 프로세스 관리 리팩토링, 크로스 플랫폼 시작/중지/헬스체크 지원',
new_0_5_17_3: 'Termux에서 hermes shebang을 파싱하여 Python을 찾아 플러그인 발견 수정',
new_0_5_17_5: '인증 잠금 창 및 개발 환경 종료 흐름 개선',
new_0_5_17_6: 'Comic 테마에 중국어(ZCOOL KuaiLe), 일본어(Zen Maru Gothic), 한국어(Gaegu) 필기 폰트 추가',
new_0_5_13_1: '메시지 큐를 통한 순차 실행 처리로 동시 요청 충돌 방지',
new_0_5_13_2: '2단계 스킬 디렉토리 구조 지원, 플랫 스킬은 기타 카테고리로 분류',
new_0_5_13_3: '시작 동기화 시 임시 세션(eph_*) 필터링',
+5
View File
@@ -785,6 +785,11 @@ jobTriggered: 'Job acionado',
new_0_5_16_2: 'Persistir uso real da API (tokens, cache, raciocínio) na tabela de estatísticas',
new_0_5_16_3: 'Adicionar código QR do grupo QQ à barra de navegação do site',
new_0_5_16_4: 'Remover campo codex_reasoning_items não utilizado do esquema de mensagens',
new_0_5_17_1: 'Compatibilidade total com Windows: manipulação de caminhos, gerenciamento de processos, terminal, análise de logs',
new_0_5_17_2: 'Refatorado gerenciamento de processos do Gateway com início/parada/health-check multiplataforma',
new_0_5_17_3: 'Corrigida descoberta de plugins no Termux analisando o shebang do hermes para localizar o Python',
new_0_5_17_5: 'Melhorada janela de bloqueio de autenticação e fluxo de desligamento do ambiente de desenvolvimento',
new_0_5_17_6: 'Tema Comic: fontes manuscritas para chinês (ZCOOL KuaiLe), japonês (Zen Maru Gothic), coreano (Gaegu)',
new_0_5_13_1: 'Fila de mensagens para processamento sequencial de execuções, evitando conflitos concorrentes',
new_0_5_13_2: 'Suporte à estrutura de diretório de skills de dois níveis com categoria diversos',
new_0_5_13_3: 'Filtrar sessões efêmeras (eph_*) durante a sincronização na inicialização',
+5
View File
@@ -1047,6 +1047,11 @@ export default {
new_0_5_16_2: '持久化真实 API 用量(token、缓存、推理)到用量统计表',
new_0_5_16_3: '官网导航栏新增 QQ 群二维码',
new_0_5_16_4: '移除消息 schema 中未使用的 codex_reasoning_items 字段',
new_0_5_17_1: '全面兼容 Windows:路径处理、进程管理、终端、日志解析',
new_0_5_17_2: '重构 Gateway 进程管理,支持跨平台启动/停止/健康检查',
new_0_5_17_3: '修复 Termux 环境下插件发现失败的问题,自动解析 hermes shebang 定位 Python',
new_0_5_17_5: '优化认证锁定窗口和开发环境关闭流程',
new_0_5_17_6: 'Comic 主题新增中文(站酷快乐体)、日文(Zen Maru Gothic)、韩文(Gaegu)手写字体',
new_0_5_13_1: '新增消息队列,顺序处理运行请求,避免并发冲突',
new_0_5_13_2: '支持二级 Skills 目录结构,扁平化 Skill 归入"杂项"分类',
new_0_5_13_3: '启动同步时过滤临时会话(eph_*),避免导入内部会话',
+15 -3
View File
@@ -5,12 +5,24 @@ import { i18n } from './i18n'
import App from './App.vue'
import './styles/global.scss'
// Apply dark class before mount to prevent FOUC
const savedTheme = localStorage.getItem('hermes_theme') || 'system'
// Apply theme classes before mount to prevent FOUC (Flash of Unstyled Content)
const savedBrightness = localStorage.getItem('hermes_brightness') || 'system'
const savedStyle = localStorage.getItem('hermes_style') || 'ink'
// Resolve dark mode
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (savedTheme === 'dark' || (savedTheme === 'system' && prefersDark)) {
const isDark = savedBrightness === 'dark' || (savedBrightness === 'system' && prefersDark)
// Resolve style
const isComic = savedStyle === 'comic'
// Apply classes to prevent FOUC
if (isDark) {
document.documentElement.classList.add('dark')
}
if (isComic) {
document.documentElement.classList.add('comic')
}
// Read token from URL BEFORE router initializes (hash router strips params)
const urlParams = new URLSearchParams(window.location.search)
+35 -16
View File
@@ -6,15 +6,47 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/ComicNeue-Regular.ttf') format('truetype');
src: url('/fonts/ComicNeue-Bold.ttf') format('truetype');
}
@font-face {
font-family: 'Comic Neue';
font-family: 'ZCOOL KuaiLe';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/ZCOOLKuaiLe-Regular.ttf') format('truetype');
}
@font-face {
font-family: 'Zen Maru Gothic';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/ZenMaruGothic-Regular.ttf') format('truetype');
}
@font-face {
font-family: 'Zen Maru Gothic';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/ComicNeue-Bold.ttf') format('truetype');
src: url('/fonts/ZenMaruGothic-Bold.ttf') format('truetype');
}
@font-face {
font-family: 'Gaegu';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/Gaegu-Regular.ttf') format('truetype');
}
@font-face {
font-family: 'Gaegu';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/Gaegu-Bold.ttf') format('truetype');
}
*,
@@ -68,20 +100,7 @@ body {
-moz-osx-font-smoothing: grayscale;
}
html.comic body {
font-weight: var(--comic-font-weight, 700);
}
html.comic button,
html.comic input,
html.comic select,
html.comic textarea,
html.comic a,
html.comic span,
html.comic div,
html.comic label {
font-weight: var(--comic-font-weight, 700);
}
code, pre, .mono {
font-family: $font-code;
+1 -1
View File
@@ -149,7 +149,7 @@ export const darkThemeOverrides: GlobalThemeOverrides = {
export function getThemeOverrides(isDark: boolean, isComic?: boolean): GlobalThemeOverrides {
const base = isDark ? darkThemeOverrides : lightThemeOverrides
if (!isComic) return base
const comicFont = "'Comic Neue', 'Comic Sans MS', cursive, sans-serif"
const comicFont = "'Comic Neue', 'ZCOOL KuaiLe', 'Zen Maru Gothic', 'Gaegu', cursive, sans-serif"
return {
...base,
common: {
+1 -2
View File
@@ -115,10 +115,9 @@
--msg-system-border: #1a1a1a;
// Comic-specific
--font-ui: 'Comic Neue', 'Comic Sans MS', cursive, sans-serif;
--font-ui: 'Comic Neue', 'ZCOOL KuaiLe', 'Zen Maru Gothic', 'Gaegu', cursive, sans-serif;
--comic-border-width: 2.5px;
--comic-shadow: 3px 3px 0px rgba(0, 0, 0, 0.15);
--comic-font-weight: 700;
}
.dark.comic {
@@ -1,8 +1,9 @@
import { readFile, writeFile, copyFile } from 'fs/promises'
import YAML from 'js-yaml'
import { restartGateway } from '../../services/hermes/hermes-cli'
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
import { getActiveConfigPath, getActiveEnvPath } from '../../services/hermes/hermes-profile'
import { saveEnvValue } from '../../services/config-helpers'
import { logger } from '../../services/logger'
const PLATFORM_SECTIONS = new Set([
'telegram', 'discord', 'slack', 'whatsapp', 'matrix',
@@ -90,7 +91,7 @@ async function readEnvPlatforms(): Promise<Record<string, any>> {
async function readConfig(): Promise<Record<string, any>> {
const raw = await readFile(configPath(), 'utf-8')
return (YAML.load(raw) as Record<string, any>) || {}
return (YAML.load(raw, { json: true }) as Record<string, any>) || {}
}
async function writeConfig(data: Record<string, any>): Promise<void> {
@@ -141,7 +142,21 @@ export async function updateConfig(ctx: any) {
const config = await readConfig()
config[section] = deepMerge(config[section] || {}, values)
await writeConfig(config)
if (PLATFORM_SECTIONS.has(section)) { await restartGateway() }
// 使用 GatewayManager 重启平台网关
if (PLATFORM_SECTIONS.has(section)) {
const mgr = getGatewayManagerInstance()
if (mgr) {
try {
const activeProfile = mgr.getActiveProfile()
await mgr.stop(activeProfile)
await mgr.start(activeProfile)
} catch (err) {
logger.error(err, 'GatewayManager restart failed')
}
}
}
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
@@ -189,7 +204,19 @@ export async function updateCredentials(ctx: any) {
}
}
if (configChanged) { await writeConfig(config) }
await restartGateway()
// 使用 GatewayManager 重启平台网关
const mgr = getGatewayManagerInstance()
if (mgr) {
try {
const activeProfile = mgr.getActiveProfile()
await mgr.stop(activeProfile)
await mgr.start(activeProfile)
} catch (err) {
logger.error(err, 'GatewayManager restart failed')
}
}
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
@@ -16,7 +16,10 @@ function parseLine(line: string): LogEntry {
if (obj.level && obj.time) {
const ts = new Date(obj.time).toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-')
const levelMap: Record<number, string> = { 10: 'DEBUG', 20: 'INFO', 30: 'WARN', 40: 'ERROR', 50: 'FATAL' }
return { timestamp: ts, level: levelMap[obj.level] || 'INFO', logger: obj.msg || '', message: typeof obj.msg === 'string' ? obj.msg : JSON.stringify(obj.msg), raw: line }
// Pino 日志格式: { level, time, msg, name (logger name), hostname, pid, ... }
const loggerName = obj.name || obj.logger || 'app'
const message = obj.msg || (obj.err ? obj.err.message : '')
return { timestamp: ts, level: levelMap[obj.level] || 'INFO', logger: loggerName, message: typeof message === 'string' ? message : JSON.stringify(message), raw: line }
}
} catch {}
let 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(.*)$/)
@@ -188,9 +188,14 @@ export async function switchProfile(ctx: any) {
try {
const detail = await hermesCli.getProfile(name)
logger.debug('Profile detail.path = %s', detail.path)
if (!existsSync(join(detail.path, 'config.yaml'))) {
try { await hermesCli.setupReset() } catch { }
// 确保配置文件存在,但不调用 setupReset()(会重置端口配置)
const profileConfig = join(detail.path, 'config.yaml')
if (!existsSync(profileConfig)) {
writeFileSync(profileConfig, '# Hermes Agent Configuration\n', 'utf-8')
logger.info('Created config.yaml for: %s', detail.path)
}
const profileEnv = join(detail.path, '.env')
if (!existsSync(profileEnv)) {
writeFileSync(profileEnv, '# Hermes Agent Environment Configuration\n', 'utf-8')
@@ -90,7 +90,8 @@ export async function create(ctx: any) {
delete config.model.base_url
delete config.model.api_key
await writeConfigYaml(config)
try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') }
// TODO: Test if provider works without gateway restart
// try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') }
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
@@ -127,7 +128,8 @@ export async function update(ctx: any) {
}
if (api_key !== undefined) { await saveEnvValue(envMapping.api_key_env, api_key) }
}
try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') }
// TODO: Test if provider works without gateway restart
// try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') }
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
@@ -185,7 +187,8 @@ export async function remove(ctx: any) {
await writeConfigYaml(freshConfig)
}
}
try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') }
// TODO: Test if provider works without gateway restart
// try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') }
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
@@ -172,7 +172,7 @@ export async function listHermesSessions(ctx: any) {
try {
const sessions = await listSessionSummaries(source, limit && limit > 0 ? limit : 2000)
ctx.body = { sessions: filterPendingDeletedSessions(sessions.filter(s => s.source !== 'api_server' && s.source !== 'cron')) }
ctx.body = { sessions: filterPendingDeletedSessions(sessions.filter(s => s.source !== 'api_server')) }
return
} catch (err) {
logger.warn(err, 'Hermes Session DB: summary query failed, falling back to CLI')
@@ -237,7 +237,7 @@ export async function getHermesSession(ctx: any) {
// Try database first (consistent with listHermesSessions)
try {
const session = await getSessionDetailFromDb(ctx.params.id)
if (session && session.source !== 'api_server' && session.source !== 'cron') {
if (session && session.source !== 'api_server') {
ctx.body = { session }
return
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { randomBytes } from 'crypto'
import { writeFile } from 'fs/promises'
import { join } from 'path'
import { config } from '../config'
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024 // 50MB
@@ -42,7 +43,7 @@ export async function handleUpload(ctx: any) {
}
const ext = filename.includes('.') ? '.' + filename.split('.').pop() : ''
const savedName = randomBytes(8).toString('hex') + ext
const savedPath = `${config.uploadDir}/${savedName}`
const savedPath = join(config.uploadDir, savedName)
await writeFile(savedPath, data)
results.push({ name: filename, path: savedPath })
}
+11 -1
View File
@@ -29,12 +29,17 @@ const APP_VERSION = typeof __APP_VERSION__ !== 'undefined'
// Global error handlers
process.on('uncaughtException', (err) => {
console.error('FATAL: Uncaught exception')
console.error(err)
logger.fatal(err, 'Uncaught exception')
process.exit(1)
})
process.on('unhandledRejection', (reason) => {
console.error('FATAL: Unhandled rejection')
console.error(reason)
logger.error(reason, 'Unhandled rejection')
process.exit(1)
})
let server: any = null
@@ -180,4 +185,9 @@ export async function bootstrap() {
startVersionCheck()
}
bootstrap()
bootstrap().catch((error) => {
console.error('FATAL: Failed to start Hermes Web UI')
console.error(error)
logger.fatal(error, 'Fatal error during bootstrap')
process.exit(1)
})
@@ -1,5 +1,5 @@
import Router from '@koa/router'
import { basename, extname } from 'path'
import { basename, extname, isAbsolute } from 'path'
import {
createFileProvider,
localProvider,
@@ -75,7 +75,7 @@ downloadRoutes.get('/api/hermes/download', async (ctx) => {
try {
// Validate the path first
// Support both absolute and relative paths
const validPath = filePath.startsWith('/') ? validatePath(filePath) : resolveHermesPath(filePath)
const validPath = isAbsolute(filePath) ? validatePath(filePath) : resolveHermesPath(filePath)
// Choose provider: always use local for upload directory files
let data: Buffer
@@ -2,6 +2,7 @@ import { WebSocketServer } from 'ws'
import type { Server as HttpServer } from 'http'
import { accessSync, chmodSync, constants as fsConstants, existsSync } from 'fs'
import { dirname, join } from 'path'
import { homedir } from 'os'
import { getToken } from '../../services/auth'
import { logger } from '../../services/logger'
@@ -43,12 +44,16 @@ try {
// ─── Shell detection ────────────────────────────────────────────
function findShell(): string {
// Windows 平台:使用 PowerShell
if (process.platform === 'win32') {
return 'powershell.exe'
}
// Unix 平台:使用 SHELL 环境变量,或回退到常用 shells
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) {
@@ -91,7 +96,7 @@ function createSession(shell: string): PtySession {
name: 'xterm-color',
cols: 80,
rows: 24,
cwd: process.env.HOME || undefined,
cwd: homedir(),
})
} catch (err: any) {
throw new Error(`Failed to spawn shell "${shell}": ${err.message}`)
+6 -1
View File
@@ -29,7 +29,12 @@ export async function getToken(): Promise<string | null> {
} catch {
const token = generateToken()
await mkdir(APP_HOME, { recursive: true })
await writeFile(TOKEN_FILE, token + '\n', { mode: 0o600 })
// Only set mode on Unix systems (Windows ignores this)
const options: any = {}
if (process.platform !== 'win32') {
options.mode = 0o600
}
await writeFile(TOKEN_FILE, token + '\n', options)
return token
}
}
@@ -73,7 +73,7 @@ const configPath = () => getActiveConfigPath()
export async function readConfigYaml(): Promise<Record<string, any>> {
const raw = await safeReadFile(configPath())
if (!raw) return {}
return (YAML.load(raw) as Record<string, any>) || {}
return (YAML.load(raw, { json: true }) as Record<string, any>) || {}
}
export async function writeConfigYaml(config: Record<string, any>): Promise<void> {
@@ -714,7 +714,7 @@ export function getTerminalConfig(): TerminalConfig {
const configPath = `${getActiveProfileDir()}/config.yaml`
if (!existsSync(configPath)) return { backend: 'local' }
const raw = readFileSync(configPath, 'utf-8')
const doc = YAML.load(raw) as any
const doc = YAML.load(raw, { json: true }) as any
const t = doc?.terminal || {}
return {
backend: (t.backend as BackendType) || 'local',
@@ -30,15 +30,16 @@
* - WSL / Dockerhermes gateway rundetached 子进程,手动 kill
*/
import { spawn, type ChildProcess } from 'child_process'
import { spawn, execSync, type ChildProcess } from 'child_process'
import { resolve, join } from 'path'
import { homedir } from 'os'
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs'
import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from 'fs'
import { execFile } from 'child_process'
import { promisify } from 'util'
import { createServer } from 'net'
import yaml from 'js-yaml'
import { logger } from '../logger'
import { detectHermesHome, getHermesBin } from './hermes-path'
const execFileAsync = promisify(execFile)
@@ -46,8 +47,8 @@ const execFileAsync = promisify(execFile)
// 常量 & 环境检测
// ============================
const HERMES_BASE = resolve(homedir(), '.hermes')
const HERMES_BIN = process.env.HERMES_BIN?.trim() || 'hermes'
const HERMES_BASE = detectHermesHome()
const HERMES_BIN = getHermesBin()
/**
* 检测系统的 init 系统(服务管理器)
@@ -94,10 +95,17 @@ function detectInitSystem(): string {
return 'unknown'
}
// 注意:虽然此函数仍然存在,但当前所有平台都统一使用 run 模式
// 保留此函数是为了将来如果需要切换回 start/stop 模式时可以参考
const initSystem = detectInitSystem()
const needsRunMode = !['systemd', 'launchd', 'windows-service'].includes(initSystem)
/**
* 所有平台统一使用 run 模式
* run 模式会自动处理锁定文件冲突(--replace 标志),更可靠
* 子进程跟随父进程生命周期,父进程关闭时子进程自动关闭
*/
const needsRunMode = true
// 启动时输出 init 系统检测结果(方便调试)
logger.debug('Detected init system: %s (needsRunMode: %s)', initSystem, needsRunMode)
logger.debug('Detected init system: %s (needsRunMode: %s, platform: %s)', initSystem, needsRunMode, process.platform)
// ============================
// 类型定义
@@ -117,9 +125,15 @@ interface ManagedGateway {
port: number
host: string
url: string
owned: boolean
process?: ChildProcess
}
interface ResolvedGatewayEndpoint {
port: number
host: string
}
function formatHostForUrl(host: string): string {
if (host.startsWith('[') && host.endsWith(']')) return host
return host.includes(':') ? `[${host}]` : host
@@ -129,6 +143,10 @@ function buildHttpUrl(host: string, port: number): string {
return `http://${formatHostForUrl(host)}:${port}`
}
function isLocalHost(host: string): boolean {
return ['127.0.0.1', 'localhost', '::1', '[::1]', '0.0.0.0'].includes(host)
}
// ============================
// GatewayManager
// ============================
@@ -169,7 +187,7 @@ export class GatewayManager {
try {
const content = readFileSync(configPath, 'utf-8')
const cfg = yaml.load(content) as any || {}
const cfg = yaml.load(content, { json: true }) as any || {}
const extra = cfg?.platforms?.api_server?.extra
const rawPort = extra?.port || 8642
@@ -223,20 +241,17 @@ export class GatewayManager {
}
/** 尝试绑定端口,检测端口是否被系统级进程占用 */
private checkPortAvailable(port: number, host: string): Promise<boolean> {
if (port < 0 || port > 65535) return Promise.resolve(false)
return new Promise((resolve) => {
const server = createServer()
server.once('error', () => {
server.close()
resolve(false)
})
server.once('listening', () => {
server.close()
resolve(true)
})
server.listen(port, host)
})
/** 清理过期的 PID 文件 */
private clearPidFile(name: string): void {
try {
const pidPath = join(this.profileDir(name), 'gateway.pid')
if (existsSync(pidPath)) {
unlinkSync(pidPath)
logger.debug('Cleared stale PID file for profile "%s"', name)
}
} catch (err) {
logger.debug('Failed to clear PID file: %s', err)
}
}
/** 从 base 端口开始递增查找空闲端口(上限 65535) */
@@ -287,8 +302,9 @@ export class GatewayManager {
const configPath = join(this.profileDir(name), 'config.yaml')
try {
const content = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : ''
const cfg = (yaml.load(content) as any) || {}
const cfg = (yaml.load(content, { json: true }) as any) || {}
// 确保 platforms.api_server 结构存在(不会影响其他位置的 platforms)
if (!cfg.platforms) cfg.platforms = {}
if (!cfg.platforms.api_server) cfg.platforms.api_server = {}
if (!cfg.platforms.api_server.extra) cfg.platforms.api_server.extra = {}
@@ -322,40 +338,53 @@ export class GatewayManager {
* 为 profile 分配可用端口(启动前调用)
*
* 检测顺序:
* 1. 已管理的网关 + 已分配的端口 → 内存级检查(快)
* 2. 系统 TCP bind 测试 → 检测外部进程占用
* 3. 冲突则从 base+1 递增找空闲端口,写入 config.yaml
* 1. 当前 profile 已经健康运行 → 直接使用运行端口
* 2. 未运行 → 从 8642 开始找空闲端口
* 3. 检查已管理 profile / 本轮已分配端口 / 系统 TCP 占用
* 4. 先写入 config.yaml,再启动 gateway
*/
private async resolvePort(name: string): Promise<{ port: number; host: string }> {
let { port, host } = this.readProfilePort(name)
const { port: configuredPort, host } = this.readProfilePort(name)
const configuredUrl = buildHttpUrl(host, configuredPort)
// 收集已占用端口:正在运行的网关 + 本次启动已分配的端口
// 检查是否是当前 profile 自己的端口(内存中的记录)
const existing = this.gateways.get(name)
if (existing && existing.host === host && this.isProcessAlive(existing.pid) && await this.checkHealth(existing.url, 1000)) {
// 如果内存中有记录且进程存活,直接使用内存中的端口
logger.info('Profile "%s" already running on port %d (in-memory record)', name, existing.port)
this.allocatedPorts.add(existing.port)
return { port: existing.port, host }
}
// 检查 PID 文件指向的当前 profile 是否仍健康运行
const pid = this.readPidFile(name)
if (pid && this.isProcessAlive(pid) && await this.checkHealth(configuredUrl, 1000)) {
logger.info('Profile "%s" already running on configured port %d (PID: %d)', name, configuredPort, pid)
this.gateways.set(name, { pid, port: configuredPort, host, url: configuredUrl, owned: false })
this.allocatedPorts.add(configuredPort)
return { port: configuredPort, host }
}
// 如果没有 PID 文件也没有内存记录,不认领端口上的未知网关
// 如果端口被占用,findFreePort 会分配新端口
// 收集已占用端口:本次启动已分配的端口 + 其他 profile 的网关端口
const usedPorts = new Set<number>(this.allocatedPorts)
for (const gw of Array.from(this.gateways.values())) {
for (const [profileName, gw] of Array.from(this.gateways.entries())) {
// 跳过当前 profile 自己的端口
if (profileName === name) continue
if (gw.host === host && this.isProcessAlive(gw.pid)) {
usedPorts.add(gw.port)
}
}
if (usedPorts.has(port)) {
// 已管理端口冲突 → 找空闲端口
const newPort = await this.findFreePort(port, host, usedPorts)
logger.info('Port %d is in use for profile "%s", reassigning to %d', port, name, newPort)
this.writeProfilePort(name, newPort, host)
port = newPort
const port = await this.findFreePort(8642, host, usedPorts)
if (configuredPort !== port) {
logger.info('Assigning port for profile "%s": %d → %d', name, configuredPort, port)
} else {
// 检查系统级端口占用(外部进程)
const available = await this.checkPortAvailable(port, host)
if (!available) {
const newPort = await this.findFreePort(port, host, usedPorts)
logger.info('Port %d is occupied by another process for profile "%s", reassigning to %d', port, name, newPort)
this.writeProfilePort(name, newPort, host)
port = newPort
} else {
// 端口空闲,写入完整配置(确保 api_server 配置齐全)
this.writeProfilePort(name, port, host)
}
logger.debug('Assigning port %d for profile "%s"', port, name)
}
this.writeProfilePort(name, port, host)
this.allocatedPorts.add(port)
return { port, host }
@@ -439,12 +468,13 @@ export class GatewayManager {
const { port, host } = this.readProfilePort(name)
const url = buildHttpUrl(host, port)
// 首先检查 PID 文件:如果存在且进程存活且健康,则标记为运行
if (pid && this.isProcessAlive(pid) && await this.checkHealth(url)) {
this.gateways.set(name, { pid, port, host, url })
this.gateways.set(name, { pid, port, host, url, owned: false })
return { profile: name, port, host, url, running: true, pid }
}
// 未运行或端口不匹配
// 没有 PID 文件时不认领端口上的未知网关,避免误判其他 profile 的网关
this.gateways.delete(name)
return { profile: name, port, host, url, running: false }
}
@@ -465,56 +495,78 @@ export class GatewayManager {
* 启动前自动调用 resolvePort() 确保端口可用且配置完整
*/
async start(name: string): Promise<GatewayStatus> {
const { port, host } = await this.resolvePort(name)
const hermesHome = this.profileDir(name)
const url = buildHttpUrl(host, port)
// 检查是否已在运行
const existing = this.gateways.get(name)
if (existing && this.isProcessAlive(existing.pid)) {
if (await this.checkHealth(existing.url, 1000)) {
logger.info('Gateway for profile "%s" already running (PID: %d, port: %d)', name, existing.pid, existing.port)
return { profile: name, port: existing.port, host: existing.host, url: existing.url, running: true, pid: existing.pid }
}
if (needsRunMode) {
// WSL / Docker:无 systemd/launchd,用 "gateway run" 作为 detached 子进程
return new Promise((resolve, reject) => {
const env = { ...process.env, HERMES_HOME: hermesHome }
const child = spawn(HERMES_BIN, ['gateway', 'run', '--replace'], {
detached: true,
stdio: 'ignore',
windowsHide: true,
env,
})
child.unref()
const pid = child.pid ?? 0
logger.info('Starting gateway for profile "%s" (run mode, PID: %d, port: %d)', name, pid, port)
this.waitForReady(name, pid, port, host, url)
.then(resolve)
.catch(reject)
})
}
// 正常系统:先 start,失败则 restart(处理服务已运行的情况)
logger.info('Starting gateway for profile "%s" (start mode, port: %d)', name, port)
const env = { ...process.env, HERMES_HOME: hermesHome }
try {
const { stdout } = await execFileAsync(HERMES_BIN, ['gateway', 'start'], {
timeout: 30000,
env,
windowsHide: true,
})
logger.debug('gateway start output: %s', stdout?.trim())
} catch {
// start 失败(可能服务已运行),用 restart
logger.info('Gateway for profile "%s" is alive but unhealthy (PID: %d, port: %d), restarting',
name, existing.pid, existing.port)
try {
const { stdout } = await execFileAsync(HERMES_BIN, ['gateway', 'restart'], {
timeout: 30000,
env,
windowsHide: true,
})
logger.debug('gateway restart output: %s', stdout?.trim())
} catch (err: any) {
logger.warn(err, 'gateway start/restart (non-fatal)')
await this.stop(name)
} catch (err) {
logger.debug('Failed to stop unhealthy gateway before restart: %s', err)
}
}
return this.waitForReady(name, 0, port, host, url)
const endpoint = await this.resolvePort(name)
return this.startResolved(name, endpoint)
}
/** 使用已经解析好的端口启动网关,避免 startAll() 中重复分配端口 */
private async startResolved(name: string, endpoint: ResolvedGatewayEndpoint): Promise<GatewayStatus> {
const { port, host } = endpoint
const hermesHome = this.profileDir(name)
const url = buildHttpUrl(host, port)
// Windows 特定:清理僵尸锁定文件
if (process.platform === 'win32') {
const lockPath = join(hermesHome, 'gateway.lock')
if (existsSync(lockPath)) {
try {
const content = readFileSync(lockPath, 'utf-8').trim()
const lockData = JSON.parse(content)
const pid = lockData.pid
if (pid && !this.isProcessAlive(pid)) {
logger.warn('Found stale gateway lock file (PID: %d), attempting cleanup', pid)
try {
// 使用 Node.js 内置方法删除文件,避免 PowerShell 弹窗
unlinkSync(lockPath)
logger.info('Successfully removed stale lock file')
} catch (err) {
logger.debug('Failed to remove lock file: %s', err)
}
}
} catch (err) {
logger.debug('Failed to check lock file: %s', err)
}
}
}
// 所有平台统一使用 run 模式:子进程跟随父进程生命周期
return new Promise((resolve, reject) => {
const env = { ...process.env, HERMES_HOME: hermesHome }
const child = spawn(HERMES_BIN, ['gateway', 'run', '--replace'], {
stdio: 'ignore',
windowsHide: true,
env,
})
// 不使用 detached 和 unref,让子进程跟随父进程生命周期
const pid = child.pid ?? 0
logger.info('Starting gateway for profile "%s" (run mode, PID: %d, port: %d)', name, pid, port)
// 保存子进程引用,用于后续管理
this.gateways.set(name, { pid, port, host, url, owned: true, process: child })
this.waitForReady(name, pid, port, host, url)
.then(resolve)
.catch(reject)
})
}
/** 等待网关健康检查通过,最多 15 秒 */
@@ -527,7 +579,15 @@ export class GatewayManager {
if (await this.checkHealth(url, 2000)) {
// "gateway start" 自行管理进程,重新从 pid 文件读取实际 PID
const actualPid = this.readPidFile(name) ?? pid
this.gateways.set(name, { pid: actualPid, port, host, url })
const previous = this.gateways.get(name)
this.gateways.set(name, {
pid: actualPid,
port,
host,
url,
owned: previous?.owned ?? true,
process: previous?.process,
})
return { profile: name, port, host, url, running: true, pid: actualPid || undefined }
}
await new Promise(r => setTimeout(r, 500))
@@ -535,41 +595,137 @@ export class GatewayManager {
throw new Error(`Gateway health check timed out after 15000ms`)
}
private async getListeningPids(port: number): Promise<number[]> {
try {
if (process.platform === 'win32') {
const { stdout } = await execFileAsync('netstat', ['-ano', '-p', 'tcp'], {
timeout: 5000,
windowsHide: true,
})
const pids = new Set<number>()
for (const line of stdout.split(/\r?\n/)) {
const parts = line.trim().split(/\s+/)
if (parts.length < 5 || parts[0].toUpperCase() !== 'TCP') continue
const localAddress = parts[1]
const state = parts[3]?.toUpperCase()
const pid = parseInt(parts[4], 10)
if (state === 'LISTENING' && localAddress.endsWith(`:${port}`) && Number.isFinite(pid)) {
pids.add(pid)
}
}
return Array.from(pids)
}
const { stdout } = await execFileAsync('lsof', [`-tiTCP:${port}`, '-sTCP:LISTEN'], {
timeout: 5000,
})
return stdout
.split(/\r?\n/)
.map(line => parseInt(line.trim(), 10))
.filter(pid => Number.isFinite(pid))
} catch {
return []
}
}
private async killPid(pid: number, force = false): Promise<void> {
if (!pid) return
if (process.platform === 'win32') {
try {
await execFileAsync('taskkill', ['/PID', String(pid), '/T', '/F'], {
timeout: 5000,
windowsHide: true,
})
} catch {
try { process.kill(pid) } catch { }
}
return
}
const signal = force ? 'SIGKILL' : 'SIGTERM'
try {
process.kill(-pid, signal)
} catch {
try { process.kill(pid, signal) } catch { }
}
}
private async stopViaHermesCli(name: string): Promise<void> {
const hermesHome = this.profileDir(name)
try {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'stop'], {
timeout: 15000,
windowsHide: true,
env: { ...process.env, HERMES_HOME: hermesHome },
})
const output = `${stdout}${stderr}`.trim()
if (output) logger.debug('%s: hermes gateway stop: %s', name, output)
} catch (err) {
logger.debug('Failed to stop gateway via Hermes CLI for profile "%s": %s', name, err)
}
}
/**
* 停止单个 profile 的网关
* 正常系统用 "gateway stop"WSL/Docker 直接 kill 进程
* 所有平台使用 run 模式,直接 kill 进程
* 返回前等待 health check 确认网关已真正停止
*/
async stop(name: string, timeoutMs = 10000): Promise<void> {
// 记录当前 URL,用于确认停止
const gw = this.gateways.get(name)
const url = gw?.url || (() => {
const { port, host } = this.readProfilePort(name)
return buildHttpUrl(host, port)
})()
const configured = this.readProfilePort(name)
const port = gw?.port ?? configured.port
const host = gw?.host ?? configured.host
const url = gw?.url || buildHttpUrl(host, port)
if (!needsRunMode) {
// 正常系统:通过 hermes CLI 停止系统服务
try {
const hermesHome = this.profileDir(name)
const env = { ...process.env, HERMES_HOME: hermesHome }
await execFileAsync(HERMES_BIN, ['gateway', 'stop'], {
timeout: 10000,
env,
windowsHide: true,
})
} catch { }
} else {
// WSL / Docker:直接杀进程组
let pid = gw?.pid
if (!pid) {
pid = this.readPidFile(name) ?? undefined
// 所有平台使用 run 模式,直接杀进程
const pids = new Set<number>()
if (gw?.process?.pid) pids.add(gw.process.pid)
if (gw?.pid) pids.add(gw.pid)
const pidFilePid = this.readPidFile(name)
if (pidFilePid) pids.add(pidFilePid)
if (isLocalHost(host)) {
for (const pid of await this.getListeningPids(port)) {
pids.add(pid)
}
if (pid) {
try { process.kill(-pid, 'SIGTERM') } catch {
try { process.kill(pid, 'SIGTERM') } catch { }
}
}
if (pids.size === 0) {
if (!(await this.checkHealth(url, 1000))) {
this.gateways.delete(name)
this.allocatedPorts.delete(port)
this.clearPidFile(name)
logger.info('Stopped gateway for profile "%s" (already stopped)', name)
return
}
await this.stopViaHermesCli(name)
if (!(await this.checkHealth(url, 1000))) {
this.gateways.delete(name)
this.allocatedPorts.delete(port)
this.clearPidFile(name)
logger.info('Stopped gateway for profile "%s"', name)
return
}
throw new Error(`Cannot stop gateway for profile "${name}": no PID available`)
}
await this.stopViaHermesCli(name)
if (!(await this.checkHealth(url, 1000))) {
this.gateways.delete(name)
this.allocatedPorts.delete(port)
this.clearPidFile(name)
logger.info('Stopped gateway for profile "%s"', name)
return
}
if (gw?.process && !gw.process.killed) {
try { gw.process.kill(process.platform === 'win32' ? undefined : 'SIGTERM') } catch { }
}
for (const pid of pids) {
await this.killPid(pid)
}
// 等待 health check 失败,确认网关已真正停止
@@ -577,19 +733,50 @@ export class GatewayManager {
while (Date.now() < deadline) {
if (!(await this.checkHealth(url, 1000))) {
this.gateways.delete(name)
this.allocatedPorts.delete(port)
this.clearPidFile(name)
logger.info('Stopped gateway for profile "%s"', name)
return
}
await new Promise(r => setTimeout(r, 300))
}
// 超时也清理
this.gateways.delete(name)
logger.warn('Stopped gateway for profile "%s" (timeout)', name)
if (isLocalHost(host)) {
const listeningPids = await this.getListeningPids(port)
if (listeningPids.length) {
logger.warn(
'Gateway for profile "%s" still listening on port %d, force killing PIDs: %s',
name,
port,
listeningPids.join(', '),
)
for (const pid of listeningPids) {
await this.killPid(pid, true)
}
const forceDeadline = Date.now() + 3000
while (Date.now() < forceDeadline) {
if (!(await this.checkHealth(url, 500))) {
this.gateways.delete(name)
this.allocatedPorts.delete(port)
this.clearPidFile(name)
logger.info('Stopped gateway for profile "%s" (force killed)', name)
return
}
await new Promise(r => setTimeout(r, 200))
}
}
}
logger.warn('Failed to stop gateway for profile "%s" within %dms', name, timeoutMs)
throw new Error(`Gateway stop timed out after ${timeoutMs}ms`)
}
/** 停止所有已管理的网关(并行执行) */
async stopAll(): Promise<void> {
const entries = Array.from(this.gateways.keys())
const entries = Array.from(this.gateways.entries())
.filter(([, gw]) => gw.owned)
.map(([name]) => name)
await Promise.allSettled(entries.map(name => this.stop(name)))
}
@@ -620,26 +807,29 @@ export class GatewayManager {
* Phase 2 — 并行启动网关进程
*/
async startAll(): Promise<void> {
const profiles = await this.listProfiles()
// 清空已分配端口集合,确保每次启动都从干净状态开始
this.allocatedPorts.clear()
const profiles = await this.listProfiles()
// Phase 1: 顺序处理
const toStart: string[] = []
const toStart: Array<{ name: string; endpoint: ResolvedGatewayEndpoint }> = []
for (const name of profiles) {
const existing = this.gateways.get(name)
if (existing && this.isProcessAlive(existing.pid)) {
logger.info('%s: already running (PID: %d)', name, existing.pid)
continue
}
if (await this.checkHealth(existing.url, 1000)) {
logger.info('%s: already running (PID: %d, port: %d)', name, existing.pid, existing.port)
continue
}
// 有 PID 文件但进程未在正确端口运行 → 旧进程,先停掉
const pid = this.readPidFile(name)
if (pid && this.isProcessAlive(pid)) {
logger.info('%s: stale process (PID: %d), stopping', name, pid)
try { await this.stop(name) } catch { }
logger.info('%s: process alive but unhealthy (PID: %d, port: %d), restarting',
name, existing.pid, existing.port)
try {
await this.stop(name)
} catch (err) {
logger.debug('Failed to stop unhealthy gateway: %s', err)
}
}
await this.resolvePort(name)
// Skip remote profiles — local hermes command cannot start remote gateways
const { host } = this.readProfilePort(name)
if (host && host !== '127.0.0.1' && host !== 'localhost') {
@@ -647,18 +837,47 @@ export class GatewayManager {
continue
}
toStart.push(name)
// 有 PID 文件但进程未在正确端口运行 → 通过 health check 检查网关状态
const pid = this.readPidFile(name)
if (pid && this.isProcessAlive(pid)) {
const { port: configuredPort, host } = this.readProfilePort(name)
const configuredUrl = buildHttpUrl(host, configuredPort)
// 检查配置文件中的端口是否有正常的网关在运行
if (await this.checkHealth(configuredUrl, 2000)) {
// Health check 通过,说明网关正常工作
logger.info('%s: gateway already running on configured port %d (PID: %d, health check passed)',
name, configuredPort, pid)
// 注册到内存中
this.gateways.set(name, { pid, port: configuredPort, host, url: configuredUrl, owned: false })
continue
} else {
// Health check 失败,说明网关有问题(僵尸进程或端口冲突)
logger.info('%s: stale process (PID: %d) health check failed on port %d, stopping and restarting',
name, pid, configuredPort)
try {
await this.stop(name)
} catch (err) {
logger.debug('Failed to stop stale gateway: %s', err)
}
// 清理过期的 PID 文件
this.clearPidFile(name)
}
}
// 只为真正需要启动的网关分配端口
const endpoint = await this.resolvePort(name)
toStart.push({ name, endpoint })
}
// Phase 2: 并行启动
const tasks = toStart.map(async (name) => {
// 串行启动网关,避免并发时的lock file竞争条件
for (const { name, endpoint } of toStart) {
try {
await this.start(name)
await this.startResolved(name, endpoint)
} catch (err: any) {
logger.error(err, '%s: failed to start', name)
}
})
await Promise.allSettled(tasks)
}
}
}
@@ -8,10 +8,12 @@ const execFileAsync = promisify(execFile)
const execOpts = { windowsHide: true }
const isDocker = existsSync('/.dockerenv')
/**
* 解析 Hermes CLI 二进制路径
* 优先使用环境变量 HERMES_BIN,否则使用 PATH 中的 'hermes' 命令
*/
function resolveHermesBin(): string {
const envBin = process.env.HERMES_BIN?.trim()
if (envBin) return envBin
return 'hermes'
return process.env.HERMES_BIN?.trim() || 'hermes'
}
const HERMES_BIN = resolveHermesBin()
@@ -273,20 +275,16 @@ export async function startGatewayBackground(): Promise<number | null> {
}
/**
* Restart Hermes gateway
* Restart Hermes gateway (stop then start)
*/
export async function restartGateway(): Promise<string> {
if (isDocker) {
try { await stopGateway() } catch { }
const pid = await startGatewayBackground()
return pid ? `Gateway restarted (PID: ${pid})` : 'Gateway restart triggered'
try {
await stopGateway()
} catch (err) {
// Ignore stop errors, gateway might not be running
}
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'restart'], {
timeout: 30000,
...execOpts,
})
return stdout || stderr
const result = await startGateway()
return result
}
/**
@@ -310,13 +308,16 @@ export async function listLogFiles(): Promise<LogFileInfo[]> {
...execOpts,
})
const files: LogFileInfo[] = []
const lines = stdout.trim().split('\n').filter(l => l.includes('.log'))
// Windows 可能使用 \r\n 换行符,统一处理
const normalized = stdout.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const lines = normalized.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)) {
// 支持更多日志类型:agent, errors, gateway, 以及其他可能的日志文件
if (['agent', 'errors', 'gateway', 'error'].includes(name)) {
files.push({ name, size: match[2], modified: match[3].trim() })
}
}
@@ -387,7 +388,9 @@ export async function listProfiles(): Promise<HermesProfile[]> {
...execOpts,
})
const lines = stdout.trim().split('\n').filter(Boolean)
// Windows 可能使用 \r\n 换行符,统一处理
const normalized = stdout.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const lines = normalized.trim().split('\n').filter(Boolean)
const profiles: HermesProfile[] = []
// Skip header lines (starts with " Profile" or " ─")
@@ -0,0 +1,50 @@
/**
* Hermes 路径检测工具 - 跨平台兼容
*
* Hermes 数据目录在不同平台上的位置:
* - Windows 原生安装: %LOCALAPPDATA%\hermes
* - Linux/macOS/WSL2: ~/.hermes
* - 用户自定义: HERMES_HOME 环境变量
*/
import { resolve, join } from 'path'
import { homedir } from 'os'
/**
* 智能检测 Hermes 数据目录
*
* 检测优先级:
* 1. HERMES_HOME 环境变量(用户自定义)
* 2. Windows: %LOCALAPPDATA%\hermes(原生安装)
* 3. 默认: ~/.hermesLinux/macOS/WSL2
*
* @returns Hermes 数据目录的绝对路径
*/
export function detectHermesHome(): string {
// 1. 用户自定义的环境变量(最高优先级)
if (process.env.HERMES_HOME) {
return resolve(process.env.HERMES_HOME)
}
// 2. Windows:直接使用 %LOCALAPPDATA%\hermes
if (process.platform === 'win32') {
const localAppData = process.env.LOCALAPPDATA || process.env.APPDATA
if (localAppData) {
return join(localAppData, 'hermes')
}
}
// 3. Linux/macOS~/.hermes
return resolve(homedir(), '.hermes')
}
/**
* 获取 Hermes CLI 二进制文件路径
* @param customBin 自定义的 hermes 二进制路径
* @returns hermes 命令名称或路径
*/
export function getHermesBin(customBin?: string): string {
if (customBin?.trim()) return customBin.trim()
if (process.env.HERMES_BIN?.trim()) return process.env.HERMES_BIN.trim()
return 'hermes'
}
@@ -1,8 +1,9 @@
import { resolve, join } from 'path'
import { homedir } from 'os'
import { readFileSync, existsSync } from 'fs'
import { detectHermesHome } from './hermes-path'
const HERMES_BASE = process.env.HERMES_HOME || resolve(homedir(), '.hermes')
const HERMES_BASE = detectHermesHome()
/**
* Get the active profile's home directory.
@@ -5,8 +5,9 @@ import yaml from 'js-yaml'
import { PROVIDER_PRESETS } from '../../shared/providers'
import { getDb } from '../../db'
import { MODEL_CONTEXT_TABLE } from '../../db/hermes/schemas'
import { detectHermesHome } from './hermes-path'
const HERMES_BASE = resolve(homedir(), '.hermes')
const HERMES_BASE = detectHermesHome()
const MODELS_DEV_CACHE = resolve(HERMES_BASE, 'models_dev_cache.json')
const DEFAULT_CONTEXT_LENGTH = 200_000
@@ -51,7 +52,7 @@ function loadConfig(profileDir: string): any | null {
const configPath = join(profileDir, 'config.yaml')
if (!existsSync(configPath)) return null
try {
return yaml.load(readFileSync(configPath, 'utf-8')) as any
return yaml.load(readFileSync(configPath, 'utf-8'), { json: true }) as any
} catch {
return null
}
+48 -8
View File
@@ -1,6 +1,7 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { existsSync, readFileSync } from 'fs'
import { dirname, join, resolve } from 'path'
import { homedir } from 'os'
import { promisify } from 'util'
const execFileAsync = promisify(execFile)
@@ -230,29 +231,68 @@ function maybeRootFromHermesBin(): string[] {
return candidates.filter((candidate, index) => candidates.indexOf(candidate) === index)
}
/**
* Parse the shebang of the hermes binary to extract the Python interpreter path.
* Works with pip-installed launchers, uv tool launchers, and manual venv installs.
* e.g. "#!/Users/ekko/.hermes/hermes-agent/venv/bin/python3" -> that path
*/
function pythonFromHermesShebang(): string | undefined {
const hermesBin = process.env.HERMES_BIN?.trim() || 'hermes'
try {
const resolved = resolve(hermesBin)
if (!existsSync(resolved)) return undefined
const head = readFileSync(resolved, 'utf8').slice(0, 256)
const match = head.match(/^#!\s*(\/[^\s\n]+)/)
return match ? match[1] : undefined
} catch {
return undefined
}
}
function resolveHermesAgentRoot(): string {
const candidates = [
process.env.HERMES_AGENT_ROOT?.trim(),
...maybeRootFromHermesBin(),
'/opt/hermes',
join(process.env.HOME || '', '.hermes', 'hermes-agent'),
].filter(Boolean) as string[]
join(homedir(), '.hermes', 'hermes-agent'), // Unix/Linux/macOS
]
// Windows specific path
if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
candidates.push(join(process.env.LOCALAPPDATA, 'hermes', 'hermes-agent'))
}
return candidates.find(hasHermesPluginModule) || ''
return (candidates.filter(Boolean) as string[]).find(hasHermesPluginModule) || ''
}
function pythonCandidates(agentRoot: string): string[] {
const hermesBin = process.env.HERMES_BIN?.trim()
const hermesBinPython = hermesBin && hermesBin.includes('/bin/') ? join(dirname(hermesBin), 'python') : undefined
const rootPythons = agentRoot
? [join(agentRoot, '.venv', 'bin', 'python'), join(agentRoot, 'venv', 'bin', 'python')]
: []
let hermesBinPython: string | undefined
if (hermesBin) {
// Windows: hermes -> venv\Scripts\python.exe
// Unix: hermes -> venv/bin/python
if (hermesBin.includes('\\Scripts\\') || hermesBin.includes('/Scripts/')) {
hermesBinPython = join(dirname(hermesBin), 'python.exe')
} else if (hermesBin.includes('/bin/') || hermesBin.includes('\\bin\\')) {
hermesBinPython = join(dirname(hermesBin), 'python')
}
}
const rootPythons = agentRoot ? [
join(agentRoot, 'venv', 'bin', 'python'), // Unix
join(agentRoot, 'venv', 'Scripts', 'python.exe'), // Windows
join(agentRoot, '.venv', 'bin', 'python'), // Unix (alternative)
join(agentRoot, '.venv', 'Scripts', 'python.exe'), // Windows (alternative)
] : []
const candidates = [
process.env.HERMES_PYTHON?.trim(),
hermesBinPython,
...rootPythons,
'python3',
'python',
pythonFromHermesShebang(),
].filter(Boolean) as string[]
return candidates.filter((candidate) => {
@@ -16,8 +16,9 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import { homedir } from 'os'
import yaml from 'js-yaml'
import { detectHermesHome } from './hermes-path'
const HERMES_BASE = join(homedir(), '.hermes')
const HERMES_BASE = detectHermesHome()
/**
* 已知"独占型"平台的环境变量前缀正则
@@ -114,7 +115,7 @@ export function disableExclusivePlatformsInConfig(configPath: string): {
const original = readFileSync(configPath, 'utf-8')
let cfg: any
try {
cfg = yaml.load(original)
cfg = yaml.load(original, { json: true })
} catch {
return { disabled: [], strippedConfigCredentials: [] }
}
@@ -14,8 +14,9 @@ import { createSession, addMessage, updateSession } from '../../db/hermes/sessio
import { getDb } from '../../db/index'
import { logger } from '../logger'
import { listSessionSummaries as listHermesSessionSummaries } from '../../db/hermes/sessions-db'
import { detectHermesHome } from './hermes-path'
const HERMES_BASE = resolve(homedir(), '.hermes')
const HERMES_BASE = detectHermesHome()
const PROFILES_DIR = join(HERMES_BASE, 'profiles')
/**
+24 -10
View File
@@ -8,6 +8,7 @@ const LOCK_FILE = join(APP_HOME, '.login-lock.json')
// Per-IP settings
const IP_MAX_FAILURES = 3
const IP_FAILURE_WINDOW_MS = 15 * 60_000 // 15 minutes
const IP_LOCK_DURATION_MS = 60 * 60_000 // 1 hour
const IP_MAP_MAX_SIZE = 10000
@@ -20,6 +21,7 @@ const GLOBAL_LOCK_DURATION_MS = 30 * 60_000 // 30 minutes
interface IpEntry {
failures: number
lockedUntil: number
firstFailureAt?: number
}
interface LimiterState {
@@ -149,6 +151,26 @@ function checkIpLock(ip: string, map: Record<string, IpEntry>): CheckResult | nu
return null
}
function recordIpFailure(map: Record<string, IpEntry>, ip: string): IpEntry {
const t = now()
let entry = map[ip]
if (!entry) {
entry = { failures: 0, lockedUntil: 0, firstFailureAt: t }
map[ip] = entry
}
const firstFailureAt = entry.firstFailureAt || t
if (entry.lockedUntil <= 0 && t - firstFailureAt > IP_FAILURE_WINDOW_MS) {
entry.failures = 0
entry.firstFailureAt = t
} else if (!entry.firstFailureAt) {
entry.firstFailureAt = firstFailureAt
}
entry.failures++
return entry
}
export function checkPassword(ip: string): CheckResult {
const global = checkGlobalLimits()
if (global) return global
@@ -178,11 +200,7 @@ export function checkToken(ip: string): CheckResult {
}
export function recordPasswordFailure(ip: string): void {
if (!state.passwordIpMap[ip]) {
state.passwordIpMap[ip] = { failures: 0, lockedUntil: 0 }
}
const entry = state.passwordIpMap[ip]
entry.failures++
const entry = recordIpFailure(state.passwordIpMap, ip)
state.globalTotalFailures++
dirty = true
@@ -201,11 +219,7 @@ export function recordPasswordFailure(ip: string): void {
}
export function recordTokenFailure(ip: string): void {
if (!state.tokenIpMap[ip]) {
state.tokenIpMap[ip] = { failures: 0, lockedUntil: 0 }
}
const entry = state.tokenIpMap[ip]
entry.failures++
const entry = recordIpFailure(state.tokenIpMap, ip)
state.globalTotalFailures++
dirty = true
+26
View File
@@ -1,5 +1,16 @@
import { logger } from './logger'
import { closeDb } from '../db'
import { getGatewayManagerInstance } from './gateway-bootstrap'
function shouldStopGatewaysOnShutdown(signal: string): boolean {
// 总是停止网关,无论是开发环境还是生产环境
// 这样可以避免 nodemon 重启时的孤儿进程问题
const override = process.env.HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN?.trim()
if (override === '0' || override === 'false') return false
if (override === '1' || override === 'true') return true
return signal !== 'SIGUSR2'
}
export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?: any): void {
let isShuttingDown = false
@@ -14,6 +25,21 @@ export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?:
logger.info('Shutting down (%s)...', signal)
try {
if (shouldStopGatewaysOnShutdown(signal)) {
// Stop gateway processes owned by this Web UI instance first.
try {
const gatewayManager = getGatewayManagerInstance()
if (gatewayManager) {
await gatewayManager.stopAll()
logger.info('All gateways stopped')
}
} catch (err) {
logger.warn(err, 'Failed to stop gateways (non-fatal)')
}
} else {
logger.info('Skipping gateway shutdown for %s', signal)
}
// Close ChatRunSocket first to abort all active runs and close EventSource connections
if (chatRunServer) {
chatRunServer.close()