Fix bridge history, profile models, and Windows gateway handling (#845)

* feat: support profile-aware group chat bridge flows

* feat: route cron jobs through hermes cli

* Fix group chat routing and isolate bridge tests

* Add Grok image-to-video media skill

* Default Grok videos to media directory

* Fix bridge profile fallback and cron repeat clearing

* Refine bridge chat and gateway platform handling

* Filter bridge tool-call text deltas

* Preserve structured bridge chat history

* Prepare beta release build artifacts

* Fix Windows run profile resolution

* Fix Windows path compatibility checks

* Fix profile-scoped model page display

* Hide Windows subprocess windows for jobs and updates

* Hide Windows file backend subprocess windows

* Avoid Windows gateway restart lock conflicts

* Treat Windows gateway lock as running on startup

* Force release Windows gateway lock on restart

* Tighten Windows gateway lock cleanup

* Update chat e2e source expectation

* Bump package version to 0.5.30

---------

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
ekko
2026-05-19 16:09:59 +08:00
committed by GitHub
parent 3d74d78698
commit 9a9416c99c
129 changed files with 7017 additions and 1838 deletions
+19 -1
View File
@@ -2,7 +2,7 @@ import { readFile, chmod } from 'fs/promises'
import { readdir, stat } from 'fs/promises'
import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import { getActiveProfileDir, getActiveConfigPath, getActiveEnvPath, getActiveAuthPath } from './hermes/hermes-profile'
import { getActiveProfileDir, getActiveConfigPath, getActiveEnvPath, getActiveAuthPath, getProfileDir } from './hermes/hermes-profile'
import { logger } from './logger'
import { safeFileStore } from './safe-file-store'
@@ -76,6 +76,10 @@ export async function readConfigYaml(): Promise<Record<string, any>> {
return safeFileStore.readYaml(configPath())
}
export async function readConfigYamlForProfile(profile: string): Promise<Record<string, any>> {
return safeFileStore.readYaml(join(getProfileDir(profile), 'config.yaml'))
}
export async function writeConfigYaml(config: Record<string, any>): Promise<void> {
await safeFileStore.writeYaml(configPath(), config, { backup: true })
}
@@ -86,6 +90,20 @@ export async function updateConfigYaml<T = void>(
return safeFileStore.updateYaml(configPath(), updater, { backup: true })
}
export function stripLegacyApiServerGatewayConfig(config: Record<string, any>): { config: Record<string, any>; changed: boolean } {
if (!config.platforms || typeof config.platforms !== 'object' || Array.isArray(config.platforms)) {
return { config, changed: false }
}
if (config.platforms.api_server !== undefined) {
delete config.platforms.api_server
if (Object.keys(config.platforms).length === 0) delete config.platforms
return { config, changed: true }
}
return { config, changed: false }
}
// --- .env helpers ---
function assertValidEnvKey(key: string): void {
@@ -1,15 +0,0 @@
let gatewayManager: any = null
export function getGatewayManagerInstance(): any {
return gatewayManager
}
export async function initGatewayManager(): Promise<void> {
const { GatewayManager } = await import('./hermes/gateway-manager')
const { getActiveProfileName } = await import('./hermes/hermes-profile')
const activeProfile = getActiveProfileName()
gatewayManager = new GatewayManager(activeProfile)
await gatewayManager.detectAllOnStartup()
await gatewayManager.startAll()
}
@@ -1,13 +1,23 @@
import { setTimeout as delay } from 'timers/promises'
import { createConnection, type Socket } from 'net'
import { tmpdir } from 'os'
import { URL } from 'url'
import { join } from 'path'
import { bridgeLogger } from '../../logger'
import { getActiveProfileName, getProfileDir } from '../hermes-profile'
export const DEFAULT_AGENT_BRIDGE_ENDPOINT = process.platform === 'win32'
? 'tcp://127.0.0.1:18765'
: 'ipc:///tmp/hermes-agent-bridge.sock'
function resolveDefaultAgentBridgeEndpoint(): string {
if (process.env.VITEST) {
return process.platform === 'win32'
? `tcp://127.0.0.1:${28000 + (process.pid % 10000)}`
: `ipc://${join(tmpdir(), `hermes-agent-bridge-test-${process.pid}.sock`)}`
}
return process.platform === 'win32'
? 'tcp://127.0.0.1:18765'
: 'ipc:///tmp/hermes-agent-bridge.sock'
}
export const DEFAULT_AGENT_BRIDGE_ENDPOINT = resolveDefaultAgentBridgeEndpoint()
export const DEFAULT_AGENT_BRIDGE_TIMEOUT_MS = 120000
function envPositiveInt(name: string): number | undefined {
@@ -26,6 +36,7 @@ export interface AgentBridgeOptions {
export interface AgentBridgeRequestOptions {
timeoutMs?: number
serialize?: boolean
}
export interface AgentBridgeChatOptions {
@@ -33,6 +44,9 @@ export interface AgentBridgeChatOptions {
storage_message?: AgentBridgeMessage
model?: string
provider?: string
source?: string
wait?: boolean
timeout?: number
}
export type AgentBridgeMessage =
@@ -298,6 +312,10 @@ export class AgentBridgeClient {
}
}
if (!options.serialize) {
return run()
}
const next = this.lock.then(run, run)
this.lock = next.catch(() => undefined)
return next
@@ -325,6 +343,9 @@ export class AgentBridgeClient {
...(profile ? { profile } : {}),
...(options.model ? { model: options.model } : {}),
...(options.provider ? { provider: options.provider } : {}),
...(options.source ? { source: options.source } : {}),
...(options.wait ? { wait: true } : {}),
...(options.timeout ? { timeout: options.timeout } : {}),
...(options.force_compress ? { force_compress: true } : {}),
})
}
@@ -383,12 +404,22 @@ export class AgentBridgeClient {
return this.request<AgentBridgeRunResult>({ action: 'get_result', run_id: runId }, options)
}
interrupt(sessionId: string, message?: string): Promise<AgentBridgeResponse> {
return this.request({ action: 'interrupt', session_id: sessionId, message })
interrupt(sessionId: string, message?: string, profile?: string): Promise<AgentBridgeResponse> {
return this.request({
action: 'interrupt',
session_id: sessionId,
message,
...(profile ? { profile } : {}),
})
}
steer(sessionId: string, text: string): Promise<AgentBridgeResponse> {
return this.request({ action: 'steer', session_id: sessionId, text })
steer(sessionId: string, text: string, profile?: string): Promise<AgentBridgeResponse> {
return this.request({
action: 'steer',
session_id: sessionId,
text,
...(profile ? { profile } : {}),
})
}
approvalRespond(approvalId: string, choice: string): Promise<AgentBridgeResponse> {
@@ -407,15 +438,27 @@ export class AgentBridgeClient {
}
destroyAll(): Promise<AgentBridgeResponse> {
return this.request({ action: 'destroy_all' })
return this.request({ action: 'destroy_all' }, { serialize: true })
}
getHistory(sessionId: string): Promise<AgentBridgeResponse> {
return this.request({ action: 'get_history', session_id: sessionId })
destroyProfile(profile: string): Promise<AgentBridgeResponse> {
return this.request({ action: 'destroy_profile', profile }, { serialize: true })
}
destroy(sessionId: string): Promise<AgentBridgeResponse> {
return this.request({ action: 'destroy', session_id: sessionId })
getHistory(sessionId: string, profile?: string): Promise<AgentBridgeResponse> {
return this.request({
action: 'get_history',
session_id: sessionId,
...(profile ? { profile } : {}),
})
}
destroy(sessionId: string, profile?: string): Promise<AgentBridgeResponse> {
return this.request({
action: 'destroy',
session_id: sessionId,
...(profile ? { profile } : {}),
})
}
list(): Promise<AgentBridgeResponse> {
@@ -423,7 +466,7 @@ export class AgentBridgeClient {
}
shutdown(): Promise<AgentBridgeResponse> {
return this.request({ action: 'shutdown' })
return this.request({ action: 'shutdown' }, { serialize: true })
}
}
@@ -11,13 +11,16 @@ from __future__ import annotations
import argparse
import copy
import hashlib
import importlib.util
import json
import os
import queue
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import time
import traceback
@@ -174,6 +177,11 @@ def _base_hermes_home() -> Path:
return _normalize_base_home(_discover_hermes_home(os.environ.get("HERMES_AGENT_BRIDGE_BASE_HOME") or DEFAULT_HERMES_HOME))
def _worker_profile() -> str | None:
raw = os.environ.get("HERMES_AGENT_BRIDGE_WORKER_PROFILE", "").strip()
return raw or None
def _profile_home(profile: str | None) -> Path:
base = _base_hermes_home()
if not profile or profile == "default":
@@ -319,8 +327,20 @@ def _restore_profile_dotenv(snapshot: dict[str, str | None]) -> None:
os.environ[key] = value
def _set_worker_profile_env(profile: str | None) -> None:
profile_home = _profile_home(profile)
os.environ["HERMES_HOME"] = str(profile_home)
os.environ["HERMES_AGENT_BRIDGE_WORKER_PROFILE"] = profile or "default"
values = _read_dotenv(profile_home / ".env")
for key, value in values.items():
os.environ[key] = value
@contextmanager
def _profile_env(profile: str | None):
if _worker_profile():
yield
return
original = _apply_profile_env(profile)
env_snapshot = _apply_profile_dotenv(profile)
try:
@@ -832,6 +852,7 @@ class AgentPool:
storage_message: Any | None,
conversation_history: list[dict[str, Any]] | None,
profile: str | None,
source: str | None = None,
) -> bool:
persist_message = storage_message if storage_message is not None else message
user_content = str(persist_message) if not isinstance(persist_message, dict) else str(persist_message.get("content", persist_message))
@@ -848,7 +869,7 @@ class AgentPool:
if hasattr(db, "create_session"):
db.create_session(
session_id=session.session_id,
source=_bridge_platform(),
source=source or _bridge_platform(),
model=session.config.get("model"),
)
@@ -958,6 +979,7 @@ class AgentPool:
force_compress: bool = False,
model: str | None = None,
provider: str | None = None,
source: str | None = None,
) -> RunRecord:
session = self.get_or_create(session_id, profile=profile, model=model, provider=provider)
with session.lock:
@@ -973,14 +995,14 @@ class AgentPool:
thread = threading.Thread(
target=self._run_chat,
args=(session, record, message, storage_message, instructions, conversation_history, profile, force_compress),
args=(session, record, message, storage_message, instructions, conversation_history, profile, force_compress, source),
daemon=True,
name=f"hermes-bridge-run-{run_id[:8]}",
)
thread.start()
return record
def _run_chat(self, session: AgentSession, record: RunRecord, message: Any, storage_message: Any | None = None, instructions: str | None = None, conversation_history: list[dict[str, Any]] | None = None, profile: str | None = None, force_compress: bool = False) -> None:
def _run_chat(self, session: AgentSession, record: RunRecord, message: Any, storage_message: Any | None = None, instructions: str | None = None, conversation_history: list[dict[str, Any]] | None = None, profile: str | None = None, force_compress: bool = False, source: str | None = None) -> None:
with self._run_lock:
with _profile_env(profile):
def stream_callback(delta: str) -> None:
@@ -1004,7 +1026,7 @@ class AgentPool:
os.environ["HERMES_EXEC_ASK"] = "1"
except Exception:
previous_approval_callback = None
self._prepersist_user_message(session, message, storage_message, conversation_history, profile)
self._prepersist_user_message(session, message, storage_message, conversation_history, profile, source)
db_count_after_prepersist = self._session_db_message_count(session.session_id, profile)
if force_compress:
compress = getattr(session.agent, "_compress_context", None)
@@ -1265,7 +1287,13 @@ class BridgeServer:
raise ValueError("action is required")
if action == "ping":
return {"pong": True, "time": time.time(), "agent_root": str(_agent_root())}
return {
"pong": True,
"time": time.time(),
"agent_root": str(_agent_root()),
"profile": _worker_profile() or "default",
"hermes_home": str(_hermes_home()),
}
if action == "chat":
session_id = str(req.get("session_id") or "").strip() or uuid.uuid4().hex
@@ -1276,6 +1304,7 @@ class BridgeServer:
profile = req.get("profile")
model = req.get("model")
provider = req.get("provider")
source = req.get("source")
record = self.pool.start_chat(
session_id,
message,
@@ -1286,6 +1315,7 @@ class BridgeServer:
bool(req.get("force_compress")),
model,
provider,
source,
)
if req.get("wait"):
timeout = float(req.get("timeout", 0) or 0)
@@ -1355,50 +1385,13 @@ class BridgeServer:
raise ValueError(f"unknown action: {action}")
def _make_server_socket(self) -> socket.socket:
if self.endpoint.startswith("ipc://"):
if not hasattr(socket, "AF_UNIX"):
raise RuntimeError("ipc:// endpoints require Unix domain socket support; use tcp://host:port on this platform")
sock_path = Path(self.endpoint.removeprefix("ipc://"))
sock_path.parent.mkdir(parents=True, exist_ok=True)
try:
sock_path.unlink(missing_ok=True)
except OSError:
pass
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(str(sock_path))
return server
parsed = urlparse(self.endpoint)
if parsed.scheme != "tcp":
raise RuntimeError(f"unsupported endpoint scheme: {self.endpoint}")
host = parsed.hostname or "127.0.0.1"
port = int(parsed.port or 0)
if port <= 0:
raise RuntimeError(f"tcp endpoint requires a port: {self.endpoint}")
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((host, port))
return server
return _make_listen_socket(self.endpoint)
def _read_request(self, conn: socket.socket) -> dict[str, Any]:
chunks: list[bytes] = []
while True:
chunk = conn.recv(65536)
if not chunk:
break
chunks.append(chunk)
if b"\n" in chunk:
break
if not chunks:
raise RuntimeError("empty request")
line = b"".join(chunks).split(b"\n", 1)[0].strip()
if not line:
raise RuntimeError("empty request")
return json.loads(line.decode("utf-8"))
return _read_json_request(conn)
def _write_response(self, conn: socket.socket, resp: dict[str, Any]) -> None:
payload = json.dumps(resp, ensure_ascii=False, default=str) + "\n"
conn.sendall(payload.encode("utf-8"))
_write_json_response(conn, resp)
def _gc_idle_sessions(self) -> None:
"""Destroy sessions idle longer than IDLE_TIMEOUT_SECONDS."""
@@ -1458,16 +1451,530 @@ class BridgeServer:
pass
class WorkerProcess:
STARTUP_TIMEOUT_SECONDS = 120
REQUEST_TIMEOUT_SECONDS = 120
def __init__(self, profile: str, endpoint: str, agent_root: str | None, hermes_home: str | None) -> None:
self.profile = profile or "default"
self.endpoint = endpoint
self.agent_root = agent_root
self.hermes_home = hermes_home
self.process: subprocess.Popen[str] | None = None
self.last_used_at = time.time()
self._lock = threading.RLock()
@property
def running(self) -> bool:
return self.process is not None and self.process.poll() is None
def start(self) -> None:
with self._lock:
if self.running:
return
args = [
sys.executable,
str(Path(__file__).resolve()),
"--endpoint",
self.endpoint,
"--worker-profile",
self.profile,
]
if self.agent_root:
args.extend(["--agent-root", self.agent_root])
if self.hermes_home:
args.extend(["--hermes-home", self.hermes_home])
env = {
**os.environ,
"HERMES_AGENT_BRIDGE_ENDPOINT": self.endpoint,
"HERMES_AGENT_BRIDGE_WORKER_PROFILE": self.profile,
}
self.process = subprocess.Popen(
args,
env=env,
cwd=os.getcwd(),
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
self._pipe_stderr()
self._wait_ready()
def _pipe_stderr(self) -> None:
proc = self.process
if proc is None or proc.stderr is None:
return
def run() -> None:
assert proc.stderr is not None
for line in proc.stderr:
text = line.rstrip()
if text:
print(f"[hermes-bridge-worker:{self.profile}] {text}", file=sys.stderr, flush=True)
threading.Thread(target=run, daemon=True, name=f"hermes-bridge-worker-stderr-{self.profile}").start()
def _wait_ready(self) -> None:
proc = self.process
if proc is None or proc.stdout is None:
raise RuntimeError(f"profile worker {self.profile} did not start")
lines: queue.Queue[str | None] = queue.Queue()
ready_event = threading.Event()
def read_stdout() -> None:
assert proc.stdout is not None
try:
for line in proc.stdout:
if ready_event.is_set():
text = line.rstrip()
if text:
print(f"[hermes-bridge-worker:{self.profile}] {text}", file=sys.stderr, flush=True)
else:
lines.put(line)
finally:
lines.put(None)
threading.Thread(target=read_stdout, daemon=True, name=f"hermes-bridge-worker-stdout-{self.profile}").start()
deadline = time.time() + self.STARTUP_TIMEOUT_SECONDS
while time.time() < deadline:
if proc.poll() is not None:
raise RuntimeError(f"profile worker {self.profile} exited before ready")
try:
line = lines.get(timeout=0.1)
except queue.Empty:
continue
if line is None:
time.sleep(0.05)
continue
text = line.strip()
if text:
print(f"[hermes-bridge-worker:{self.profile}] {text}", file=sys.stderr, flush=True)
try:
data = json.loads(text)
if data.get("event") == "ready":
ready_event.set()
return
except Exception:
pass
self.stop()
raise RuntimeError(f"profile worker {self.profile} did not become ready within {self.STARTUP_TIMEOUT_SECONDS}s")
def stop(self) -> None:
with self._lock:
proc = self.process
self.process = None
if proc is None:
return
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=3)
if self.endpoint.startswith("ipc://"):
try:
Path(self.endpoint.removeprefix("ipc://")).unlink(missing_ok=True)
except OSError:
pass
def request(self, req: dict[str, Any]) -> dict[str, Any]:
self.start()
self.last_used_at = time.time()
return _send_bridge_request(self.endpoint, req, self.REQUEST_TIMEOUT_SECONDS)
def _worker_endpoint(profile: str) -> str:
safe = hashlib.sha256(profile.encode("utf-8")).hexdigest()[:16]
if os.name == "nt":
port_base = int(os.environ.get("HERMES_AGENT_BRIDGE_WORKER_PORT_BASE", "18780"))
return f"tcp://127.0.0.1:{port_base + int(safe[:4], 16) % 1000}"
root = Path(tempfile.gettempdir()) / "hermes-agent-bridge-workers"
return f"ipc://{root / f'{safe}.sock'}"
def _connect_bridge_socket(endpoint: str, timeout: float) -> socket.socket:
if endpoint.startswith("ipc://"):
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(timeout)
sock.connect(endpoint.removeprefix("ipc://"))
return sock
parsed = urlparse(endpoint)
if parsed.scheme != "tcp":
raise RuntimeError(f"unsupported endpoint scheme: {endpoint}")
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
sock.connect((parsed.hostname or "127.0.0.1", int(parsed.port or 0)))
return sock
def _send_bridge_request(endpoint: str, req: dict[str, Any], timeout: float) -> dict[str, Any]:
sock = _connect_bridge_socket(endpoint, timeout)
try:
sock.sendall((json.dumps(req, ensure_ascii=False, default=str) + "\n").encode("utf-8"))
chunks: list[bytes] = []
while True:
chunk = sock.recv(65536)
if not chunk:
break
chunks.append(chunk)
if b"\n" in chunk:
break
line = b"".join(chunks).split(b"\n", 1)[0].strip()
if not line:
raise RuntimeError("worker closed without a response")
resp = json.loads(line.decode("utf-8"))
if not resp.get("ok"):
raise RuntimeError(str(resp.get("error") or "worker request failed"))
return resp
finally:
try:
sock.close()
except OSError:
pass
def _make_listen_socket(endpoint: str) -> socket.socket:
if endpoint.startswith("ipc://"):
if not hasattr(socket, "AF_UNIX"):
raise RuntimeError("ipc:// endpoints require Unix domain socket support; use tcp://host:port on this platform")
sock_path = Path(endpoint.removeprefix("ipc://"))
sock_path.parent.mkdir(parents=True, exist_ok=True)
try:
sock_path.unlink(missing_ok=True)
except OSError:
pass
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(str(sock_path))
return server
parsed = urlparse(endpoint)
if parsed.scheme != "tcp":
raise RuntimeError(f"unsupported endpoint scheme: {endpoint}")
host = parsed.hostname or "127.0.0.1"
port = int(parsed.port or 0)
if port <= 0:
raise RuntimeError(f"tcp endpoint requires a port: {endpoint}")
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((host, port))
return server
def _read_json_request(conn: socket.socket) -> dict[str, Any]:
chunks: list[bytes] = []
while True:
chunk = conn.recv(65536)
if not chunk:
break
chunks.append(chunk)
if b"\n" in chunk:
break
if not chunks:
raise RuntimeError("empty request")
line = b"".join(chunks).split(b"\n", 1)[0].strip()
if not line:
raise RuntimeError("empty request")
return json.loads(line.decode("utf-8"))
def _write_json_response(conn: socket.socket, resp: dict[str, Any]) -> None:
payload = json.dumps(resp, ensure_ascii=False, default=str) + "\n"
conn.sendall(payload.encode("utf-8"))
class BridgeBroker:
IDLE_TIMEOUT_SECONDS = 30 * 60
GC_INTERVAL_SECONDS = 60
def __init__(self, endpoint: str, agent_root: str | None = None, hermes_home: str | None = None) -> None:
self.endpoint = endpoint
self.agent_root = agent_root
self.hermes_home = hermes_home
self._workers: dict[str, WorkerProcess] = {}
self._run_profile: dict[str, str] = {}
self._session_profile: dict[str, str] = {}
self._approval_profile: dict[str, str] = {}
self._compression_profile: dict[str, str] = {}
self._lock = threading.RLock()
self._stop = threading.Event()
self._last_gc = time.time()
def _normalize_profile(self, value: Any) -> str:
profile = str(value or "").strip()
return profile or "default"
def _worker_for_profile(self, profile: str) -> WorkerProcess:
profile = self._normalize_profile(profile)
with self._lock:
worker = self._workers.get(profile)
if worker is None:
worker = WorkerProcess(profile, _worker_endpoint(profile), self.agent_root, self.hermes_home)
self._workers[profile] = worker
return worker
def _profile_for_run(self, run_id: str) -> str:
with self._lock:
profile = self._run_profile.get(run_id)
if not profile:
raise KeyError(f"unknown run: {run_id}")
return profile
def _profile_for_session(self, session_id: str, fallback_profile: Any = None) -> str:
with self._lock:
profile = self._session_profile.get(session_id)
if not profile:
fallback = self._normalize_profile(fallback_profile)
if fallback_profile is not None and fallback:
return fallback
raise KeyError(f"unknown session: {session_id}")
return profile
def _record_response_routes(self, profile: str, resp: dict[str, Any]) -> None:
run_id = str(resp.get("run_id") or "")
session_id = str(resp.get("session_id") or "")
with self._lock:
if run_id:
self._run_profile[run_id] = profile
if session_id:
self._session_profile[session_id] = profile
for event in resp.get("events") or []:
if not isinstance(event, dict):
continue
approval_id = str(event.get("approval_id") or "")
if approval_id:
self._approval_profile[approval_id] = profile
request_id = str(event.get("request_id") or "")
if event.get("event") == "bridge.compression.requested" and request_id:
self._compression_profile[request_id] = profile
if event.get("event") in {"bridge.compression.completed", "bridge.compression.failed"} and request_id:
self._compression_profile.pop(request_id, None)
def _forward(self, profile: str, req: dict[str, Any]) -> dict[str, Any]:
worker = self._worker_for_profile(profile)
forwarded = dict(req)
forwarded["profile"] = profile
resp = worker.request(forwarded)
self._record_response_routes(profile, resp)
return resp
def handle(self, req: dict[str, Any]) -> dict[str, Any]:
action = str(req.get("action") or "").strip()
if not action:
raise ValueError("action is required")
if action == "ping":
with self._lock:
workers = {profile: worker.running for profile, worker in self._workers.items()}
return {"pong": True, "time": time.time(), "mode": "broker", "workers": workers}
if action == "worker_ping":
profile = self._normalize_profile(req.get("profile"))
resp = self._forward(profile, {"action": "ping"})
resp["worker_profile"] = profile
return resp
if action == "chat":
profile = self._normalize_profile(req.get("profile"))
return self._forward(profile, req)
if action in {"get_result", "get_output"}:
profile = self._profile_for_run(str(req.get("run_id") or ""))
return self._forward(profile, req)
if action in {"interrupt", "steer", "get_history", "destroy"}:
session_id = str(req.get("session_id") or "")
profile = self._profile_for_session(session_id, req.get("profile"))
resp = self._forward(profile, req)
if action == "destroy":
with self._lock:
self._session_profile.pop(session_id, None)
return resp
if action == "approval_respond":
approval_id = str(req.get("approval_id") or "").strip()
if not approval_id:
raise ValueError("approval_id is required")
with self._lock:
profile = self._approval_profile.get(approval_id)
if not profile:
raise KeyError(f"unknown approval request: {approval_id}")
return self._forward(profile, req)
if action == "compression_respond":
request_id = str(req.get("request_id") or "").strip()
if not request_id:
raise ValueError("request_id is required")
with self._lock:
profile = self._compression_profile.get(request_id)
if not profile:
raise KeyError(f"unknown compression request: {request_id}")
return self._forward(profile, req)
if action == "destroy_all":
with self._lock:
workers = list(self._workers.values())
self._run_profile.clear()
self._session_profile.clear()
self._approval_profile.clear()
self._compression_profile.clear()
destroyed = 0
for worker in workers:
if not worker.running:
worker.stop()
continue
try:
resp = worker.request({"action": "destroy_all"})
destroyed += int(resp.get("destroyed") or 0)
except Exception:
pass
return {"destroyed": destroyed}
if action == "destroy_profile":
profile = self._normalize_profile(req.get("profile"))
with self._lock:
worker = self._workers.get(profile)
self._run_profile = {key: value for key, value in self._run_profile.items() if value != profile}
self._session_profile = {key: value for key, value in self._session_profile.items() if value != profile}
self._approval_profile = {key: value for key, value in self._approval_profile.items() if value != profile}
self._compression_profile = {key: value for key, value in self._compression_profile.items() if value != profile}
if worker is None or not worker.running:
if worker is not None:
worker.stop()
return {"profile": profile, "destroyed": 0}
try:
resp = worker.request({"action": "destroy_all"})
return {"profile": profile, "destroyed": int(resp.get("destroyed") or 0)}
except Exception:
return {"profile": profile, "destroyed": 0}
if action == "list":
sessions: list[Any] = []
with self._lock:
workers = list(self._workers.items())
for profile, worker in workers:
if not worker.running:
continue
try:
resp = worker.request({"action": "list"})
for session in resp.get("sessions") or []:
if isinstance(session, dict):
session.setdefault("profile", profile)
sessions.append(session)
except Exception:
pass
return {"sessions": sessions}
if action == "shutdown":
self._stop.set()
with self._lock:
workers = list(self._workers.values())
for worker in workers:
if not worker.running:
worker.stop()
continue
try:
worker.request({"action": "shutdown"})
except Exception:
worker.stop()
return {"status": "shutting_down"}
raise ValueError(f"unknown action: {action}")
def _make_server_socket(self) -> socket.socket:
return _make_listen_socket(self.endpoint)
def _read_request(self, conn: socket.socket) -> dict[str, Any]:
return _read_json_request(conn)
def _write_response(self, conn: socket.socket, resp: dict[str, Any]) -> None:
_write_json_response(conn, resp)
def _gc_idle_workers(self) -> None:
now = time.time()
if now - self._last_gc < self.GC_INTERVAL_SECONDS:
return
self._last_gc = now
with self._lock:
idle = [
profile for profile, worker in self._workers.items()
if worker.running and now - worker.last_used_at > self.IDLE_TIMEOUT_SECONDS
]
for profile in idle:
with self._lock:
worker = self._workers.pop(profile, None)
if worker:
worker.stop()
def serve_forever(self) -> None:
server = self._make_server_socket()
server.listen(64)
server.settimeout(0.2)
print(json.dumps({"event": "ready", "endpoint": self.endpoint, "mode": "broker"}), flush=True)
while not self._stop.is_set():
conn: socket.socket | None = None
try:
try:
conn, _addr = server.accept()
except socket.timeout:
self._gc_idle_workers()
continue
try:
req = self._read_request(conn)
data = self.handle(req)
resp = {"ok": True, **_jsonable(data)}
except Exception as exc:
resp = {
"ok": False,
"error": str(exc),
"error_type": exc.__class__.__name__,
}
self._write_response(conn, resp)
except KeyboardInterrupt:
break
except Exception as exc:
print(f"[hermes-bridge-broker] server loop error: {exc}", file=sys.stderr, flush=True)
finally:
if conn is not None:
try:
conn.close()
except OSError:
pass
with self._lock:
workers = list(self._workers.values())
self._workers.clear()
for worker in workers:
worker.stop()
server.close()
if self.endpoint.startswith("ipc://"):
try:
Path(self.endpoint.removeprefix("ipc://")).unlink(missing_ok=True)
except OSError:
pass
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Hermes AIAgent in-process bridge")
parser.add_argument("--endpoint", default=os.environ.get("HERMES_AGENT_BRIDGE_ENDPOINT", DEFAULT_ENDPOINT))
parser.add_argument("--agent-root", default=os.environ.get("HERMES_AGENT_ROOT", DEFAULT_AGENT_ROOT))
parser.add_argument("--hermes-home", default=os.environ.get("HERMES_HOME", DEFAULT_HERMES_HOME))
parser.add_argument("--worker-profile", default=os.environ.get("HERMES_AGENT_BRIDGE_WORKER_PROFILE"))
args = parser.parse_args(argv)
_set_path_env(args.agent_root, args.hermes_home)
_ensure_agent_imports()
BridgeServer(args.endpoint).serve_forever()
if args.worker_profile:
_set_worker_profile_env(str(args.worker_profile or "default"))
BridgeServer(args.endpoint).serve_forever()
else:
BridgeBroker(args.endpoint, args.agent_root, args.hermes_home).serve_forever()
return 0
@@ -127,7 +127,7 @@ export class ContextEngine {
// Under threshold — return summary + new messages directly
if (totalTokens <= config.triggerTokens) {
logger.debug(`[ContextEngine] [Path A] UNDER threshold — return summary + ${newMessages.length} verbatim msgs directly`)
const history = this.buildHistory(snapshot.summary, newMessages, input.agentSocketId)
const history = this.buildHistory(snapshot.summary, newMessages, input.agentSocketId, input.agentName)
this.logHistory('Path A (no compress)', history)
return { conversationHistory: history, instructions, meta }
}
@@ -155,7 +155,7 @@ export class ContextEngine {
meta.summaryTokenEstimate = this.countTokens(result.summary)
logger.debug(`[ContextEngine] [Path A] incremental compression DONE in ${elapsed}ms, newSummaryLen=${result.summary.length}, newLastMsgId=${lastMsg.id}`)
logger.debug(`[ContextEngine] [Path A] NEW SUMMARY (${result.summary.length} chars): ${result.summary.slice(0, 300)}`)
const history = this.buildHistory(result.summary, newMessages, input.agentSocketId)
const history = this.buildHistory(result.summary, newMessages, input.agentSocketId, input.agentName)
this.logHistory('Path A (after incremental compress)', history)
if (result.sessionId) this.sessionCleaner?.(result.sessionId)
return { conversationHistory: history, instructions, meta }
@@ -163,7 +163,7 @@ export class ContextEngine {
// Compression failed — degrade
logger.warn(`[ContextEngine] [Path A] incremental compression FAILED (${elapsed}ms) — degrading to summary + trimmed verbatim`)
const history = this.buildHistory(snapshot.summary, newMessages, input.agentSocketId)
const history = this.buildHistory(snapshot.summary, newMessages, input.agentSocketId, input.agentName)
this.trimToBudget(history, summaryTokens, config.maxHistoryTokens)
return { conversationHistory: history, instructions, meta }
}
@@ -177,7 +177,7 @@ export class ContextEngine {
// Under threshold — pass all messages verbatim
if (totalTokens <= config.triggerTokens) {
logger.debug(`[ContextEngine] [Path B] UNDER threshold — return all ${total} msgs verbatim`)
const history = messages.map(m => this.mapToHistory(m, input.agentSocketId))
const history = messages.map(m => this.mapToHistory(m, input.agentSocketId, input.agentName))
this.logHistory('Path B (no compress)', history)
return { conversationHistory: history, instructions, meta }
}
@@ -209,7 +209,7 @@ export class ContextEngine {
meta.summaryTokenEstimate = this.countTokens(result.summary)
logger.debug(`[ContextEngine] [Path B] full compression DONE in ${elapsed}ms, summaryLen=${result.summary.length}, compressed=${toCompress.length} msgs, keptTail=${tail.length} msgs, savedLastMsgId=${lastCompressedMsg.id}`)
logger.debug(`[ContextEngine] [Path B] COMPRESSED SUMMARY (${result.summary.length} chars): ${result.summary.slice(0, 300)}`)
const history = this.buildHistory(result.summary, tail, input.agentSocketId)
const history = this.buildHistory(result.summary, tail, input.agentSocketId, input.agentName)
this.logHistory('Path B (after full compress)', history)
if (result.sessionId) this.sessionCleaner?.(result.sessionId)
return { conversationHistory: history, instructions, meta }
@@ -217,7 +217,7 @@ export class ContextEngine {
// Compression failed — degrade
logger.warn(`[ContextEngine] [Path B] full compression FAILED (${elapsed}ms) — degrading to trimmed verbatim`)
const history = messages.map(m => this.mapToHistory(m, input.agentSocketId))
const history = messages.map(m => this.mapToHistory(m, input.agentSocketId, input.agentName))
this.trimToBudget(history, 0, config.maxHistoryTokens)
meta.verbatimCount = history.length
return { conversationHistory: history, instructions, meta }
@@ -265,6 +265,7 @@ export class ContextEngine {
summary: string,
messages: StoredMessage[],
agentSocketId: string,
agentName: string,
): Array<{ role: 'user' | 'assistant'; content: string }> {
const history: Array<{ role: 'user' | 'assistant'; content: string }> = []
@@ -275,7 +276,7 @@ export class ContextEngine {
)
}
history.push(...messages.map(m => this.mapToHistory(m, agentSocketId)))
history.push(...messages.map(m => this.mapToHistory(m, agentSocketId, agentName)))
return history
}
@@ -314,11 +315,51 @@ export class ContextEngine {
private mapToHistory(
msg: StoredMessage,
agentSocketId: string,
agentName: string,
): { role: 'user' | 'assistant'; content: string } {
if (msg.senderId === agentSocketId) {
return { role: 'assistant', content: msg.content }
const senderName = msg.senderName || 'unknown'
const isOwnAgent = msg.senderId === agentSocketId || senderName === agentName
if (msg.role === 'tool') {
const label = msg.tool_name ? `Tool result: ${msg.tool_name}` : 'Tool result'
return { role: 'user', content: `[${senderName}] [${label}]\n${msg.content || ''}` }
}
return { role: 'user', content: `[${msg.senderName}]: ${msg.content}` }
if (msg.role === 'assistant' && msg.tool_calls?.length) {
const toolsInfo = msg.tool_calls.map(tc => {
const name = tc.function?.name || 'unknown'
let args = tc.function?.arguments || '{}'
if (args.length > 4000) args = `${args.slice(0, 4000)}...`
return `[Calling tool: ${name} with arguments: ${args}]`
}).join('\n')
const content = msg.content?.trim()
return {
role: isOwnAgent ? 'assistant' : 'user',
content: content
? `${this.formatAttributedContent(senderName, content)}\n${this.formatAttributionPrefix(senderName, content)}${toolsInfo}`
: `${this.formatAttributionPrefix(senderName, content)}${toolsInfo}`,
}
}
return {
role: isOwnAgent ? 'assistant' : 'user',
content: this.formatAttributedContent(senderName, msg.content || ''),
}
}
private formatAttributedContent(senderName: string, content: string): string {
return `${this.formatAttributionPrefix(senderName)}${this.stripMentions(content)}`
}
private formatAttributionPrefix(senderName: string, _content?: string): string {
return `[${senderName}]: `
}
private stripMentions(content: string): string {
return String(content || '')
.replace(/@([^\s@]+)/g, '')
.replace(/[ \t]{2,}/g, ' ')
.replace(/^\s+/, '')
}
private trimToBudget(
@@ -6,10 +6,11 @@ import {
} from './prompt'
import { updateUsage } from '../../../db/hermes/usage-store'
import { logger } from '../../logger'
import { AgentBridgeClient, type AgentBridgeRunResult } from '../agent-bridge'
/**
* Calls Hermes /v1/responses to produce LLM-generated summaries.
* The context engine owns history assembly; Responses storage/chaining is not used.
* Calls the local bridge to produce LLM-generated summaries.
* The context engine owns history assembly; gateway storage/chaining is not used.
*/
export class GatewaySummarizer implements GatewayCaller {
private timeoutMs: number
@@ -19,8 +20,8 @@ export class GatewaySummarizer implements GatewayCaller {
}
async summarize(
upstream: string,
apiKey: string | null,
_upstream: string,
_apiKey: string | null,
systemPrompt: string,
messages: StoredMessage[],
roomId: string,
@@ -29,7 +30,7 @@ export class GatewaySummarizer implements GatewayCaller {
): Promise<{ summary: string; sessionId: string }> {
const history: Array<{ role: string; content: string }> = messages.map(m => ({
role: 'user',
content: `[${m.senderName}]: ${m.content}`,
content: summarizeMessageForPrompt(m),
}))
if (previousSummary) {
@@ -43,132 +44,67 @@ export class GatewaySummarizer implements GatewayCaller {
? buildIncrementalUpdatePrompt()
: buildFullSummaryPrompt()
const res = await fetch(`${upstream.replace(/\/$/, '')}/v1/responses`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
body: JSON.stringify({
input: userPrompt,
const bridge = new AgentBridgeClient({ timeoutMs: this.timeoutMs + 15_000 })
const sessionId = `gc_compress_${roomId}_${profile}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
.replace(/[^a-zA-Z0-9_-]/g, '_')
.slice(0, 160)
try {
const result = await bridge.request<AgentBridgeRunResult>({
action: 'chat',
session_id: sessionId,
message: userPrompt,
instructions: systemPrompt || buildSummarizationSystemPrompt(),
conversation_history: history,
stream: true,
store: false,
}),
signal: AbortSignal.timeout(this.timeoutMs),
})
profile,
source: 'api_server',
wait: true,
timeout: Math.ceil(this.timeoutMs / 1000),
}, { timeoutMs: this.timeoutMs + 15_000 })
if (!res.ok) {
throw new Error(`Summarization response failed: ${res.status}`)
}
if (!res.body) {
throw new Error('Summarization response stream missing')
}
let output = ''
for await (const frame of readSseFrames(res.body)) {
let parsed: any
try {
parsed = JSON.parse(frame.data)
} catch {
continue
}
const eventType = parsed.type || frame.event || parsed.event
if (eventType === 'response.output_text.delta' && parsed.delta) {
output += parsed.delta
continue
if (result.status === 'error') {
throw new Error(result.error || 'Summarization bridge run failed')
}
if (eventType === 'response.completed') {
const response = parsed.response || parsed
const finalText = extractResponseText(response)
if (!output && finalText) output = finalText
const payload = result.result as any
const output = String(payload?.final_response || result.output || '').trim()
if (!output) throw new Error('Empty summarization response')
const usage = response.usage || {}
const usage = payload?.usage || payload?.response?.usage
if (usage) {
updateUsage(roomId, {
inputTokens: usage.input_tokens ?? usage.inputTokens ?? 0,
outputTokens: usage.output_tokens ?? usage.outputTokens ?? 0,
cacheReadTokens: usage.cache_read_tokens ?? usage.cacheReadTokens ?? 0,
cacheWriteTokens: usage.cache_write_tokens ?? usage.cacheWriteTokens ?? 0,
reasoningTokens: usage.reasoning_tokens ?? usage.reasoningTokens ?? 0,
model: response.model || '',
model: payload?.model || payload?.response?.model || '',
profile,
})
logger.debug(`[GatewaySummarizer] Recorded response usage for compression room ${roomId} (profile=${profile}): input=${usage.input_tokens ?? 0}, output=${usage.output_tokens ?? 0}`)
if (!output || output.trim() === '') {
throw new Error('Empty summarization response')
}
return { summary: output.trim(), sessionId: '' }
}
if (eventType === 'response.failed') {
throw new Error(parsed.error?.message || parsed.error || 'Summarization response failed')
}
logger.debug(`[GatewaySummarizer] Bridge compression completed for room ${roomId} (profile=${profile})`)
return { summary: output, sessionId }
} finally {
await bridge.destroy(sessionId, profile).catch(() => undefined)
}
throw new Error('Summarization response stream ended without a terminal event')
}
}
async function* readSseFrames(stream: ReadableStream<Uint8Array>): AsyncGenerator<{ event?: string; data: string }> {
const decoder = new TextDecoder()
const reader = stream.getReader()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let boundary = buffer.indexOf('\n\n')
while (boundary >= 0) {
const raw = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const frame = parseSseFrame(raw)
if (frame?.data) yield frame
boundary = buffer.indexOf('\n\n')
}
}
buffer += decoder.decode()
const frame = parseSseFrame(buffer)
if (frame?.data) yield frame
} finally {
reader.releaseLock()
function summarizeMessageForPrompt(message: StoredMessage): string {
if (message.role === 'tool') {
const label = message.tool_name ? `Tool result: ${message.tool_name}` : 'Tool result'
return `[${label}]\n${message.content || ''}`
}
}
function parseSseFrame(raw: string): { event?: string; data: string } | null {
let event: string | undefined
const data: string[] = []
for (const line of raw.split(/\r?\n/)) {
if (!line || line.startsWith(':')) continue
if (line.startsWith('event:')) {
event = line.slice(6).trim()
} else if (line.startsWith('data:')) {
data.push(line.slice(5).trimStart())
}
if (message.role === 'assistant' && message.tool_calls?.length) {
const toolsInfo = message.tool_calls.map(tc => {
const name = tc.function?.name || 'tool'
const args = tc.function?.arguments || '{}'
return `${name}(${args})`
}).join(', ')
const content = message.content?.trim()
return `[${message.senderName}]: ${content ? `${content}\n` : ''}[Tool calls: ${toolsInfo}]`
}
if (data.length === 0) return null
return { event, data: data.join('\n') }
}
function extractResponseText(response: any): string {
const output = Array.isArray(response?.output) ? response.output : []
const parts: string[] = []
for (const item of output) {
if (item.type !== 'message') continue
const content = Array.isArray(item.content) ? item.content : []
for (const part of content) {
if (part.type === 'output_text' || part.type === 'text') {
parts.push(part.text || '')
}
}
}
if (parts.length > 0) return parts.join('')
return typeof response?.output_text === 'string' ? response.output_text : ''
return `[${message.senderName}]: ${message.content}`
}
@@ -52,15 +52,23 @@ export function buildAgentInstructions(params: AgentInstructionsParams): string
${memberSection}
规则:
- 有人用 @${params.agentName} 提及你时才需要回复,重点回应提及你的人
- 禁止@自己
- 当你收到群聊任务时,说明系统已经判断你需要回复;请直接回应当前消息,不要因为消息里同时提及其他成员而拒绝回复或输出空回复
- 重点回应提及你的人
- 回答简洁、对群聊有帮助。
- 不要假装是人类,需要时明确表明自己是 AI。
- 对话历史中包含多个人的消息,每条消息前标有发送者名字。
- 对话开头可能包含之前的对话摘要,用于提供更早的上下文
- 回复最新一条提及你的消息
- 如果需要其他 agent 协作或明确回复某个人,使用 @名字 来提及对方
- 自行判断对话是否已经结束——如果问题已解决、达成共识、或对方只是陈述不需要回复,则不要再 @任何人,直接结束回复,避免产生无意义的循环对话。`
- 不要假装是人类,需要时明确表明自己是 AI。
- 对话历史中包含多个人的消息,每条消息前标有发送者名字。
- 历史消息里的"[发送者]: ..."只是系统添加的归属标记,用来帮助你理解谁说了这句话;不要在你的回复中复述或模仿这种方括号前缀
- 回复时使用自然语言即可;如果需要点名某人,只使用 @名字,不要输出"[${params.agentName}]:"这类格式
- 对话开头可能包含之前的对话摘要,用于提供更早的上下文
- 回复最新一条提及你的消息。
- 群聊系统支持 agent 之间通过 @名字 接力:当你在回复中写出 @某个成员,系统会把消息路由给对应成员。
- 如果用户明确要求你叫、让、请某个 agent 执行任务,不要自己代办,不要说你无法指挥其他 agent;请直接用 @名字 转交任务,并简短说明你已转交。
- 如果需要其他 agent 协作或明确回复某个人,使用 @名字 来提及对方,并把需要对方执行的任务写清楚。
- 不要主动 @ 任何人,除非最新消息明确要求你转交、邀请、询问某个具体成员。
- 如果只是回答提问,直接回答,不要在结尾 @ 其他成员继续接力。
- 不要为了活跃气氛、征求补充、让别人也看看而 @ 其他 agent 或用户。
- 只有在确实需要对方执行动作、提供信息、确认决策时,才可以 @名字。
- 自行判断对话是否已经结束——如果问题已解决、达成共识、或对方只是陈述不需要回复,则不要再 @任何人,直接结束回复,避免产生无意义的循环对话。`
return getSystemPrompt(basePrompt)
}
@@ -8,6 +8,11 @@ export interface StoredMessage {
senderName: string
content: string
timestamp: number
role?: string
tool_call_id?: string | null
tool_calls?: Array<{ id?: string; type?: string; function?: { name?: string; arguments?: string } }> | null
tool_name?: string | null
finish_reason?: string | null
}
// ─── Compression Config ────────────────────────────────────
@@ -1,11 +1,12 @@
import { readFile, stat as fsStat, readdir, mkdir, rm, rename, copyFile as fsCopyFile, writeFile as fsWriteFile } from 'fs/promises'
import { resolve, normalize, isAbsolute, basename } from 'path'
import { resolve, normalize, isAbsolute, basename, join } from 'path'
import { execFile } from 'child_process'
import { promisify } from 'util'
import { existsSync, readFileSync } from 'fs'
import YAML from 'js-yaml'
import { config } from '../../config'
import { getActiveProfileDir, getActiveEnvPath } from './hermes-profile'
import { isPathWithin, relativePathFromBase } from './hermes-path'
const execFileAsync = promisify(execFile)
const execOpts = { windowsHide: true }
@@ -90,11 +91,7 @@ export function validatePath(filePath: string): string {
* Check if a path is inside the upload directory.
*/
export function isInUploadDir(filePath: string): boolean {
const normalized = normalize(resolve(filePath))
const uploadNormalized = normalize(resolve(config.uploadDir))
return normalized.startsWith(uploadNormalized + '/')
|| normalized.startsWith(uploadNormalized + '\\')
|| normalized === uploadNormalized
return isPathWithin(filePath, config.uploadDir)
}
/**
@@ -120,7 +117,7 @@ export function resolveHermesPath(relativePath: string): string {
throw Object.assign(new Error('Invalid file path'), { code: 'invalid_path' })
}
const resolved = resolve(homeDir, normalized)
if (!resolved.startsWith(homeDir)) {
if (!isPathWithin(resolved, homeDir)) {
throw Object.assign(new Error('Path traversal detected'), { code: 'invalid_path' })
}
return resolved
@@ -160,9 +157,7 @@ export class LocalFileProvider implements FileProvider {
try {
const fullPath = resolve(p, entry.name)
const s = await fsStat(fullPath)
const relPath = fullPath.startsWith(homeDir)
? fullPath.slice(homeDir.length + 1)
: entry.name
const relPath = relativePathFromBase(fullPath, homeDir) ?? entry.name
results.push({
name: entry.name,
path: relPath,
@@ -181,9 +176,7 @@ export class LocalFileProvider implements FileProvider {
const p = validatePath(filePath)
const homeDir = getActiveProfileDir()
const s = await fsStat(p)
const relPath = p.startsWith(homeDir)
? p.slice(homeDir.length + 1)
: basename(p)
const relPath = relativePathFromBase(p, homeDir) ?? basename(p)
return {
name: basename(p),
path: relPath || basename(p),
@@ -291,7 +284,7 @@ export class DockerFileProvider implements FileProvider {
// Node.js supports encoding: 'buffer' but @types/node doesn't type it correctly
const { stdout } = await execFileAsync('docker', [
'exec', this.containerName, 'cat', p,
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any })
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any, ...execOpts })
return stdout as unknown as Buffer
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) {
@@ -309,7 +302,7 @@ export class DockerFileProvider implements FileProvider {
try {
await execFileAsync('docker', [
'exec', this.containerName, 'test', '-f', p,
], { timeout: 5000 })
], { timeout: 5000, ...execOpts })
return true
} catch {
return false
@@ -321,9 +314,9 @@ export class DockerFileProvider implements FileProvider {
try {
const { stdout } = await execFileAsync('docker', [
'exec', this.containerName, 'ls', '-la', '--time-style=+%Y-%m-%dT%H:%M:%S', p,
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT })
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT, ...execOpts })
const homeDir = getActiveProfileDir()
const relParent = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : ''
const relParent = relativePathFromBase(p, homeDir) ?? ''
return parseLsOutput(stdout, relParent)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
@@ -338,9 +331,9 @@ export class DockerFileProvider implements FileProvider {
try {
const { stdout } = await execFileAsync('docker', [
'exec', this.containerName, 'stat', '-c', '%n|%F|%s|%Y', p,
], { timeout: BACKEND_TIMEOUT })
], { timeout: BACKEND_TIMEOUT, ...execOpts })
const homeDir = getActiveProfileDir()
const relPath = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : basename(p)
const relPath = relativePathFromBase(p, homeDir) ?? basename(p)
return parseStatOutput(stdout, relPath)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
@@ -354,7 +347,7 @@ export class DockerFileProvider implements FileProvider {
try {
await execFileAsync('docker', [
'exec', '-i', this.containerName, 'sh', '-c', `cat > '${p.replace(/'/g, "'\\''")}'`,
], { timeout: BACKEND_TIMEOUT, input: content } as any)
], { timeout: BACKEND_TIMEOUT, input: content, ...execOpts } as any)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
@@ -364,7 +357,7 @@ export class DockerFileProvider implements FileProvider {
async deleteFile(filePath: string): Promise<void> {
const p = validatePath(filePath)
try {
await execFileAsync('docker', ['exec', this.containerName, 'rm', p], { timeout: BACKEND_TIMEOUT })
await execFileAsync('docker', ['exec', this.containerName, 'rm', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
@@ -374,7 +367,7 @@ export class DockerFileProvider implements FileProvider {
async deleteDir(dirPath: string): Promise<void> {
const p = validatePath(dirPath)
try {
await execFileAsync('docker', ['exec', this.containerName, 'rm', '-rf', p], { timeout: BACKEND_TIMEOUT })
await execFileAsync('docker', ['exec', this.containerName, 'rm', '-rf', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
@@ -385,7 +378,7 @@ export class DockerFileProvider implements FileProvider {
const op = validatePath(oldPath)
const np = validatePath(newPath)
try {
await execFileAsync('docker', ['exec', this.containerName, 'mv', op, np], { timeout: BACKEND_TIMEOUT })
await execFileAsync('docker', ['exec', this.containerName, 'mv', op, np], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
@@ -395,7 +388,7 @@ export class DockerFileProvider implements FileProvider {
async mkDir(dirPath: string): Promise<void> {
const p = validatePath(dirPath)
try {
await execFileAsync('docker', ['exec', this.containerName, 'mkdir', '-p', p], { timeout: BACKEND_TIMEOUT })
await execFileAsync('docker', ['exec', this.containerName, 'mkdir', '-p', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
@@ -406,7 +399,7 @@ export class DockerFileProvider implements FileProvider {
const sp = validatePath(srcPath)
const dp = validatePath(destPath)
try {
await execFileAsync('docker', ['exec', this.containerName, 'cp', sp, dp], { timeout: BACKEND_TIMEOUT })
await execFileAsync('docker', ['exec', this.containerName, 'cp', sp, dp], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
@@ -451,7 +444,7 @@ export class SSHFileProvider implements FileProvider {
// Pass a single quoted command string to prevent shell injection on remote
const { stdout } = await execFileAsync('ssh', [
...this.sshArgs(), `cat ${this.shellEscape(p)}`,
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any })
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any, ...execOpts })
return stdout as unknown as Buffer
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) {
@@ -469,7 +462,7 @@ export class SSHFileProvider implements FileProvider {
try {
await execFileAsync('ssh', [
...this.sshArgs(), `test -f ${this.shellEscape(p)}`,
], { timeout: 5000 })
], { timeout: 5000, ...execOpts })
return true
} catch {
return false
@@ -481,9 +474,9 @@ export class SSHFileProvider implements FileProvider {
try {
const { stdout } = await execFileAsync('ssh', [
...this.sshArgs(), `ls -la --time-style=+%Y-%m-%dT%H:%M:%S ${this.shellEscape(p)}`,
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT })
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT, ...execOpts })
const homeDir = getActiveProfileDir()
const relParent = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : ''
const relParent = relativePathFromBase(p, homeDir) ?? ''
return parseLsOutput(stdout, relParent)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
@@ -498,9 +491,9 @@ export class SSHFileProvider implements FileProvider {
try {
const { stdout } = await execFileAsync('ssh', [
...this.sshArgs(), `stat -c '%n|%F|%s|%Y' ${this.shellEscape(p)}`,
], { timeout: BACKEND_TIMEOUT })
], { timeout: BACKEND_TIMEOUT, ...execOpts })
const homeDir = getActiveProfileDir()
const relPath = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : basename(p)
const relPath = relativePathFromBase(p, homeDir) ?? basename(p)
return parseStatOutput(stdout, relPath)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
@@ -514,7 +507,7 @@ export class SSHFileProvider implements FileProvider {
try {
await execFileAsync('ssh', [
...this.sshArgs(), `cat > ${this.shellEscape(p)}`,
], { timeout: BACKEND_TIMEOUT, input: content } as any)
], { timeout: BACKEND_TIMEOUT, input: content, ...execOpts } as any)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
@@ -524,7 +517,7 @@ export class SSHFileProvider implements FileProvider {
async deleteFile(filePath: string): Promise<void> {
const p = validatePath(filePath)
try {
await execFileAsync('ssh', [...this.sshArgs(), `rm ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT })
await execFileAsync('ssh', [...this.sshArgs(), `rm ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
@@ -534,7 +527,7 @@ export class SSHFileProvider implements FileProvider {
async deleteDir(dirPath: string): Promise<void> {
const p = validatePath(dirPath)
try {
await execFileAsync('ssh', [...this.sshArgs(), `rm -rf ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT })
await execFileAsync('ssh', [...this.sshArgs(), `rm -rf ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
@@ -545,7 +538,7 @@ export class SSHFileProvider implements FileProvider {
const op = validatePath(oldPath)
const np = validatePath(newPath)
try {
await execFileAsync('ssh', [...this.sshArgs(), `mv ${this.shellEscape(op)} ${this.shellEscape(np)}`], { timeout: BACKEND_TIMEOUT })
await execFileAsync('ssh', [...this.sshArgs(), `mv ${this.shellEscape(op)} ${this.shellEscape(np)}`], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
@@ -555,7 +548,7 @@ export class SSHFileProvider implements FileProvider {
async mkDir(dirPath: string): Promise<void> {
const p = validatePath(dirPath)
try {
await execFileAsync('ssh', [...this.sshArgs(), `mkdir -p ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT })
await execFileAsync('ssh', [...this.sshArgs(), `mkdir -p ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
@@ -566,7 +559,7 @@ export class SSHFileProvider implements FileProvider {
const sp = validatePath(srcPath)
const dp = validatePath(destPath)
try {
await execFileAsync('ssh', [...this.sshArgs(), `cp ${this.shellEscape(sp)} ${this.shellEscape(dp)}`], { timeout: BACKEND_TIMEOUT })
await execFileAsync('ssh', [...this.sshArgs(), `cp ${this.shellEscape(sp)} ${this.shellEscape(dp)}`], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
@@ -590,7 +583,7 @@ export class SingularityFileProvider implements FileProvider {
// Node.js supports encoding: 'buffer' but @types/node doesn't type it correctly
const { stdout } = await execFileAsync('singularity', [
'exec', this.imagePath, 'cat', p,
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any })
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any, ...execOpts })
return stdout as unknown as Buffer
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) {
@@ -608,7 +601,7 @@ export class SingularityFileProvider implements FileProvider {
try {
await execFileAsync('singularity', [
'exec', this.imagePath, 'test', '-f', p,
], { timeout: 5000 })
], { timeout: 5000, ...execOpts })
return true
} catch {
return false
@@ -620,9 +613,9 @@ export class SingularityFileProvider implements FileProvider {
try {
const { stdout } = await execFileAsync('singularity', [
'exec', this.imagePath, 'ls', '-la', '--time-style=+%Y-%m-%dT%H:%M:%S', p,
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT })
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT, ...execOpts })
const homeDir = getActiveProfileDir()
const relParent = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : ''
const relParent = relativePathFromBase(p, homeDir) ?? ''
return parseLsOutput(stdout, relParent)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
@@ -637,9 +630,9 @@ export class SingularityFileProvider implements FileProvider {
try {
const { stdout } = await execFileAsync('singularity', [
'exec', this.imagePath, 'stat', '-c', '%n|%F|%s|%Y', p,
], { timeout: BACKEND_TIMEOUT })
], { timeout: BACKEND_TIMEOUT, ...execOpts })
const homeDir = getActiveProfileDir()
const relPath = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : basename(p)
const relPath = relativePathFromBase(p, homeDir) ?? basename(p)
return parseStatOutput(stdout, relPath)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
@@ -653,7 +646,7 @@ export class SingularityFileProvider implements FileProvider {
try {
await execFileAsync('singularity', [
'exec', this.imagePath, 'sh', '-c', `cat > '${p.replace(/'/g, "'\\''")}'`,
], { timeout: BACKEND_TIMEOUT, input: content } as any)
], { timeout: BACKEND_TIMEOUT, input: content, ...execOpts } as any)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
@@ -663,7 +656,7 @@ export class SingularityFileProvider implements FileProvider {
async deleteFile(filePath: string): Promise<void> {
const p = validatePath(filePath)
try {
await execFileAsync('singularity', ['exec', this.imagePath, 'rm', p], { timeout: BACKEND_TIMEOUT })
await execFileAsync('singularity', ['exec', this.imagePath, 'rm', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
@@ -673,7 +666,7 @@ export class SingularityFileProvider implements FileProvider {
async deleteDir(dirPath: string): Promise<void> {
const p = validatePath(dirPath)
try {
await execFileAsync('singularity', ['exec', this.imagePath, 'rm', '-rf', p], { timeout: BACKEND_TIMEOUT })
await execFileAsync('singularity', ['exec', this.imagePath, 'rm', '-rf', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
@@ -684,7 +677,7 @@ export class SingularityFileProvider implements FileProvider {
const op = validatePath(oldPath)
const np = validatePath(newPath)
try {
await execFileAsync('singularity', ['exec', this.imagePath, 'mv', op, np], { timeout: BACKEND_TIMEOUT })
await execFileAsync('singularity', ['exec', this.imagePath, 'mv', op, np], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
@@ -694,7 +687,7 @@ export class SingularityFileProvider implements FileProvider {
async mkDir(dirPath: string): Promise<void> {
const p = validatePath(dirPath)
try {
await execFileAsync('singularity', ['exec', this.imagePath, 'mkdir', '-p', p], { timeout: BACKEND_TIMEOUT })
await execFileAsync('singularity', ['exec', this.imagePath, 'mkdir', '-p', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
@@ -705,7 +698,7 @@ export class SingularityFileProvider implements FileProvider {
const sp = validatePath(srcPath)
const dp = validatePath(destPath)
try {
await execFileAsync('singularity', ['exec', this.imagePath, 'cp', sp, dp], { timeout: BACKEND_TIMEOUT })
await execFileAsync('singularity', ['exec', this.imagePath, 'cp', sp, dp], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
@@ -720,7 +713,7 @@ export class SingularityFileProvider implements FileProvider {
*/
export function getTerminalConfig(): TerminalConfig {
try {
const configPath = `${getActiveProfileDir()}/config.yaml`
const configPath = join(getActiveProfileDir(), 'config.yaml')
if (!existsSync(configPath)) return { backend: 'local' }
const raw = readFileSync(configPath, 'utf-8')
const doc = YAML.load(raw, { json: true }) as any
@@ -777,7 +770,7 @@ async function resolveDockerContainer(cfg: TerminalConfig): Promise<string> {
try {
const { stdout } = await execFileAsync('docker', [
'ps', '-q', '--filter', `ancestor=${cfg.docker_image}`, '--latest',
], { timeout: 5000 })
], { timeout: 5000, ...execOpts })
const id = stdout.trim()
if (id) return id
} catch { }
@@ -0,0 +1,125 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { join } from 'path'
import { promisify } from 'util'
import { stripLegacyApiServerGatewayConfig } from '../config-helpers'
import { logger } from '../logger'
import { safeFileStore } from '../safe-file-store'
import { getProfileDir, listProfileNamesFromDisk } from './hermes-profile'
import { startGatewayRunManaged } from './gateway-runner'
const execFileAsync = promisify(execFile)
function resolveHermesBin(): string {
return process.env.HERMES_BIN?.trim() || 'hermes'
}
function isDockerRuntime(): boolean {
return existsSync('/.dockerenv')
}
function isTermuxRuntime(): boolean {
const prefix = process.env.PREFIX || ''
return !!process.env.TERMUX_VERSION ||
prefix.includes('/com.termux/') ||
existsSync('/data/data/com.termux/files/usr')
}
export function gatewayStatusLooksRunning(output: string): boolean {
const text = output.toLowerCase()
if (text.includes('gateway is not running') || text.includes('not running')) return false
return text.includes('gateway is running') || text.includes('running')
}
export function gatewayStatusLooksRuntimeLocked(output: string): boolean {
const text = output.toLowerCase()
return text.includes('runtime lock is already held')
|| text.includes('gateway runtime lock is already held')
|| text.includes('already held by another instance')
}
export async function isGatewayRunningForProfile(hermesBin: string, profileDir: string): Promise<boolean> {
try {
const { stdout, stderr } = await execFileAsync(hermesBin, ['gateway', 'status'], {
timeout: 10000,
windowsHide: true,
env: {
...process.env,
HERMES_HOME: profileDir,
},
})
return gatewayStatusLooksRunning(`${stdout}\n${stderr}`)
} catch (err: any) {
const output = `${err?.stdout || ''}\n${err?.stderr || ''}\n${err?.message || ''}`
if (gatewayStatusLooksRuntimeLocked(output)) {
logger.info({ profileDir }, 'Hermes gateway status reported runtime lock held; treating gateway as already running')
return true
}
if (output.trim()) {
logger.warn({ err, profileDir }, 'Hermes gateway status failed; treating as not running')
}
return false
}
}
async function startGatewayForProfile(hermesBin: string, profile: string, profileDir: string): Promise<void> {
if (isDockerRuntime() || isTermuxRuntime()) {
const result = startGatewayRunManaged(hermesBin, { profileDir })
logger.info(
'[gateway-autostart] gateway started via background run profile=%s home=%s pid=%s',
profile,
profileDir,
result.pid || 'unknown',
)
return
}
try {
await execFileAsync(hermesBin, ['gateway', 'start'], {
timeout: 30000,
windowsHide: true,
env: {
...process.env,
HERMES_HOME: profileDir,
},
})
logger.info('[gateway-autostart] gateway started via Hermes CLI service profile=%s home=%s', profile, profileDir)
} catch (err) {
logger.warn(err, '[gateway-autostart] Hermes CLI gateway start failed; falling back to background run profile=%s home=%s', profile, profileDir)
const result = startGatewayRunManaged(hermesBin, { profileDir })
logger.info(
'[gateway-autostart] gateway started via fallback background run profile=%s home=%s pid=%s',
profile,
profileDir,
result.pid || 'unknown',
)
}
}
export async function clearApiServerForProfile(profileDir: string): Promise<void> {
const configPath = join(profileDir, 'config.yaml')
try {
await safeFileStore.updateYaml(configPath, (config) => {
const result = stripLegacyApiServerGatewayConfig(config)
return { data: result.config, result: undefined, write: result.changed }
}, { backup: true })
} catch (err) {
logger.warn(err, 'Failed to clear legacy api_server gateway config before gateway startup: %s', profileDir)
}
}
export async function ensureProfileGatewaysRunning(): Promise<void> {
const hermesBin = resolveHermesBin()
const profiles = listProfileNamesFromDisk()
for (const profile of profiles) {
const profileDir = getProfileDir(profile)
const running = await isGatewayRunningForProfile(hermesBin, profileDir)
if (running) {
logger.info('[gateway-autostart] gateway already running profile=%s home=%s', profile, profileDir)
continue
}
await clearApiServerForProfile(profileDir)
await startGatewayForProfile(hermesBin, profile, profileDir)
}
}
@@ -14,13 +14,13 @@
* - 否 → 标记为 stopped
*
* detectStatus 只做只读检测:不会认领未知端口上的进程,也不会探测实际监听端口后回写
* config.yaml。端口修正发生在启动前的 resolvePort 阶段。
* config.yaml。
*
* 端口分配流程(resolvePort,启动前调用):
* ① 读取配置端口
* ② 如果内存记录或 PID 文件对应的配置端口仍健康运行,复用该端口
* ③ 收集本轮已分配端口、其他已管理网关端口、Web UI 端口
* ④ 从 8642 起递增查找空闲端口,并写入 config.yaml
* ④ 从 8642 起递增查找空闲端口,仅返回本次运行使用的端口,不再回写 config.yaml
*
* 启动模式:
* - 所有平台统一使用 `hermes gateway run --replace`
@@ -36,7 +36,6 @@ import { createServer } from 'net'
import yaml from 'js-yaml'
import { logger } from '../logger'
import { detectHermesHome, getHermesBin } from './hermes-path'
import { safeFileStore } from '../safe-file-store'
const execFileAsync = promisify(execFile)
@@ -334,53 +333,6 @@ export class GatewayManager {
})
}
// ============================
// 配置写入
// ============================
/**
* 将端口和主机写入 profile 的 config.yaml
* 写入完整结构:
* platforms:
* api_server:
* enabled: true
* key: ''
* cors_origins: '*'
* extra:
* port: <port>
* host: <host>
* 同时清理旧的顶层 port/host(避免 Hermes 读取错误)
*/
private async writeProfilePort(name: string, port: number, host: string): Promise<void> {
const configPath = join(this.profileDir(name), 'config.yaml')
try {
await safeFileStore.updateYaml(configPath, (cfg) => {
// 确保 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 = {}
cfg.platforms.api_server.enabled = true
cfg.platforms.api_server.key = ''
cfg.platforms.api_server.cors_origins = '*'
cfg.platforms.api_server.extra.port = port
cfg.platforms.api_server.extra.host = host
// 清理旧的顶层 port/hostHermes 只从 extra 读取
if (cfg.platforms.api_server.port !== undefined) {
delete cfg.platforms.api_server.port
}
if (cfg.platforms.api_server.host !== undefined) {
delete cfg.platforms.api_server.host
}
return cfg
})
logger.debug('Updated %s: api_server.extra.port = %d', configPath, port)
} catch (err) {
logger.error(err, 'Failed to write config for profile "%s"', name)
}
}
// ============================
// 端口分配
// ============================
@@ -392,7 +344,6 @@ export class GatewayManager {
* 1. 当前 profile 已经健康运行 → 直接使用运行端口
* 2. 未运行 → 从 8642 开始找空闲端口
* 3. 检查已管理 profile / 本轮已分配端口 / 系统 TCP 占用
* 4. 先写入 config.yaml,再启动 gateway
*/
private async resolvePort(name: string): Promise<{ port: number; host: string }> {
const { port: configuredPort, host } = this.readProfilePort(name)
@@ -437,8 +388,6 @@ export class GatewayManager {
} else {
logger.debug('Assigning port %d for profile "%s"', port, name)
}
await this.writeProfilePort(name, port, host)
this.allocatedPorts.add(port)
return { port, host }
}
@@ -0,0 +1,22 @@
import { spawn } from 'child_process'
import { getActiveProfileDir } from './hermes-profile'
export function startGatewayRunManaged(
hermesBin: string,
opts: { profileDir?: string } = {},
): { pid: number | null; reused: boolean } {
const profileDir = opts.profileDir || getActiveProfileDir()
const child = spawn(hermesBin, ['gateway', 'run', '--replace'], {
detached: true,
stdio: 'ignore',
windowsHide: true,
env: {
...process.env,
HERMES_HOME: profileDir,
},
})
child.unref()
const pid = child.pid ?? null
return { pid, reused: false }
}
@@ -1,8 +1,10 @@
import { io, Socket } from 'socket.io-client'
import { getToken } from '../../../services/auth'
import type { GatewayManager } from '../gateway-manager'
import { logger } from '../../../services/logger'
import { updateUsage } from '../../../db/hermes/usage-store'
import { AgentBridgeClient, type AgentBridgeMessage, type AgentBridgeOutput } from '../agent-bridge'
import { convertContentBlocksForAgent, isContentBlockArray } from '../run-chat/content-blocks'
import type { ContentBlock } from '../run-chat/types'
// ─── Types ────────────────────────────────────────────────────
@@ -22,6 +24,15 @@ interface MessageData {
timestamp: number
}
type MentionMessage = {
content: string
senderName: string
senderId: string
timestamp: number
input?: string | ContentBlock[]
mentionDepth?: number
}
interface MemberData {
id: string
name: string
@@ -55,9 +66,10 @@ class AgentClient {
private joinedRooms = new Set<string>()
private handlers: AgentEventHandler
private _reconnecting = false
private gatewayManager: GatewayManager | null = null
private contextEngine: any = null
private storage: any = null
private pendingToolCallIds = new Map<string, string[]>()
private pendingToolBaseIds = new Map<string, string>()
constructor(config: AgentConfig, handlers: AgentEventHandler = {}) {
this.agentId = Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
@@ -75,10 +87,6 @@ class AgentClient {
return this.socket?.id
}
setGatewayManager(manager: GatewayManager): void {
this.gatewayManager = manager
}
setContextEngine(engine: any): void {
this.contextEngine = engine
}
@@ -146,10 +154,10 @@ class AgentClient {
})
}
sendMessage(roomId: string, content: string): Promise<string> {
sendMessage(roomId: string, content: string, messageId?: string, extra?: Record<string, unknown>): Promise<string> {
this.ensureConnected()
return new Promise((resolve, reject) => {
this.socket!.emit('message', { roomId, content }, (res: { id?: string; error?: string }) => {
this.socket!.emit('message', { roomId, content, id: messageId, ...extra }, (res: { id?: string; error?: string }) => {
if (res.error) {
reject(new Error(res.error))
} else {
@@ -174,6 +182,52 @@ class AgentClient {
this.socket!.emit('context_status', { roomId, agentName: this.name, status })
}
emitApprovalRequested(roomId: string, payload: Record<string, unknown>): void {
this.ensureConnected()
this.socket!.emit('approval.requested', { roomId, agentName: this.name, ...payload })
}
emitApprovalResolved(roomId: string, payload: Record<string, unknown>): void {
this.ensureConnected()
this.socket!.emit('approval.resolved', { roomId, agentName: this.name, ...payload })
}
async interrupt(roomId: string): Promise<void> {
const sessionSeed = String(this.storage?.getRoom?.(roomId)?.sessionSeed || '0')
const sessionId = groupBridgeSessionId(roomId, this.profile, this.name, sessionSeed)
await new AgentBridgeClient().interrupt(sessionId, 'Interrupted by group chat user', this.profile)
this.stopTyping(roomId)
this.emitContextStatus(roomId, 'ready')
}
emitMessageStreamStart(roomId: string, messageId: string): void {
this.ensureConnected()
this.socket!.emit('message_stream_start', {
roomId,
id: messageId,
senderId: this.socket?.id || this.agentId,
senderName: this.name,
timestamp: Date.now(),
})
}
emitMessageStreamDelta(roomId: string, messageId: string, delta: string): void {
if (!delta) return
this.ensureConnected()
this.socket!.emit('message_stream_delta', { roomId, id: messageId, delta })
}
emitMessageReasoningDelta(roomId: string, messageId: string, delta: string): void {
if (!delta) return
this.ensureConnected()
this.socket!.emit('message_reasoning_delta', { roomId, id: messageId, delta })
}
emitMessageStreamEnd(roomId: string, messageId: string): void {
this.ensureConnected()
this.socket!.emit('message_stream_end', { roomId, id: messageId })
}
getJoinedRooms(): string[] {
return Array.from(this.joinedRooms)
}
@@ -193,23 +247,10 @@ class AgentClient {
*/
async replyToMention(
roomId: string,
msg: { content: string; senderName: string; senderId: string; timestamp: number },
msg: MentionMessage,
onStatus?: (status: 'compressing' | 'replying' | 'ready') => void,
): Promise<void> {
logger.debug(`[AgentClients] ${this.name} mentioned by ${msg.senderName}: "${msg.content.slice(0, 50)}"`)
if (!this.gatewayManager) {
logger.debug(`[AgentClients] ${this.name}: gatewayManager is null, skipping`)
return
}
const upstream = this.gatewayManager.getUpstream(this.profile)
const apiKey = this.gatewayManager.getApiKey(this.profile)
logger.debug(`[AgentClients] ${this.name}: upstream=${upstream}, profile=${this.profile}`)
if (!upstream) {
logger.error(`[AgentClients] ${this.name}: no gateway upstream for profile "${this.profile}"`)
return
}
try {
// Notify room that agent is typing
this.startTyping(roomId)
@@ -244,8 +285,8 @@ class AgentClient {
roomName: roomId,
memberNames,
members,
upstream,
apiKey,
upstream: '',
apiKey: null,
currentMessage: msg,
compression,
profile: this.profile,
@@ -261,86 +302,101 @@ class AgentClient {
}
}
// Strip @mention from input — agent already knows it was mentioned
const input = msg.content.replace(new RegExp(`@${this.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi'), '').trim() || msg.content
const responseRes = await fetch(`${upstream.replace(/\/$/, '')}/v1/responses`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
// Keep the original mentions visible and add an explicit routing note.
// When a user mentions multiple agents, stripping only this agent's
// name can make the remaining input look like it was meant for
// someone else.
const routedPrefix = `群聊系统:这条消息已经提及你(${this.name}),请直接回复;即使消息同时提及其他成员,也不要因此输出空回复。`
const ownMentionPattern = new RegExp(`@${this.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi')
const rawInput = msg.input || msg.content
const input = isContentBlockArray(rawInput)
? rawInput.map((block) => {
if (block.type !== 'text') return block
const text = String(block.text || msg.content).replace(ownMentionPattern, '').trim()
return { ...block, text: `${routedPrefix}\n\n原始消息:${text || msg.content}` }
})
: `${routedPrefix}\n\n原始消息:${msg.content.replace(ownMentionPattern, '').trim() || msg.content}`
const bridgeInput: AgentBridgeMessage = isContentBlockArray(input)
? await convertContentBlocksForAgent(input)
: input
const bridge = new AgentBridgeClient()
const sessionSeed = String(this.storage?.getRoom?.(roomId)?.sessionSeed || '0')
const sessionId = groupBridgeSessionId(roomId, this.profile, this.name, sessionSeed)
const runMessageId = groupMessageId(roomId, this.profile, this.name)
let partIndex = 0
let streamMessageId = groupMessagePartId(runMessageId, partIndex)
let currentContent = ''
let totalContent = ''
let reasoningContent = ''
const flushedAssistantParts = new Set<string>()
let lastChunk: AgentBridgeOutput | null = null
const started = await bridge.chat(
sessionId,
bridgeInput,
conversationHistory,
instructions,
this.profile,
{
source: 'api_server',
},
body: JSON.stringify({
input,
...(conversationHistory.length > 0 ? { conversation_history: conversationHistory } : {}),
...(instructions ? { instructions } : {}),
stream: true,
store: false,
}),
signal: AbortSignal.timeout(120000),
})
)
if (!responseRes.ok) {
const text = await responseRes.text().catch(() => '')
logger.error(`[AgentClients] ${this.name}: gateway response failed (${responseRes.status}): ${text}`)
this.stopTyping(roomId)
onStatus?.('ready')
return
}
if (!responseRes.body) {
logger.error(`[AgentClients] ${this.name}: gateway response stream missing`)
this.stopTyping(roomId)
onStatus?.('ready')
return
}
let fullContent = ''
for await (const frame of readSseFrames(responseRes.body)) {
let parsed: any
try {
parsed = JSON.parse(frame.data)
} catch {
continue
}
const eventType = parsed.type || frame.event || parsed.event
logger.debug(`[AgentClients] ${this.name}: event=${eventType}`)
if (eventType === 'response.output_text.delta' && parsed.delta) {
fullContent += parsed.delta
continue
}
if (eventType === 'response.completed') {
const response = parsed.response || parsed
const finalText = extractResponseText(response)
if (!fullContent && finalText) fullContent = finalText
const usage = response.usage || {}
updateUsage(roomId, {
inputTokens: usage.input_tokens ?? usage.inputTokens ?? 0,
outputTokens: usage.output_tokens ?? usage.outputTokens ?? 0,
cacheReadTokens: usage.cache_read_tokens ?? usage.cacheReadTokens ?? 0,
cacheWriteTokens: usage.cache_write_tokens ?? usage.cacheWriteTokens ?? 0,
reasoningTokens: usage.reasoning_tokens ?? usage.reasoningTokens ?? 0,
model: response.model || '',
profile: this.profile,
})
logger.debug(`[AgentClients] ${this.name}: response completed, content length=${fullContent.length}`)
if (fullContent) {
this.stopTyping(roomId)
this.sendMessage(roomId, fullContent)
this.emitMessageStreamStart(roomId, streamMessageId)
for await (const chunk of bridge.streamOutput(started.run_id, { timeoutMs: 120000 })) {
lastChunk = chunk
reasoningContent += await this.recordBridgeEvents(roomId, chunk, () => streamMessageId, async () => {
const toolBaseId = streamMessageId
if (currentContent.trim()) {
await this.sendMessage(roomId, currentContent, streamMessageId, {
role: 'assistant',
mentionDepth: nextMentionDepth(msg),
reasoning: reasoningContent || null,
reasoning_content: reasoningContent || null,
})
flushedAssistantParts.add(streamMessageId)
currentContent = ''
}
onStatus?.('ready')
return
}
if (eventType === 'response.failed') {
logger.error(`[AgentClients] ${this.name}: response failed`)
this.stopTyping(roomId)
onStatus?.('ready')
return
this.emitMessageStreamEnd(roomId, toolBaseId)
partIndex += 1
streamMessageId = groupMessagePartId(runMessageId, partIndex)
this.emitMessageStreamStart(roomId, streamMessageId)
return toolBaseId
})
if (chunk.delta) {
currentContent += chunk.delta
totalContent += chunk.delta
this.emitMessageStreamDelta(roomId, streamMessageId, chunk.delta)
}
}
logger.warn(`[AgentClients] ${this.name}: response stream ended without terminal event`)
if (lastChunk?.status === 'error') {
logger.error(`[AgentClients] ${this.name}: bridge response failed: ${lastChunk.error || 'unknown error'}`)
this.emitMessageStreamEnd(roomId, streamMessageId)
this.stopTyping(roomId)
onStatus?.('ready')
return
}
if (!totalContent) {
currentContent = extractBridgeFinalText(lastChunk)
totalContent = currentContent
}
recordBridgeUsage(roomId, this.profile, lastChunk?.result)
logger.debug(`[AgentClients] ${this.name}: bridge response completed, content length=${totalContent.length}`)
if (currentContent) {
this.stopTyping(roomId)
await this.sendMessage(roomId, currentContent, streamMessageId, {
role: 'assistant',
mentionDepth: nextMentionDepth(msg),
reasoning: reasoningContent || null,
reasoning_content: reasoningContent || null,
})
this.emitMessageStreamEnd(roomId, streamMessageId)
onStatus?.('ready')
return
}
logger.warn(`[AgentClients] ${this.name}: bridge response completed without content`)
this.emitMessageStreamEnd(roomId, streamMessageId)
this.stopTyping(roomId)
onStatus?.('ready')
} catch (err: any) {
@@ -350,6 +406,132 @@ class AgentClient {
}
}
private async recordBridgeEvents(
roomId: string,
chunk: AgentBridgeOutput,
getCurrentMessageId: () => string,
beforeToolStarted: () => Promise<string>,
): Promise<string> {
let reasoning = ''
for (const ev of chunk.events || []) {
const eventType = String((ev as any)?.event || '')
if (eventType === 'tool.started') {
const toolBaseId = await beforeToolStarted()
this.recordToolStarted(roomId, ev as Record<string, unknown>, toolBaseId)
} else if (eventType === 'tool.completed') {
this.recordToolCompleted(roomId, ev as Record<string, unknown>)
} else if (eventType === 'approval.requested') {
this.emitApprovalRequested(roomId, {
event: 'approval.requested',
approval_id: (ev as any).approval_id,
command: (ev as any).command,
description: (ev as any).description,
choices: Array.isArray((ev as any).choices) ? (ev as any).choices : undefined,
allow_permanent: (ev as any).allow_permanent,
})
} else if (eventType === 'approval.resolved') {
this.emitApprovalResolved(roomId, {
event: 'approval.resolved',
approval_id: (ev as any).approval_id,
choice: (ev as any).choice,
})
} else if (eventType === 'reasoning.delta' || eventType === 'thinking.delta') {
const text = String((ev as any)?.text || '')
reasoning += text
this.emitMessageReasoningDelta(roomId, getCurrentMessageId(), text)
}
}
return reasoning
}
private recordToolStarted(roomId: string, ev: Record<string, unknown>, runMessageId: string): void {
const toolName = String(ev.tool_name || ev.tool || ev.name || '')
const toolCallId = groupToolCallId(ev.tool_call_id, toolName, this.nextToolIndex(roomId, toolName))
this.trackPendingToolCall(roomId, toolName, toolCallId)
this.pendingToolBaseIds.set(toolCallId, runMessageId)
const timestamp = Date.now()
const rawArgs = ev.args ?? ev.arguments ?? ev.input ?? {}
const args = normalizeToolArgs(rawArgs)
const toolCall = {
id: toolCallId,
type: 'function',
function: {
name: toolName,
arguments: JSON.stringify(args),
},
}
const msg: MessageData & Record<string, any> = {
id: `${runMessageId}_toolcall_${safeId(toolCallId)}`,
roomId,
senderId: this.socket?.id || this.agentId,
senderName: this.name,
content: '',
timestamp,
role: 'assistant',
tool_calls: [toolCall],
finish_reason: 'tool_calls',
}
this.sendMessage(roomId, '', msg.id, {
role: 'assistant',
tool_calls: msg.tool_calls,
finish_reason: 'tool_calls',
timestamp,
}).catch((err: any) => logger.warn(`[AgentClients] failed to record tool call: ${err.message}`))
}
private recordToolCompleted(roomId: string, ev: Record<string, unknown>): void {
const toolName = String(ev.tool_name || ev.tool || ev.name || '')
const rawId = String(ev.tool_call_id || '').trim()
const toolCallId = rawId || this.takePendingToolCall(roomId, toolName) || groupToolCallId(null, toolName, this.nextToolIndex(roomId, toolName))
const runMessageId = this.pendingToolBaseIds.get(toolCallId) || groupMessagePartId(groupMessageId(roomId, this.profile, this.name), 0)
this.pendingToolBaseIds.delete(toolCallId)
const output = bridgeToolOutput(ev)
const timestamp = Date.now()
const msg: MessageData & Record<string, any> = {
id: `${runMessageId}_toolresult_${safeId(toolCallId)}_${Date.now()}`,
roomId,
senderId: this.socket?.id || this.agentId,
senderName: this.name,
content: output,
timestamp,
role: 'tool',
tool_call_id: toolCallId,
tool_name: toolName || null,
}
this.sendMessage(roomId, output, msg.id, {
role: 'tool',
tool_call_id: toolCallId,
tool_name: toolName || null,
timestamp,
}).catch((err: any) => logger.warn(`[AgentClients] failed to record tool result: ${err.message}`))
}
private pendingToolKey(roomId: string, toolName: string): string {
return `${roomId}::${toolName || 'tool'}`
}
private trackPendingToolCall(roomId: string, toolName: string, toolCallId: string): void {
const key = this.pendingToolKey(roomId, toolName)
const list = this.pendingToolCallIds.get(key) || []
list.push(toolCallId)
this.pendingToolCallIds.set(key, list)
}
private takePendingToolCall(roomId: string, toolName: string): string | undefined {
const key = this.pendingToolKey(roomId, toolName)
const list = this.pendingToolCallIds.get(key)
if (!list?.length) return undefined
const id = list.shift()
if (list.length) this.pendingToolCallIds.set(key, list)
else this.pendingToolCallIds.delete(key)
return id
}
private nextToolIndex(roomId: string, toolName: string): number {
const key = this.pendingToolKey(roomId, toolName)
return (this.pendingToolCallIds.get(key)?.length || 0) + 1
}
private bindEvents(): void {
const s = this.socket!
@@ -387,77 +569,79 @@ class AgentClient {
}
}
async function* readSseFrames(stream: ReadableStream<Uint8Array>): AsyncGenerator<{ event?: string; data: string }> {
const decoder = new TextDecoder()
const reader = stream.getReader()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let boundary = buffer.indexOf('\n\n')
while (boundary >= 0) {
const raw = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const frame = parseSseFrame(raw)
if (frame?.data) yield frame
boundary = buffer.indexOf('\n\n')
}
}
buffer += decoder.decode()
const frame = parseSseFrame(buffer)
if (frame?.data) yield frame
} finally {
reader.releaseLock()
}
function groupBridgeSessionId(roomId: string, profile: string, name: string, sessionSeed: string): string {
const raw = `gc_${roomId}_${profile}_${name}_${sessionSeed || '0'}`
return raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 120)
}
function parseSseFrame(raw: string): { event?: string; data: string } | null {
let event: string | undefined
const data: string[] = []
for (const line of raw.split(/\r?\n/)) {
if (!line || line.startsWith(':')) continue
if (line.startsWith('event:')) {
event = line.slice(6).trim()
} else if (line.startsWith('data:')) {
data.push(line.slice(5).trimStart())
}
}
if (data.length === 0) return null
return { event, data: data.join('\n') }
function groupMessageId(roomId: string, profile: string, name: string): string {
const raw = `gcmsg_${safeId(roomId)}_${safeId(profile)}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
return raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 160)
}
function extractResponseText(response: any): string {
const output = Array.isArray(response?.output) ? response.output : []
const parts: string[] = []
for (const item of output) {
if (item.type !== 'message') continue
const content = Array.isArray(item.content) ? item.content : []
for (const part of content) {
if (part.type === 'output_text' || part.type === 'text') {
parts.push(part.text || '')
}
function groupMessagePartId(runMessageId: string, partIndex: number): string {
return `${safeId(runMessageId)}_part_${partIndex}`
}
function groupToolCallId(rawToolCallId: unknown, toolName: string, index: number): string {
const raw = String(rawToolCallId || '').trim()
if (raw) return raw
return `cli_${safeId(toolName || 'tool')}_${Date.now()}_${index}`
}
function safeId(value: string): string {
return String(value || 'item').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80)
}
function bridgeToolOutput(ev: Record<string, unknown>): string {
const value = ev.result ?? ev.output ?? ev.result_preview ?? ev.preview ?? ''
return typeof value === 'string' ? value : JSON.stringify(value ?? '')
}
function normalizeToolArgs(value: unknown): Record<string, unknown> {
if (!value) return {}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : { value }
} catch {
return { value }
}
}
if (parts.length > 0) return parts.join('')
return typeof response?.output_text === 'string' ? response.output_text : ''
return typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : { value }
}
function extractBridgeFinalText(chunk: AgentBridgeOutput | null): string {
const result = chunk?.result as any
const output = result?.final_response || chunk?.output || ''
return typeof output === 'string' ? output.trim() : ''
}
function recordBridgeUsage(roomId: string, profile: string, result: unknown): void {
const payload = result as any
const usage = payload?.usage || payload?.response?.usage
if (!usage) return
updateUsage(roomId, {
inputTokens: usage.input_tokens ?? usage.inputTokens ?? 0,
outputTokens: usage.output_tokens ?? usage.outputTokens ?? 0,
cacheReadTokens: usage.cache_read_tokens ?? usage.cacheReadTokens ?? 0,
cacheWriteTokens: usage.cache_write_tokens ?? usage.cacheWriteTokens ?? 0,
reasoningTokens: usage.reasoning_tokens ?? usage.reasoningTokens ?? 0,
model: payload?.model || payload?.response?.model || '',
profile,
})
}
// ─── AgentClients (roomId -> agents) ──────────────────────────
export class AgentClients {
private rooms = new Map<string, Map<string, AgentClient>>()
private _gatewayManager: GatewayManager | null = null
private _contextEngine: any = null
private _storage: any = null
// Per-room processing lock + mention queue
private _processingRooms = new Set<string>()
private _mentionQueue = new Map<string, Array<{ agent: AgentClient; msg: { content: string; senderName: string; senderId: string; timestamp: number } }>>()
private _mentionQueue = new Map<string, Array<{ agent: AgentClient; msg: MentionMessage }>>()
/**
* Create an agent client and connect it to the server.
@@ -468,7 +652,6 @@ export class AgentClients {
await client.connect(port)
// Auto-apply stored references (fixes propagation for agents created after set*)
if (this._gatewayManager) client.setGatewayManager(this._gatewayManager)
if (this._contextEngine) client.setContextEngine(this._contextEngine)
if (this._storage) client.setStorage(this._storage)
@@ -557,6 +740,13 @@ export class AgentClients {
return Promise.all(agents.map((agent) => agent.sendMessage(roomId, content)))
}
async interruptAgent(roomId: string, agentName: string): Promise<void> {
const agent = this.getAgents(roomId).find(a => a.name === agentName)
if (!agent) throw new Error(`Agent "${agentName}" not found in room "${roomId}"`)
this._mentionQueue.delete(`${roomId}:${agent.name}`)
await agent.interrupt(roomId)
}
/**
* Disconnect all agents in a room.
*/
@@ -576,7 +766,12 @@ export class AgentClients {
resetRoomContext(roomId: string): void {
this._mentionQueue.delete(roomId)
this._processingRooms.delete(roomId)
for (const key of Array.from(this._mentionQueue.keys())) {
if (key.startsWith(`${roomId}:`)) this._mentionQueue.delete(key)
}
for (const key of Array.from(this._processingRooms)) {
if (key.startsWith(`${roomId}:`)) this._processingRooms.delete(key)
}
if (this._contextEngine) {
try { this._contextEngine.invalidateRoom(roomId) } catch { /* ignore */ }
}
@@ -593,16 +788,6 @@ export class AgentClients {
logger.info('[AgentClients] All agents disconnected')
}
/**
* Set gateway manager for all existing and future agents.
*/
setGatewayManager(manager: GatewayManager): void {
this._gatewayManager = manager
this.rooms.forEach((room) => {
room.forEach((client) => client.setGatewayManager(manager))
})
}
/**
* Set context engine for all existing and future agents.
*/
@@ -628,13 +813,14 @@ export class AgentClients {
* Server-side: parse @mentions and forward to matching agents directly.
* If the room is already processing (compressing/replying), queue the mention.
*/
async processMentions(roomId: string, msg: { content: string; senderName: string; senderId: string; timestamp: number }): Promise<void> {
if (!this._gatewayManager) return
const content = msg.content.toLowerCase()
async processMentions(roomId: string, msg: MentionMessage): Promise<void> {
const agents = this.getAgents(roomId)
const senderName = msg.senderName.toLowerCase()
const mentioned = agents.filter(a => content.includes(`@${a.name.toLowerCase()}`))
const mentioned = agents.filter(a => (
a.name.toLowerCase() !== senderName &&
isAgentMentioned(msg.content, a.name)
))
if (mentioned.length === 0) return
logger.debug(`[AgentClients] ${mentioned.map(a => a.name).join(', ')} mentioned by ${msg.senderName}`)
@@ -652,7 +838,7 @@ export class AgentClients {
private async _processAgentMention(
roomId: string,
agent: AgentClient,
msg: { content: string; senderName: string; senderId: string; timestamp: number },
msg: MentionMessage,
): Promise<void> {
const agentKey = `${roomId}:${agent.name}`
if (this._processingRooms.has(agentKey)) {
@@ -693,9 +879,16 @@ export class AgentClients {
// Process the last queued mention only (most recent, discards stale intermediate ones)
const last = queue[queue.length - 1]
this._processingRooms.add(agentKey)
this._processAgentMention(roomId, last.agent, last.msg).catch((err) => {
logger.error(`[AgentClients] error processing queued mention: ${err.message}`)
})
await this._processAgentMention(roomId, last.agent, last.msg)
}
}
function nextMentionDepth(msg: MentionMessage): number {
return Math.max(0, msg.mentionDepth || 0) + 1
}
function isAgentMentioned(content: string, agentName: string): boolean {
const escaped = agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(`@${escaped}(?=$|\\s|[.,!?;:,。!?;:])`, 'i')
return pattern.test(content)
}
@@ -6,6 +6,8 @@ import { getDb } from '../../../db'
import { AgentClients } from './agent-clients'
import { ContextEngine } from '../context-engine/compressor'
import { SessionDeleter } from '../session-deleter'
import { countTokens, SUMMARY_PREFIX } from '../../../lib/context-compressor'
import { AgentBridgeClient } from '../agent-bridge'
// ─── Types ────────────────────────────────────────────────────
@@ -16,6 +18,43 @@ interface ChatMessage {
senderName: string
content: string
timestamp: number
role?: string
tool_call_id?: string | null
tool_calls?: any[] | null
tool_name?: string | null
finish_reason?: string | null
reasoning?: string | null
reasoning_details?: string | null
reasoning_content?: string | null
mentionDepth?: number
}
function contentToStorageString(content: unknown): string {
if (typeof content === 'string') return content
return JSON.stringify(content ?? '')
}
function contentToText(content: unknown): string {
if (typeof content === 'string') {
const trimmed = content.trim()
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
try {
return contentToText(JSON.parse(trimmed))
} catch {
return content
}
}
return content
}
if (Array.isArray(content)) {
return content.map((block: any) => {
if (block?.type === 'text') return block.text || ''
if (block?.type === 'image') return `[Image: ${block.name || block.path || ''}]`
if (block?.type === 'file') return `[File: ${block.name || block.path || ''}]`
return ''
}).filter(Boolean).join('\n')
}
return content == null ? '' : String(content)
}
interface RoomAgent {
@@ -64,6 +103,64 @@ export interface PendingSessionDeleteDrainResult {
failed: Array<{ sessionId: string; error: string }>
}
function parseJsonArray(value: unknown): any[] | null {
if (value == null || value === '') return null
if (Array.isArray(value)) return value
if (typeof value !== 'string') return null
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed : null
} catch {
return null
}
}
function normalizeMessageRole(role: unknown): string {
const value = String(role || '').trim()
return ['user', 'assistant', 'tool', 'command'].includes(value) ? value : 'user'
}
function normalizeMentionDepth(depth: unknown): number {
const value = Number(depth)
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0
}
function groupRunOrder(id: string): { baseId: string; phase: number } {
const value = String(id || '')
const partMatch = value.match(/^(.*)_part_(\d+)(?:_(toolcall|toolresult)_.+)?$/)
if (partMatch) {
const part = Number(partMatch[2] || 0)
const kind = partMatch[3] || 'assistant'
const offset = kind === 'toolcall' ? 1 : kind === 'toolresult' ? 2 : 0
return { baseId: partMatch[1], phase: part * 3 + offset }
}
const toolIdx = value.indexOf('_toolcall_')
if (toolIdx >= 0) return { baseId: value.slice(0, toolIdx), phase: 0 }
const resultIdx = value.indexOf('_toolresult_')
if (resultIdx >= 0) return { baseId: value.slice(0, resultIdx), phase: 1 }
return { baseId: value, phase: 2 }
}
function sortGroupMessages<T extends { id: string; timestamp: number }>(messages: T[]): T[] {
const baseMinTimestamp = new Map<string, number>()
for (const msg of messages) {
const { baseId } = groupRunOrder(msg.id)
const existing = baseMinTimestamp.get(baseId)
if (existing == null || msg.timestamp < existing) baseMinTimestamp.set(baseId, msg.timestamp)
}
return [...messages].sort((a, b) => {
const ao = groupRunOrder(a.id)
const bo = groupRunOrder(b.id)
const at = baseMinTimestamp.get(ao.baseId) ?? a.timestamp
const bt = baseMinTimestamp.get(bo.baseId) ?? b.timestamp
if (at !== bt) return at - bt
if (ao.baseId !== bo.baseId) return ao.baseId.localeCompare(bo.baseId)
if (ao.phase !== bo.phase) return ao.phase - bo.phase
if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp
return a.id.localeCompare(b.id)
})
}
class ChatStorage {
private db() { return getDb() }
@@ -175,16 +272,16 @@ class ChatStorage {
// ─── Rooms ────────────────────────────────────────────────
getRoom(roomId: string): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number } | undefined {
return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens FROM gc_rooms WHERE id = ?').get(roomId) as any
getRoom(roomId: string): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number; sessionSeed: string } | undefined {
return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed FROM gc_rooms WHERE id = ?').get(roomId) as any
}
getRoomByInviteCode(code: string): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number } | undefined {
return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens FROM gc_rooms WHERE inviteCode = ?').get(code) as any
getRoomByInviteCode(code: string): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number; sessionSeed: string } | undefined {
return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed FROM gc_rooms WHERE inviteCode = ?').get(code) as any
}
getAllRooms(): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number }[] {
return (this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens FROM gc_rooms ORDER BY id').all() || []) as any[]
getAllRooms(): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number; sessionSeed: string }[] {
return (this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed FROM gc_rooms ORDER BY id').all() || []) as any[]
}
saveRoom(id: string, name: string, inviteCode?: string, config?: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number }): void {
@@ -212,25 +309,132 @@ class ChatStorage {
this.db()?.prepare('UPDATE gc_rooms SET totalTokens = ? WHERE id = ?').run(tokens, roomId)
}
rotateRoomSessionSeed(roomId: string): string {
const seed = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
this.db()?.prepare('UPDATE gc_rooms SET sessionSeed = ? WHERE id = ?').run(seed, roomId)
return seed
}
estimateTokens(text: string): number {
const cjk = (text.match(/[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff00-\uffef]/g) || []).length
const other = text.length - cjk
return Math.ceil(cjk * 1.5 + other / 4)
}
private contentToUsageText(content: unknown): string {
if (typeof content === 'string') return content
if (!content) return ''
if (Array.isArray(content)) {
return content.map((block: any) => {
if (typeof block?.text === 'string') return block.text
if (typeof block?.type === 'string') return `[${block.type}]`
return String(block || '')
}).join('\n')
}
return String(content)
}
private estimateUsageTokensFromMessages(messages: ChatMessage[]): { inputTokens: number; outputTokens: number } {
const inputTokens = messages
.filter(m => (m.role || 'user') === 'user')
.reduce((sum, m) => sum + countTokens(this.contentToUsageText(m.content)), 0)
const outputTokens = messages
.filter(m => m.role === 'assistant' || m.role === 'tool')
.reduce((sum, m) => sum + countTokens(this.contentToUsageText(m.content)) + countTokens(String(m.tool_calls || '')), 0)
return { inputTokens, outputTokens }
}
private estimateRoomTotalTokens(roomId: string, messages: ChatMessage[]): number {
const snapshot = this.getContextSnapshot(roomId)
if (snapshot && messages.length) {
const snapshotIdx = messages.findIndex(m => m.id === snapshot.lastMessageId)
const newMessages = snapshotIdx >= 0
? messages.slice(snapshotIdx + 1)
: messages.filter(m => m.timestamp > snapshot.lastMessageTimestamp)
const newUsage = this.estimateUsageTokensFromMessages(newMessages)
return countTokens(SUMMARY_PREFIX + snapshot.summary) + newUsage.inputTokens + newUsage.outputTokens
}
const usage = this.estimateUsageTokensFromMessages(messages)
return usage.inputTokens + usage.outputTokens
}
// ─── Messages ─────────────────────────────────────────────
getMessages(roomId: string, limit = 500): ChatMessage[] {
const rows = (this.db()?.prepare(
'SELECT id, roomId, senderId, senderName, content, timestamp FROM gc_messages WHERE roomId = ? ORDER BY timestamp DESC LIMIT ?'
'SELECT id, roomId, senderId, senderName, content, timestamp, role, tool_call_id, tool_calls, tool_name, finish_reason, reasoning, reasoning_details, reasoning_content FROM gc_messages WHERE roomId = ? ORDER BY timestamp DESC LIMIT ?'
).all(roomId, limit) || []) as any[]
return rows.reverse()
return sortGroupMessages(rows.map(row => ({
...row,
tool_calls: parseJsonArray(row.tool_calls),
})))
}
getMessage(messageId: string): ChatMessage | null {
const row = this.db()?.prepare(
'SELECT id, roomId, senderId, senderName, content, timestamp, role, tool_call_id, tool_calls, tool_name, finish_reason, reasoning, reasoning_details, reasoning_content FROM gc_messages WHERE id = ?'
).get(messageId) as any
if (!row) return null
return {
...row,
tool_calls: parseJsonArray(row.tool_calls),
}
}
addMessage(msg: ChatMessage): void {
this.upsertMessage(msg)
}
upsertMessage(msg: ChatMessage): void {
const toolCallsJson = msg.tool_calls ? JSON.stringify(msg.tool_calls) : null
this.db()?.prepare(
'INSERT INTO gc_messages (id, roomId, senderId, senderName, content, timestamp) VALUES (?, ?, ?, ?, ?, ?)'
).run(msg.id, msg.roomId, msg.senderId, msg.senderName, msg.content, msg.timestamp)
`INSERT INTO gc_messages (id, roomId, senderId, senderName, content, timestamp, role, tool_call_id, tool_calls, tool_name, finish_reason, reasoning, reasoning_details, reasoning_content)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
+ ` ON CONFLICT(id) DO UPDATE SET
roomId = excluded.roomId,
senderId = excluded.senderId,
senderName = excluded.senderName,
content = excluded.content,
timestamp = excluded.timestamp,
role = excluded.role,
tool_call_id = excluded.tool_call_id,
tool_calls = excluded.tool_calls,
tool_name = excluded.tool_name,
finish_reason = excluded.finish_reason,
reasoning = excluded.reasoning,
reasoning_details = excluded.reasoning_details,
reasoning_content = excluded.reasoning_content`
).run(
msg.id, msg.roomId, msg.senderId, msg.senderName, msg.content, msg.timestamp,
msg.role || 'user',
msg.tool_call_id ?? null,
toolCallsJson,
msg.tool_name ?? null,
msg.finish_reason ?? null,
msg.reasoning ?? null,
msg.reasoning_details ?? null,
msg.reasoning_content ?? null,
)
}
saveMessageAndRefreshRoom(msg: ChatMessage, options: { preserveExistingTimestamp?: boolean } = {}): { message: ChatMessage; totalTokens: number } {
const db = this.db()
if (!db) return { message: msg, totalTokens: 0 }
db.exec('BEGIN IMMEDIATE')
try {
const existing = this.getMessage(msg.id)
const message = existing && options.preserveExistingTimestamp ? { ...msg, timestamp: existing.timestamp } : msg
this.upsertMessage(message)
this.pruneMessages(msg.roomId)
const messages = this.getMessages(msg.roomId)
const totalTokens = this.estimateRoomTotalTokens(msg.roomId, messages)
this.updateRoomTotalTokens(msg.roomId, totalTokens)
db.exec('COMMIT')
return { message, totalTokens }
} catch (err) {
try { db.exec('ROLLBACK') } catch { /* ignore */ }
throw err
}
}
clearRoomContext(roomId: string): void {
@@ -238,7 +442,7 @@ class ChatStorage {
if (!db) return
db.prepare('DELETE FROM gc_messages WHERE roomId = ?').run(roomId)
db.prepare('DELETE FROM gc_context_snapshots WHERE roomId = ?').run(roomId)
db.prepare('UPDATE gc_rooms SET totalTokens = 0 WHERE id = ?').run(roomId)
db.prepare('UPDATE gc_rooms SET totalTokens = 0, sessionSeed = ? WHERE id = ?').run(`${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`, roomId)
}
pruneMessages(roomId: string, keep = 500): void {
@@ -419,13 +623,6 @@ export class GroupChatServer {
/** roomId -> (agentName -> { agentName, status }) */
private contextStatusState = new Map<string, Map<string, { agentName: string; status: string }>>()
setGatewayManager(manager: any): void {
this.agentClients.setGatewayManager(manager)
if (this._contextEngine && manager) {
this._contextEngine.setUpstream(manager.getUpstream(''), manager.getApiKey(''))
}
}
constructor(httpServers: HttpServer | HttpServer[]) {
this.storage = new ChatStorage()
this.storage.init()
@@ -569,10 +766,18 @@ export class GroupChatServer {
logger.debug(`[GroupChat] Connected: ${userName} (socket=${socket.id}, user=${userId})`)
socket.on('join', (data: { roomId?: string; name?: string }, ack?: (response?: unknown) => void) => this.handleJoin(socket, data, ack))
socket.on('message', (data: { roomId?: string; content: string }, ack?: (response?: unknown) => void) => this.handleMessage(socket, data, ack))
socket.on('message', (data: Partial<ChatMessage> & { roomId?: string; content: string | Array<Record<string, unknown>>; id?: string; mentionDepth?: number }, ack?: (response?: unknown) => void) => this.handleMessage(socket, data, ack))
socket.on('message_stream_start', (data: { roomId?: string; id?: string; senderId?: string; senderName?: string; timestamp?: number }) => this.handleMessageStreamStart(socket, data))
socket.on('message_stream_delta', (data: { roomId?: string; id?: string; delta?: string }) => this.handleMessageStreamDelta(socket, data))
socket.on('message_reasoning_delta', (data: { roomId?: string; id?: string; delta?: string }) => this.handleMessageReasoningDelta(socket, data))
socket.on('message_stream_end', (data: { roomId?: string; id?: string }) => this.handleMessageStreamEnd(socket, data))
socket.on('typing', (data: { roomId?: string }) => this.handleTyping(socket, data))
socket.on('stop_typing', (data: { roomId?: string }) => this.handleStopTyping(socket, data))
socket.on('context_status', (data: { roomId?: string; agentName?: string; status?: string }) => this.handleContextStatus(socket, data))
socket.on('interrupt_agent', (data: { roomId?: string; agentName?: string }, ack?: (response?: unknown) => void) => this.handleInterruptAgent(socket, data, ack))
socket.on('approval.requested', (data: { roomId?: string; agentName?: string; approval_id?: string; command?: string; description?: string; choices?: string[]; allow_permanent?: boolean }) => this.handleApprovalRequested(socket, data))
socket.on('approval.resolved', (data: { roomId?: string; agentName?: string; approval_id?: string; choice?: string }) => this.handleApprovalResolved(socket, data))
socket.on('approval.respond', (data: { roomId?: string; approval_id?: string; choice?: string }, ack?: (response?: unknown) => void) => this.handleApprovalRespond(socket, data, ack))
socket.on('disconnect', () => this.handleDisconnect(socket))
}
@@ -581,14 +786,18 @@ export class GroupChatServer {
private handleJoin(socket: Socket, data: { roomId?: string; name?: string; description?: string }, ack?: (res: any) => void): void {
const socketId = socket.id
const userId = this.socketUserMap.get(socketId) || socketId
const userInfo = this.userInfoMap.get(userId) || { name: `User-${userId.slice(0, 6)}`, description: '' }
const userName = data.name || userInfo.name
const description = data.description || userInfo.description
const roomId = data.roomId || 'general'
const existingMember = this.storage.getMemberByUserId(roomId, userId)
const userInfo = this.userInfoMap.get(userId) || {
name: existingMember?.name || `User-${userId.slice(0, 6)}`,
description: existingMember?.description || '',
}
const userName = data.name || existingMember?.name || userInfo.name
const description = data.description || existingMember?.description || userInfo.description
// Update stored user info
this.userInfoMap.set(userId, { name: userName, description })
const roomId = data.roomId || 'general'
let room = this.rooms.get(roomId)
if (!room) {
room = new ChatRoom(roomId)
@@ -628,7 +837,7 @@ export class GroupChatServer {
logger.debug(`[GroupChat] ${userName} (user=${userId}) joined room: ${roomId}`)
}
private handleMessage(socket: Socket, data: { roomId?: string; content: string }, ack?: (res: any) => void): void {
private handleMessage(socket: Socket, data: Partial<ChatMessage> & { roomId?: string; content: string | Array<Record<string, unknown>>; id?: string; mentionDepth?: number }, ack?: (res: any) => void): void {
const socketId = socket.id
const roomId = data.roomId || 'general'
const room = this.rooms.get(roomId)
@@ -643,37 +852,105 @@ export class GroupChatServer {
const userName = member?.name || `User-${socketId.slice(0, 6)}`
const msg: ChatMessage = {
id: this.generateId(),
id: this.normalizeClientMessageId(data.id) || this.generateId(),
roomId,
senderId: userId,
senderName: userName,
content: data.content,
timestamp: Date.now(),
content: contentToStorageString(data.content),
timestamp: this.normalizeMessageTimestamp(data.timestamp, data.role),
role: normalizeMessageRole(data.role),
tool_call_id: data.tool_call_id ?? null,
tool_calls: Array.isArray(data.tool_calls) ? data.tool_calls : null,
tool_name: data.tool_name ?? null,
finish_reason: data.finish_reason ?? null,
reasoning: data.reasoning ?? null,
reasoning_details: data.reasoning_details ?? null,
reasoning_content: data.reasoning_content ?? null,
}
this.storage.addMessage(msg)
this.storage.pruneMessages(roomId)
const saved = this.storage.saveMessageAndRefreshRoom(msg)
const savedMsg = saved.message
const totalTokens = saved.totalTokens
// Recalculate total tokens for the room
const messages = this.storage.getMessages(roomId)
const totalTokens = this.storage.estimateTokens(messages.map(m => m.content + m.senderName).join(''))
this.storage.updateRoomTotalTokens(roomId, totalTokens)
this.nsp.to(roomId).emit('message', msg)
this.nsp.to(roomId).emit('message', savedMsg)
this.nsp.to(roomId).emit('room_updated', { roomId, totalTokens })
ack?.({ id: msg.id })
ack?.({ id: savedMsg.id })
// Server-side @mention routing — parse mentions and invoke agents directly
this.agentClients.processMentions(roomId, {
content: msg.content,
senderName: msg.senderName,
senderId: msg.senderId,
timestamp: msg.timestamp,
}).catch((err) => {
logger.error(`[GroupChat] processMentions error: ${err.message}`)
const mentionDepth = normalizeMentionDepth(data.mentionDepth)
const shouldRouteMentions =
savedMsg.role === 'user' ||
(savedMsg.role === 'assistant' && mentionDepth < 2)
if (shouldRouteMentions) {
// Server-side @mention routing — parse user mentions and invoke agents directly.
this.agentClients.processMentions(roomId, {
content: contentToText(savedMsg.content),
input: Array.isArray(data.content) ? data.content : undefined,
senderName: savedMsg.senderName,
senderId: savedMsg.senderId,
timestamp: savedMsg.timestamp,
mentionDepth,
}).catch((err) => {
logger.error(`[GroupChat] processMentions error: ${err.message}`)
})
}
}
private handleMessageStreamStart(socket: Socket, data: { roomId?: string; id?: string; senderId?: string; senderName?: string; timestamp?: number }): void {
const roomId = data.roomId || 'general'
const room = this.rooms.get(roomId)
if (!room || !room.hasOnlineMember(socket.id)) return
const id = this.normalizeClientMessageId(data.id)
if (!id) return
const member = room.getOnlineMemberBySocketId(socket.id)
this.nsp.to(roomId).emit('message_stream_start', {
id,
roomId,
senderId: data.senderId || member?.userId || socket.id,
senderName: data.senderName || member?.name || `User-${socket.id.slice(0, 6)}`,
content: '',
timestamp: data.timestamp || Date.now(),
role: 'assistant',
finish_reason: 'streaming',
})
}
private handleMessageStreamDelta(socket: Socket, data: { roomId?: string; id?: string; delta?: string }): void {
const roomId = data.roomId || 'general'
const room = this.rooms.get(roomId)
if (!room || !room.hasOnlineMember(socket.id)) return
const id = this.normalizeClientMessageId(data.id)
if (!id || !data.delta) return
this.nsp.to(roomId).emit('message_stream_delta', {
roomId,
id,
delta: String(data.delta),
})
}
private handleMessageReasoningDelta(socket: Socket, data: { roomId?: string; id?: string; delta?: string }): void {
const roomId = data.roomId || 'general'
const room = this.rooms.get(roomId)
if (!room || !room.hasOnlineMember(socket.id)) return
const id = this.normalizeClientMessageId(data.id)
if (!id || !data.delta) return
this.nsp.to(roomId).emit('message_reasoning_delta', {
roomId,
id,
delta: String(data.delta),
})
}
private handleMessageStreamEnd(socket: Socket, data: { roomId?: string; id?: string }): void {
const roomId = data.roomId || 'general'
const room = this.rooms.get(roomId)
if (!room || !room.hasOnlineMember(socket.id)) return
const id = this.normalizeClientMessageId(data.id)
if (!id) return
this.nsp.to(roomId).emit('message_stream_end', { roomId, id })
}
private handleTyping(socket: Socket, data: { roomId?: string }): void {
const roomId = data.roomId || 'general'
const userId = this.socketUserMap.get(socket.id) || socket.id
@@ -749,6 +1026,75 @@ export class GroupChatServer {
})
}
private async handleInterruptAgent(socket: Socket, data: { roomId?: string; agentName?: string }, ack?: (response?: unknown) => void): Promise<void> {
const roomId = data.roomId
const agentName = data.agentName
if (!roomId || !agentName) {
ack?.({ error: 'roomId and agentName are required' })
return
}
const room = this.rooms.get(roomId)
if (!room?.hasOnlineMember(socket.id)) {
ack?.({ error: 'Not in room' })
return
}
try {
await this.agentClients.interruptAgent(roomId, agentName)
this.nsp.to(roomId).emit('context_status', { roomId, agentName, status: 'ready' })
ack?.({ ok: true })
} catch (err: any) {
logger.warn(`[GroupChat] failed to interrupt agent ${agentName} in room ${roomId}: ${err.message}`)
ack?.({ error: err.message || 'interrupt failed' })
}
}
private handleApprovalRequested(socket: Socket, data: { roomId?: string; agentName?: string; approval_id?: string; command?: string; description?: string; choices?: string[]; allow_permanent?: boolean }): void {
const roomId = data.roomId
if (!roomId || !data.approval_id) return
this.nsp.to(roomId).emit('approval.requested', {
event: 'approval.requested',
roomId,
agentName: data.agentName || '',
approval_id: data.approval_id,
command: data.command || '',
description: data.description || '',
choices: Array.isArray(data.choices) ? data.choices : ['once', 'session', 'deny'],
allow_permanent: Boolean(data.allow_permanent),
})
}
private handleApprovalResolved(socket: Socket, data: { roomId?: string; agentName?: string; approval_id?: string; choice?: string }): void {
const roomId = data.roomId
if (!roomId || !data.approval_id) return
this.nsp.to(roomId).emit('approval.resolved', {
event: 'approval.resolved',
roomId,
agentName: data.agentName || '',
approval_id: data.approval_id,
choice: data.choice || '',
})
}
private async handleApprovalRespond(socket: Socket, data: { roomId?: string; approval_id?: string; choice?: string }, ack?: (response?: unknown) => void): Promise<void> {
const roomId = data.roomId
if (!roomId || !data.approval_id) {
ack?.({ error: 'roomId and approval_id are required' })
return
}
const room = this.rooms.get(roomId)
if (!room?.hasOnlineMember(socket.id)) {
ack?.({ error: 'Not in room' })
return
}
try {
const result = await new AgentBridgeClient().approvalRespond(data.approval_id, data.choice || 'deny')
ack?.({ ok: true, resolved: Boolean((result as any)?.resolved) })
} catch (err: any) {
logger.warn(`[GroupChat] failed to respond approval ${data.approval_id}: ${err.message}`)
ack?.({ error: err.message || 'approval response failed' })
}
}
private handleDisconnect(socket: Socket): void {
const socketId = socket.id
const userId = this.socketUserMap.get(socketId)
@@ -804,4 +1150,19 @@ export class GroupChatServer {
private generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
}
private normalizeClientMessageId(id?: string): string | null {
const cleaned = String(id || '').trim()
if (!cleaned || cleaned.length > 160) return null
return /^[a-zA-Z0-9_-]+$/.test(cleaned) ? cleaned : null
}
private normalizeMessageTimestamp(timestamp?: unknown, role?: unknown): number {
const normalizedRole = normalizeMessageRole(role)
if (normalizedRole !== 'user') {
const value = Number(timestamp)
if (Number.isFinite(value) && value > 0) return value
}
return Date.now()
}
}
+215 -14
View File
@@ -1,12 +1,20 @@
import { execFile, spawn } from 'child_process'
import { existsSync } from 'fs'
import { existsSync, readFileSync, unlinkSync } from 'fs'
import { join } from 'path'
import { promisify } from 'util'
import { logger } from '../logger'
import { stripLegacyApiServerGatewayConfig, updateConfigYaml } from '../config-helpers'
import { getActiveProfileDir, getProfileDir } from './hermes-profile'
import { startGatewayRunManaged } from './gateway-runner'
import { isGatewayRunningForProfile } from './gateway-autostart'
const execFileAsync = promisify(execFile)
const execOpts = { windowsHide: true }
const isDocker = existsSync('/.dockerenv')
const isTermux = !!process.env.TERMUX_VERSION ||
(process.env.PREFIX || '').includes('/com.termux/') ||
existsSync('/data/data/com.termux/files/usr')
/**
* 解析 Hermes CLI 二进制路径
@@ -18,6 +26,156 @@ function resolveHermesBin(): string {
const HERMES_BIN = resolveHermesBin()
async function waitForGatewayRunning(profileDir: string, timeoutMs = 15000): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (await isGatewayRunningForProfile(HERMES_BIN, profileDir)) return true
await new Promise(resolve => setTimeout(resolve, 500))
}
return false
}
async function stopGatewayForActiveProfile(): Promise<void> {
try {
await execFileAsync(HERMES_BIN, ['gateway', 'stop'], {
timeout: 30000,
...activeGatewayExecOpts(),
})
} catch (err) {
logger.warn(err, 'hermes gateway stop before restart failed; continuing with run --replace')
}
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (err: any) {
return err?.code === 'EPERM'
}
}
function readJsonPid(path: string): number | null {
if (!existsSync(path)) return null
try {
const data = JSON.parse(readFileSync(path, 'utf-8'))
const pid = typeof data?.pid === 'number' ? data.pid : parseInt(String(data?.pid || ''), 10)
return Number.isFinite(pid) && pid > 0 ? pid : null
} catch {
return null
}
}
function readGatewayLockPid(profileDir: string): number | null {
return readJsonPid(join(profileDir, 'gateway.lock'))
}
function readGatewayStatePid(profileDir: string): number | null {
const pid = readJsonPid(join(profileDir, 'gateway.pid'))
if (pid) return pid
const statePath = join(profileDir, 'gateway_state.json')
if (!existsSync(statePath)) return null
try {
const data = JSON.parse(readFileSync(statePath, 'utf-8'))
const state = data?.gateway_state
const statePid = typeof data?.pid === 'number' ? data.pid : parseInt(String(data?.pid || ''), 10)
return statePid && Number.isFinite(statePid) && statePid > 0 && (state === 'running' || state === 'starting')
? statePid
: null
} catch {
return null
}
}
async function killWindowsPid(pid: number): Promise<void> {
if (!pid || process.platform !== 'win32') return
try {
await execFileAsync('taskkill', ['/PID', String(pid), '/T', '/F'], {
timeout: 5000,
windowsHide: true,
})
} catch (err) {
logger.warn(err, 'Failed to taskkill gateway PID %d; falling back to process.kill', pid)
try { process.kill(pid) } catch {}
}
}
function cleanupStaleGatewayLock(profileDir: string, allowMalformedDelete = false): boolean {
const lockPath = join(profileDir, 'gateway.lock')
if (!existsSync(lockPath)) return true
try {
const lockData = JSON.parse(readFileSync(lockPath, 'utf-8'))
const pid = Number(lockData?.pid)
if (Number.isFinite(pid) && pid > 0 && isProcessAlive(pid)) return false
unlinkSync(lockPath)
return true
} catch {
if (!allowMalformedDelete) return false
try {
unlinkSync(lockPath)
return true
} catch {
return false
}
}
}
async function waitForGatewayLockReleased(profileDir: string, timeoutMs = 15000, allowMalformedDelete = false): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (cleanupStaleGatewayLock(profileDir, allowMalformedDelete)) return true
await sleep(500)
}
return cleanupStaleGatewayLock(profileDir, allowMalformedDelete)
}
async function forceReleaseWindowsGatewayLock(profileDir: string): Promise<void> {
if (process.platform !== 'win32') return
const pids = new Set<number>()
const lockPid = readGatewayLockPid(profileDir)
const statePid = readGatewayStatePid(profileDir)
if (lockPid) pids.add(lockPid)
if (statePid) pids.add(statePid)
for (const pid of pids) {
if (isProcessAlive(pid)) {
logger.warn('Gateway lock is still held by PID %d; force killing Windows process tree', pid)
await killWindowsPid(pid)
}
}
}
async function waitForGatewayLockReleasedAfterStop(profileDir: string, timeoutMs = 15000): Promise<boolean> {
if (await waitForGatewayLockReleased(profileDir, timeoutMs)) return true
await forceReleaseWindowsGatewayLock(profileDir)
return waitForGatewayLockReleased(profileDir, 5000, true)
}
function activeGatewayExecOpts() {
return {
...execOpts,
env: {
...process.env,
HERMES_HOME: getActiveProfileDir(),
},
}
}
async function clearLegacyApiServerGatewayConfig(): Promise<void> {
try {
await updateConfigYaml((config) => {
const result = stripLegacyApiServerGatewayConfig(config)
return { data: result.config, result: undefined, write: result.changed }
})
} catch (err) {
logger.warn(err, 'Failed to clear legacy api_server gateway config before restart')
}
}
export interface HermesSession {
id: string
source: string
@@ -210,6 +368,26 @@ export async function deleteSession(id: string): Promise<boolean> {
}
}
/**
* Delete a session from a specific Hermes profile.
*/
export async function deleteSessionForProfile(id: string, profile: string): Promise<boolean> {
try {
await execFileAsync(HERMES_BIN, ['sessions', 'delete', id, '--yes'], {
timeout: 10000,
...execOpts,
env: {
...process.env,
HERMES_HOME: getProfileDir(profile),
},
})
return true
} catch (err: any) {
logger.error({ err, sessionId: id, profile }, 'Hermes CLI: profile session delete failed')
return false
}
}
/**
* Rename a session title via Hermes CLI
*/
@@ -255,7 +433,7 @@ export async function startGateway(): Promise<string> {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'start'], {
timeout: 30000,
...execOpts,
...activeGatewayExecOpts(),
})
return stdout || stderr
}
@@ -269,22 +447,49 @@ export async function startGatewayBackground(): Promise<number | null> {
detached: true,
stdio: 'ignore',
windowsHide: true,
env: {
...process.env,
HERMES_HOME: getActiveProfileDir(),
},
})
child.unref()
return child.pid ?? null
}
/**
* Restart Hermes gateway (stop then start)
* Restart Hermes gateway through Hermes CLI, falling back to detached
* `gateway run` when the environment does not support `gateway restart`.
*/
export async function restartGateway(): Promise<string> {
try {
await stopGateway()
} catch (err) {
// Ignore stop errors, gateway might not be running
await clearLegacyApiServerGatewayConfig()
const profileDir = getActiveProfileDir()
if (isDocker || isTermux || process.platform === 'win32') {
await stopGatewayForActiveProfile()
const lockReleased = await waitForGatewayLockReleasedAfterStop(profileDir)
if (!lockReleased) throw new Error('Gateway stopped but runtime lock is still held by another process')
const result = startGatewayRunManaged(HERMES_BIN, { profileDir })
const ready = await waitForGatewayRunning(profileDir)
if (!ready) throw new Error(`Gateway run replace triggered but gateway did not report running within timeout${result.pid ? ` (PID: ${result.pid})` : ''}`)
return result.pid ? `Gateway run replaced (PID: ${result.pid})` : 'Gateway run replaced'
}
try {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'restart'], {
timeout: 30000,
...activeGatewayExecOpts(),
})
const ready = await waitForGatewayRunning(profileDir)
if (!ready) throw new Error('Hermes gateway restart completed but gateway did not report running within timeout')
return stdout || stderr
} catch (err: any) {
logger.warn(err, 'hermes gateway restart failed; falling back to gateway run')
await stopGatewayForActiveProfile()
const lockReleased = await waitForGatewayLockReleasedAfterStop(profileDir)
if (!lockReleased) throw new Error('Gateway restart failed and runtime lock is still held by another process')
const result = startGatewayRunManaged(HERMES_BIN, { profileDir })
const ready = await waitForGatewayRunning(profileDir)
if (!ready) throw new Error(`Gateway run fallback triggered but gateway did not report running within timeout${result.pid ? ` (PID: ${result.pid})` : ''}`)
return result.pid ? `Gateway run started (PID: ${result.pid})` : 'Gateway run started'
}
const result = await startGateway()
return result
}
/**
@@ -293,7 +498,7 @@ export async function restartGateway(): Promise<string> {
export async function stopGateway(): Promise<string> {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'stop'], {
timeout: 30000,
...execOpts,
...activeGatewayExecOpts(),
})
return stdout || stderr
}
@@ -363,7 +568,6 @@ export interface HermesProfile {
name: string
active: boolean
model: string
gateway: string
alias: string
}
@@ -372,7 +576,6 @@ export interface HermesProfileDetail {
path: string
model: string
provider: string
gateway: string
skills: number
hasEnv: boolean
hasSoulMd: boolean
@@ -403,7 +606,6 @@ export async function listProfiles(): Promise<HermesProfile[]> {
name: match[2],
active: !!match[1],
model: match[3],
gateway: match[4],
alias: match[5].trim() === '—' ? '' : match[5].trim(),
})
}
@@ -443,7 +645,6 @@ export async function getProfile(name: string): Promise<HermesProfileDetail> {
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',
@@ -7,7 +7,7 @@
* - 用户自定义: HERMES_HOME 环境变量
*/
import { basename, dirname, resolve, join } from 'path'
import { basename, dirname, isAbsolute, relative, resolve, join } from 'path'
import { homedir } from 'os'
/**
@@ -62,3 +62,20 @@ export function getHermesBin(customBin?: string): string {
if (process.env.HERMES_BIN?.trim()) return process.env.HERMES_BIN.trim()
return 'hermes'
}
function comparablePath(path: string): string {
return process.platform === 'win32' ? path.toLowerCase() : path
}
export function isPathWithin(targetPath: string, basePath: string): boolean {
const base = resolve(basePath)
const target = resolve(targetPath)
const rel = relative(comparablePath(base), comparablePath(target))
return rel === '' || (!!rel && !rel.startsWith('..') && !isAbsolute(rel))
}
export function relativePathFromBase(targetPath: string, basePath: string): string | null {
if (!isPathWithin(targetPath, basePath)) return null
const rel = relative(resolve(basePath), resolve(targetPath))
return rel.replace(/\\/g, '/')
}
@@ -1,5 +1,5 @@
import { join } from 'path'
import { readFileSync, existsSync } from 'fs'
import { readFileSync, existsSync, readdirSync } from 'fs'
import { detectHermesRootHome } from './hermes-path'
export function getHermesBaseDir(): string {
@@ -69,3 +69,21 @@ export function getProfileDir(name: string): string {
const dir = join(hermesBase, 'profiles', name)
return existsSync(dir) ? dir : hermesBase
}
export function listProfileNamesFromDisk(): string[] {
const hermesBase = getHermesBaseDir()
const names = new Set<string>(['default'])
const profilesDir = join(hermesBase, 'profiles')
try {
for (const entry of readdirSync(profilesDir, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name.trim()) {
names.add(entry.name)
}
}
} catch {}
return [...names].sort((a, b) => {
if (a === 'default') return -1
if (b === 'default') return 1
return a.localeCompare(b)
})
}
@@ -10,6 +10,12 @@ const HERMES_BASE = detectHermesHome()
const MODELS_DEV_CACHE = resolve(HERMES_BASE, 'models_dev_cache.json')
const DEFAULT_CONTEXT_LENGTH = 200_000
export interface ModelContextLengthOptions {
profile?: string
model?: string | null
provider?: string | null
}
interface ModelLimit {
context?: number
output?: number
@@ -351,15 +357,19 @@ function lookupContextFromDatabase(modelName: string, provider: string | null):
}
}
export function getModelContextLength(profile?: string): number {
export function getModelContextLength(input?: string | ModelContextLengthOptions): number {
const options: ModelContextLengthOptions = typeof input === 'string'
? { profile: input }
: input || {}
const profile = options.profile
const profileDir = getProfileDir(profile)
const config = loadConfig(profileDir)
if (!config) return DEFAULT_CONTEXT_LENGTH
const model = getDefaultModel(config)
const model = String(options.model || '').trim() || getDefaultModel(config)
if (!model) return DEFAULT_CONTEXT_LENGTH
const provider = getDefaultProvider(config)
const provider = String(options.provider || '').trim() || getDefaultProvider(config)
// 0. Database model_context table (highest priority)
const dbCtx = lookupContextFromDatabase(model, provider)
@@ -60,7 +60,7 @@ export async function handleAbort(
if (state.source === 'cli') {
try {
await bridge.interrupt(sessionId, 'Aborted by user')
await bridge.interrupt(sessionId, 'Aborted by user', state.profile)
} catch (err) {
logger.warn(err, '[chat-run-socket][abort] failed to interrupt CLI bridge for session %s', sessionId)
}
@@ -0,0 +1,95 @@
export interface BridgeDeltaFilterState {
bridgePendingToolCallMarkup?: string
}
const TOOL_CALL_MARKER = '[Calling tool:'
const MAX_PENDING_TOOL_MARKUP_LENGTH = 100_000
function findToolMarkupEnd(text: string, start: number): number {
let depth = 0
let inString = false
let escaped = false
for (let i = start; i < text.length; i += 1) {
const ch = text[i]
if (inString) {
if (escaped) {
escaped = false
} else if (ch === '\\') {
escaped = true
} else if (ch === '"') {
inString = false
}
continue
}
if (ch === '"') {
inString = true
continue
}
if (ch === '[') {
depth += 1
continue
}
if (ch === ']') {
depth -= 1
if (depth === 0) return i + 1
}
}
return -1
}
function trailingMarkerPrefixLength(text: string): number {
const max = Math.min(text.length, TOOL_CALL_MARKER.length - 1)
for (let len = max; len > 0; len -= 1) {
if (TOOL_CALL_MARKER.startsWith(text.slice(text.length - len))) return len
}
return 0
}
export function filterBridgeToolCallMarkupDelta(
state: BridgeDeltaFilterState,
delta: string,
): string {
if (!delta) return ''
const text = `${state.bridgePendingToolCallMarkup || ''}${delta}`
state.bridgePendingToolCallMarkup = ''
let out = ''
let idx = 0
while (idx < text.length) {
const markerIdx = text.indexOf(TOOL_CALL_MARKER, idx)
if (markerIdx < 0) {
const rest = text.slice(idx)
const pendingPrefixLength = trailingMarkerPrefixLength(rest)
if (pendingPrefixLength > 0) {
out += rest.slice(0, rest.length - pendingPrefixLength)
state.bridgePendingToolCallMarkup = rest.slice(rest.length - pendingPrefixLength)
} else {
out += rest
}
break
}
out += text.slice(idx, markerIdx)
const end = findToolMarkupEnd(text, markerIdx)
if (end < 0) {
state.bridgePendingToolCallMarkup = text.slice(markerIdx)
if (state.bridgePendingToolCallMarkup.length > MAX_PENDING_TOOL_MARKUP_LENGTH) {
state.bridgePendingToolCallMarkup = ''
}
break
}
idx = end
if (text[idx] === '\r' && text[idx + 1] === '\n') {
idx += 2
} else if (text[idx] === '\n') {
idx += 1
}
}
return out
}
@@ -5,6 +5,7 @@
import {
getSessionDetail,
getSession,
} from '../../../db/hermes/session-store'
import { getCompressionSnapshot } from '../../../db/hermes/compression-snapshot'
import { ChatContextCompressor, SUMMARY_PREFIX } from '../../../lib/context-compressor'
@@ -96,12 +97,17 @@ export async function buildCompressedHistory(
apiKey: string | undefined,
emit: (event: string, payload: any) => void,
sessionMap: Map<string, SessionState>,
modelContext: { model?: string | null; provider?: string | null } = {},
): Promise<ChatMessage[]> {
try {
let history = await buildDbHistory(sessionId, { excludeLastUser: true })
if (history.length === 0) return []
const contextLength = getModelContextLength(profile)
const contextLength = getModelContextLength({
profile,
model: modelContext.model,
provider: modelContext.provider,
})
const triggerTokens = Math.floor(contextLength / 2)
const cState = getOrCreateSession(sessionMap, sessionId)
const assembledTokens = await calcAndUpdateUsage(sessionId, cState, emit)
@@ -118,13 +124,13 @@ export async function buildCompressedHistory(
...newMessages,
] as ChatMessage[]
} else {
history = await compressHistory(history, newMessages, sessionId, upstream, apiKey, cState, totalTokens, emit, sessionMap)
history = await compressHistory(history, newMessages, sessionId, upstream, apiKey, cState, totalTokens, emit, sessionMap, modelContext)
}
} else if (history.length > 4) {
if (totalTokens <= triggerTokens && history.length <= 150) {
logger.info('[context-compress] session=%s: %d messages, ~%d tokens — under threshold, skip', sessionId, history.length, totalTokens)
} else {
history = await compressHistory(history, null, sessionId, upstream, apiKey, cState, totalTokens, emit, sessionMap)
history = await compressHistory(history, null, sessionId, upstream, apiKey, cState, totalTokens, emit, sessionMap, modelContext)
}
}
@@ -145,6 +151,7 @@ export async function compressHistory(
totalTokens: number,
emit: (event: string, payload: any) => void,
sessionMap: Map<string, SessionState>,
modelContext: { model?: string | null; provider?: string | null } = {},
): Promise<ChatMessage[]> {
const msgCount = newMessagesOnly ? newMessagesOnly.length : history.length
pushState(sessionMap, sessionId, 'compression.started', {
@@ -155,7 +162,12 @@ export async function compressHistory(
})
try {
const result = await compressor.compress(history, upstream, apiKey, sessionId)
const session = getSession(sessionId)
const result = await compressor.compress(history, upstream, apiKey, sessionId, {
profile: session?.profile,
model: modelContext.model || session?.model,
provider: modelContext.provider || session?.provider,
})
const afterTokens = await calcAndUpdateUsage(sessionId, cState, emit)
const compressedMeta = {
event: 'compression.completed' as const,
@@ -211,8 +223,6 @@ export async function forceCompressBridgeHistory(
sessionId: string,
profile: string,
_messages: ChatMessage[],
getUpstream: (profile: string) => string,
getApiKey: (profile: string) => string | undefined,
): Promise<BridgeCompressionResult> {
const history = await buildDbHistory(sessionId, { excludeLastUser: true })
@@ -231,8 +241,9 @@ export async function forceCompressBridgeHistory(
}
}
const upstream = getUpstream(profile).replace(/\/$/, '')
const apiKey = getApiKey(profile) || undefined
const upstream = ''
const apiKey = undefined
const session = getSession(sessionId)
const beforeUsage = estimateSnapshotAwareHistoryUsage(sessionId, history)
const totalTokens = beforeUsage.tokenCount
bridgeLogger.info({
@@ -245,7 +256,11 @@ export async function forceCompressBridgeHistory(
snapshotAware: true,
}, '[chat-run-socket] bridge forced compression started')
const result = await compressor.compress(history, upstream, apiKey, sessionId, profile)
const result = await compressor.compress(history, upstream, apiKey, sessionId, {
profile: session?.profile || profile,
model: session?.model,
provider: session?.provider,
})
const compressedMessages = result.messages.map(m => {
const msg: any = { role: m.role, content: m.content }
if (m.reasoning_content) msg.reasoning_content = m.reasoning_content
@@ -1,5 +1,8 @@
import type { ContentBlock } from './types'
type ResponseContentPart = { type: string; text?: string; image_url?: string }
type AgentContentPart = { type: string; text?: string; image_url?: { url: string } }
/**
* Convert ContentBlock[] to string for display/storage
*/
@@ -29,22 +32,16 @@ export function isContentBlockArray(input: any): input is ContentBlock[] {
/**
* Convert ContentBlock[] to multimodal format for /v1/responses API.
*/
export async function convertContentBlocks(blocks: ContentBlock[]): Promise<Array<{ type: string; text?: string; image_url?: string }>> {
const parts: Array<{ type: string; text?: string; image_url?: string }> = []
const fs = await import('fs/promises')
const path = await import('path')
export async function convertContentBlocks(blocks: ContentBlock[]): Promise<ResponseContentPart[]> {
const parts: ResponseContentPart[] = []
for (const block of blocks) {
if (block.type === 'text') {
parts.push({ type: 'input_text', text: block.text })
} else if (block.type === 'image') {
try {
const buf = await fs.readFile(block.path)
const ext = path.extname(block.path).toLowerCase().replace('.', '')
const mime = ext === 'jpg' ? 'jpeg' : ext || 'png'
const base64 = buf.toString('base64')
parts.push({ type: 'input_image', image_url: `data:image/${mime};base64,${base64}` })
} catch {
const dataUri = await imageBlockToDataUri(block)
if (dataUri) {
parts.push({ type: 'input_image', image_url: dataUri })
} else {
parts.push({ type: 'input_text', text: `[Image: ${block.path}]` })
}
} else if (block.type === 'file') {
@@ -59,15 +56,42 @@ export async function convertContentBlocks(blocks: ContentBlock[]): Promise<Arra
* Convert ContentBlock[] to the normalized multimodal shape Hermes agent
* receives after /v1/responses input normalization.
*/
export async function convertContentBlocksForAgent(blocks: ContentBlock[]): Promise<Array<{ type: string; text?: string; image_url?: { url: string } }>> {
const responseParts = await convertContentBlocks(blocks)
return responseParts.map((part) => {
if (part.type === 'input_text') {
return { type: 'text', text: part.text || '' }
export async function convertContentBlocksForAgent(blocks: ContentBlock[]): Promise<AgentContentPart[]> {
const parts: AgentContentPart[] = []
for (const block of blocks) {
if (block.type === 'text') {
parts.push({ type: 'text', text: block.text || '' })
} else if (block.type === 'image') {
parts.push({
type: 'text',
text: `[Attached image: ${block.name || block.path}]\nLocal image path for tools: ${block.path}`,
})
const dataUri = await imageBlockToDataUri(block)
if (dataUri) {
parts.push({ type: 'image_url', image_url: { url: dataUri } })
}
} else if (block.type === 'file') {
parts.push({
type: 'text',
text: `[Attached file: ${block.name || block.path}]\nLocal file path for tools: ${block.path}`,
})
}
if (part.type === 'input_image') {
return { type: 'image_url', image_url: { url: part.image_url || '' } }
}
return { type: 'text', text: part.text || '' }
})
}
return parts
}
async function imageBlockToDataUri(block: Extract<ContentBlock, { type: 'image' }>): Promise<string | null> {
try {
const fs = await import('fs/promises')
const path = await import('path')
const buf = await fs.readFile(block.path)
const ext = path.extname(block.path).toLowerCase().replace('.', '')
const mimeFromExt = ext === 'jpg' ? 'jpeg' : ext || 'png'
const mime = block.media_type?.startsWith('image/')
? block.media_type.slice('image/'.length)
: mimeFromExt
return `data:image/${mime};base64,${buf.toString('base64')}`
} catch {
return null
}
}
@@ -25,15 +25,8 @@ import { countTokens, SUMMARY_PREFIX } from '../../../lib/context-compressor'
import { getCompressionSnapshot } from '../../../db/hermes/compression-snapshot'
import type { ContentBlock, SessionState, ChatRunSource } from './types'
export function resolveRunSource(source?: string, sessionId?: string): ChatRunSource {
const normalized = String(source || '').trim()
if (normalized === 'cli') return 'cli'
if (normalized === 'api_server') return 'api_server'
if (sessionId) {
const existing = getSession(sessionId)
if (existing?.source === 'cli') return 'cli'
}
return 'api_server'
export function resolveRunSource(_source?: string, _sessionId?: string): ChatRunSource {
return 'cli'
}
export async function loadSessionStateFromDb(sid: string, _sessionMap: Map<string, SessionState>): Promise<SessionState> {
@@ -78,7 +71,6 @@ export async function handleApiRun(
data: { input: string | ContentBlock[]; session_id?: string; model?: string; provider?: string; instructions?: string; source?: string },
profile: string,
sessionMap: Map<string, SessionState>,
gatewayManager: any,
skipUserMessage = false,
dequeueNextQueuedRun: (socket: Socket, sessionId: string, fallbackProfile?: string) => void,
) {
@@ -96,8 +88,8 @@ export async function handleApiRun(
}
}
const upstream = gatewayManager.getUpstream(profile).replace(/\/$/, '')
const apiKey = gatewayManager.getApiKey(profile) || undefined
const upstream = ''
const apiKey = undefined
const runMarker = session_id
? `resp_run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
@@ -179,7 +171,11 @@ export async function handleApiRun(
if (model) body.model = model
body.instructions = fullInstructions
if (session_id) {
const compressed = await buildCompressedHistory(session_id, profile, upstream, apiKey, emit, sessionMap)
const sessionRow = getSession(session_id)
const compressed = await buildCompressedHistory(session_id, profile, upstream, apiKey, emit, sessionMap, {
model: sessionRow?.model || model,
provider: sessionRow?.provider || provider,
})
if (compressed.length > 0) {
body.conversation_history = compressed
}
@@ -9,7 +9,6 @@ import { getSession, createSession, addMessage, updateSession, updateSessionStat
import { updateUsage } from '../../../db/hermes/usage-store'
import { logger, bridgeLogger } from '../../logger'
import { AgentBridgeClient, type AgentBridgeMessage, type AgentBridgeOutput } from '../agent-bridge'
import { readConfigYaml } from '../../config-helpers'
import { contentBlocksToString, convertContentBlocksForAgent, extractTextForPreview, isContentBlockArray } from './content-blocks'
import { buildCompressedHistory } from './compression'
import { pushState, replaceState } from './compression'
@@ -24,43 +23,19 @@ import {
import { forceCompressBridgeHistory } from './compression'
import { summarizeToolArguments } from './response-utils'
import { buildDbHistory } from './compression'
import { convertHistoryFormat } from './message-format'
import type { ContentBlock, SessionState } from './types'
import type { ChatMessage } from '../../../lib/context-compressor'
import { resolveBridgeRunModelConfig, type RunModelGroup } from './model-config'
import { filterBridgeToolCallMarkupDelta } from './bridge-delta'
const BRIDGE_USAGE_FLUSH_DELAY_MS = 200
type RunModelGroup = { provider: string; models: string[] }
async function resolveDefaultModelConfig(): Promise<{ model: string; provider: string }> {
try {
const config = await readConfigYaml()
const modelConfig = config?.model
const model = typeof modelConfig === 'string'
? modelConfig.trim()
: String(modelConfig?.default || '').trim()
const provider = typeof modelConfig === 'object'
? String(modelConfig?.provider || '').trim()
: ''
return { model, provider }
} catch {
return { model: '', provider: '' }
}
}
function hasModelInGroups(groups: RunModelGroup[] | undefined, provider: string, model: string): boolean {
if (!groups?.length || !provider || !model) return false
const group = groups.find(item => item.provider === provider)
return Array.isArray(group?.models) && group.models.includes(model)
}
export async function handleBridgeRun(
nsp: ReturnType<Server['of']>,
socket: Socket,
data: { input: string | ContentBlock[]; session_id?: string; model?: string; provider?: string; model_groups?: RunModelGroup[]; instructions?: string; source?: string },
profile: string,
sessionMap: Map<string, SessionState>,
gatewayManager: any,
bridge: AgentBridgeClient,
_skipUserMessage = false,
loadSessionStateFromDbFn: (sid: string, sessionMap: Map<string, SessionState>) => Promise<SessionState>,
@@ -78,14 +53,14 @@ export async function handleBridgeRun(
const sessionRow = getSession(session_id)
const sessionModel = sessionRow?.model || ''
const sessionProvider = sessionRow?.provider || ''
const hasGroups = Array.isArray(data.model_groups) && data.model_groups.length > 0
const sessionModelAvailable = hasGroups && hasModelInGroups(data.model_groups, sessionProvider, sessionModel)
const shouldUseDefault = !sessionModel || !sessionProvider || !sessionModelAvailable
const defaultModelConfig = shouldUseDefault
? await resolveDefaultModelConfig()
: { model: '', provider: '' }
const resolvedModel = shouldUseDefault ? defaultModelConfig.model : sessionModel
const resolvedProvider = shouldUseDefault ? defaultModelConfig.provider : sessionProvider
const { model: resolvedModel, provider: resolvedProvider } = await resolveBridgeRunModelConfig({
profile,
sessionModel,
sessionProvider,
requestedModel: data.model,
requestedProvider: data.provider,
modelGroups: data.model_groups,
})
if (sessionRow) {
const updates: { model?: string; provider?: string } = {}
if (resolvedModel && sessionRow.model !== resolvedModel) updates.model = resolvedModel
@@ -117,6 +92,7 @@ export async function handleBridgeRun(
state.bridgeOutput = ''
state.bridgePendingAssistantContent = ''
state.bridgePendingReasoningContent = ''
state.bridgePendingToolCallMarkup = ''
state.bridgeToolCounter = 0
state.bridgePendingTools = []
state.responseRun = undefined
@@ -154,12 +130,13 @@ export async function handleBridgeRun(
const history = await buildCompressedHistory(
session_id, profile,
gatewayManager.getUpstream(profile).replace(/\/$/, ''),
gatewayManager.getApiKey(profile) || undefined,
'',
undefined,
emit,
sessionMap,
{ model: resolvedModel, provider: resolvedProvider },
)
const bridgeHistory = history.length > 0 ? convertHistoryFormat(history) : history
const bridgeHistory = history
try {
const bridgeInput = isContentBlockArray(input)
@@ -207,7 +184,7 @@ export async function handleBridgeRun(
})
for await (const chunk of bridge.streamOutput(started.run_id)) {
await applyBridgeChunkAsync(nsp, socket, state, session_id, runMarker, chunk, emit, profile, sessionMap, gatewayManager, bridge, dequeueNextQueuedRun)
await applyBridgeChunkAsync(nsp, socket, state, session_id, runMarker, chunk, emit, profile, sessionMap, bridge, dequeueNextQueuedRun)
if (chunk.done) break
}
} catch (err: any) {
@@ -220,6 +197,7 @@ export async function handleBridgeRun(
state.runId = undefined
state.activeRunMarker = undefined
state.events = []
state.bridgePendingToolCallMarkup = undefined
flushBridgePendingToDb(state, session_id)
updateSessionStats(session_id)
const message = err instanceof Error ? err.message : String(err)
@@ -244,7 +222,6 @@ async function applyBridgeChunkAsync(
emit: (event: string, payload: any) => void,
profile: string,
sessionMap: Map<string, SessionState>,
gatewayManager: any,
bridge: AgentBridgeClient,
dequeueNextQueuedRun: (socket: Socket, sessionId: string, fallbackProfile?: string) => void,
): Promise<void> {
@@ -357,8 +334,6 @@ async function applyBridgeChunkAsync(
sessionId,
profile,
ev.messages as ChatMessage[],
(p: string) => gatewayManager.getUpstream(p),
(p: string) => gatewayManager.getApiKey(p),
)
state.bridgeCompressionResults = state.bridgeCompressionResults || {}
state.bridgeCompressionResults[String(ev.request_id)] = compressed
@@ -421,30 +396,33 @@ async function applyBridgeChunkAsync(
}
if (chunk.delta) {
state.bridgeOutput = (state.bridgeOutput || '') + chunk.delta
state.bridgePendingAssistantContent = (state.bridgePendingAssistantContent || '') + chunk.delta
const last = [...state.messages].reverse().find(m => m.runMarker === runMarker)
if (last?.role === 'assistant' && last.finish_reason == null) {
last.content += chunk.delta
syncBridgeReasoningToMessage(last, state.bridgePendingReasoningContent)
} else {
state.messages.push({
id: state.messages.length + 1,
session_id: sessionId,
runMarker,
role: 'assistant',
content: chunk.delta,
reasoning: state.bridgePendingReasoningContent || null,
reasoning_content: state.bridgePendingReasoningContent || null,
timestamp: Math.floor(Date.now() / 1000),
const delta = filterBridgeToolCallMarkupDelta(state, chunk.delta)
if (delta) {
state.bridgeOutput = (state.bridgeOutput || '') + delta
state.bridgePendingAssistantContent = (state.bridgePendingAssistantContent || '') + delta
const last = [...state.messages].reverse().find(m => m.runMarker === runMarker)
if (last?.role === 'assistant' && last.finish_reason == null) {
last.content += delta
syncBridgeReasoningToMessage(last, state.bridgePendingReasoningContent)
} else {
state.messages.push({
id: state.messages.length + 1,
session_id: sessionId,
runMarker,
role: 'assistant',
content: delta,
reasoning: state.bridgePendingReasoningContent || null,
reasoning_content: state.bridgePendingReasoningContent || null,
timestamp: Math.floor(Date.now() / 1000),
})
}
emit('message.delta', {
event: 'message.delta',
run_id: chunk.run_id,
delta,
output: state.bridgeOutput,
})
}
emit('message.delta', {
event: 'message.delta',
run_id: chunk.run_id,
delta: chunk.delta,
output: state.bridgeOutput,
})
}
if (!chunk.done) return
@@ -459,6 +437,7 @@ async function applyBridgeChunkAsync(
}
flushBridgePendingToDb(state, sessionId)
state.bridgePendingToolCallMarkup = undefined
updateSessionStats(sessionId)
await delay(BRIDGE_USAGE_FLUSH_DELAY_MS)
const usage = await calcAndUpdateUsage(sessionId, state, emit)
@@ -12,7 +12,7 @@ import type { Server, Socket } from 'socket.io'
import { logger } from '../../logger'
import { getSystemPrompt } from '../../../lib/llm-prompt'
import { getSession } from '../../../db/hermes/session-store'
import { getActiveProfileName } from '../hermes-profile'
import { getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from '../hermes-profile'
import { AgentBridgeClient } from '../agent-bridge'
import { handleApiRun, resolveRunSource, loadSessionStateFromDb } from './handle-api-run'
import { handleBridgeRun } from './handle-bridge-run'
@@ -25,14 +25,12 @@ export type { ContentBlock } from './types'
export class ChatRunSocket {
private nsp: ReturnType<Server['of']>
private gatewayManager: any
private bridge = new AgentBridgeClient()
/** sessionId → session state (messages, working status, events, run tracking) */
private sessionMap = new Map<string, SessionState>()
constructor(io: Server, gatewayManager: any) {
constructor(io: Server) {
this.nsp = io.of('/chat-run')
this.gatewayManager = gatewayManager
}
init() {
@@ -60,6 +58,17 @@ export class ChatRunSocket {
private onConnection(socket: Socket) {
const socketProfile = (socket.handshake.query?.profile as string) || 'default'
const currentProfile = () => getActiveProfileName() || socketProfile || 'default'
const profileExists = (profile: string) => {
if (!profile || profile === 'default') return true
return listProfileNamesFromDisk().includes(profile)
}
const resolveRunProfile = (sessionId?: string, requested?: string) => {
const requestedProfile = typeof requested === 'string' ? requested.trim() : ''
if (requestedProfile && profileExists(requestedProfile)) return requestedProfile
if (!sessionId) return currentProfile()
const storedProfile = getSession(sessionId)?.profile || ''
return storedProfile && profileExists(storedProfile) ? storedProfile : currentProfile()
}
socket.on('run', async (data: {
input: string | ContentBlock[]
@@ -70,7 +79,9 @@ export class ChatRunSocket {
model_groups?: Array<{ provider: string; models: string[] }>
queue_id?: string
source?: string
profile?: string
}) => {
const runProfile = resolveRunProfile(data.session_id, data.profile)
if (data.session_id) {
const state = getOrCreateSession(this.sessionMap, data.session_id)
const source = resolveRunSource(data.source, data.session_id)
@@ -82,8 +93,7 @@ export class ChatRunSocket {
socket,
sessionMap: this.sessionMap,
bridge: this.bridge,
gatewayManager: this.gatewayManager,
profile: currentProfile(),
profile: runProfile,
model: data.model,
instructions: data.instructions,
runQueuedItem: this.runQueuedItem.bind(this),
@@ -107,7 +117,7 @@ export class ChatRunSocket {
provider: data.provider,
model_groups: data.model_groups,
instructions: data.instructions,
profile: currentProfile(),
profile: runProfile,
source,
})
this.nsp.to(`session:${data.session_id}`).emit('run.queued', {
@@ -119,11 +129,11 @@ export class ChatRunSocket {
return
}
state.isWorking = true
state.profile = currentProfile()
state.profile = runProfile
state.source = source
}
try {
await this.handleRun(socket, data, currentProfile())
await this.handleRun(socket, data, runProfile)
} catch (err) {
if (data.session_id) {
const state = this.sessionMap.get(data.session_id)
@@ -224,7 +234,7 @@ export class ChatRunSocket {
await handleBridgeRun(
this.nsp, socket, { ...data, instructions: fullInstructions }, profile,
this.sessionMap, this.gatewayManager, this.bridge,
this.sessionMap, this.bridge,
skipUserMessage,
loadSessionStateFromDb,
this.dequeueNextQueuedRun.bind(this),
@@ -234,7 +244,7 @@ export class ChatRunSocket {
await handleApiRun(
this.nsp, socket, data, profile,
this.sessionMap, this.gatewayManager,
this.sessionMap,
skipUserMessage,
this.dequeueNextQueuedRun.bind(this),
)
@@ -0,0 +1,47 @@
import { readConfigYamlForProfile } from '../../config-helpers'
export type RunModelGroup = { provider: string; models: string[] }
async function resolveDefaultModelConfig(profile: string): Promise<{ model: string; provider: string }> {
try {
const config = await readConfigYamlForProfile(profile)
const modelConfig = config?.model
const model = typeof modelConfig === 'string'
? modelConfig.trim()
: String(modelConfig?.default || '').trim()
const provider = typeof modelConfig === 'object'
? String(modelConfig?.provider || '').trim()
: ''
return { model, provider }
} catch {
return { model: '', provider: '' }
}
}
function hasModelInGroups(groups: RunModelGroup[] | undefined, provider: string, model: string): boolean {
if (!groups?.length || !provider || !model) return false
const group = groups.find(item => item.provider === provider)
return Array.isArray(group?.models) && group.models.includes(model)
}
export async function resolveBridgeRunModelConfig(options: {
profile: string
sessionModel?: string | null
sessionProvider?: string | null
requestedModel?: string | null
requestedProvider?: string | null
modelGroups?: RunModelGroup[]
}): Promise<{ model: string; provider: string }> {
const sessionModel = String(options.sessionModel || '').trim()
const sessionProvider = String(options.sessionProvider || '').trim()
const requestedModel = String(options.requestedModel || '').trim()
const requestedProvider = String(options.requestedProvider || '').trim()
const candidateModel = sessionModel || requestedModel
const candidateProvider = sessionProvider || requestedProvider
const hasGroups = Array.isArray(options.modelGroups) && options.modelGroups.length > 0
const candidateAvailable = hasGroups && hasModelInGroups(options.modelGroups, candidateProvider, candidateModel)
const shouldUseDefault = !candidateModel || !candidateProvider || !candidateAvailable
return shouldUseDefault
? resolveDefaultModelConfig(options.profile)
: { model: candidateModel, provider: candidateProvider }
}
@@ -30,7 +30,6 @@ interface SessionCommandContext {
socket: Socket
sessionMap: Map<string, SessionState>
bridge: AgentBridgeClient
gatewayManager: any
profile: string
model?: string
instructions?: string
@@ -243,8 +242,6 @@ export async function handleSessionCommand(
sessionId,
ctx.profile,
[],
(profile: string) => ctx.gatewayManager.getUpstream(profile),
(profile: string) => ctx.gatewayManager.getApiKey(profile),
)
state.bridgeCompressionResults = state.bridgeCompressionResults || {}
await calcAndUpdateUsage(sessionId, state, emit)
@@ -312,11 +309,11 @@ export async function handleSessionCommand(
try {
if (wasWorking) {
flushBridgePendingToDb(state, sessionId)
await ctx.bridge.interrupt(sessionId, 'Destroyed by user').catch((err) => {
await ctx.bridge.interrupt(sessionId, 'Destroyed by user', state.profile).catch((err) => {
logger.warn(err, '[chat-run-socket] /destroy interrupt failed for session %s', sessionId)
})
}
await ctx.bridge.destroy(sessionId).catch((err) => {
await ctx.bridge.destroy(sessionId, state.profile).catch((err) => {
bridgeReachable = false
bridgeError = err instanceof Error ? err.message : String(err)
logger.warn(err, '[chat-run-socket] /destroy bridge unavailable for session %s', sessionId)
@@ -337,6 +334,7 @@ export async function handleSessionCommand(
state.queue = []
state.bridgePendingAssistantContent = undefined
state.bridgePendingReasoningContent = undefined
state.bridgePendingToolCallMarkup = undefined
state.bridgeOutput = undefined
state.bridgePendingTools = undefined
state.bridgeCompressionResults = undefined
@@ -366,6 +364,7 @@ export async function handleSessionCommand(
function clearTransientRunState(state: SessionState) {
state.events = []
state.bridgePendingTools = undefined
state.bridgePendingToolCallMarkup = undefined
state.bridgeCompressionResults = undefined
state.responseRun = undefined
state.activeRunMarker = undefined
@@ -52,6 +52,7 @@ export interface SessionState {
source?: ChatRunSource
bridgePendingAssistantContent?: string
bridgePendingReasoningContent?: string
bridgePendingToolCallMarkup?: string
bridgeOutput?: string
bridgeToolCounter?: number
bridgePendingTools?: Array<{
@@ -0,0 +1,97 @@
import { copyFile, mkdir, readdir, rm, stat } from 'fs/promises'
import { existsSync } from 'fs'
import { join, resolve } from 'path'
import { getActiveProfileDir } from './hermes-profile'
import { logger } from '../logger'
export interface SkillInjectionResult {
sourceDir: string
targetDir: string
injected: string[]
updated: string[]
skipped: string[]
}
export class HermesSkillInjector {
constructor(
private readonly sourceDir = HermesSkillInjector.resolveSourceDir(),
private readonly targetDir = join(getActiveProfileDir(), 'skills'),
) {}
static resolveSourceDir(env: NodeJS.ProcessEnv = process.env, baseDir = __dirname): string {
const override = env.HERMES_WEB_UI_SKILLS_DIR?.trim()
if (override) return resolve(override)
const candidates = [
// Production bundle: dist/server/index.js with dist/skills copied by build.
resolve(baseDir, '../skills'),
// Development/test: packages/server/src/services/hermes -> packages/skills.
resolve(baseDir, '../../../../skills'),
// Running from repository root without bundling.
resolve(process.cwd(), 'packages/skills'),
]
return candidates.find(candidate => existsSync(candidate)) || candidates[0]
}
async injectMissingSkills(): Promise<SkillInjectionResult> {
const result: SkillInjectionResult = {
sourceDir: this.sourceDir,
targetDir: this.targetDir,
injected: [],
updated: [],
skipped: [],
}
if (!await this.isDirectory(this.sourceDir)) {
logger.debug('[skill-injector] no bundled skills directory at %s', this.sourceDir)
return result
}
await mkdir(this.targetDir, { recursive: true })
const entries = await readdir(this.sourceDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
const sourceSkillDir = join(this.sourceDir, entry.name)
const targetSkillDir = join(this.targetDir, entry.name)
const existed = existsSync(targetSkillDir)
if (existsSync(targetSkillDir)) {
await rm(targetSkillDir, { recursive: true, force: true })
}
await this.copyDir(sourceSkillDir, targetSkillDir)
if (existed) result.updated.push(entry.name)
else result.injected.push(entry.name)
}
if (result.injected.length > 0 || result.updated.length > 0) {
logger.info({
injected: result.injected,
updated: result.updated,
targetDir: this.targetDir,
}, '[skill-injector] synced bundled skills')
}
return result
}
private async isDirectory(path: string): Promise<boolean> {
try {
return (await stat(path)).isDirectory()
} catch {
return false
}
}
private async copyDir(sourceDir: string, targetDir: string): Promise<void> {
await mkdir(targetDir, { recursive: true })
const entries = await readdir(sourceDir, { withFileTypes: true })
for (const entry of entries) {
const sourcePath = join(sourceDir, entry.name)
const targetPath = join(targetDir, entry.name)
if (entry.isDirectory()) {
await this.copyDir(sourcePath, targetPath)
} else if (entry.isFile()) {
await copyFile(sourcePath, targetPath)
}
}
}
}
+5 -2
View File
@@ -1,12 +1,15 @@
import pino from 'pino'
import { resolve } from 'path'
import { tmpdir } from 'os'
import { join, resolve } from 'path'
import { mkdirSync, statSync, truncateSync, openSync, readSync, closeSync, writeFileSync } from 'fs'
import { config } from '../config'
const MAX_LOG_SIZE = 3 * 1024 * 1024 // 3MB
const CHECK_INTERVAL = 60_000 // Check every minute
const logDir = resolve(config.appHome, 'logs')
const logDir = process.env.VITEST
? resolve(tmpdir(), 'hermes-web-ui-test-logs', String(process.pid))
: resolve(config.appHome, 'logs')
mkdirSync(logDir, { recursive: true })
const logFile = resolve(logDir, 'server.log')
-43
View File
@@ -1,31 +1,5 @@
import { logger } from './logger'
import { closeDb } from '../db'
import { getGatewayManagerInstance } from './gateway-bootstrap'
function shouldStopGatewaysOnShutdown(signal: string): boolean {
// nodemon may use SIGTERM on Windows restarts, so dev mode opts out via env.
// Production keeps stopping owned gateways by default.
const override = process.env.HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN?.trim()
console.log(`[shutdown] Signal: ${signal}, HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN: ${override}`)
// Explicit '0' or 'false' means dev mode: never stop gateways
if (override === '0' || override === 'false') {
console.log('[shutdown] Dev mode detected: NOT stopping gateways')
return false
}
// Explicit '1' or 'true' means always stop gateways
if (override === '1' || override === 'true') {
console.log('[shutdown] Explicit gateway shutdown enabled: stopping gateways')
return true
}
// Default behavior: only stop gateways on explicit termination, not on reload
const shouldStop = signal !== 'SIGUSR2'
console.log(`[shutdown] Default behavior: ${shouldStop ? 'STOPPING' : 'NOT stopping'} gateways (signal: ${signal})`)
return shouldStop
}
export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?: any, agentBridgeManager?: any): void {
let isShuttingDown = false
@@ -39,25 +13,8 @@ export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?:
logger.info('Shutting down (%s)...', signal)
console.log(`[shutdown] Received signal: ${signal}`)
console.log(`[shutdown] HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN = ${process.env.HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN}`)
console.log(`[shutdown] shouldStopGatewaysOnShutdown = ${shouldStopGatewaysOnShutdown(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)
}
if (agentBridgeManager) {
try {
await agentBridgeManager.stop()