Update CLI chat session bridge (#697)

* feat: add CLI chat sessions with Python agent bridge

Introduce a new CLI chat mode that connects Web UI directly to Hermes
Agent's AIAgent via a Python bridge subprocess and Socket.IO, bypassing
the API Server /v1/responses path. Supports streaming, slash commands
(/new, /undo, /retry, /branch, /compress, /save, /title), interrupt,
and steer.

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

* feat: update CLI chat session bridge

* fix: extend agent bridge startup timeouts

* docs: update bridge chat session design

* feat: align bridge compression and provider registry

* chore: bump version to 0.5.20

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-05-14 09:03:57 +08:00
committed by GitHub
parent e0fcc0040b
commit eae7195ba8
31 changed files with 3906 additions and 1040 deletions
+2
View File
@@ -10,6 +10,8 @@ package-lock.json
node_modules node_modules
dist dist
dist-ssr dist-ssr
__pycache__/
*.py[cod]
server/dist server/dist
packages/server/dist packages/server/dist
*.local *.local
+1
View File
@@ -33,6 +33,7 @@ RUN npm run build && npm prune --omit=dev
ENV NODE_ENV=production ENV NODE_ENV=production
ENV HOME=/home/agent ENV HOME=/home/agent
ENV HERMES_HOME=/home/agent/.hermes ENV HERMES_HOME=/home/agent/.hermes
ENV PATH=/opt/hermes/.venv/bin:$PATH
EXPOSE 6060 EXPOSE 6060
+1
View File
@@ -14,6 +14,7 @@ services:
- PORT=${PORT:-6060} - PORT=${PORT:-6060}
- HERMES_HOME=/home/agent/.hermes - HERMES_HOME=/home/agent/.hermes
- HERMES_BIN=/opt/hermes/.venv/bin/hermes - HERMES_BIN=/opt/hermes/.venv/bin/hermes
- PATH=/opt/hermes/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
- AUTH_DISABLED=${AUTH_DISABLED:-false} - AUTH_DISABLED=${AUTH_DISABLED:-false}
- HERMES_ALLOW_ROOT_GATEWAY=1 - HERMES_ALLOW_ROOT_GATEWAY=1
restart: unless-stopped restart: unless-stopped
+459
View File
@@ -0,0 +1,459 @@
# CLI/Bridge Chat Sessions 实现文档
> 分支:`feat/cli-chat-sessions`
## 概述
当前实现把原来的聊天通道统一到 Socket.IO namespace `/chat-run`。前端仍使用同一套 `ChatPanel + MessageList + ChatInput`,通过会话的 `source` 字段区分运行方式:
| source | 运行路径 | 说明 |
|--------|----------|------|
| `api_server` | Web UI Server → Hermes Gateway `/v1/responses` | 默认聊天路径 |
| `cli` | Web UI Server → Python agent bridge → `AIAgent` | Bridge(beta),在 Web UI 服务端子进程里直接运行 Hermes Agent |
Bridge 会话不是一个独立 UI 面板,而是普通会话的一种来源。用户通过“新建聊天”下拉菜单选择 `API``Bridge (beta)`
Bridge 模式支持:
- 流式文本输出
- reasoning/thinking 增量
- tool started/completed 事件
- 工具审批请求与响应
- abort 中断
- per-session 队列
- profile 隔离
- 从 DB resume 会话
- 与 API Server 路径共用上下文压缩逻辑
当前不再支持旧文档里的独立 `/cli-chat-run` namespace、`CliChatPanel.vue``cli-chat.ts` 和 CLI 命令控制层。前端不会再发送 `command``steer` socket 事件,也不会把 `/new``/reset``/undo``/retry``/branch``/compress` 等输入当作特殊命令处理。
---
## 整体架构
```text
ChatPanel.vue
├─ MessageList.vue
└─ ChatInput.vue
│ Socket.IO /chat-run
ChatRunSocket (Node.js)
├─ source=api_server → Hermes Gateway /v1/responses
└─ source=cli → AgentBridgeClient
│ TCP/Unix socket, newline JSON
hermes_bridge.py
│ in-process import
AIAgent (hermes-agent)
```
### 分流规则
`ChatRunSocket.resolveRunSource()` 决定本轮运行走哪个后端:
1. `run` payload 中 `source === 'cli'` 时走 bridge。
2. `source === 'api_server'` 时走 gateway。
3. 未显式传 `source` 时,如果 DB 中已有 session 的 `source``cli`,继续走 bridge。
4. 其他情况默认走 `api_server`
---
## 主要文件
### 前端
| 文件 | 说明 |
|------|------|
| `packages/client/src/components/hermes/chat/ChatPanel.vue` | 统一聊天面板;新建菜单包含 `API``Bridge (beta)`;渲染审批条 |
| `packages/client/src/components/hermes/chat/MessageList.vue` | 统一消息列表;展示文本、reasoning、tool 消息等 |
| `packages/client/src/components/hermes/chat/ChatInput.vue` | 统一输入框;发送、停止、附件上传入口 |
| `packages/client/src/api/hermes/chat.ts` | `/chat-run` Socket.IO 客户端;注册 session 事件处理器;发送 run/abort/approval |
| `packages/client/src/stores/hermes/chat.ts` | 会话状态、发送流程、resume、队列、审批、消息映射 |
### 后端
| 文件 | 说明 |
|------|------|
| `packages/server/src/services/hermes/chat-run-socket.ts` | `/chat-run` Socket.IO 服务;同时处理 API Server 和 Bridge 运行 |
| `packages/server/src/services/hermes/agent-bridge/client.ts` | Node 端 bridge 客户端;通过 socket 请求 Python bridge |
| `packages/server/src/services/hermes/agent-bridge/manager.ts` | Python bridge 子进程生命周期管理 |
| `packages/server/src/services/hermes/agent-bridge/hermes_bridge.py` | Python bridge 服务;创建并复用 `AIAgent` 实例 |
| `packages/server/src/services/hermes/agent-bridge/index.ts` | bridge 模块导出 |
| `packages/server/src/index.ts` | 启动 `AgentBridgeManager``ChatRunSocket` |
| `packages/server/src/services/shutdown.ts` | 关闭时停止 chat socket 和 bridge 子进程 |
| `packages/server/src/controllers/hermes/sessions.ts` | 会话列表和详情读取,包含 `source` 信息 |
| `packages/server/src/controllers/hermes/profiles.ts` | profile 切换/管理时清理 bridge 内存会话 |
### 已移除的旧文件
| 文件 | 状态 |
|------|------|
| `packages/client/src/api/hermes/cli-chat.ts` | 已删除 |
| `packages/client/src/components/hermes/chat/CliChatPanel.vue` | 已删除 |
| `packages/server/src/services/hermes/cli-chat-run-socket.ts` | 已删除 |
---
## 前端流程
### 新建会话
`ChatPanel.vue` 中的新建按钮使用下拉菜单:
- `API`:调用 `chatStore.newChat()`,创建默认 `api_server` 会话。
- `Bridge (beta)`:调用 `chatStore.newCliSession()`,创建 `source: 'cli'` 会话。
Bridge 会话 ID 使用类似 `YYYYMMDD_HHMMSS_xxxxxx` 的格式,便于与 Hermes CLI 风格的 session ID 对齐。
### 发送消息
1. `ChatInput.vue` 触发 store 的发送逻辑。
2. `chat.ts` 根据 active session 组装输入内容,附件会被转为 `ContentBlock[]`
3. 调用 `startRunViaSocket()`
4. 前端向 `/chat-run` emit
```ts
socket.emit('run', {
session_id,
input,
instructions,
model,
queue_id,
source, // api_server 或 cli
})
```
5. 前端注册本 session 的事件 handler,通过 `session_id` 隔离多会话并发事件。
### Resume
切换会话、页面恢复可见、或刷新后,前端通过:
```ts
socket.emit('resume', { session_id })
```
服务端返回:
```ts
{
session_id,
messages,
isWorking,
isAborting,
events,
inputTokens,
outputTokens,
queueLength,
}
```
如果服务端发现该 session 仍在运行,前端会重新注册 handler,并允许继续 abort。
### 审批
Bridge 工具需要人工确认时,服务端会发 `approval.requested`,前端 store 记录为 `activePendingApproval``ChatPanel.vue` 在输入框上方显示审批条。
前端响应审批:
```ts
socket.emit('approval.respond', {
session_id,
approval_id,
choice, // once | session | always | deny
})
```
---
## `/chat-run` Socket.IO 协议
### 客户端 → 服务端
| 事件 | 数据 | 说明 |
|------|------|------|
| `run` | `{ session_id, input, model?, instructions?, queue_id?, source? }` | 启动一轮运行;`source` 决定 API Server 或 Bridge |
| `resume` | `{ session_id }` | 加入 session room 并恢复状态 |
| `abort` | `{ session_id }` | 中断当前运行 |
| `cancel_queued_run` | `{ session_id, queue_id }` | 取消等待队列中的一条 run |
| `approval.respond` | `{ session_id, approval_id, choice }` | 响应 Bridge 工具审批 |
当前没有 `command``steer` 或 slash-command 相关 Socket.IO 事件。
### 服务端 → 客户端
| 事件 | 说明 |
|------|------|
| `resumed` | 返回 DB 消息、运行状态、队列长度和最近事件 |
| `run.started` | 运行开始 |
| `run.queued` | 当前 session 已有运行,新请求进入队列 |
| `message.delta` | 文本增量 |
| `reasoning.delta` | reasoning 增量 |
| `thinking.delta` | thinking 增量 |
| `reasoning.available` | reasoning 内容可用 |
| `tool.started` | 工具调用开始 |
| `tool.completed` | 工具调用结束 |
| `approval.requested` | Bridge 工具请求人工审批 |
| `approval.resolved` | 审批完成或超时 |
| `compression.started` | 上下文压缩开始 |
| `compression.completed` | 上下文压缩结束 |
| `usage.updated` | token 用量更新 |
| `abort.started` | 中断开始 |
| `abort.completed` | 中断结束 |
| `run.completed` | 运行完成 |
| `run.failed` | 运行失败 |
### 认证
`/chat-run` 使用 Socket.IO auth token
```ts
io(`${baseUrl}/chat-run`, {
auth: { token },
query: { profile },
})
```
如果未设置 `AUTH_DISABLED=1`,服务端会与 Web UI token 比对。
---
## ChatRunSocket 后端行为
### API Server 路径
`source=api_server` 时:
1. 写入用户消息到 Web UI 本地 session DB。
2. 通过 `buildCompressedHistory()` 构建上下文。
3. 请求当前 profile 的 Hermes Gateway
```text
POST <upstream>/v1/responses
```
4. 读取 SSE frame,映射为统一的 `/chat-run` 事件。
5. 完成后写入 assistant/tool 消息,更新 usage。
### Bridge 路径
`source=cli` 时:
1. 写入用户消息到 Web UI 本地 session DB。
2. 复用同一套 `buildCompressedHistory()` 构建压缩上下文。
3. 调用:
```ts
this.bridge.chat(session_id, input, history, instructions, profile)
```
4. 轮询 `AgentBridgeClient.streamOutput(run_id)`
5. 将 Python bridge 的 delta 和 events 映射成统一事件。
6. 将 assistant 文本、reasoning、tool 调用结果 flush 回 DB。
### 队列
同一个 `session_id` 同时只能有一个 active run。新的 `run` 到达时:
- 如果当前 session 正在运行,则放入 `state.queue`
- 发送 `run.queued` 更新队列长度。
- 当前 run 结束或 abort 完成后,自动执行下一条 queued run。
---
## Python Agent Bridge
### 通信协议
Node 和 Python bridge 之间使用本地 socket 的单行 JSON 协议:
```json
{ "action": "chat", "session_id": "xxx", "message": "hello" }
```
响应也是单行 JSON
```json
{ "ok": true, "run_id": "xxx", "session_id": "xxx", "status": "running" }
```
### Endpoint
默认 endpoint 按平台选择:
| 平台 | 默认 endpoint |
|------|---------------|
| Windows | `tcp://127.0.0.1:18765` |
| macOS/Linux | `ipc:///tmp/hermes-agent-bridge.sock` |
Windows 使用 TCP 是因为部分 Python/Windows 环境没有 Unix domain socket 支持。
### 当前实际使用的 action
| Action | 说明 |
|--------|------|
| `chat` | 启动一轮 `AIAgent.run_conversation()` |
| `get_output` | 通过 `cursor``event_cursor` 获取增量文本与事件 |
| `interrupt` | 调用 agent 中断当前运行 |
| `approval_respond` | 响应工具审批 |
| `destroy_all` | profile 切换/管理时销毁全部 bridge 内存 session |
bridge 代码里还保留了一些调试/维护 action,例如 `ping``get_result``get_history``destroy``list``shutdown``steer`,但当前 `/chat-run` 前端路径不会暴露这些能力。
旧的 `command` action 已移除,bridge 不再处理 `/new``/undo``/retry``/branch``/compress` 等斜杠命令。
### 会话和 profile
`AgentPool` 维护 `session_id -> AgentSession`
- 每个 session 持有独立 `AIAgent` 实例。
- session 按 profile 创建,profile 改变时会重建对应 agent。
- `HERMES_HOME` 会在创建 agent 时临时切到 profile home。
- `SessionDB` 按 profile 的 `state.db` 路径缓存。
- 空闲 session 会被 bridge GC,默认 30 分钟无运行后销毁内存态。
### 工具和审批事件
bridge 从 `AIAgent` 回调中收集事件:
- `stream.delta`
- `reasoning.delta`
- `thinking.delta`
- `tool.started`
- `tool.completed`
- `tool.progress`
- `approval.requested`
- `approval.resolved`
- `turn.boundary`
- `status`
`ChatRunSocket` 会把这些事件转换为前端统一事件,并负责 DB 落盘。
审批默认等待 60 秒,超时自动 `deny`
---
## AgentBridgeClient
`AgentBridgeClient` 是 Node 端本地 socket 客户端。
行为:
- 支持 `ipc://``tcp://` endpoint。
- 每次请求新建 socket,发送一行 JSON,读取一行 JSON。
- 请求通过内部 lock 串行化。
- 默认请求响应超时为 `120000ms`
- `streamOutput()` 每 100ms 轮询一次 `get_output`
示例:
```ts
const started = await bridge.chat(sessionId, input, history, instructions, profile)
for await (const chunk of bridge.streamOutput(started.run_id)) {
// chunk.delta
// chunk.events
// chunk.done
}
```
注意:目前 socket connect 阶段没有独立 connect timeout,主要依赖系统连接错误和请求响应 timeout。
---
## AgentBridgeManager
`AgentBridgeManager` 负责启动和停止 Python bridge。
启动流程:
1. 定位 `hermes_bridge.py`
2. 发现 `hermes-agent` 根目录。
3. 选择 Python 解释器。
4. 以子进程启动:
```text
python hermes_bridge.py --endpoint <endpoint> --agent-root <root> --hermes-home <home>
```
5. 监听 stdout,等待:
```json
{ "event": "ready", "endpoint": "..." }
```
6. 默认 ready 超时为 `120000ms`
Python 选择优先级:
1. `HERMES_AGENT_BRIDGE_PYTHON`
2. `agentRoot/venv``agentRoot/.venv`
3. installed `hermes` 命令 shebang
4. `uv run --project <agentRoot> python`
5. 系统 `python3` / `python`
关闭时先发 `SIGTERM`1.5 秒后仍未退出则 `SIGKILL`
---
## 启动与关闭
### 启动
`bootstrap()` 中会先尝试启动 bridge
```ts
agentBridgeManager = await startAgentBridgeManager()
```
bridge 启动失败不会阻止 Web UI 启动,但 Bridge(beta) 会话后续运行会失败。
随后创建统一的 chat socket
```ts
chatRunServer = new ChatRunSocket(groupChatServer.getIO(), getGatewayManagerInstance())
chatRunServer.init()
```
### 关闭
服务关闭时会清理:
- `/chat-run` Socket.IO 状态
- Python agent bridge 子进程
- 其他 WebSocket/Socket.IO 服务
---
## 环境变量
| 变量 | 说明 |
|------|------|
| `HERMES_AGENT_BRIDGE_ENDPOINT` | Bridge endpointWindows 默认 `tcp://127.0.0.1:18765`macOS/Linux 默认 `ipc:///tmp/hermes-agent-bridge.sock` |
| `HERMES_AGENT_BRIDGE_TIMEOUT_MS` | Node 等待 bridge 请求响应的超时,默认 `120000` ms |
| `HERMES_AGENT_BRIDGE_STARTUP_TIMEOUT_MS` | Node 等待 Python bridge ready 的超时,默认 `120000` ms |
| `HERMES_AGENT_BRIDGE_PYTHON` | 指定 Python 解释器路径 |
| `HERMES_AGENT_ROOT` | hermes-agent 安装目录 |
| `HERMES_AGENT_BRIDGE_UV` | 指定 uv 可执行文件路径 |
| `HERMES_AGENT_BRIDGE_PLATFORM` | bridge 传给 Hermes Agent 的平台标识,默认 `cli` |
| `HERMES_BRIDGE_PROVIDER` | 覆盖 bridge 使用的 provider |
| `HERMES_BRIDGE_MAX_TURNS` | 覆盖 bridge 最大轮数 |
| `UV` | uv 可执行文件路径 fallback |
Windows 首次启动慢时可以临时放大:
```powershell
$env:HERMES_AGENT_BRIDGE_STARTUP_TIMEOUT_MS = "300000"
$env:HERMES_AGENT_BRIDGE_TIMEOUT_MS = "300000"
```
---
## 当前限制
- Bridge(beta) 仍依赖 Python bridge 成功启动;启动失败时 Web UI 可用,但 bridge 会话不可用。
- bridge socket connect 阶段还没有单独 connect timeout。
- 旧 CLI 独立面板和独立 `/cli-chat-run` namespace 已移除。
- 旧 bridge 斜杠命令和 `command/steer` socket 控制层已移除;现在输入框内容一律按普通用户消息发送。
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hermes-web-ui", "name": "hermes-web-ui",
"version": "0.5.17", "version": "0.5.20",
"description": "Self-hosted AI chat dashboard for Hermes Agent — multi-model web UI with multi-platform integration", "description": "Self-hosted AI chat dashboard for Hermes Agent — multi-model web UI with multi-platform integration",
"repository": { "repository": {
"type": "git", "type": "git",
+51 -1
View File
@@ -17,6 +17,7 @@ export interface StartRunRequest {
session_id?: string session_id?: string
model?: string model?: string
queue_id?: string queue_id?: string
source?: 'api_server' | 'cli'
} }
export interface StartRunResponse { export interface StartRunResponse {
@@ -77,6 +78,8 @@ const sessionEventHandlers = new Map<string, {
onAbortCompleted: (event: RunEvent) => void onAbortCompleted: (event: RunEvent) => void
onUsageUpdated: (event: RunEvent) => void onUsageUpdated: (event: RunEvent) => void
onRunQueued?: (event: RunEvent) => void onRunQueued?: (event: RunEvent) => void
onApprovalRequested?: (event: RunEvent) => void
onApprovalResolved?: (event: RunEvent) => void
}>() }>()
/** /**
@@ -288,6 +291,26 @@ function globalUsageUpdatedHandler(event: RunEvent): void {
} }
} }
function globalApprovalRequestedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onApprovalRequested) {
handlers.onApprovalRequested(event)
}
}
function globalApprovalResolvedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onApprovalResolved) {
handlers.onApprovalResolved(event)
}
}
/** /**
* Register event handlers for a session * Register event handlers for a session
* @param sessionId - Session ID * @param sessionId - Session ID
@@ -312,6 +335,8 @@ export function registerSessionHandlers(
onAbortCompleted: (event: RunEvent) => void onAbortCompleted: (event: RunEvent) => void
onUsageUpdated: (event: RunEvent) => void onUsageUpdated: (event: RunEvent) => void
onRunQueued?: (event: RunEvent) => void onRunQueued?: (event: RunEvent) => void
onApprovalRequested?: (event: RunEvent) => void
onApprovalResolved?: (event: RunEvent) => void
} }
): () => void { ): () => void {
sessionEventHandlers.set(sessionId, handlers) sessionEventHandlers.set(sessionId, handlers)
@@ -330,6 +355,19 @@ export function unregisterSessionHandlers(sessionId: string): void {
sessionEventHandlers.delete(sessionId) sessionEventHandlers.delete(sessionId)
} }
export function respondToolApproval(
sessionId: string,
approvalId: string,
choice: 'once' | 'session' | 'always' | 'deny',
): void {
const socket = connectChatRun()
socket.emit('approval.respond', {
session_id: sessionId,
approval_id: approvalId,
choice,
})
}
export function getChatRunSocket(): Socket | null { export function getChatRunSocket(): Socket | null {
return chatRunSocket return chatRunSocket
} }
@@ -365,7 +403,9 @@ export function connectChatRun(): Socket {
reconnection: true, reconnection: true,
reconnectionAttempts: Infinity, reconnectionAttempts: Infinity,
reconnectionDelay: 1000, reconnectionDelay: 1000,
reconnectionDelayMax: 10000, reconnectionDelayMax: 30000,
randomizationFactor: 0.5,
timeout: 30000,
}) })
// Register global listeners only once per socket connection // Register global listeners only once per socket connection
@@ -385,6 +425,8 @@ export function connectChatRun(): Socket {
chatRunSocket.on('run.failed', globalRunFailedHandler) chatRunSocket.on('run.failed', globalRunFailedHandler)
chatRunSocket.on('run.completed', globalRunCompletedHandler) chatRunSocket.on('run.completed', globalRunCompletedHandler)
chatRunSocket.on('run.queued', globalRunQueuedHandler) chatRunSocket.on('run.queued', globalRunQueuedHandler)
chatRunSocket.on('approval.requested', globalApprovalRequestedHandler)
chatRunSocket.on('approval.resolved', globalApprovalResolvedHandler)
// Compression events // Compression events
chatRunSocket.on('compression.started', globalCompressionStartedHandler) chatRunSocket.on('compression.started', globalCompressionStartedHandler)
@@ -527,6 +569,14 @@ export function startRunViaSocket(
if (closed) return if (closed) return
onEvent(evt) onEvent(evt)
}, },
onApprovalRequested: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onApprovalResolved: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
} }
// Register handlers in the global session map // Register handlers in the global session map
+3 -2
View File
@@ -66,11 +66,13 @@ export function connectGroupChat(opts?: { userId?: string; userName?: string; de
name: opts?.userName || localStorage.getItem('gc_user_name') || undefined, name: opts?.userName || localStorage.getItem('gc_user_name') || undefined,
description: opts?.description || localStorage.getItem('gc_user_description') || undefined, description: opts?.description || localStorage.getItem('gc_user_description') || undefined,
}, },
transports: ['websocket'], transports: ['websocket', 'polling'],
reconnection: true, reconnection: true,
reconnectionAttempts: Infinity, reconnectionAttempts: Infinity,
reconnectionDelay: 1000, reconnectionDelay: 1000,
reconnectionDelayMax: 30000, reconnectionDelayMax: 30000,
randomizationFactor: 0.5,
timeout: 30000,
}) })
return socket return socket
@@ -185,4 +187,3 @@ export async function forceCompress(roomId: string): Promise<{ success: boolean;
method: 'POST', method: 'POST',
}) })
} }
@@ -124,11 +124,16 @@ const groupedSessions = computed<SessionGroup[]>(() => {
return keys.map((key) => ({ return keys.map((key) => ({
source: key, source: key,
label: key ? getSourceLabel(key) : t("chat.other"), label: key ? getChatSourceLabel(key) : t("chat.other"),
sessions: sortSessionsWithActiveFirst(map.get(key)!), sessions: sortSessionsWithActiveFirst(map.get(key)!),
})); }));
}); });
function getChatSourceLabel(source?: string): string {
if (source === "cli") return "Bridge (beta)";
return getSourceLabel(source);
}
function toggleGroup(source: string) { function toggleGroup(source: string) {
const isExpanded = !collapsedGroups.value.has(source); const isExpanded = !collapsedGroups.value.has(source);
if (isExpanded) { if (isExpanded) {
@@ -204,10 +209,40 @@ const activeSessionSource = computed(() =>
currentMode.value === "chat" ? chatStore.activeSession?.source || "" : "", currentMode.value === "chat" ? chatStore.activeSession?.source || "" : "",
); );
const activeApproval = computed(() => chatStore.activePendingApproval);
function handleNewChat() { function handleNewChat() {
chatStore.newChat(); chatStore.newChat();
} }
function handleNewCliChat() {
const session = chatStore.newCliSession()
chatStore.switchSession(session.id)
}
const newChatOptions = computed(() => [
{
label: "API",
key: "api_server",
},
{
label: "Bridge (beta)",
key: "cli",
},
]);
function handleNewChatSelect(key: string | number) {
if (key === "cli") {
handleNewCliChat();
return;
}
handleNewChat();
}
function handleApproval(choice: "once" | "session" | "always" | "deny") {
chatStore.respondApproval(choice);
}
async function copySessionId(id?: string) { async function copySessionId(id?: string) {
const sessionId = id || chatStore.activeSessionId; const sessionId = id || chatStore.activeSessionId;
if (sessionId) { if (sessionId) {
@@ -556,7 +591,12 @@ async function handleWorkspaceConfirm() {
</svg> </svg>
</template> </template>
</NButton> </NButton>
<NButton quaternary size="tiny" @click="handleNewChat" circle> <NDropdown
trigger="click"
:options="newChatOptions"
@select="handleNewChatSelect"
>
<NButton quaternary size="tiny" circle>
<template #icon> <template #icon>
<svg <svg
width="14" width="14"
@@ -571,6 +611,7 @@ async function handleWorkspaceConfirm() {
</svg> </svg>
</template> </template>
</NButton> </NButton>
</NDropdown>
</div> </div>
</div> </div>
<div v-if="showSessions" class="session-scope-note"> <div v-if="showSessions" class="session-scope-note">
@@ -723,7 +764,7 @@ async function handleWorkspaceConfirm() {
</NButton> </NButton>
<span class="header-session-title">{{ headerTitle }}</span> <span class="header-session-title">{{ headerTitle }}</span>
<span v-if="activeSessionSource" class="source-badge">{{ <span v-if="activeSessionSource" class="source-badge">{{
getSourceLabel(activeSessionSource) getChatSourceLabel(activeSessionSource)
}}</span> }}</span>
<span <span
v-if="chatStore.activeSession?.workspace" v-if="chatStore.activeSession?.workspace"
@@ -766,7 +807,12 @@ async function handleWorkspaceConfirm() {
</template> </template>
{{ t("chat.copySessionId") }} {{ t("chat.copySessionId") }}
</NTooltip> </NTooltip>
<NButton size="small" :circle="isMobile" @click="handleNewChat"> <NDropdown
trigger="click"
:options="newChatOptions"
@select="handleNewChatSelect"
>
<NButton size="small" :circle="isMobile">
<template #icon> <template #icon>
<svg <svg
width="14" width="14"
@@ -782,12 +828,53 @@ async function handleWorkspaceConfirm() {
</template> </template>
<template v-if="!isMobile">{{ t("chat.newChat") }}</template> <template v-if="!isMobile">{{ t("chat.newChat") }}</template>
</NButton> </NButton>
</NDropdown>
</template> </template>
</div> </div>
</header> </header>
<template v-if="currentMode === 'chat'"> <template v-if="currentMode === 'chat'">
<MessageList /> <MessageList />
<div v-if="activeApproval" class="approval-bar">
<div class="approval-main">
<div class="approval-title">Tool approval required</div>
<div class="approval-desc">{{ activeApproval.description }}</div>
<code class="approval-command">{{ activeApproval.command }}</code>
</div>
<div class="approval-actions">
<NButton
v-if="activeApproval.choices.includes('once')"
size="small"
type="primary"
@click="handleApproval('once')"
>
Allow once
</NButton>
<NButton
v-if="activeApproval.choices.includes('session')"
size="small"
@click="handleApproval('session')"
>
Allow session
</NButton>
<NButton
v-if="activeApproval.choices.includes('always')"
size="small"
@click="handleApproval('always')"
>
Always
</NButton>
<NButton
v-if="activeApproval.choices.includes('deny')"
size="small"
type="error"
ghost
@click="handleApproval('deny')"
>
Deny
</NButton>
</div>
</div>
<ChatInput /> <ChatInput />
</template> </template>
<ConversationMonitorPane <ConversationMonitorPane
@@ -1259,6 +1346,54 @@ async function handleWorkspaceConfirm() {
} }
} }
.approval-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-top: 1px solid $border-color;
background: $bg-card;
}
.approval-main {
flex: 1;
min-width: 0;
}
.approval-title {
font-size: 13px;
font-weight: 600;
color: $text-primary;
}
.approval-desc {
margin-top: 2px;
font-size: 12px;
color: $text-secondary;
}
.approval-command {
display: block;
margin-top: 6px;
max-height: 56px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
color: $text-primary;
background: $bg-secondary;
border: 1px solid $border-color;
border-radius: 6px;
padding: 6px 8px;
}
.approval-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
@keyframes rainbow-glow { @keyframes rainbow-glow {
0% { 0% {
box-shadow: box-shadow:
@@ -26,10 +26,6 @@ function formatToolDuration(seconds: number): string {
return `${mins}m ${secs}s` return `${mins}m ${secs}s`
} }
const displayMessages = computed(() =>
chatStore.messages.filter((m) => m.role !== "tool"),
);
const currentToolCalls = computed(() => { const currentToolCalls = computed(() => {
const msgs = chatStore.messages; const msgs = chatStore.messages;
// Find the last user message index // Find the last user message index
@@ -45,6 +41,22 @@ const currentToolCalls = computed(() => {
return [...tools].reverse(); return [...tools].reverse();
}); });
const displayMessages = computed(() =>
chatStore.messages.filter((m) => {
if (m.role === "tool") return false;
if (
m.role === "assistant" &&
m.isStreaming &&
!m.content?.trim() &&
!!m.reasoning?.trim() &&
currentToolCalls.value.length === 0
) {
return false;
}
return true;
}),
);
const queuedMessages = computed(() => { const queuedMessages = computed(() => {
const sid = chatStore.activeSessionId; const sid = chatStore.activeSessionId;
if (!sid) return []; if (!sid) return [];
+2
View File
@@ -131,6 +131,7 @@ export default {
contextEditSuccess: 'Context length updated', contextEditSuccess: 'Context length updated',
contextEditFailed: 'Update failed', contextEditFailed: 'Update failed',
emptyState: 'Start a conversation with Hermes Agent', emptyState: 'Start a conversation with Hermes Agent',
cliEmptyState: 'Start a CLI chat session',
inputPlaceholder: 'Type a message... (Enter to send, Shift+Enter for new line)', inputPlaceholder: 'Type a message... (Enter to send, Shift+Enter for new line)',
attachFiles: 'Attach files', attachFiles: 'Attach files',
autoPlaySpeech: 'Auto-play voice', autoPlaySpeech: 'Auto-play voice',
@@ -159,6 +160,7 @@ export default {
searchEnterHint: 'Enter to open · Esc to close', searchEnterHint: 'Enter to open · Esc to close',
searchFailed: 'Failed to search sessions', searchFailed: 'Failed to search sessions',
newChat: 'New Chat', newChat: 'New Chat',
newCliChat: 'New CLI',
deleteSession: 'Delete this session?', deleteSession: 'Delete this session?',
sessionDeleted: 'Session deleted', sessionDeleted: 'Session deleted',
toggleBatchMode: 'Batch selection', toggleBatchMode: 'Batch selection',
+2
View File
@@ -131,6 +131,7 @@ export default {
contextEditSuccess: '上下文长度已更新', contextEditSuccess: '上下文长度已更新',
contextEditFailed: '更新失败', contextEditFailed: '更新失败',
emptyState: '开始与 Hermes Agent 对话', emptyState: '开始与 Hermes Agent 对话',
cliEmptyState: '开始 CLI 对话',
inputPlaceholder: '输入消息... (Enter 发送,Shift+Enter 换行)', inputPlaceholder: '输入消息... (Enter 发送,Shift+Enter 换行)',
attachFiles: '添加附件', attachFiles: '添加附件',
autoPlaySpeech: '自动播放语音', autoPlaySpeech: '自动播放语音',
@@ -159,6 +160,7 @@ export default {
searchEnterHint: 'Enter 打开 · Esc 关闭', searchEnterHint: 'Enter 打开 · Esc 关闭',
searchFailed: '搜索会话失败', searchFailed: '搜索会话失败',
newChat: '新建对话', newChat: '新建对话',
newCliChat: '新建 CLI',
deleteSession: '确定删除此会话?', deleteSession: '确定删除此会话?',
sessionDeleted: '会话已删除', sessionDeleted: '会话已删除',
toggleBatchMode: '批量选择', toggleBatchMode: '批量选择',
-310
View File
@@ -1,310 +0,0 @@
/**
* Provider registry — single source of truth for both frontend and backend.
* Synced from hermes-agent hermes_cli/models.py _PROVIDER_MODELS.
*/
export interface ProviderPreset {
label: string
value: string
base_url: string
models: string[]
}
export const PROVIDER_PRESETS: ProviderPreset[] = [
{
label: 'Anthropic',
value: 'anthropic',
base_url: 'https://api.anthropic.com',
models: [
'claude-opus-4-7',
'claude-opus-4-6',
'claude-sonnet-4-6',
'claude-opus-4-5-20251101',
'claude-sonnet-4-5-20250929',
'claude-opus-4-20250514',
'claude-sonnet-4-20250514',
'claude-haiku-4-5-20251001',
],
},
{
label: 'Google AI Studio',
value: 'gemini',
base_url: 'https://generativelanguage.googleapis.com/v1beta/openai',
models: [
'gemini-3.1-pro-preview',
'gemini-3-flash-preview',
'gemini-3.1-flash-lite-preview',
'gemini-2.5-pro',
'gemini-2.5-flash',
'gemini-2.5-flash-lite',
'gemma-4-31b-it',
'gemma-4-26b-it',
],
},
{
label: 'DeepSeek',
value: 'deepseek',
base_url: 'https://api.deepseek.com',
models: ['deepseek-chat', 'deepseek-reasoner'],
},
{
label: 'Z.AI / GLM',
value: 'zai',
base_url: 'https://api.z.ai/api/paas/v4',
models: ['glm-5.1', 'glm-5', 'glm-5v-turbo', 'glm-5-turbo', 'glm-4.7', 'glm-4.5', 'glm-4.5-flash'],
},
{
label: 'Kimi for Coding',
value: 'kimi-coding',
base_url: 'https://api.kimi.com/coding/v1',
models: [
'kimi-for-coding',
'kimi-k2.5',
'kimi-k2-thinking',
'kimi-k2-thinking-turbo',
'kimi-k2-turbo-preview',
'kimi-k2-0905-preview',
],
},
{
label: 'Kimi for Coding (CN)',
value: 'kimi-coding-cn',
base_url: 'https://api.kimi.com/coding/v1',
models: [
'kimi-k2.5',
'kimi-k2-thinking',
'kimi-k2-turbo-preview',
'kimi-k2-0905-preview',
],
},
{
label: 'Moonshot',
value: 'moonshot',
base_url: 'https://api.moonshot.cn/v1',
models: [
'kimi-k2.5',
'kimi-k2-thinking',
'kimi-k2-turbo-preview',
'kimi-k2-0905-preview',
],
},
{
label: 'xAI',
value: 'xai',
base_url: 'https://api.x.ai/v1',
models: ['grok-4.20-reasoning', 'grok-4-1-fast-reasoning'],
},
{
label: 'MiniMax',
value: 'minimax',
base_url: 'https://api.minimax.io/anthropic/v1',
models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2', 'MiniMax-M2-highspeed'],
},
{
label: 'MiniMax (China)',
value: 'minimax-cn',
base_url: 'https://api.minimaxi.com/v1',
models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2', 'MiniMax-M2-highspeed'],
},
{
label: 'Alibaba Cloud',
value: 'alibaba',
base_url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
models: [
'qwen3.5-plus',
'qwen3-coder-plus',
'qwen3-coder-next',
'glm-5',
'glm-4.7',
'kimi-k2.5',
'MiniMax-M2.5',
],
},
{
label: 'Alibaba Cloud (Coding Plan)',
value: 'alibaba-coding-plan',
// NOTE: This is the international (intl) DashScope endpoint, matching upstream
// hermes-agent (auth.py:255). Mainland China DashScope accounts (sk-sp-* keys
// issued by dashscope.aliyun.com) must override via ALIBABA_CODING_PLAN_BASE_URL=
// https://coding.dashscope.aliyuncs.com/v1 (no -intl), since the -intl endpoint
// returns HTTP 401 for those keys.
base_url: 'https://coding-intl.dashscope.aliyuncs.com/v1',
models: [
'qwen3.5-plus',
'qwen3-max-2026-01-23',
'qwen3-coder-next',
'qwen3-coder-plus',
'glm-5',
'glm-4.7',
'kimi-k2.5',
'MiniMax-M2.5',
],
},
{
label: 'Hugging Face',
value: 'huggingface',
base_url: 'https://router.huggingface.co/v1',
models: [
'Qwen/Qwen3.5-397B-A17B',
'Qwen/Qwen3.5-35B-A3B',
'deepseek-ai/DeepSeek-V3.2',
'moonshotai/Kimi-K2.5',
'MiniMaxAI/MiniMax-M2.5',
'zai-org/GLM-5',
'XiaomiMiMo/MiMo-V2-Flash',
'moonshotai/Kimi-K2-Thinking',
],
},
{
label: 'Xiaomi MiMo',
value: 'xiaomi',
base_url: 'https://api.xiaomimimo.com/v1',
models: ['mimo-v2-pro', 'mimo-v2-omni', 'mimo-v2-flash'],
},
{
label: 'Kilo Code',
value: 'kilocode',
base_url: 'https://api.kilo.ai/api/gateway',
models: [
'anthropic/claude-opus-4.6',
'anthropic/claude-sonnet-4.6',
'openai/gpt-5.4',
'google/gemini-3-pro-preview',
'google/gemini-3-flash-preview',
],
},
{
label: 'Vercel AI Gateway',
value: 'ai-gateway',
base_url: 'https://ai-gateway.vercel.sh/v1',
models: [
'anthropic/claude-opus-4.6',
'anthropic/claude-sonnet-4.6',
'anthropic/claude-sonnet-4.5',
'anthropic/claude-haiku-4.5',
'openai/gpt-5',
'openai/gpt-4.1',
'openai/gpt-4.1-mini',
'google/gemini-3-pro-preview',
'google/gemini-3-flash',
'google/gemini-2.5-pro',
'google/gemini-2.5-flash',
'deepseek/deepseek-v3.2',
],
},
{
label: 'CLIProxyAPI',
value: 'cliproxyapi',
base_url: 'http://127.0.0.1:8317/v1',
models: [
'gpt-5.5',
'gpt-5-codex',
'claude-sonnet-4-6',
'claude-sonnet-4-5-20250929',
'gemini-3.1-pro-preview',
'gemini-2.5-pro',
],
},
{
label: 'OpenCode Zen',
value: 'opencode-zen',
base_url: 'https://opencode.ai/zen/v1',
models: [
'gpt-5.4-pro',
'gpt-5.4',
'gpt-5.3-codex',
'gpt-5.3-codex-spark',
'gpt-5.2',
'gpt-5.2-codex',
'gpt-5.1',
'gpt-5.1-codex',
'gpt-5.1-codex-max',
'gpt-5.1-codex-mini',
'gpt-5',
'gpt-5-codex',
'gpt-5-nano',
'claude-opus-4-6',
'claude-opus-4-5',
'claude-opus-4-1',
'claude-sonnet-4-6',
'claude-sonnet-4-5',
'claude-sonnet-4',
'claude-haiku-4-5',
'claude-3-5-haiku',
'gemini-3.1-pro',
'gemini-3-pro',
'gemini-3-flash',
'minimax-m2.7',
'minimax-m2.5',
'minimax-m2.5-free',
'minimax-m2.1',
'glm-5',
'glm-4.7',
'glm-4.6',
'kimi-k2.5',
'kimi-k2-thinking',
'kimi-k2',
'qwen3-coder',
'big-pickle',
],
},
{
label: 'OpenCode Go',
value: 'opencode-go',
base_url: 'https://opencode.ai/zen/go/v1',
models: ['glm-5.1', 'glm-5', 'kimi-k2.5', 'mimo-v2-pro', 'mimo-v2-omni', 'minimax-m2.7', 'minimax-m2.5'],
},
{
label: 'OpenAI Codex',
value: 'openai-codex',
base_url: 'https://chatgpt.com/backend-api/codex',
models: ['gpt-5.5', 'gpt-5.4-mini', 'gpt-5.4', 'gpt-5.3-codex', 'gpt-5.2-codex', 'gpt-5.1-codex-max', 'gpt-5.1-codex-mini'],
},
{
label: 'Arcee AI',
value: 'arcee',
base_url: 'https://api.arcee.ai/v1',
models: ['trinity-large-thinking', 'trinity-large-preview', 'trinity-mini'],
},
{
label: 'OpenRouter',
value: 'openrouter',
base_url: 'https://openrouter.ai/api/v1',
models: [],
},
{
label: 'GitHub Copilot',
value: 'copilot',
base_url: 'https://api.githubcopilot.com',
models: [
'gpt-5.4',
'gpt-5.4-mini',
'gpt-5-mini',
'gpt-5.3-codex',
'gpt-5.2-codex',
'gpt-4.1',
'gpt-4o',
'gpt-4o-mini',
'claude-sonnet-4.6',
'claude-sonnet-4',
'claude-sonnet-4.5',
'claude-haiku-4.5',
'gemini-3.1-pro-preview',
'gemini-3-pro-preview',
'gemini-3-flash-preview',
'gemini-2.5-pro',
'grok-code-fast-1',
],
},
]
/** Build a Record<providerKey, models[]> for backend lookup */
export function buildProviderModelMap(): Record<string, string[]> {
const map: Record<string, string[]> = {}
for (const p of PROVIDER_PRESETS) {
if (p.models.length > 0) {
map[p.value] = p.models
}
}
return map
}
+147 -1
View File
@@ -1,4 +1,4 @@
import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, type RunEvent, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat' import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, respondToolApproval, type RunEvent, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat'
import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions' import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions'
import { getApiKey } from '@/api/client' import { getApiKey } from '@/api/client'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
@@ -43,6 +43,16 @@ export interface Message {
queued?: boolean queued?: boolean
} }
export interface PendingApproval {
sessionId: string
approvalId: string
command: string
description: string
choices: Array<'once' | 'session' | 'always' | 'deny'>
allowPermanent: boolean
requestedAt: number
}
export interface Session { export interface Session {
id: string id: string
title: string title: string
@@ -320,6 +330,11 @@ export const useChatStore = defineStore('chat', () => {
const queueLengths = ref<Map<string, number>>(new Map()) const queueLengths = ref<Map<string, number>>(new Map())
/** sessionId → queued user messages not yet visible in the transcript */ /** sessionId → queued user messages not yet visible in the transcript */
const queuedUserMessages = ref<Map<string, Message[]>>(new Map()) const queuedUserMessages = ref<Map<string, Message[]>>(new Map())
const pendingApprovals = ref<Map<string, PendingApproval>>(new Map())
const activePendingApproval = computed(() => {
const sid = activeSessionId.value
return sid ? pendingApprovals.value.get(sid) || null : null
})
// 自动播放语音开关 // 自动播放语音开关
const autoPlaySpeechEnabled = ref(false) const autoPlaySpeechEnabled = ref(false)
@@ -432,6 +447,30 @@ export const useChatStore = defineStore('chat', () => {
return session return session
} }
function newCliSession(): Session {
const now = new Date()
const ts = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
'_',
String(now.getHours()).padStart(2, '0'),
String(now.getMinutes()).padStart(2, '0'),
String(now.getSeconds()).padStart(2, '0'),
].join('')
const hex = Math.random().toString(16).slice(2, 8)
const session: Session = {
id: `${ts}_${hex}`,
title: '',
source: 'cli',
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
sessions.value.unshift(session)
return session
}
async function switchSession(sessionId: string, focusId?: string | null) { async function switchSession(sessionId: string, focusId?: string | null) {
clearThinkingObservationFor(sessionId) clearThinkingObservationFor(sessionId)
activeSessionId.value = sessionId activeSessionId.value = sessionId
@@ -503,6 +542,49 @@ export const useChatStore = defineStore('chat', () => {
setAbortState({ aborting: true, synced: null }) setAbortState({ aborting: true, synced: null })
} else if (e.event === 'abort.completed') { } else if (e.event === 'abort.completed') {
setAbortState({ aborting: false, synced: e.synced ?? false }) setAbortState({ aborting: false, synced: e.synced ?? false })
} else if (e.event === 'approval.requested') {
setPendingApproval({ ...e, session_id: sessionId } as RunEvent)
} else if (e.event === 'approval.resolved') {
clearPendingApproval({ ...e, session_id: sessionId } as RunEvent)
} else if (e.event === 'tool.started') {
const msgs = getSessionMsgs(sessionId)
const toolCallId = e.tool_call_id as string | undefined
const existingTool = toolCallId
? msgs.find(m => m.role === 'tool' && m.toolCallId === toolCallId)
: null
if (existingTool) {
updateMessage(sessionId, existingTool.id, {
toolName: e.tool || e.name,
toolArgs: typeof e.arguments === 'string' ? e.arguments : existingTool.toolArgs,
toolPreview: e.preview || existingTool.toolPreview,
toolStatus: existingTool.toolStatus || 'running',
})
} else {
addMessage(sessionId, {
id: uid(),
role: 'tool',
content: '',
timestamp: Date.now(),
toolName: e.tool || e.name,
toolCallId,
toolPreview: e.preview,
toolArgs: typeof e.arguments === 'string' ? e.arguments : undefined,
toolStatus: 'running',
})
}
} else if (e.event === 'tool.completed') {
const msgs = getSessionMsgs(sessionId)
const toolCallId = e.tool_call_id as string | undefined
const toolMsgs = toolCallId
? msgs.filter(m => m.role === 'tool' && m.toolCallId === toolCallId)
: msgs.filter(m => m.role === 'tool' && m.toolStatus === 'running')
if (toolMsgs.length > 0) {
updateMessage(sessionId, toolMsgs[toolMsgs.length - 1].id, {
toolStatus: e.error === true ? 'error' : 'done',
toolDuration: e.duration,
toolResult: typeof e.output === 'string' ? e.output : undefined,
})
}
} }
} }
} }
@@ -603,6 +685,45 @@ export const useChatStore = defineStore('chat', () => {
}) })
} }
function setPendingApproval(evt: RunEvent) {
const sid = evt.session_id
const approvalId = (evt as any).approval_id as string | undefined
if (!sid || !approvalId) return
const rawChoices = Array.isArray((evt as any).choices) ? (evt as any).choices : ['once', 'session', 'deny']
const choices = rawChoices
.filter((choice: unknown): choice is PendingApproval['choices'][number] =>
choice === 'once' || choice === 'session' || choice === 'always' || choice === 'deny')
pendingApprovals.value.set(sid, {
sessionId: sid,
approvalId,
command: String((evt as any).command || ''),
description: String((evt as any).description || ''),
choices: choices.length ? choices : ['once', 'session', 'deny'],
allowPermanent: Boolean((evt as any).allow_permanent),
requestedAt: Date.now(),
})
pendingApprovals.value = new Map(pendingApprovals.value)
}
function clearPendingApproval(evt: RunEvent) {
const sid = evt.session_id
if (!sid) return
const current = pendingApprovals.value.get(sid)
if (!current) return
const approvalId = (evt as any).approval_id
if (approvalId && current.approvalId !== approvalId) return
pendingApprovals.value.delete(sid)
pendingApprovals.value = new Map(pendingApprovals.value)
}
function respondApproval(choice: PendingApproval['choices'][number]) {
const pending = activePendingApproval.value
if (!pending) return
respondToolApproval(pending.sessionId, pending.approvalId, choice)
pendingApprovals.value.delete(pending.sessionId)
pendingApprovals.value = new Map(pendingApprovals.value)
}
function showNextQueuedUserMessage(sessionId: string) { function showNextQueuedUserMessage(sessionId: string) {
const queue = queuedUserMessages.value.get(sessionId) const queue = queuedUserMessages.value.get(sessionId)
if (!queue?.length) return if (!queue?.length) return
@@ -715,6 +836,7 @@ export const useChatStore = defineStore('chat', () => {
session_id: sid, session_id: sid,
model: sessionModel || undefined, model: sessionModel || undefined,
queue_id: userMsg.id, queue_id: userMsg.id,
source: (activeSession.value?.source === 'cli' ? 'cli' : 'api_server') as 'cli' | 'api_server',
} }
if (shouldQueue) { if (shouldQueue) {
@@ -967,6 +1089,16 @@ export const useChatStore = defineStore('chat', () => {
break break
} }
case 'approval.requested': {
setPendingApproval(evt)
break
}
case 'approval.resolved': {
clearPendingApproval(evt)
break
}
case 'run.completed': { case 'run.completed': {
const msgs = getSessionMsgs(sid) const msgs = getSessionMsgs(sid)
const lastMsg = activeAssistantMessageId const lastMsg = activeAssistantMessageId
@@ -1394,6 +1526,16 @@ export const useChatStore = defineStore('chat', () => {
break break
} }
case 'approval.requested': {
setPendingApproval(evt)
break
}
case 'approval.resolved': {
clearPendingApproval(evt)
break
}
case 'run.completed': { case 'run.completed': {
const hasQueue = (evt as any).queue_remaining > 0 const hasQueue = (evt as any).queue_remaining > 0
if (hasQueue) { if (hasQueue) {
@@ -1689,12 +1831,15 @@ export const useChatStore = defineStore('chat', () => {
isAborting, isAborting,
queueLengths, queueLengths,
queuedUserMessages, queuedUserMessages,
pendingApprovals,
activePendingApproval,
removeQueuedMessage, removeQueuedMessage,
isLoadingSessions, isLoadingSessions,
sessionsLoaded, sessionsLoaded,
isLoadingMessages, isLoadingMessages,
newChat, newChat,
newCliSession,
switchSession, switchSession,
switchSessionModel, switchSessionModel,
addOrUpdateSession, addOrUpdateSession,
@@ -1702,6 +1847,7 @@ export const useChatStore = defineStore('chat', () => {
deleteSession, deleteSession,
sendMessage, sendMessage,
stopStreaming, stopStreaming,
respondApproval,
loadSessions, loadSessions,
refreshActiveSession, refreshActiveSession,
getThinkingObservation, getThinkingObservation,
@@ -240,12 +240,12 @@ watch(hermesSessionsLoaded, (loaded) => {
if (loaded && hermesSessions.value.length > 0) { if (loaded && hermesSessions.value.length > 0) {
// Only auto-load if no session is currently active // Only auto-load if no session is currently active
if (!historySessionId.value || !hermesSessions.value.find(s => s.id === historySessionId.value)) { if (!historySessionId.value || !hermesSessions.value.find(s => s.id === historySessionId.value)) {
// Find first CLI session // Find first CLI session.
const firstCliSession = hermesSessions.value.find(s => s.source === 'cli') const firstCliSession = hermesSessions.value.find(s => s.source === 'cli')
if (firstCliSession) { if (firstCliSession) {
// Ensure the CLI group is expanded // Ensure the CLI group is expanded
if (collapsedGroups.value.has('cli')) { if (collapsedGroups.value.has(firstCliSession.source)) {
collapsedGroups.value = new Set([...collapsedGroups.value].filter(s => s !== 'cli')) collapsedGroups.value = new Set([...collapsedGroups.value].filter(s => s !== firstCliSession.source))
} }
// Load session details // Load session details
handleSessionClick(firstCliSession.id) handleSessionClick(firstCliSession.id)
@@ -7,6 +7,26 @@ import { SessionDeleter } from '../../services/hermes/session-deleter'
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap' import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
import { logger } from '../../services/logger' import { logger } from '../../services/logger'
import { smartCloneCleanup } from '../../services/hermes/profile-credentials' import { smartCloneCleanup } from '../../services/hermes/profile-credentials'
import { detectHermesHome } from '../../services/hermes/hermes-path'
function profileExistsForManualSwitch(name: string): boolean {
const base = detectHermesHome()
if (!name || name === 'default') return true
return existsSync(join(base, 'profiles', name, 'config.yaml')) || existsSync(join(base, 'profiles', name))
}
async function useProfileWithFallback(name: string): Promise<string> {
try {
return await hermesCli.useProfile(name)
} catch (err: any) {
if (!profileExistsForManualSwitch(name)) throw err
const base = detectHermesHome()
writeFileSync(join(base, 'active_profile'), `${name}\n`, 'utf-8')
logger.warn(err, '[switchProfile] hermes profile use failed; wrote active_profile directly for existing profile "%s"', name)
return `Switched to profile ${name}`
}
}
export async function list(ctx: any) { export async function list(ctx: any) {
try { try {
@@ -159,7 +179,7 @@ export async function switchProfile(ctx: any) {
return return
} }
try { try {
const output = await hermesCli.useProfile(name) const output = await useProfileWithFallback(name)
// Verify the active_profile file immediately (Hermes CLI writes synchronously) // Verify the active_profile file immediately (Hermes CLI writes synchronously)
// Quick verification with 2 retries to handle edge cases (filesystem delays, concurrency) // Quick verification with 2 retries to handle edge cases (filesystem delays, concurrency)
@@ -185,6 +205,16 @@ export async function switchProfile(ctx: any) {
const mgr = getGatewayManagerInstance() const mgr = getGatewayManagerInstance()
if (mgr) { mgr.setActiveProfile(name) } if (mgr) { mgr.setActiveProfile(name) }
// Destroy all bridge sessions so they get recreated with the new profile config
try {
const { AgentBridgeClient } = await import('../../services/hermes/agent-bridge')
const bridge = new AgentBridgeClient()
await bridge.destroyAll()
logger.info('[switchProfile] destroyed all bridge sessions for profile "%s"', name)
} catch (err: any) {
logger.warn(err, '[switchProfile] failed to destroy bridge sessions')
}
try { try {
const detail = await hermesCli.getProfile(name) const detail = await hermesCli.getProfile(name)
logger.debug('Profile detail.path = %s', detail.path) logger.debug('Profile detail.path = %s', detail.path)
@@ -1,19 +1,16 @@
import * as hermesCli from '../../services/hermes/hermes-cli' import * as hermesCli from '../../services/hermes/hermes-cli'
import { listConversationSummaries, getConversationDetail } from '../../services/hermes/conversations' import { listSessionSummaries, getUsageStatsFromDb, getSessionDetailFromDb } from '../../db/hermes/sessions-db'
import { listConversationSummariesFromDb, getConversationDetailFromDb } from '../../db/hermes/conversations-db'
import { listSessionSummaries, searchSessionSummaries, getUsageStatsFromDb, getSessionDetailFromDb } from '../../db/hermes/sessions-db'
import { import {
listSessions as localListSessions, listSessions as localListSessions,
searchSessions as localSearchSessions, searchSessions as localSearchSessions,
getSessionDetail as localGetSessionDetail, getSessionDetail as localGetSessionDetail,
deleteSession as localDeleteSession, deleteSession as localDeleteSession,
renameSession as localRenameSession, renameSession as localRenameSession,
useLocalSessionStore,
} from '../../db/hermes/session-store' } from '../../db/hermes/session-store'
import { ExportCompressor } from '../../lib/context-compressor/export-compressor' import { ExportCompressor } from '../../lib/context-compressor/export-compressor'
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap' import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
import { deleteUsage, getUsage, getUsageBatch, getLocalUsageStats } from '../../db/hermes/usage-store' import { deleteUsage, getUsage, getUsageBatch } from '../../db/hermes/usage-store'
import type { LocalUsageStats, UsageStatsModelRow, UsageStatsDailyRow } from '../../db/hermes/usage-store' import type { UsageStatsModelRow, UsageStatsDailyRow } from '../../db/hermes/usage-store'
import { getModelContextLength } from '../../services/hermes/model-context' import { getModelContextLength } from '../../services/hermes/model-context'
import { getActiveProfileName } from '../../services/hermes/hermes-profile' import { getActiveProfileName } from '../../services/hermes/hermes-profile'
import { getGroupChatServer } from '../../routes/hermes/group-chat' import { getGroupChatServer } from '../../routes/hermes/group-chat'
@@ -36,10 +33,8 @@ function filterPendingDeletedConversationSummaries(items: ConversationSummary[])
export async function listConversations(ctx: any) { export async function listConversations(ctx: any) {
const source = (ctx.query.source as string) || undefined const source = (ctx.query.source as string) || undefined
const humanOnly = (ctx.query.humanOnly as string) !== 'false' && ctx.query.humanOnly !== '0'
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
if (useLocalSessionStore()) {
const profile = getActiveProfileName() const profile = getActiveProfileName()
const sessions = localListSessions(profile, source, limit && limit > 0 ? limit : 200) const sessions = localListSessions(profile, source, limit && limit > 0 ? limit : 200)
const summaries: ConversationSummary[] = sessions.map(s => ({ const summaries: ConversationSummary[] = sessions.map(s => ({
@@ -67,26 +62,11 @@ export async function listConversations(ctx: any) {
thread_session_count: 1, thread_session_count: 1,
})) }))
ctx.body = { sessions: filterPendingDeletedConversationSummaries(summaries) } ctx.body = { sessions: filterPendingDeletedConversationSummaries(summaries) }
return
}
try {
const sessions = await listConversationSummariesFromDb({ source, humanOnly, limit })
ctx.body = { sessions: filterPendingDeletedConversationSummaries(sessions) }
return
} catch (err) {
logger.warn(err, 'Hermes Conversation DB: summary query failed, falling back to CLI export')
}
const sessions = await listConversationSummaries({ source, humanOnly, limit })
ctx.body = { sessions: filterPendingDeletedConversationSummaries(sessions) }
} }
export async function getConversationMessages(ctx: any) { export async function getConversationMessages(ctx: any) {
const source = (ctx.query.source as string) || undefined
const humanOnly = (ctx.query.humanOnly as string) !== 'false' && ctx.query.humanOnly !== '0' const humanOnly = (ctx.query.humanOnly as string) !== 'false' && ctx.query.humanOnly !== '0'
if (useLocalSessionStore()) {
const detail = localGetSessionDetail(ctx.params.id) const detail = localGetSessionDetail(ctx.params.id)
if (!detail) { if (!detail) {
ctx.status = 404 ctx.status = 404
@@ -112,54 +92,16 @@ export async function getConversationMessages(ctx: any) {
visible_count: messages.length, visible_count: messages.length,
thread_session_count: 1, thread_session_count: 1,
} }
return
}
try {
const detail = await getConversationDetailFromDb(ctx.params.id, { source, humanOnly })
if (!detail) {
ctx.status = 404
ctx.body = { error: 'Conversation not found' }
return
}
ctx.body = detail
return
} catch (err) {
logger.warn(err, 'Hermes Conversation DB: detail query failed, falling back to CLI export')
}
const detail = await getConversationDetail(ctx.params.id, { source, humanOnly })
if (!detail) {
ctx.status = 404
ctx.body = { error: 'Conversation not found' }
return
}
ctx.body = detail
} }
export async function list(ctx: any) { export async function list(ctx: any) {
if (useLocalSessionStore()) {
const source = (ctx.query.source as string) || undefined const source = (ctx.query.source as string) || undefined
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
const profile = getActiveProfileName() const profile = getActiveProfileName()
const sessions = localListSessions(profile, source, limit && limit > 0 ? limit : 2000) const effectiveLimit = limit && limit > 0 ? limit : 2000
ctx.body = { sessions: filterPendingDeletedSessions(sessions) }
return
}
const source = (ctx.query.source as string) || undefined const allSessions = localListSessions(profile, source, effectiveLimit)
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined ctx.body = { sessions: filterPendingDeletedSessions(allSessions.filter(s => s.source === 'api_server' || s.source === 'cli')) }
try {
const sessions = await listSessionSummaries(source, limit && limit > 0 ? limit : 2000)
ctx.body = { sessions: filterPendingDeletedSessions(sessions) }
return
} catch (err) {
logger.warn(err, 'Hermes Session DB: summary query failed, falling back to CLI')
}
const sessions = await hermesCli.listSessions(source, limit)
ctx.body = { sessions: filterPendingDeletedSessions(sessions) }
} }
/** /**
@@ -169,47 +111,22 @@ export async function list(ctx: any) {
export async function listHermesSessions(ctx: any) { export async function listHermesSessions(ctx: any) {
const source = (ctx.query.source as string) || undefined const source = (ctx.query.source as string) || undefined
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
const profile = getActiveProfileName()
const effectiveLimit = limit && limit > 0 ? limit : 2000
try { const allSessions = localListSessions(profile, source, effectiveLimit)
const sessions = await listSessionSummaries(source, limit && limit > 0 ? limit : 2000) ctx.body = { sessions: filterPendingDeletedSessions(allSessions.filter(s => s.source !== 'api_server')) }
ctx.body = { sessions: filterPendingDeletedSessions(sessions.filter(s => s.source !== 'api_server')) }
return
} catch (err) {
logger.warn(err, 'Hermes Session DB: summary query failed, falling back to CLI')
}
const sessions = await hermesCli.listSessions(source, limit)
ctx.body = { sessions: filterPendingDeletedSessions(sessions.filter(s => s.source !== 'api_server')) }
} }
export async function search(ctx: any) { export async function search(ctx: any) {
if (useLocalSessionStore()) {
const q = typeof ctx.query.q === 'string' ? ctx.query.q : '' const q = typeof ctx.query.q === 'string' ? ctx.query.q : ''
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
const profile = getActiveProfileName() const profile = getActiveProfileName()
const results = localSearchSessions(profile, q, limit && limit > 0 ? limit : 20) const results = localSearchSessions(profile, q, limit && limit > 0 ? limit : 20)
ctx.body = { results: filterPendingDeletedSessions(results) } ctx.body = { results: filterPendingDeletedSessions(results) }
return
}
const q = typeof ctx.query.q === 'string' ? ctx.query.q : ''
const source = typeof ctx.query.source === 'string' && ctx.query.source.trim()
? ctx.query.source.trim()
: undefined
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
try {
const results = await searchSessionSummaries(q, source, limit && limit > 0 ? limit : 20)
ctx.body = { results: filterPendingDeletedSessions(results) }
} catch (err) {
logger.error(err, 'Hermes Session DB: search failed')
ctx.status = 500
ctx.body = { error: 'Failed to search sessions' }
}
} }
export async function get(ctx: any) { export async function get(ctx: any) {
if (useLocalSessionStore()) {
const session = localGetSessionDetail(ctx.params.id) const session = localGetSessionDetail(ctx.params.id)
if (!session) { if (!session) {
ctx.status = 404 ctx.status = 404
@@ -217,16 +134,6 @@ export async function get(ctx: any) {
return return
} }
ctx.body = { session } ctx.body = { session }
return
}
const session = await hermesCli.getSession(ctx.params.id)
if (!session) {
ctx.status = 404
ctx.body = { error: 'Session not found' }
return
}
ctx.body = { session }
} }
/** /**
@@ -262,7 +169,6 @@ export async function getHermesSession(ctx: any) {
} }
export async function remove(ctx: any) { export async function remove(ctx: any) {
if (useLocalSessionStore()) {
const sessionId = ctx.params.id const sessionId = ctx.params.id
const ok = localDeleteSession(sessionId) const ok = localDeleteSession(sessionId)
if (!ok) { if (!ok) {
@@ -272,18 +178,6 @@ export async function remove(ctx: any) {
} }
deleteUsage(sessionId) deleteUsage(sessionId)
ctx.body = { ok: true } ctx.body = { ok: true }
return
}
const sessionId = ctx.params.id
const ok = await hermesCli.deleteSession(sessionId)
if (!ok) {
ctx.status = 500
ctx.body = { error: 'Failed to delete session' }
return
}
deleteUsage(sessionId)
ctx.body = { ok: true }
} }
export async function batchRemove(ctx: any) { export async function batchRemove(ctx: any) {
@@ -307,7 +201,6 @@ export async function batchRemove(ctx: any) {
errors: [] as Array<{ id: string; error: string }> errors: [] as Array<{ id: string; error: string }>
} }
if (useLocalSessionStore()) {
for (const id of validIds) { for (const id of validIds) {
const ok = localDeleteSession(id) const ok = localDeleteSession(id)
if (ok) { if (ok) {
@@ -318,18 +211,6 @@ export async function batchRemove(ctx: any) {
results.errors.push({ id, error: 'Failed to delete session' }) results.errors.push({ id, error: 'Failed to delete session' })
} }
} }
} else {
for (const id of validIds) {
const ok = await hermesCli.deleteSession(id)
if (ok) {
deleteUsage(id)
results.deleted++
} else {
results.failed++
results.errors.push({ id, error: 'Failed to delete session' })
}
}
}
ctx.body = { ...results, ok: true } ctx.body = { ...results, ok: true }
} }
@@ -354,7 +235,6 @@ export async function usageSingle(ctx: any) {
} }
export async function rename(ctx: any) { export async function rename(ctx: any) {
if (useLocalSessionStore()) {
const { title } = ctx.request.body as { title?: string } const { title } = ctx.request.body as { title?: string }
if (!title || typeof title !== 'string') { if (!title || typeof title !== 'string') {
ctx.status = 400 ctx.status = 400
@@ -368,22 +248,6 @@ export async function rename(ctx: any) {
return return
} }
ctx.body = { ok: true } ctx.body = { ok: true }
return
}
const { title } = ctx.request.body as { title?: string }
if (!title || typeof title !== 'string') {
ctx.status = 400
ctx.body = { error: 'title is required' }
return
}
const ok = await hermesCli.renameSession(ctx.params.id, title.trim())
if (!ok) {
ctx.status = 500
ctx.body = { error: 'Failed to rename session' }
return
}
ctx.body = { ok: true }
} }
export async function setWorkspace(ctx: any) { export async function setWorkspace(ctx: any) {
@@ -393,20 +257,14 @@ export async function setWorkspace(ctx: any) {
ctx.body = { error: 'workspace must be a string or null' } ctx.body = { error: 'workspace must be a string or null' }
return return
} }
if (useLocalSessionStore()) {
const { updateSession, getSession, createSession } = await import('../../db/hermes/session-store') const { updateSession, getSession, createSession } = await import('../../db/hermes/session-store')
const { getActiveProfileName } = await import('../../services/hermes/hermes-profile') const { getActiveProfileName } = await import('../../services/hermes/hermes-profile')
const id = ctx.params.id const id = ctx.params.id
// Create session if it doesn't exist yet (user may set workspace before sending first message)
if (!getSession(id)) { if (!getSession(id)) {
createSession({ id, profile: getActiveProfileName(), title: '' }) createSession({ id, profile: getActiveProfileName(), title: '' })
} }
updateSession(id, { workspace: workspace || null } as any) updateSession(id, { workspace: workspace || null } as any)
ctx.body = { ok: true } ctx.body = { ok: true }
return
}
ctx.status = 501
ctx.body = { error: 'Workspace setting only supported in local session store mode' }
} }
export async function contextLength(ctx: any) { export async function contextLength(ctx: any) {
@@ -418,11 +276,6 @@ export async function usageStats(ctx: any) {
const rawDays = parseInt(String(ctx.query?.days ?? '30'), 10) const rawDays = parseInt(String(ctx.query?.days ?? '30'), 10)
const days = Number.isFinite(rawDays) && rawDays > 0 ? Math.min(rawDays, 365) : 30 const days = Number.isFinite(rawDays) && rawDays > 0 ? Math.min(rawDays, 365) : 30
// Local Web UI chat usage is kept in the dashboard DB and must be merged
// with Hermes' native state.db analytics for the same period.
const currentProfile = getActiveProfileName()
const local = getLocalUsageStats(currentProfile, days)
let hermes = { let hermes = {
input_tokens: 0, input_tokens: 0,
output_tokens: 0, output_tokens: 0,
@@ -442,29 +295,6 @@ export async function usageStats(ctx: any) {
logger.warn(err, 'usageStats: failed to load Hermes usage analytics from state.db') logger.warn(err, 'usageStats: failed to load Hermes usage analytics from state.db')
} }
const totalInput = local.input_tokens + hermes.input_tokens
const totalOutput = local.output_tokens + hermes.output_tokens
const totalCacheRead = local.cache_read_tokens + hermes.cache_read_tokens
const totalCacheWrite = local.cache_write_tokens + hermes.cache_write_tokens
const totalReasoning = local.reasoning_tokens + hermes.reasoning_tokens
const totalSessions = local.sessions + hermes.sessions
const modelMap = new Map<string, UsageStatsModelRow>()
for (const m of [...local.by_model, ...hermes.by_model]) {
const model = (m.model || '').trim() || 'unknown'
const existing = modelMap.get(model)
if (existing) {
existing.input_tokens += m.input_tokens
existing.output_tokens += m.output_tokens
existing.cache_read_tokens += m.cache_read_tokens
existing.cache_write_tokens += m.cache_write_tokens
existing.reasoning_tokens += m.reasoning_tokens
existing.sessions += m.sessions
} else {
modelMap.set(model, { ...m, model })
}
}
const dayMap = new Map<string, UsageStatsDailyRow>() const dayMap = new Map<string, UsageStatsDailyRow>()
const now = new Date() const now = new Date()
for (let i = days - 1; i >= 0; i--) { for (let i = days - 1; i >= 0; i--) {
@@ -473,7 +303,7 @@ export async function usageStats(ctx: any) {
const key = d.toISOString().slice(0, 10) const key = d.toISOString().slice(0, 10)
dayMap.set(key, { date: key, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, sessions: 0, errors: 0, cost: 0 }) dayMap.set(key, { date: key, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, sessions: 0, errors: 0, cost: 0 })
} }
for (const d of [...local.by_day, ...hermes.by_day]) { for (const d of hermes.by_day) {
const existing = dayMap.get(d.date) const existing = dayMap.get(d.date)
if (existing) { if (existing) {
existing.input_tokens += d.input_tokens; existing.output_tokens += d.output_tokens existing.input_tokens += d.input_tokens; existing.output_tokens += d.output_tokens
@@ -483,16 +313,16 @@ export async function usageStats(ctx: any) {
} }
ctx.body = { ctx.body = {
total_input_tokens: totalInput, total_input_tokens: hermes.input_tokens,
total_output_tokens: totalOutput, total_output_tokens: hermes.output_tokens,
total_cache_read_tokens: totalCacheRead, total_cache_read_tokens: hermes.cache_read_tokens,
total_cache_write_tokens: totalCacheWrite, total_cache_write_tokens: hermes.cache_write_tokens,
total_reasoning_tokens: totalReasoning, total_reasoning_tokens: hermes.reasoning_tokens,
total_sessions: totalSessions, total_sessions: hermes.sessions,
total_cost: hermes.cost, total_cost: hermes.cost,
total_api_calls: hermes.total_api_calls, total_api_calls: hermes.total_api_calls,
period_days: days, period_days: days,
model_usage: [...modelMap.values()].sort((a, b) => (b.input_tokens + b.output_tokens) - (a.input_tokens + a.output_tokens)), model_usage: hermes.by_model.sort((a, b) => (b.input_tokens + b.output_tokens) - (a.input_tokens + a.output_tokens)),
daily_usage: [...dayMap.values()], daily_usage: [...dayMap.values()],
} }
} }
@@ -545,20 +375,7 @@ export async function listWorkspaceFolders(ctx: any) {
const exportCompressor = new ExportCompressor() const exportCompressor = new ExportCompressor()
export async function exportSession(ctx: any) { export async function exportSession(ctx: any) {
let session: any = null const session = localGetSessionDetail(ctx.params.id)
if (useLocalSessionStore()) {
session = localGetSessionDetail(ctx.params.id)
} else {
try {
session = await getSessionDetailFromDb(ctx.params.id)
} catch (err) {
logger.warn(err, 'Hermes Session DB: export detail query failed, falling back to CLI')
}
if (!session) {
session = await hermesCli.getSession(ctx.params.id)
}
}
if (!session) { if (!session) {
ctx.status = 404 ctx.status = 404
@@ -630,7 +447,6 @@ export async function getConversationMessagesPaginated(ctx: any) {
const offset = ctx.query.offset ? parseInt(ctx.query.offset as string, 10) : 0 const offset = ctx.query.offset ? parseInt(ctx.query.offset as string, 10) : 0
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : 50 const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : 50
if (useLocalSessionStore()) {
const { getSessionDetailPaginated } = await import('../../db/hermes/session-store') const { getSessionDetailPaginated } = await import('../../db/hermes/session-store')
const result = getSessionDetailPaginated(ctx.params.id, offset, limit) const result = getSessionDetailPaginated(ctx.params.id, offset, limit)
@@ -659,9 +475,4 @@ export async function getConversationMessagesPaginated(ctx: any) {
limit: result.limit, limit: result.limit,
hasMore: result.hasMore, hasMore: result.hasMore,
} }
return
}
ctx.status = 404
ctx.body = { error: 'Conversation not found' }
} }
@@ -129,14 +129,16 @@ function mapMessageRow(row: Record<string, unknown>): HermesMessageRow {
export function createSession(data: { export function createSession(data: {
id: string id: string
profile?: string profile?: string
source?: string
model?: string model?: string
title?: string title?: string
workspace?: string workspace?: string
}): HermesSessionRow { }): HermesSessionRow {
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
const source = data.source || 'api_server'
if (!isSqliteAvailable()) { if (!isSqliteAvailable()) {
return { return {
id: data.id, profile: data.profile || 'default', source: 'api_server', id: data.id, profile: data.profile || 'default', source,
user_id: null, model: data.model || '', title: data.title || null, user_id: null, model: data.model || '', title: data.title || null,
started_at: now, ended_at: null, end_reason: null, started_at: now, ended_at: null, end_reason: null,
message_count: 0, tool_call_count: 0, message_count: 0, tool_call_count: 0,
@@ -148,8 +150,8 @@ export function createSession(data: {
const db = getDb()! const db = getDb()!
db.prepare( db.prepare(
`INSERT INTO ${SESSIONS_TABLE} (id, profile, source, model, title, started_at, last_active, workspace) `INSERT INTO ${SESSIONS_TABLE} (id, profile, source, model, title, started_at, last_active, workspace)
VALUES (?, ?, 'api_server', ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run(data.id, data.profile || 'default', data.model || '', data.title || null, now, now, data.workspace || null) ).run(data.id, data.profile || 'default', source, data.model || '', data.title || null, now, now, data.workspace || null)
return getSession(data.id)! return getSession(data.id)!
} }
+15 -13
View File
@@ -565,6 +565,10 @@ function aggregateSessionDetail(
} }
} }
function chainOrderSql(ids: string[]): string {
return ids.map((_, index) => `WHEN ? THEN ${index}`).join(' ')
}
async function openSessionDb() { async function openSessionDb() {
if (!SQLITE_AVAILABLE) { if (!SQLITE_AVAILABLE) {
throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`) throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`)
@@ -598,7 +602,7 @@ export async function getSessionMessagesFromDb(sessionId: string): Promise<{
const messageRows = db.prepare(` const messageRows = db.prepare(`
SELECT * FROM messages SELECT * FROM messages
WHERE session_id = ? WHERE session_id = ?
ORDER BY timestamp, id ORDER BY id
`).all(sessionId) as Record<string, unknown>[] `).all(sessionId) as Record<string, unknown>[]
return { return {
@@ -622,11 +626,12 @@ export async function getSessionDetailFromDb(sessionId: string): Promise<HermesS
const ids = chain.map(session => session.id) const ids = chain.map(session => session.id)
const placeholders = ids.map(() => '?').join(', ') const placeholders = ids.map(() => '?').join(', ')
const orderSql = chainOrderSql(ids)
const messageRows = db.prepare(` const messageRows = db.prepare(`
SELECT * FROM messages SELECT * FROM messages
WHERE session_id IN (${placeholders}) WHERE session_id IN (${placeholders})
ORDER BY timestamp, id ORDER BY CASE session_id ${orderSql} ELSE ${ids.length} END, id
`).all(...ids) as Record<string, unknown>[] `).all(...ids, ...ids) as Record<string, unknown>[]
const messages = messageRows.map(mapMessageRow) const messages = messageRows.map(mapMessageRow)
return aggregateSessionDetail(chain, messages, sessionId) return aggregateSessionDetail(chain, messages, sessionId)
} finally { } finally {
@@ -648,11 +653,12 @@ export async function getSessionDetailFromDbWithProfile(sessionId: string, profi
const ids = chain.map(session => session.id) const ids = chain.map(session => session.id)
const placeholders = ids.map(() => '?').join(', ') const placeholders = ids.map(() => '?').join(', ')
const orderSql = chainOrderSql(ids)
const messageRows = db.prepare(` const messageRows = db.prepare(`
SELECT * FROM messages SELECT * FROM messages
WHERE session_id IN (${placeholders}) WHERE session_id IN (${placeholders})
ORDER BY timestamp, id ORDER BY CASE session_id ${orderSql} ELSE ${ids.length} END, id
`).all(...ids) as Record<string, unknown>[] `).all(...ids, ...ids) as Record<string, unknown>[]
const messages = messageRows.map(mapMessageRow) const messages = messageRows.map(mapMessageRow)
return aggregateSessionDetail(chain, messages, sessionId) return aggregateSessionDetail(chain, messages, sessionId)
} finally { } finally {
@@ -672,7 +678,7 @@ export async function getExactSessionDetailFromDbWithProfile(sessionId: string,
const messageRows = db.prepare(` const messageRows = db.prepare(`
SELECT * FROM messages SELECT * FROM messages
WHERE session_id = ? WHERE session_id = ?
ORDER BY timestamp, id ORDER BY id
`).all(sessionId) as Record<string, unknown>[] `).all(sessionId) as Record<string, unknown>[]
const messages = messageRows.map(mapMessageRow) const messages = messageRows.map(mapMessageRow)
return aggregateSessionDetail([requested], messages, sessionId) return aggregateSessionDetail([requested], messages, sessionId)
@@ -818,10 +824,6 @@ export async function getUsageStatsFromDb(
const apiCallsExpr = tableHasColumn(db, 'sessions', 'api_call_count') const apiCallsExpr = tableHasColumn(db, 'sessions', 'api_call_count')
? 'COALESCE(SUM(api_call_count), 0)' ? 'COALESCE(SUM(api_call_count), 0)'
: '0' : '0'
const sourceFilter = tableHasColumn(db, 'sessions', 'source')
? " AND COALESCE(source, '') != 'api_server'"
: ''
const totals = db.prepare(` const totals = db.prepare(`
SELECT SELECT
COALESCE(SUM(input_tokens), 0) AS input_tokens, COALESCE(SUM(input_tokens), 0) AS input_tokens,
@@ -833,7 +835,7 @@ export async function getUsageStatsFromDb(
COUNT(*) AS sessions, COUNT(*) AS sessions,
${apiCallsExpr} AS total_api_calls ${apiCallsExpr} AS total_api_calls
FROM sessions FROM sessions
WHERE started_at > ?${sourceFilter} WHERE started_at > ?
`).get(since) as Record<string, unknown> | undefined `).get(since) as Record<string, unknown> | undefined
if (!totals) return empty if (!totals) return empty
@@ -848,7 +850,7 @@ export async function getUsageStatsFromDb(
COALESCE(SUM(reasoning_tokens), 0) AS reasoning_tokens, COALESCE(SUM(reasoning_tokens), 0) AS reasoning_tokens,
COUNT(*) AS sessions COUNT(*) AS sessions
FROM sessions FROM sessions
WHERE started_at > ?${sourceFilter} AND model IS NOT NULL WHERE started_at > ? AND model IS NOT NULL
GROUP BY model GROUP BY model
ORDER BY COALESCE(SUM(input_tokens), 0) + COALESCE(SUM(output_tokens), 0) DESC ORDER BY COALESCE(SUM(input_tokens), 0) + COALESCE(SUM(output_tokens), 0) DESC
`).all(since).map(row => ({ `).all(since).map(row => ({
@@ -871,7 +873,7 @@ export async function getUsageStatsFromDb(
COUNT(*) AS sessions, COUNT(*) AS sessions,
COALESCE(SUM(COALESCE(actual_cost_usd, estimated_cost_usd, 0)), 0) AS cost COALESCE(SUM(COALESCE(actual_cost_usd, estimated_cost_usd, 0)), 0) AS cost
FROM sessions FROM sessions
WHERE started_at > ?${sourceFilter} WHERE started_at > ?
GROUP BY date GROUP BY date
ORDER BY date ASC ORDER BY date ASC
`).all(since).map(row => ({ `).all(since).map(row => ({
+10 -6
View File
@@ -20,6 +20,7 @@ import { setGroupChatServer } from './routes/hermes/group-chat'
import { setChatRunServer } from './routes/hermes/chat-run' import { setChatRunServer } from './routes/hermes/chat-run'
import { GroupChatServer } from './services/hermes/group-chat' import { GroupChatServer } from './services/hermes/group-chat'
import { ChatRunSocket } from './services/hermes/chat-run-socket' import { ChatRunSocket } from './services/hermes/chat-run-socket'
import { startAgentBridgeManager } from './services/hermes/agent-bridge'
import { logger } from './services/logger' import { logger } from './services/logger'
// Injected by esbuild at build time; fallback to reading package.json in dev mode // Injected by esbuild at build time; fallback to reading package.json in dev mode
@@ -46,6 +47,7 @@ process.on('unhandledRejection', (reason) => {
let server: any = null let server: any = null
let servers: any[] = [] let servers: any[] = []
let chatRunServer: any = null let chatRunServer: any = null
let agentBridgeManager: any = null
interface ListenResult { interface ListenResult {
primary: any primary: any
@@ -94,6 +96,13 @@ export async function bootstrap() {
await initGatewayManager() await initGatewayManager()
console.log('[bootstrap] gateway manager initialized') console.log('[bootstrap] gateway manager initialized')
try {
agentBridgeManager = await startAgentBridgeManager()
console.log('[bootstrap] agent bridge started')
} catch (err) {
logger.warn(err, '[bootstrap] agent bridge failed to start')
console.warn('[bootstrap] agent bridge failed to start:', err instanceof Error ? err.message : err)
}
await new Promise(resolve => setTimeout(resolve, 1000)) await new Promise(resolve => setTimeout(resolve, 1000))
// Initialize all web-ui SQLite tables // Initialize all web-ui SQLite tables
const { initAllStores } = await import('./db/hermes/init') const { initAllStores } = await import('./db/hermes/init')
@@ -102,11 +111,6 @@ export async function bootstrap() {
await new Promise(resolve => setTimeout(resolve, 1000)) await new Promise(resolve => setTimeout(resolve, 1000))
console.log('[bootstrap] all stores initialized') console.log('[bootstrap] all stores initialized')
// Sync Hermes sessions from all profiles (only if local DB is empty)
const { syncAllHermesSessionsOnStartup } = await import('./services/hermes/session-sync')
await syncAllHermesSessionsOnStartup()
console.log('[bootstrap] Hermes session sync completed')
app.use(cors({ origin: config.corsOrigins })) app.use(cors({ origin: config.corsOrigins }))
app.use(bodyParser()) app.use(bodyParser())
console.log('[bootstrap] cors + bodyParser registered') console.log('[bootstrap] cors + bodyParser registered')
@@ -187,7 +191,7 @@ export async function bootstrap() {
}) })
}) })
bindShutdown(servers, groupChatServer, chatRunServer) bindShutdown(servers, groupChatServer, chatRunServer, agentBridgeManager)
startVersionCheck() startVersionCheck()
} }
@@ -0,0 +1,85 @@
# Agent Bridge
Optional backend-side bridge for talking to `~/.hermes/hermes-agent` by
instantiating `run_agent.AIAgent` directly in a Python process.
This is intentionally separate from the current Web UI chat path.
## Python Service
```bash
python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py
```
Default endpoint:
```text
ipc:///tmp/hermes-agent-bridge.sock
```
On Windows, the default endpoint is TCP because Python may not support Unix
domain sockets there:
```text
tcp://127.0.0.1:18765
```
Override with:
```bash
HERMES_AGENT_BRIDGE_ENDPOINT=tcp://127.0.0.1:8765 python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py
```
The service discovers Hermes Agent in this order:
1. `--agent-root`
2. `HERMES_AGENT_ROOT`
3. the installed `hermes` command path
4. current working directory and parent directories
5. common locations such as `~/.hermes/hermes-agent`, `~/hermes-agent`, and `/opt/hermes-agent`
Hermes home is resolved from `--hermes-home`, `HERMES_HOME`, then `~/.hermes`.
Default agent root:
```text
~/.hermes/hermes-agent
```
You can pass both paths explicitly:
```bash
python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py \
--agent-root ~/.hermes/hermes-agent \
--hermes-home ~/.hermes
```
The socket transport uses Python and Node standard libraries. No ZMQ dependency
is required.
## Backend Usage
```ts
import { AgentBridgeClient } from './services/hermes/agent-bridge'
const bridge = new AgentBridgeClient()
const run = await bridge.chat(sessionId, message)
for await (const chunk of bridge.streamOutput(run.run_id)) {
if (chunk.delta) {
// forward chunk.delta to Socket.IO/SSE/etc.
}
}
```
The external chat call only sends `session_id` and `message`. Provider, model,
keys, tools, reasoning, and session DB are resolved by hermes-agent from the
normal Hermes config and environment.
The bridge instantiates `AIAgent` with `platform="cli"` by default so behavior
matches CLI chat. Override it only if a caller intentionally needs a distinct
platform identity:
```bash
HERMES_AGENT_BRIDGE_PLATFORM=agent-bridge python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py
```
@@ -0,0 +1,330 @@
import { setTimeout as delay } from 'timers/promises'
import { createConnection, type Socket } from 'net'
import { URL } from 'url'
export const DEFAULT_AGENT_BRIDGE_ENDPOINT = process.platform === 'win32'
? 'tcp://127.0.0.1:18765'
: 'ipc:///tmp/hermes-agent-bridge.sock'
export const DEFAULT_AGENT_BRIDGE_TIMEOUT_MS = 120000
function envPositiveInt(name: string): number | undefined {
const raw = process.env[name]
if (!raw) return undefined
const value = Number(raw)
return Number.isFinite(value) && value > 0 ? value : undefined
}
export type AgentBridgeStatus = 'running' | 'complete' | 'interrupted' | 'error'
export interface AgentBridgeOptions {
endpoint?: string
timeoutMs?: number
}
export interface AgentBridgeRequestOptions {
timeoutMs?: number
}
export type AgentBridgeMessage =
| string
| Array<Record<string, unknown>>
export interface AgentBridgeResponse {
ok: true
[key: string]: unknown
}
export interface AgentBridgeChatStarted extends AgentBridgeResponse {
run_id: string
session_id: string
status: AgentBridgeStatus
}
export interface AgentBridgeOutput extends AgentBridgeResponse {
run_id: string
session_id: string
status: AgentBridgeStatus
delta: string
cursor: number
output: string
done: boolean
result?: unknown
error?: string | null
events: Array<Record<string, unknown>>
event_cursor: number
}
export interface AgentBridgeRunResult extends AgentBridgeResponse {
run_id: string
session_id: string
status: AgentBridgeStatus
output: string
deltas: string[]
events: unknown[]
result?: unknown
error?: string | null
}
export interface AgentBridgeCommandResult extends AgentBridgeResponse {
session_id: string
command: string
handled: boolean
message?: string
new_session_id?: string
history?: unknown[]
retry?: boolean
retry_input?: AgentBridgeMessage
title?: string
}
export class AgentBridgeError extends Error {
response?: unknown
}
export class AgentBridgeClient {
readonly endpoint: string
readonly timeoutMs: number
private lock: Promise<unknown> = Promise.resolve()
constructor(options: AgentBridgeOptions = {}) {
this.endpoint = options.endpoint || process.env.HERMES_AGENT_BRIDGE_ENDPOINT || DEFAULT_AGENT_BRIDGE_ENDPOINT
this.timeoutMs = options.timeoutMs ?? envPositiveInt('HERMES_AGENT_BRIDGE_TIMEOUT_MS') ?? DEFAULT_AGENT_BRIDGE_TIMEOUT_MS
}
async connect(): Promise<this> {
return this
}
async close(): Promise<void> {
return undefined
}
private connectSocket(): Promise<Socket> {
return new Promise((resolveConnect, rejectConnect) => {
const endpoint = this.endpoint
let socket: Socket
if (endpoint.startsWith('ipc://')) {
socket = createConnection(endpoint.slice('ipc://'.length))
} else if (endpoint.startsWith('tcp://')) {
const url = new URL(endpoint)
socket = createConnection({
host: url.hostname || '127.0.0.1',
port: Number(url.port),
})
} else {
rejectConnect(new Error(`unsupported agent bridge endpoint: ${endpoint}`))
return
}
const cleanup = () => {
socket.off('connect', onConnect)
socket.off('error', onError)
}
const onConnect = () => {
cleanup()
resolveConnect(socket)
}
const onError = (err: Error) => {
cleanup()
socket.destroy()
rejectConnect(err)
}
socket.once('connect', onConnect)
socket.once('error', onError)
})
}
private readResponse(socket: Socket, timeoutMs: number): Promise<string> {
return new Promise((resolveRead, rejectRead) => {
let buffer = ''
const timeout = timeoutMs > 0
? setTimeout(() => {
cleanup()
socket.destroy()
rejectRead(new Error(`Agent bridge request timed out after ${timeoutMs}ms`))
}, timeoutMs)
: null
const cleanup = () => {
if (timeout) clearTimeout(timeout)
socket.off('data', onData)
socket.off('error', onError)
socket.off('end', onEnd)
socket.off('close', onClose)
}
const finish = (line: string) => {
cleanup()
socket.end()
resolveRead(line)
}
const onData = (chunk: Buffer) => {
buffer += chunk.toString('utf8')
const idx = buffer.indexOf('\n')
if (idx >= 0) finish(buffer.slice(0, idx))
}
const onError = (err: Error) => {
cleanup()
socket.destroy()
rejectRead(err)
}
const onEnd = () => {
const line = buffer.trim()
if (line) finish(line)
}
const onClose = () => {
if (!buffer.trim()) {
cleanup()
rejectRead(new Error('Agent bridge socket closed without a response'))
}
}
socket.on('data', onData)
socket.once('error', onError)
socket.once('end', onEnd)
socket.once('close', onClose)
})
}
async request<T extends AgentBridgeResponse = AgentBridgeResponse>(
payload: Record<string, unknown>,
options: AgentBridgeRequestOptions = {},
): Promise<T> {
const run = async (): Promise<T> => {
const timeoutMs = options.timeoutMs || this.timeoutMs
const socket = await this.connectSocket()
socket.write(`${JSON.stringify(payload)}\n`)
const raw = await this.readResponse(socket, timeoutMs)
const response = JSON.parse(raw) as { ok?: boolean; error?: string }
if (!response.ok) {
const error = new AgentBridgeError(response.error || 'Agent bridge request failed')
error.response = response
throw error
}
return response as T
}
const next = this.lock.then(run, run)
this.lock = next.catch(() => undefined)
return next
}
ping(): Promise<AgentBridgeResponse> {
return this.request({ action: 'ping' })
}
chat(
sessionId: string,
message: AgentBridgeMessage,
conversationHistory?: unknown[],
instructions?: string,
profile?: string,
): Promise<AgentBridgeChatStarted> {
return this.request<AgentBridgeChatStarted>({
action: 'chat',
session_id: sessionId,
message,
...(conversationHistory ? { conversation_history: conversationHistory } : {}),
...(instructions ? { instructions } : {}),
...(profile ? { profile } : {}),
})
}
command(sessionId: string, command: string): Promise<AgentBridgeCommandResult> {
return this.request<AgentBridgeCommandResult>({
action: 'command',
session_id: sessionId,
command,
})
}
getOutput(runId: string, cursor = 0, eventCursor = 0, options: AgentBridgeRequestOptions = {}): Promise<AgentBridgeOutput> {
return this.request<AgentBridgeOutput>({
action: 'get_output',
run_id: runId,
cursor,
event_cursor: eventCursor,
}, options)
}
async *streamOutput(
runId: string,
options: AgentBridgeRequestOptions & { intervalMs?: number } = {},
): AsyncGenerator<AgentBridgeOutput> {
const intervalMs = options.intervalMs || 100
let cursor = 0
let eventCursor = 0
for (;;) {
const chunk = await this.getOutput(runId, cursor, eventCursor, options)
cursor = chunk.cursor
eventCursor = chunk.event_cursor
if (chunk.delta || chunk.done || (chunk.events && chunk.events.length > 0)) yield chunk
if (chunk.done) return
await delay(intervalMs)
}
}
async chatStream(
sessionId: string,
message: AgentBridgeMessage,
onDelta: (delta: string, chunk: AgentBridgeOutput) => void | Promise<void>,
options: AgentBridgeRequestOptions & { intervalMs?: number } = {},
): Promise<AgentBridgeOutput> {
const started = await this.chat(sessionId, message)
let last: AgentBridgeOutput | null = null
for await (const chunk of this.streamOutput(started.run_id, options)) {
last = chunk
if (chunk.delta) await onDelta(chunk.delta, chunk)
}
if (!last) throw new Error(`Agent bridge run ${started.run_id} produced no output state`)
return last
}
getResult(runId: string, options: AgentBridgeRequestOptions = {}): Promise<AgentBridgeRunResult> {
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 })
}
steer(sessionId: string, text: string): Promise<AgentBridgeResponse> {
return this.request({ action: 'steer', session_id: sessionId, text })
}
approvalRespond(approvalId: string, choice: string): Promise<AgentBridgeResponse> {
return this.request({ action: 'approval_respond', approval_id: approvalId, choice })
}
compressionRespond(
requestId: string,
payload: { messages?: unknown[]; system_message?: string; error?: string },
): Promise<AgentBridgeResponse> {
return this.request({
action: 'compression_respond',
request_id: requestId,
...payload,
}, { timeoutMs: this.timeoutMs })
}
destroyAll(): Promise<AgentBridgeResponse> {
return this.request({ action: 'destroy_all' })
}
getHistory(sessionId: string): Promise<AgentBridgeResponse> {
return this.request({ action: 'get_history', session_id: sessionId })
}
destroy(sessionId: string): Promise<AgentBridgeResponse> {
return this.request({ action: 'destroy', session_id: sessionId })
}
list(): Promise<AgentBridgeResponse> {
return this.request({ action: 'list' })
}
shutdown(): Promise<AgentBridgeResponse> {
return this.request({ action: 'shutdown' })
}
}
export default AgentBridgeClient
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
export * from './client'
export * from './manager'
@@ -0,0 +1,360 @@
import { execFileSync, spawn, type ChildProcess } from 'child_process'
import { existsSync, readFileSync } from 'fs'
import { dirname, isAbsolute, join, resolve } from 'path'
import { logger } from '../../logger'
import { detectHermesHome, getHermesBin } from '../hermes-path'
import { DEFAULT_AGENT_BRIDGE_ENDPOINT } from './client'
const DEFAULT_AGENT_BRIDGE_STARTUP_TIMEOUT_MS = 120000
export interface AgentBridgeManagerOptions {
endpoint?: string
python?: string
agentRoot?: string
hermesHome?: string
startupTimeoutMs?: number
}
interface BridgeCommand {
command: string
argsPrefix: string[]
agentRoot?: string
hermesHome: string
}
function envPositiveInt(name: string): number | undefined {
const raw = process.env[name]
if (!raw) return undefined
const value = Number(raw)
return Number.isFinite(value) && value > 0 ? value : undefined
}
function pathCandidates(agentRoot?: string): string[] {
if (!agentRoot) return []
return process.platform === 'win32'
? [
join(agentRoot, 'venv', 'Scripts', 'python.exe'),
join(agentRoot, 'venv', 'Scripts', 'python3.exe'),
join(agentRoot, '.venv', 'Scripts', 'python.exe'),
join(agentRoot, '.venv', 'Scripts', 'python3.exe'),
]
: [
join(agentRoot, 'venv', 'bin', 'python3'),
join(agentRoot, 'venv', 'bin', 'python'),
join(agentRoot, '.venv', 'bin', 'python3'),
join(agentRoot, '.venv', 'bin', 'python'),
]
}
function uvCandidates(agentRoot?: string): string[] {
return [
process.env.HERMES_AGENT_BRIDGE_UV,
process.env.UV,
...(process.platform === 'win32'
? [
agentRoot ? join(agentRoot, 'venv', 'Scripts', 'uv.exe') : '',
agentRoot ? join(agentRoot, 'venv', 'Scripts', 'uv.cmd') : '',
agentRoot ? join(agentRoot, '.venv', 'Scripts', 'uv.exe') : '',
agentRoot ? join(agentRoot, '.venv', 'Scripts', 'uv.cmd') : '',
]
: [
agentRoot ? join(agentRoot, 'venv', 'bin', 'uv') : '',
agentRoot ? join(agentRoot, '.venv', 'bin', 'uv') : '',
]),
'uv',
].filter((value): value is string => !!value && value.trim().length > 0)
}
function resolveExecutable(command: string): string | undefined {
const trimmed = command.trim()
if (!trimmed) return undefined
if (isAbsolute(trimmed) || trimmed.includes('/') || trimmed.includes('\\')) {
return existsSync(trimmed) ? resolve(trimmed) : undefined
}
try {
const lookup = process.platform === 'win32'
? execFileSync('where.exe', [trimmed], { encoding: 'utf-8', windowsHide: true })
: execFileSync('which', [trimmed], { encoding: 'utf-8' })
return lookup.split(/\r?\n/).map(line => line.trim()).find(Boolean)
} catch {
return undefined
}
}
function agentRootFromHermesBin(): string | undefined {
const hermesBin = resolveExecutable(getHermesBin())
if (!hermesBin) return undefined
const binDir = dirname(hermesBin)
const rootCandidates = [
resolve(binDir, '..'),
resolve(binDir, '..', '..'),
resolve(binDir, '..', 'hermes-agent'),
resolve(binDir, '..', '..', 'hermes-agent'),
]
const root = rootCandidates.find(candidate => existsSync(join(candidate, 'run_agent.py')))
if (root) return root
try {
const first = readFileSync(hermesBin, 'utf-8').split(/\r?\n/, 1)[0]
const match = first.match(/^#!\s*(.+)$/)
const python = match?.[1]?.trim().split(/\s+/)[0]
if (python) {
const pyDir = dirname(python)
const shebangRootCandidates = [
resolve(pyDir, '..', '..'),
resolve(pyDir, '..', '..', 'hermes-agent'),
]
return shebangRootCandidates.find(candidate => existsSync(join(candidate, 'run_agent.py')))
}
} catch {}
return undefined
}
function hermesBinPython(): string | undefined {
const hermesBin = resolveExecutable(getHermesBin())
if (!hermesBin) return undefined
try {
const first = readFileSync(hermesBin, 'utf-8').split(/\r?\n/, 1)[0]
const match = first.match(/^#!\s*(.+)$/)
const python = match?.[1]?.trim().split(/\s+/)[0]
return python && existsSync(python) ? python : undefined
} catch {
return undefined
}
}
function firstExistingExecutable(candidates: string[]): string | undefined {
for (const candidate of candidates) {
if (!isAbsolute(candidate) && !candidate.includes('/') && !candidate.includes('\\')) {
const resolved = resolveExecutable(candidate)
if (resolved) return resolved
continue
}
try {
if (existsSync(candidate)) return candidate
} catch {}
}
return undefined
}
function resolveAgentRoot(explicit?: string, hermesHome = detectHermesHome()): string | undefined {
const candidates = [
explicit,
process.env.HERMES_AGENT_ROOT,
join(hermesHome, 'hermes-agent'),
agentRootFromHermesBin(),
process.cwd(),
join(process.cwd(), 'hermes-agent'),
].filter((value): value is string => !!value && value.trim().length > 0)
return candidates.find(candidate => existsSync(join(candidate, 'run_agent.py')))
}
function bridgeCommand(options: AgentBridgeManagerOptions): BridgeCommand {
const hermesHome = options.hermesHome || detectHermesHome()
const agentRoot = resolveAgentRoot(options.agentRoot, hermesHome)
const explicitPython = options.python || process.env.HERMES_AGENT_BRIDGE_PYTHON
if (explicitPython) {
return { command: explicitPython, argsPrefix: [], agentRoot, hermesHome }
}
const venvPython = firstExistingExecutable(pathCandidates(agentRoot))
if (venvPython) {
return { command: venvPython, argsPrefix: [], agentRoot, hermesHome }
}
const shebangPython = hermesBinPython()
if (shebangPython && existsSync(shebangPython)) {
return { command: shebangPython, argsPrefix: [], agentRoot, hermesHome }
}
const uv = firstExistingExecutable(uvCandidates(agentRoot))
if (uv) {
const prefix = ['run']
if (agentRoot) prefix.push('--project', agentRoot)
prefix.push('python')
return { command: uv, argsPrefix: prefix, agentRoot, hermesHome }
}
const fallback = firstExistingExecutable([
process.env.PYTHON || '',
...(process.platform === 'win32' ? ['py', 'python', 'python3'] : ['python3', 'python']),
]) || (process.platform === 'win32' ? 'python' : 'python3')
return { command: fallback, argsPrefix: [], agentRoot, hermesHome }
}
function bridgeScriptPath(): string {
const candidates = [
// Built server: dist/server/index.js -> dist/server/agent-bridge/hermes_bridge.py
resolve(__dirname, 'agent-bridge', 'hermes_bridge.py'),
// ts-node/dev source tree.
resolve(__dirname, 'services/hermes/agent-bridge/hermes_bridge.py'),
resolve(process.cwd(), 'packages/server/src/services/hermes/agent-bridge/hermes_bridge.py'),
]
const found = candidates.find(candidate => existsSync(candidate))
if (!found) {
throw new Error(`agent bridge Python script not found. Tried: ${candidates.join(', ')}`)
}
return found
}
export class AgentBridgeManager {
readonly endpoint: string
private readonly options: AgentBridgeManagerOptions
private child: ChildProcess | null = null
private starting: Promise<void> | null = null
private ready = false
constructor(options: AgentBridgeManagerOptions = {}) {
this.options = options
this.endpoint = options.endpoint || process.env.HERMES_AGENT_BRIDGE_ENDPOINT || DEFAULT_AGENT_BRIDGE_ENDPOINT
}
get running(): boolean {
return !!this.child && !this.child.killed && this.ready
}
async start(): Promise<void> {
if (this.running) return
if (this.starting) return this.starting
this.starting = this.startProcess()
try {
await this.starting
} finally {
this.starting = null
}
}
private async startProcess(): Promise<void> {
const script = bridgeScriptPath()
const command = bridgeCommand(this.options)
const args = [...command.argsPrefix, script, '--endpoint', this.endpoint]
const agentRoot = command.agentRoot
const hermesHome = command.hermesHome
if (agentRoot) args.push('--agent-root', agentRoot)
if (hermesHome) args.push('--hermes-home', hermesHome)
const env = {
...process.env,
HERMES_AGENT_BRIDGE_ENDPOINT: this.endpoint,
HERMES_HOME: hermesHome,
...(agentRoot ? { HERMES_AGENT_ROOT: agentRoot } : {}),
}
logger.info('[agent-bridge] starting: %s %s', command.command, args.join(' '))
const child = spawn(command.command, args, {
env,
cwd: process.cwd(),
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
})
this.child = child
this.ready = false
child.once('exit', (code, signal) => {
logger.warn('[agent-bridge] exited code=%s signal=%s', code, signal)
this.ready = false
if (this.child === child) this.child = null
})
child.stderr?.on('data', chunk => {
const text = String(chunk).trim()
if (text) logger.warn('[agent-bridge] %s', text)
})
await new Promise<void>((resolveReady, rejectReady) => {
let buffered = ''
const startupTimeoutMs = this.options.startupTimeoutMs
?? envPositiveInt('HERMES_AGENT_BRIDGE_STARTUP_TIMEOUT_MS')
?? DEFAULT_AGENT_BRIDGE_STARTUP_TIMEOUT_MS
const timeout = setTimeout(() => {
cleanup()
rejectReady(new Error(`agent bridge did not become ready within ${startupTimeoutMs}ms`))
}, startupTimeoutMs)
const cleanup = () => {
clearTimeout(timeout)
child.off('exit', onExitBeforeReady)
child.off('error', onError)
}
const onError = (err: Error) => {
cleanup()
child.stdout?.off('data', onStdout)
rejectReady(err)
}
const onExitBeforeReady = (code: number | null, signal: NodeJS.Signals | null) => {
cleanup()
child.stdout?.off('data', onStdout)
rejectReady(new Error(`agent bridge exited before ready code=${code} signal=${signal}`))
}
let readyResolved = false
const onStdout = (chunk: Buffer) => {
const text = chunk.toString('utf8')
buffered += text
for (;;) {
const newline = buffered.indexOf('\n')
if (newline < 0) break
const line = buffered.slice(0, newline).trim()
buffered = buffered.slice(newline + 1)
if (!line) continue
logger.info('[agent-bridge] %s', line)
if (!readyResolved) {
try {
const parsed = JSON.parse(line)
if (parsed?.event === 'ready') {
this.ready = true
readyResolved = true
cleanup()
resolveReady()
return
}
} catch {}
}
}
}
child.once('error', onError)
child.once('exit', onExitBeforeReady)
child.stdout?.on('data', onStdout)
})
logger.info('[agent-bridge] ready at %s', this.endpoint)
}
async stop(): Promise<void> {
const child = this.child
if (!child) return
this.ready = false
this.child = null
await new Promise<void>((resolveStop) => {
const timeout = setTimeout(() => {
if (!child.killed) child.kill('SIGKILL')
resolveStop()
}, 1500)
child.once('exit', () => {
clearTimeout(timeout)
resolveStop()
})
if (!child.killed) {
child.kill('SIGTERM')
}
})
}
}
let singleton: AgentBridgeManager | null = null
export function getAgentBridgeManager(): AgentBridgeManager {
if (!singleton) singleton = new AgentBridgeManager()
return singleton
}
export async function startAgentBridgeManager(): Promise<AgentBridgeManager> {
const manager = getAgentBridgeManager()
await manager.start()
return manager
}
File diff suppressed because it is too large Load Diff
@@ -101,6 +101,8 @@ class AgentClient {
reconnectionAttempts: Infinity, reconnectionAttempts: Infinity,
reconnectionDelay: 1000, reconnectionDelay: 1000,
reconnectionDelayMax: 30000, reconnectionDelayMax: 30000,
randomizationFactor: 0.5,
timeout: 30000,
}) })
this.bindEvents() this.bindEvents()
@@ -424,7 +424,13 @@ export class GroupChatServer {
const servers = Array.isArray(httpServers) ? httpServers : [httpServers] const servers = Array.isArray(httpServers) ? httpServers : [httpServers]
this.io = new Server(servers[0], { this.io = new Server(servers[0], {
cors: { origin: '*' } cors: { origin: '*' },
pingInterval: 25_000,
pingTimeout: 90_000,
connectionStateRecovery: {
maxDisconnectionDuration: 2 * 60_000,
skipMiddlewares: true,
},
}) })
servers.slice(1).forEach((httpServer) => this.io.attach(httpServer)) servers.slice(1).forEach((httpServer) => this.io.attach(httpServer))
this.nsp = this.io.of('/group-chat') this.nsp = this.io.of('/group-chat')
+10 -1
View File
@@ -27,7 +27,7 @@ function shouldStopGatewaysOnShutdown(signal: string): boolean {
return shouldStop return shouldStop
} }
export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?: any): void { export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?: any, agentBridgeManager?: any): void {
let isShuttingDown = false let isShuttingDown = false
const shutdown = async (signal: string) => { const shutdown = async (signal: string) => {
@@ -58,6 +58,15 @@ export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?:
logger.info('Skipping gateway shutdown for %s', signal) logger.info('Skipping gateway shutdown for %s', signal)
} }
if (agentBridgeManager) {
try {
await agentBridgeManager.stop()
logger.info('Agent bridge stopped')
} catch (err) {
logger.warn(err, 'Failed to stop agent bridge (non-fatal)')
}
}
// Close ChatRunSocket first to abort all active runs and close EventSource connections // Close ChatRunSocket first to abort all active runs and close EventSource connections
if (chatRunServer) { if (chatRunServer) {
chatRunServer.close() chatRunServer.close()
+56 -34
View File
@@ -62,13 +62,9 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
base_url: 'https://generativelanguage.googleapis.com/v1beta/openai', base_url: 'https://generativelanguage.googleapis.com/v1beta/openai',
models: [ models: [
'gemini-3.1-pro-preview', 'gemini-3.1-pro-preview',
'gemini-3-pro-preview',
'gemini-3-flash-preview', 'gemini-3-flash-preview',
'gemini-3.1-flash-lite-preview', 'gemini-3.1-flash-lite-preview',
'gemini-2.5-pro',
'gemini-2.5-flash',
'gemini-2.5-flash-lite',
'gemma-4-31b-it',
'gemma-4-26b-it',
], ],
}, },
{ {
@@ -76,7 +72,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
value: 'deepseek', value: 'deepseek',
builtin: true, builtin: true,
base_url: 'https://api.deepseek.com', base_url: 'https://api.deepseek.com',
models: ['deepseek-v4-flash', 'deepseek-v4-pro'], models: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-chat', 'deepseek-reasoner'],
}, },
{ {
label: 'Z.AI / GLM', label: 'Z.AI / GLM',
@@ -98,7 +94,6 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
builtin: true, builtin: true,
base_url: 'https://api.kimi.com/coding/v1', base_url: 'https://api.kimi.com/coding/v1',
models: [ models: [
'kimi-for-coding',
'kimi-k2.6', 'kimi-k2.6',
'kimi-k2.5', 'kimi-k2.5',
'kimi-k2-thinking', 'kimi-k2-thinking',
@@ -124,7 +119,17 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
value: 'xai', value: 'xai',
builtin: true, builtin: true,
base_url: 'https://api.x.ai/v1', base_url: 'https://api.x.ai/v1',
models: ['grok-4.20-reasoning', 'grok-4-1-fast-reasoning'], models: [
'grok-4.20-0309-reasoning',
'grok-4.20-0309-non-reasoning',
'grok-4.20-multi-agent-0309',
'grok-4-1-fast',
'grok-4-1-fast-non-reasoning',
'grok-4-fast',
'grok-4-fast-non-reasoning',
'grok-4',
'grok-code-fast-1',
],
}, },
{ {
label: 'MiniMax', label: 'MiniMax',
@@ -146,12 +151,13 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
builtin: true, builtin: true,
base_url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', base_url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
models: [ models: [
'qwen3.6-plus',
'kimi-k2.5',
'qwen3.5-plus', 'qwen3.5-plus',
'qwen3-coder-plus', 'qwen3-coder-plus',
'qwen3-coder-next', 'qwen3-coder-next',
'glm-5', 'glm-5',
'glm-4.7', 'glm-4.7',
'kimi-k2.5',
'MiniMax-M2.5', 'MiniMax-M2.5',
], ],
}, },
@@ -166,13 +172,13 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
// returns HTTP 401 for those keys. // returns HTTP 401 for those keys.
base_url: 'https://coding-intl.dashscope.aliyuncs.com/v1', base_url: 'https://coding-intl.dashscope.aliyuncs.com/v1',
models: [ models: [
'qwen3.6-plus',
'qwen3.5-plus', 'qwen3.5-plus',
'qwen3-max-2026-01-23',
'qwen3-coder-next',
'qwen3-coder-plus', 'qwen3-coder-plus',
'qwen3-coder-next',
'kimi-k2.5',
'glm-5', 'glm-5',
'glm-4.7', 'glm-4.7',
'kimi-k2.5',
'MiniMax-M2.5', 'MiniMax-M2.5',
], ],
}, },
@@ -182,14 +188,15 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
builtin: true, builtin: true,
base_url: 'https://router.huggingface.co/v1', base_url: 'https://router.huggingface.co/v1',
models: [ models: [
'moonshotai/Kimi-K2.5',
'Qwen/Qwen3.5-397B-A17B', 'Qwen/Qwen3.5-397B-A17B',
'Qwen/Qwen3.5-35B-A3B', 'Qwen/Qwen3.5-35B-A3B',
'deepseek-ai/DeepSeek-V3.2', 'deepseek-ai/DeepSeek-V3.2',
'moonshotai/Kimi-K2.5',
'MiniMaxAI/MiniMax-M2.5', 'MiniMaxAI/MiniMax-M2.5',
'zai-org/GLM-5', 'zai-org/GLM-5',
'XiaomiMiMo/MiMo-V2-Flash', 'XiaomiMiMo/MiMo-V2-Flash',
'moonshotai/Kimi-K2-Thinking', 'moonshotai/Kimi-K2-Thinking',
'moonshotai/Kimi-K2.6',
], ],
}, },
{ {
@@ -214,14 +221,11 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
builtin: true, builtin: true,
base_url: 'https://api.xiaomimimo.com/v1', base_url: 'https://api.xiaomimimo.com/v1',
models: [ models: [
'mimo-v2-omni',
'mimo-v2-pro',
'mimo-v2-tts',
'mimo-v2.5',
'mimo-v2.5-pro', 'mimo-v2.5-pro',
'mimo-v2.5-tts', 'mimo-v2.5',
'mimo-v2.5-tts-voiceclone', 'mimo-v2-pro',
'mimo-v2.5-tts-voicedesign', 'mimo-v2-omni',
'mimo-v2-flash',
], ],
}, },
{ {
@@ -243,18 +247,21 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
builtin: true, builtin: true,
base_url: 'https://ai-gateway.vercel.sh/v1', base_url: 'https://ai-gateway.vercel.sh/v1',
models: [ models: [
'anthropic/claude-opus-4.6', 'moonshotai/kimi-k2.6',
'alibaba/qwen3.6-plus',
'zai/glm-5.1',
'minimax/minimax-m2.7',
'anthropic/claude-sonnet-4.6', 'anthropic/claude-sonnet-4.6',
'anthropic/claude-sonnet-4.5', 'anthropic/claude-opus-4.7',
'anthropic/claude-opus-4.6',
'anthropic/claude-haiku-4.5', 'anthropic/claude-haiku-4.5',
'openai/gpt-5', 'openai/gpt-5.4',
'openai/gpt-4.1', 'openai/gpt-5.4-mini',
'openai/gpt-4.1-mini', 'openai/gpt-5.3-codex',
'google/gemini-3-pro-preview', 'google/gemini-3.1-pro-preview',
'google/gemini-3-flash', 'google/gemini-3-flash',
'google/gemini-2.5-pro', 'google/gemini-3.1-flash-lite-preview',
'google/gemini-2.5-flash', 'xai/grok-4.20-reasoning',
'deepseek/deepseek-v3.2',
], ],
}, },
{ {
@@ -277,10 +284,10 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
builtin: true, builtin: true,
base_url: 'https://opencode.ai/zen/v1', base_url: 'https://opencode.ai/zen/v1',
models: [ models: [
'kimi-k2.5',
'gpt-5.4-pro', 'gpt-5.4-pro',
'gpt-5.4', 'gpt-5.4',
'gpt-5.3-codex', 'gpt-5.3-codex',
'gpt-5.3-codex-spark',
'gpt-5.2', 'gpt-5.2',
'gpt-5.2-codex', 'gpt-5.2-codex',
'gpt-5.1', 'gpt-5.1',
@@ -308,7 +315,6 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
'glm-5', 'glm-5',
'glm-4.7', 'glm-4.7',
'glm-4.6', 'glm-4.6',
'kimi-k2.5',
'kimi-k2-thinking', 'kimi-k2-thinking',
'kimi-k2', 'kimi-k2',
'qwen3-coder', 'qwen3-coder',
@@ -320,7 +326,20 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
value: 'opencode-go', value: 'opencode-go',
builtin: true, builtin: true,
base_url: 'https://opencode.ai/zen/go/v1', base_url: 'https://opencode.ai/zen/go/v1',
models: ['glm-5.1', 'glm-5', 'kimi-k2.5', 'mimo-v2-pro', 'mimo-v2-omni', 'minimax-m2.7', 'minimax-m2.5'], models: [
'kimi-k2.6',
'kimi-k2.5',
'glm-5.1',
'glm-5',
'mimo-v2.5-pro',
'mimo-v2.5',
'mimo-v2-pro',
'mimo-v2-omni',
'minimax-m2.7',
'minimax-m2.5',
'qwen3.6-plus',
'qwen3.5-plus',
],
}, },
{ {
label: 'LongCat', label: 'LongCat',
@@ -352,12 +371,13 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
'moonshotai/kimi-k2.6', 'moonshotai/kimi-k2.6',
'xiaomi/mimo-v2.5-pro', 'xiaomi/mimo-v2.5-pro',
'xiaomi/mimo-v2.5', 'xiaomi/mimo-v2.5',
'tencent/hy3-preview',
'anthropic/claude-opus-4.7', 'anthropic/claude-opus-4.7',
'anthropic/claude-opus-4.6', 'anthropic/claude-opus-4.6',
'anthropic/claude-sonnet-4.6', 'anthropic/claude-sonnet-4.6',
'anthropic/claude-sonnet-4.5', 'anthropic/claude-sonnet-4.5',
'anthropic/claude-haiku-4.5', 'anthropic/claude-haiku-4.5',
'openai/gpt-5.4', 'openai/gpt-5.5',
'openai/gpt-5.4-mini', 'openai/gpt-5.4-mini',
'openai/gpt-5.3-codex', 'openai/gpt-5.3-codex',
'google/gemini-3-pro-preview', 'google/gemini-3-pro-preview',
@@ -374,10 +394,12 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
'z-ai/glm-5v-turbo', 'z-ai/glm-5v-turbo',
'z-ai/glm-5-turbo', 'z-ai/glm-5-turbo',
'x-ai/grok-4.20-beta', 'x-ai/grok-4.20-beta',
'x-ai/grok-4.3',
'nvidia/nemotron-3-super-120b-a12b', 'nvidia/nemotron-3-super-120b-a12b',
'arcee-ai/trinity-large-thinking', 'arcee-ai/trinity-large-thinking',
'openai/gpt-5.4-pro', 'openai/gpt-5.5-pro',
'openai/gpt-5.4-nano', 'openai/gpt-5.4-nano',
'deepseek/deepseek-v4-pro',
], ],
}, },
{ {
+8 -1
View File
@@ -1,7 +1,7 @@
import * as esbuild from 'esbuild' import * as esbuild from 'esbuild'
import { resolve, dirname } from 'path' import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import { readFileSync } from 'fs' import { cpSync, mkdirSync, readFileSync } from 'fs'
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const pkg = JSON.parse(readFileSync(resolve(rootDir, 'package.json'), 'utf-8')) const pkg = JSON.parse(readFileSync(resolve(rootDir, 'package.json'), 'utf-8'))
@@ -23,3 +23,10 @@ await esbuild.build({
treeShaking: true, treeShaking: true,
logLevel: 'info', logLevel: 'info',
}) })
const bridgeOutDir = resolve(rootDir, 'dist/server/agent-bridge')
mkdirSync(bridgeOutDir, { recursive: true })
cpSync(
resolve(rootDir, 'packages/server/src/services/hermes/agent-bridge/hermes_bridge.py'),
resolve(bridgeOutDir, 'hermes_bridge.py'),
)
+1 -7
View File
@@ -4,10 +4,6 @@ import {
PROVIDER_PRESETS as SERVER_PROVIDER_PRESETS, PROVIDER_PRESETS as SERVER_PROVIDER_PRESETS,
buildProviderModelMap as buildServerProviderModelMap, buildProviderModelMap as buildServerProviderModelMap,
} from '../../packages/server/src/shared/providers' } from '../../packages/server/src/shared/providers'
import {
PROVIDER_PRESETS as CLIENT_PROVIDER_PRESETS,
buildProviderModelMap as buildClientProviderModelMap,
} from '../../packages/client/src/shared/providers'
const OPENAI_CODEX_PROVIDER = 'openai-codex' const OPENAI_CODEX_PROVIDER = 'openai-codex'
const GPT_5_5_MODEL = 'gpt-5.5' const GPT_5_5_MODEL = 'gpt-5.5'
@@ -19,13 +15,11 @@ function modelsForProvider(providerPresets: Array<{ value: string; models: strin
} }
describe('provider presets', () => { describe('provider presets', () => {
it('lists GPT-5.5 for OpenAI Codex on both client and server', () => { it('lists GPT-5.5 for OpenAI Codex', () => {
expect(modelsForProvider(CLIENT_PROVIDER_PRESETS, OPENAI_CODEX_PROVIDER)).toContain(GPT_5_5_MODEL)
expect(modelsForProvider(SERVER_PROVIDER_PRESETS, OPENAI_CODEX_PROVIDER)).toContain(GPT_5_5_MODEL) expect(modelsForProvider(SERVER_PROVIDER_PRESETS, OPENAI_CODEX_PROVIDER)).toContain(GPT_5_5_MODEL)
}) })
it('exposes GPT-5.5 through provider model maps', () => { it('exposes GPT-5.5 through provider model maps', () => {
expect(buildClientProviderModelMap()[OPENAI_CODEX_PROVIDER]).toContain(GPT_5_5_MODEL)
expect(buildServerProviderModelMap()[OPENAI_CODEX_PROVIDER]).toContain(GPT_5_5_MODEL) expect(buildServerProviderModelMap()[OPENAI_CODEX_PROVIDER]).toContain(GPT_5_5_MODEL)
}) })
}) })