Skill: wufan2026/openclaw-admin-main (version 0.2.8)

# SKILL.md This skill provides a comprehensive administrative web interface for managing your OpenClaw deployment. It allows you to monitor nodes, manage agent configurations, and oversee the entire ecosystem through a centralized dashboard. ## Key Features ### Node Management - Monitor the health and connectivity status of all registered OpenClaw nodes (Android, iOS, macOS, and Linux). - Check real-time logs and performance metrics for your Gateway and companion apps. - Manage pairing tokens and troubleshoot connection issues. ### Agent Configuration - Dynamically adjust agent settings and permissions without restarting services. - View active automation tasks and scheduled cron jobs. - Audit agent actions and system-wide changes. ### System Oversight - Visualize system-wide resource usage. - Manage security policies and access control lists (ACLs). - Review historical activity and event logs. ## Installation and Setup ### Prerequisites - A running OpenClaw Gateway. - Node.js environment (for backend components). ### Deployment You can deploy this admin interface using the `clawhub` CLI: ```bash clawhub publish D:\skill\OpenClaw-Admin-main\OpenClaw-Admin-main ``` After publishing, ensure your gateway is configured to allow traffic from the admin dashboard's network location. ## Troubleshooting If you encounter connection issues between the dashboard and the gateway, check your `gateway.remote.url` configuration and ensure all necessary ports are open in your firewall.

path: AGENTS.md
content: # 仓库协作指南

## 项目结构与模块组织
- `src/main.ts`：应用入口，注册 Pinia 与 Router。
- `src/views/**`：路由级页面，功能页使用 `*Page.vue` 命名。
- `src/components/layout`：布局壳组件；`src/components/common`：可复用通用组件。
- `src/stores`：Pinia 领域状态（auth、websocket、session、channel、config 等）。
- `src/api`：WebSocket/RPC 集成；共享类型位于 `src/api/types`。
- `src/composables` 与 `src/utils`：可复用逻辑与纯函数工具。
- `src/assets/styles/main.css`：全局样式；`public/`：静态资源。

## 构建、测试与开发命令
- `npm install`：安装依赖。
- `npm run dev`：启动 Vite 开发服务（`http://localhost:3001`）。
- `npm run build`：执行 `vue-tsc -b` 类型检查并产出 `dist/` 生产包。
- `npm run preview`：本地预览生产构建结果。

## 代码风格与命名规范
- 使用 Vue 3 Composition API + `<script setup lang="ts">`。
- 遵循现有格式：2 空格缩进、单引号、尾随逗号、无分号。
- `src` 路径优先使用 `@/` 别名导入。
- 命名约定：
  - 组件：`PascalCase.vue`
  - 路由页面：`*Page.vue`（例如 `SessionsPage.vue`）
  - store/composable/utils：简洁的小写或 camelCase 文件名（例如 `session.ts`、`useTheme.ts`）

## 测试要求
- 当前仓库尚未配置自动化测试框架。
- 合并前必须检查：
  - `npm run build` 通过，无类型与构建错误。
  - 针对改动流程做手工冒烟测试（登录、WebSocket 连接、相关页面），并连接可用 Gateway（`ws://127.0.0.1:18789`）。
- 对于非简单逻辑改动，请在 PR 中附测试计划；后续迭代优先补充 Vitest 单元测试。

## 提交与 PR 规范
- 提交前缀建议使用 Conventional Commit：`feat:`、`fix:`、`refactor:`、`docs:`、`chore:`。
- 每个提交只关注一个主题，避免占位式提交信息。
- PR 需包含：目标、关键改动、关联任务/Issue、验证步骤，以及 UI 改动截图/GIF。

## 安全与配置提示
- 禁止提交真实 Gateway Token、凭证或其他敏感信息。
- 运行时配置放在 `.env.development` / `.env.production`，新增 `VITE_` 变量需同步记录到 `README.md`。

## Codex 宪法（本仓库）
- 事实优先：先读现状再改代码；不得基于猜测修改 RPC、类型或页面行为。
- 单一来源：同一能力只保留一个权威入口与实现，避免页面与 store 双重定义。
- 兼容优先：优先适配现有 Gateway RPC 形态，不引入未经验证的新协议分支。
- 渐进改造：先打通最小闭环（可用），再做视觉与体验增强，避免一次性过度重构。
- 安全默认：凭证永不明文回显；未输入新值不得提交凭证 patch。
- 主题一致：样式优先复用全局变量（`--bg-*`、`--text-*`、`--border-color`），避免硬编码颜色。
- 渲染边界清晰：凡是 `v-html` 注入内容，若页面使用 `<style scoped>`，必须使用 `:deep(...)`（或改为非 scoped 受控样式）覆盖其内部元素；禁止误以为 scoped 选择器可直接命中运行时注入节点。
- 可验证：每次改动后至少保证可构建，并说明影响面与回归点。

## 实施教训（频道管理改造）
- 组件默认样式有“首项特例”（如 `Collapse` 的 first-child 规则），改间距前必须先核对组件源码。
- 间距系统必须“单机制”管理（统一使用 gap 或 margin），混用会造成首行/后续行视觉不一致。
- 暗色适配不能只改颜色值，应以主题变量驱动并限制选择器作用域，避免污染其他页面。
- 遇到“看起来像 spacing 问题”的异常，通常同时涉及 `margin/padding/border` 叠加，需要逐层排查。
- UI 微调应先保证信息架构不变，再收敛 spacing 与密度，避免视觉改善引发交互回归。


path: CHANGELOG.md
content: # Changelog

All notable changes to this project will be documented in this file.

## [Unreleased]

### Added

- 补充开源基础文档：`CONTRIBUTING.md`、`CHANGELOG.md`、`.env.example`

### Changed

- 修复 TypeScript strict/erasableSyntaxOnly 相关构建报错，`npm run build` 恢复通过
- README 对齐当前产品能力与路由结构
- `.gitignore` 增加 `.env*` 忽略规则并保留 `.env.example`
- `.env.development` / `.env.production` 改为本地默认值模板



path: CONTRIBUTING.md
content: # Contributing

感谢你参与 OpenClaw Web。

## 开发环境

- Node.js >= 20
- npm >= 10

安装依赖：

```bash
npm install
```

启动开发：

```bash
npm run dev
```

生产构建检查：

```bash
npm run build
```

## 分支与提交建议

- 每个 PR 聚焦一个主题，避免混合大批量改动
- 推荐提交前缀：
  - `feat:` 新功能
  - `fix:` 缺陷修复
  - `refactor:` 重构
  - `docs:` 文档
  - `chore:` 工程杂项

## 代码规范

- Vue 3 Composition API + `<script setup lang="ts">`
- TypeScript strict 模式下通过构建
- 保持现有格式风格：2 空格缩进、单引号、无分号
- 新增逻辑优先复用 `stores` / `composables`，避免页面层复制逻辑

## 提交前检查

至少完成以下检查：

1. `npm run build` 通过
2. 影响功能完成基本手测（登录、对话、模型、Cron、记忆）
3. 若改动 UI，建议附截图

## Pull Request 内容建议

PR 描述建议包含：

- 变更目的
- 关键改动点
- 验证步骤
- 潜在影响范围



path: DYNAMIC_AGENTS_FIX_SUMMARY.md
content: # OpenClaw-Admin dynamicAgent 修复实施总结

## 问题回顾

wecom 插件创建的 `dynamicAgent`（动态代理）在 OpenClaw-Admin 中显示不出来，但在 openclaw-control-center 中能正常显示。

## 根本原因

OpenClaw-Admin 的代理列表只在页面初始加载时获取一次，没有定期刷新机制，因此无法发现运行时动态创建的代理。

## 解决方案

实现了一个**混合机制**，结合：

1. **定期自动刷新**：每 10 秒自动刷新一次代理列表
2. **WebSocket 事件监听**：监听 Gateway 的 agent 生命周期事件（如果支持）
3. **智能初始化**：在应用启动时自动启动刷新和事件监听

## 代码变更

### 1. [src/stores/agent.ts](src/stores/agent.ts)

添加了 4 个新方法：

- **`startAutoRefresh(intervalMs = 10000): void`**
  - 启动定期刷新代理列表
  - 默认间隔：10 秒
  - 会自动清理之前的间隔

- **`stopAutoRefresh(): void`**
  - 停止定期刷新
  - 在应用卸载时自动调用

- **`setupEventListeners(): void`**
  - 监听 WebSocket 事件
  - 监听的事件：
    - `agent.created` - 新代理创建
    - `agent.deleted` - 代理删除
    - `agents.updated` - 代理列表更新

- **`initialize(): Promise<void>`**
  - 初始化 store，包括：
    - 初次获取代理和模型列表
    - 设置 WebSocket 事件监听
    - 启动定期刷新

### 2. [src/App.vue](src/App.vue)

在主应用组件中添加初始化逻辑：

```typescript
onMounted(async () => {
  await agentStore.initialize()
})

onUnmounted(() => {
  agentStore.stopAutoRefresh()
})
```

这确保：

- 应用启动时自动初始化代理监控
- 应用关闭时清理资源

### 3. [src/views/agents/AgentsPage.vue](src/views/agents/AgentsPage.vue)

改进了 onMounted 注释，说明代理列表现在被自动刷新：

```typescript
/**
 * 应用的 App.vue 会自动初始化 agent store，
 * 包括每 10 秒刷新一次列表以检测动态创建的代理
 */
onMounted(() => {
  loadData()
})
```

## 行为变化

### 之前

```
页面加载 → 获取初始代理列表 → 停止（除非用户手动点击刷新）
                ↓
         wecom 创建新 agent → 不显示（除非重新加载页面或手动刷新）
```

### 之后

```
应用启动
    ↓
App.vue 调用 agentStore.initialize()
    ↓
    ├─ 初次获取代理列表
    ├─ 启动定期刷新（每 10 秒）
    ├─ 设置 WebSocket 事件监听
    └─ 自动发现动态创建的代理
    
页面显示代理列表...

wecom 创建新 agent
    ↓
    ├─ (如果支持) WebSocket 事件立即刷新列表
    └─ (否则) 定期刷新在 10 秒内自动发现
    
新代理自动出现在列表中！
```

## 性能考虑

- **定期刷新间隔**：10 秒（可调整）
  - 在 [src/stores/agent.ts](src/stores/agent.ts#L23) 中修改 `AUTO_REFRESH_INTERVAL_MS`
  
- **网络开销**：
  - 仅发送必要的 RPC 调用
  - 与 openclaw-control-center 类似的轮询策略

- **CPU 开销**：
  - 最小化：仅在定时器触发时执行
  - 支持事件驱动的快速更新（无需等待定时器）

## 配置

### 修改刷新间隔

编辑 [src/stores/agent.ts](src/stores/agent.ts#L23)：

```typescript
const AUTO_REFRESH_INTERVAL_MS = 5000  // 改为 5 秒
```

### 禁用自动刷新

如果需要手动控制，可以在 App.vue 中注释掉 initialize() 调用：

```typescript
// 仅在需要时手动调用
// await agentStore.initialize()

// 手动启动刷新
await agentStore.fetchAgents()
agentStore.startAutoRefresh()
```

## 验证

### 构建验证

```bash
npm run build
# ✓ built in 4.41s (成功)
```

### 运行时验证

打开浏览器控制台，查看日志：

```
[App] Agent store initialized successfully
[AgentStore] Auto-refresh started with interval 10000ms
[AgentStore] Event listeners setup complete
[AgentsPage] Note: Agent list is auto-refreshing in background
```

当 wecom 创建新的 dynamicAgent 时：

```
[AgentStore] Agent created event: wecom-default-dm-zhangsan
[AgentStore] Refreshing agents list...
# 新代理立即出现在 UI 中
```

## 兼容性

- ✅ 不破坏现有的手动刷新功能
- ✅ 与 openclaw-control-center 的策略一致
- ✅ 支持 wecom 插件的动态代理创建
- ✅ 支持其他通过 RPC 动态创建的代理

## 相关文件

- [DYNAMIC_AGENTS_ISSUE.md](DYNAMIC_AGENTS_ISSUE.md) - 详细的问题分析和备选方案
- [src/stores/agent.ts](src/stores/agent.ts) - 核心 store 实现
- [src/App.vue](src/App.vue) - 应用级初始化
- [src/views/agents/AgentsPage.vue](src/views/agents/AgentsPage.vue) - 代理列表页面

## 后续优化

### 短期

- [ ] 验证 Gateway 是否支持 `agent.created`/`agent.deleted` 事件
- [ ] 如果不支持，定期刷新作为可靠的备选方案

### 中期

- [ ] 考虑添加用户配置界面来调整刷新间隔
- [ ] 添加 UI 指示符显示最后一次刷新的时间

### 长期

- [ ] 与 openclaw-control-center 完全对齐（文件系统级别的发现能力）
- [ ] 实现更智能的刷新策略（根据实际变化调整间隔）

## 2026-03-23 实施完成


path: DYNAMIC_AGENTS_ISSUE.md
content: # dynamicAgent 在 OpenClaw-Admin 读取不到的问题分析

## 问题描述

wecom 插件创建的 `dynamicAgent`（动态代理）在 OpenClaw-Admin 中显示不出来，但在 openclaw-control-center 中能正常显示。

## 根本原因分析

### 1. OpenClaw-Admin 的实现方式

**当前流程：**

- [src/stores/agent.ts - fetchAgents()](src/stores/agent.ts#L54-L66)
  - 仅通过 RPC 调用 `wsStore.rpc.listAgents()` 获取代理列表
  - 获取的结果存储在 `agents.value` 中
  - 只在页面挂载时（onMounted）调用一次，或用户手动点击刷新按钮

- [src/views/agents/AgentsPage.vue - onMounted()](src/views/agents/AgentsPage.vue#L554-L556)
  - 仅在页面初始化时调用 `loadData()`
  - 之后不会自动刷新代理列表

**问题：**

- dynamicAgent 是在运行时动态创建的，不在初始的 `listAgents()` 响应中
- OpenClaw-Admin 没有定期刷新列表的机制
- 没有监听 WebSocket 事件来响应新 agent 的创建

### 2. openclaw-control-center 的实现方式

**当前流程：**

- [src/runtime/agent-roster.ts - loadBestEffortAgentRoster()](https://github.com/TianyiDataScience/openclaw-control-center/blob/main/src/runtime/agent-roster.ts#L23-L42)
  - 优先从 `openclaw.json` 中的 `agents.list` 读取代理列表
  - 如果配置为空，则扫描 `~/.openclaw/agents/*` 目录来发现所有代理

- [src/clients/openclaw-live-client.ts - loadSessionsFromStores()](https://github.com/TianyiDataScience/openclaw-control-center/blob/main/src/clients/openclaw-live-client.ts#L243-L259)
  - 实时扫描运行时目录 `~/.openclaw/agents` 获取最新的 agent 列表
  - 支持过滤已配置的代理，但同时能发现动态创建的代理

- [src/runtime/current-agent-catalog.ts - loadCurrentAgentCatalog()](https://github.com/TianyiDataScience/openclaw-control-center/blob/main/src/runtime/current-agent-catalog.ts#L19-L43)
  - 定期加载配置文件，既能读取静态配置的 agent，也能兼容动态 agent

**优势：**

- 通过文件系统扫描动态发现 agent
- 定期刷新（通过轮询机制）
- 既支持配置驱动，也支持运行时发现

## 解决方案

### 方案 A：添加定期刷新机制（推荐）

在 agent store 中添加一个定期刷新的机制：

```typescript
// src/stores/agent.ts
let refreshInterval: ReturnType<typeof setInterval> | null = null

function startAutoRefresh(intervalMs = 10000) {
  if (refreshInterval) clearInterval(refreshInterval)
  refreshInterval = setInterval(() => {
    fetchAgents().catch(e => console.error('[AgentStore] Auto-refresh failed:', e))
  }, intervalMs)
}

function stopAutoRefresh() {
  if (refreshInterval) {
    clearInterval(refreshInterval)
    refreshInterval = null
  }
}

// 在应用初始化时启动
function initialize() {
  fetchAgents()
  startAutoRefresh(10000) // 每 10 秒刷新一次
}

return {
  // ... 现有的返回值
  startAutoRefresh,
  stopAutoRefresh,
  initialize
}
```

### 方案 B：添加 WebSocket 事件监听

监听 Gateway 的事件通知（如果支持）：

```typescript
// src/stores/agent.ts
import { watch } from 'vue'

function setupEventListeners() {
  // 监听可能的 agent.created 或相似事件
  wsStore.subscribe('agent.created', async () => {
    console.log('[AgentStore] New agent detected, refreshing list')
    await fetchAgents()
  })
  
  wsStore.subscribe('agent.deleted', async () => {
    console.log('[AgentStore] Agent deleted, refreshing list')
    await fetchAgents()
  })
}
```

### 方案 C：混合方案（最佳）

结合定期刷新 + 事件监听：

```typescript
// src/stores/agent.ts
export const useAgentStore = defineStore('agent', () => {
  // ... 现有代码
  
  let refreshInterval: ReturnType<typeof setInterval> | null = null
  
  // 事件监听
  function setupEventListeners() {
    wsStore.subscribe('agent.created', async () => {
      await fetchAgents()
    })
    wsStore.subscribe('agent.deleted', async () => {
      await fetchAgents()
    })
  }
  
  // 定期刷新（作为备用方案）
  function startAutoRefresh(intervalMs = 10000) {
    if (refreshInterval) clearInterval(refreshInterval)
    refreshInterval = setInterval(() => {
      fetchAgents().catch(e => {
        console.warn('[AgentStore] Auto-refresh failed:', e)
      })
    }, intervalMs)
  }
  
  function stopAutoRefresh() {
    if (refreshInterval) {
      clearInterval(refreshInterval)
      refreshInterval = null
    }
  }
  
  function initialize() {
    fetchAgents()
    setupEventListeners()
    startAutoRefresh(10000)
  }
  
  return {
    agents,
    defaultAgentId,
    mainKey,
    loading,
    error,
    lastUpdatedAt,
    models,
    methodUnknown,
    supportsAgents,
    agentStats,
    fetchAgents,
    fetchModels,
    addAgent,
    deleteAgent,
    setAgentIdentity,
    // ... 其他现有方法
    initialize,
    startAutoRefresh,
    stopAutoRefresh
  }
})
```

### 方案 D：实现文件系统扫描（与 openclaw-control-center 对齐）

如果后端支持，可以实现类似 openclaw-control-center 的发现机制：

```typescript
// src/api/rpc-client.ts
async function listAgentsWithRuntime() {
  // 1. 先获取配置的 agent
  const configAgents = await listAgents()
  
  // 2. 如果支持，也获取运行时发现的 agent
  const runtimeAgents = await rpc.call('agents.runtime.list')
  
  // 3. 合并（去重）
  const merged = new Map()
  configAgents.agents.forEach(a => merged.set(a.id, a))
  runtimeAgents.agents.forEach(a => merged.set(a.id, a))
  
  return { agents: Array.from(merged.values()) }
}
```

## 实施建议

### 短期修复（1-2 天）

使用方案 C（定期刷新 + 事件监听）：

1. 在 `src/stores/agent.ts` 中添加定期刷新逻辑
2. 在app初始化时调用 `agentStore.initialize()`
3. 在需要的地方调用 `stopAutoRefresh()` 清理

### 中期改进（1-2 周）

1. 检查 Gateway 是否支持 agent 相关事件
2. 如果支持，优先使用事件监听（比定期刷新效率高）
3. 如果不支持，保留定期刷新作为备选

### 长期优化（1-2 月）

考虑与 openclaw-control-center 对齐，实现文件系统级别的 agent 发现能力

## 相关文件

- [src/stores/agent.ts](src/stores/agent.ts) - Agent 状态管理
- [src/views/agents/AgentsPage.vue](src/views/agents/AgentsPage.vue) - 代理列表页面
- [src/stores/websocket.ts](src/stores/websocket.ts) - WebSocket 连接管理
- [AGENTS.md](AGENTS.md) - 项目架构指南

## 参考

- wecom 插件文档：<https://github.com/YanHaidao/wecom#14-dynamicagents-详细说明为什么生产环境建议开启>
- openclaw-control-center 实现：<https://github.com/TianyiDataScience/openclaw-control-center/blob/main/src/runtime/agent-roster.ts>


path: DYNAMIC_AGENTS_USER_GUIDE.md
content: # wecom dynamicAgent 在 OpenClaw-Admin 中的使用指南

## 概述

从 v0.2.3 开始，OpenClaw-Admin 已经实现了对 wecom 插件动态代理的实时支持。系统会自动检测并显示由 wecom 插件动态创建的代理。

## 快速开始

### 1. 确保 wecom 插件已启用并配置了 dynamicAgents

在 `~/.openclaw/openclaw.json` 中配置：

```json
{
  "channels": {
    "wecom": {
      "enabled": true,
      "dynamicAgents": {
        "enabled": true,
        "dmCreateAgent": true,
        "groupEnabled": true,
        "adminUsers": ["admin"]
      }
    }
  }
}
```

### 2. 启动 OpenClaw-Admin

打开浏览器访问 `http://localhost:3001`

### 3. 查看"多智能体"页面

动态创建的代理会自动出现在 **多智能体 (Agents)** 页面上，不需要任何手动刷新操作。

## 工作原理

### 自动刷新流程

OpenClaw-Admin 使用两层机制来检测动态代理：

```
┌─────────────────────────────────────────┐
│  应用启动 (App.vue)                      │
└────────────┬────────────────────────────┘
             │
             ▼
┌─────────────────────────────────────────┐
│  agentStore.initialize()                 │
│  ├─ 初次获取代理列表                     │
│  ├─ setupEventListeners()                │
│  │  └─ 监听 WebSocket 事件              │
│  └─ startAutoRefresh(10s)                │
│     └─ 每 10 秒获取一次列表             │
└────────────┬────────────────────────────┘
             │
             ▼
┌─────────────────────────────────────────┐
│  wecom 插件创建新 agent                  │
│  (例如: wecom-default-dm-zhangsan)      │
└────────────┬────────────────────────────┘
             │
    ┌────────┴──────┐
    │               │
    ▼               ▼
┌─────────┐   ┌──────────┐
│ WebSocket    │  定期刷新 │
│ 事件        │  (10s)    │
│ (立即)      │           │
└────┬────┘   └────┬─────┘
     │             │
     └─────┬───────┘
           │
           ▼
┌─────────────────────────────────────┐
│ agentStore 更新动态代理列表          │
└─────────────────────────────────────┘
           │
           ▼
┌─────────────────────────────────────┐
│ UI 自动刷新，显示新代理             │
└─────────────────────────────────────┘
```

### 两层检测机制的优势

1. **WebSocket 事件**（如果 Gateway 支持）
   - 立即检测到变化
   - 低延迟
   - 最有效率的方式

2. **定期刷新**（每 10 秒）
   - 作为备选方案
   - 确保最多 10 秒内发现任何新代理
   - 与 openclaw-control-center 保持一致

## 常见场景

### 场景 1：wecom 用户初次与机器人交互

```
时间线：
00:00  用户 zhangsan 发送消息到企业微信的 wecom 机器人
       ↓
00:01  wecom 插件创建 dynamicAgent: wecom-default-dm-zhangsan
       ↓
00:02  OpenClaw-Admin 通过 WebSocket 事件立即检测（或最迟在下一个刷新周期）
       ↓
00:03  新代理在"多智能体"页面显示
```

### 场景 2：多人同时使用机器人

```
时间线：
00:00  多个用户同时发送消息
       ↓
00:01  wecom 插件为每个用户创建独立的 dynamicAgent
       └─ wecom-default-dm-user1
       └─ wecom-default-dm-user2
       └─ wecom-default-dm-user3
       ↓
00:02  OpenClaw-Admin 自动发现所有新代理
       ↓
00:03  所有代理都显示在列表中
```

### 场景 3：群聊场景

```
时间线：
00:00  群聊发送消息
       ↓
00:01  wecom 插件创建 groupEnabled 的 dynamicAgent
       └─ wecom-default-group-[groupId]
       ↓
00:02  OpenClaw-Admin 自动发现
       ↓
00:03  群聊代理显示在列表中
```

## 常见问题

### Q: 为什么新代理不是立即显示？

A: 有两个可能的原因：

1. **WebSocket 事件不支持**：Gateway 没有发送 `agent.created` 事件
   - 解决方案：等待定期刷新（最多 10 秒）

2. **刷新间隔太长**：默认是 10 秒
   - 解决方案：手动点击"刷新"按钮，或修改 [src/stores/agent.ts](src/stores/agent.ts) 中的 `AUTO_REFRESH_INTERVAL_MS`

### Q: 可以禁用自动刷新吗？

A: 可以，但不推荐。如果必须禁用，可以：

编辑 [src/App.vue](src/App.vue)，注释掉：

```typescript
// await agentStore.initialize()
```

但这样就无法自动检测动态代理。

### Q: 自动刷新会影响性能吗？

A: 不会。

- 每 10 秒仅发送一个网络请求
- 仅在应用运行时执行（不会持续消耗资源）
- 与 openclaw-control-center 的策略相同

### Q: 如何修改刷新间隔？

A: 编辑 [src/stores/agent.ts](src/stores/agent.ts)，修改第 23 行：

```typescript
const AUTO_REFRESH_INTERVAL_MS = 5000  // 改为 5 秒，或其他值
```

然后重新编译：

```bash
npm run build
npm run dev
```

### Q: 新代理会自动与会话关联吗？

A: 是的。

wecom 插件创建的 dynamicAgent 已经包含了必要的配置。当会话通过该代理处理时，会自动关联。

## 故障排除

### 问题：即使启用了 dynamicAgents，新代理仍不显示

**检查清单：**

1. ✅ 确保 wecom 插件已启用

   ```bash
   openclaw plugins list
   # 应该显示 wecom 已启用
   ```

2. ✅ 检查 openclaw.json 中的 dynamicAgents 配置

   ```bash
   cat ~/.openclaw/openclaw.json | grep -A 5 dynamicAgents
   ```

3. ✅ 查看浏览器控制台，检查错误信息
   - 打开 DevTools (F12)
   - 查看 **Console** 标签
   - 搜索 `[AgentStore]` 或 `[App]`

4. ✅ 检查 Gateway 日志

   ```bash
   openclaw channels logs --channel wecom --lines 50
   ```

### 问题：浏览器控制台显示 `Auto-refresh started` 但列表没有更新

**可能原因：**

- Gateway 连接不稳定
- 代理创建失败（检查 wecom 日志）
- 权限问题

**解决方案：**

```bash
# 检查 Gateway 状态
openclaw status

# 查看 wecom 详细日志
openclaw channels logs --channel wecom --probe

# 检查是否有权限创建代理
openclaw agents list
```

### 问题：刷新间隔太频繁，导致网络请求过多

**解决方案：**

增加刷新间隔，编辑 [src/stores/agent.ts](src/stores/agent.ts)：

```typescript
const AUTO_REFRESH_INTERVAL_MS = 30000  // 改为 30 秒
```

## 最佳实践

### 1. 监控日志

在生产环境中，监控浏览器控制台和 Gateway 日志：

```bash
# 监控 Gateway wecom 日志
openclaw channels logs --channel wecom --follow

# 监控代理列表变化
openclaw agents list --watch
```

### 2. 调整刷新间隔以平衡延迟和负载

| 场景 | 推荐间隔 | 说明 |
|-----|---------|------|
| 高频用户交互 | 5-10 秒 | 快速检测，避免延迟 |
| 一般生产环境 | 10-15 秒 | 平衡延迟和负载 |
| 低频交互 | 30-60 秒 | 降低网络开销 |

### 3. 与管理员协调

如果禁用了 WebSocket 事件支持，提前告知用户可能需要等待最多 10 秒来看到新代理。

## 技术细节

### 实现位置

- **核心逻辑**：[src/stores/agent.ts](src/stores/agent.ts)
  - `startAutoRefresh()`
  - `setupEventListeners()`
  - `initialize()`

- **应用初始化**：[src/App.vue](src/App.vue)
  - onMounted → agentStore.initialize()
  - onUnmounted → agentStore.stopAutoRefresh()

- **页面逻辑**：[src/views/agents/AgentsPage.vue](src/views/agents/AgentsPage.vue)
  - 保持现有的手动刷新功能

### 监听的事件

```typescript
wsStore.subscribe('agent.created', ...)      // 新代理创建
wsStore.subscribe('agent.deleted', ...)      // 代理删除
wsStore.subscribe('agents.updated', ...)     // 通用更新事件
```

## 反馈和报告

如果遇到问题，请提供：

1. 浏览器控制台日志（包含 `[AgentStore]` 消息）
2. Gateway 日志（`openclaw channels logs --channel wecom`）
3. wecom 插件配置
4. 期望的行为 vs 实际行为

---

**最后更新**：2026-03-23 | **版本**：0.2.3+


path: index.html
content: <!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>openclaw-web</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>


path: package.json
content: {
  "name": "openclaw-web",
  "private": true,
  "version": "0.2.8",
  "license": "MIT",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "dev:server": "node --env-file=.env server/index.js",
    "dev:all": "concurrently -n \"frontend,backend\" -c \"blue,green\" \"vite\" \"node --env-file=.env server/index.js\"",
    "build": "vue-tsc -b && vite build",
    "preview": "vite preview",
    "start": "node --env-file=.env server/index.js"
  },
  "dependencies": {
    "@fortawesome/fontawesome-svg-core": "^7.2.0",
    "@fortawesome/free-brands-svg-icons": "^7.2.0",
    "@fortawesome/free-solid-svg-icons": "^7.2.0",
    "@fortawesome/vue-fontawesome": "^3.1.3",
    "@noble/ed25519": "^3.0.0",
    "@types/katex": "^0.16.8",
    "@vicons/ionicons5": "^0.13.0",
    "@xterm/addon-fit": "^0.11.0",
    "@xterm/addon-web-links": "^0.12.0",
    "@xterm/xterm": "^6.0.0",
    "adm-zip": "^0.5.16",
    "archiver": "^7.0.1",
    "better-sqlite3": "^12.6.2",
    "check-disk-space": "^3.4.0",
    "cors": "^2.8.6",
    "dotenv": "^17.3.1",
    "express": "^5.2.1",
    "highlight.js": "^11.11.1",
    "katex": "^0.16.44",
    "markdown-it": "^14.1.1",
    "multer": "^2.0.2",
    "naive-ui": "^2.43.2",
    "node-pty": "^1.1.0",
    "pdfjs-dist": "^3.11.174",
    "pinia": "^3.0.4",
    "ssh2": "^1.17.0",
    "tar": "^7.5.11",
    "unzipper": "^0.12.3",
    "uuid": "^13.0.0",
    "vue": "^3.5.25",
    "vue-i18n": "^9.0.0",
    "vue-pdf-embed": "^2.1.4",
    "vue-router": "^4.6.4",
    "ws": "^8.19.0"
  },
  "devDependencies": {
    "@types/markdown-it": "^14.1.2",
    "@types/node": "^24.10.1",
    "@vitejs/plugin-vue": "^6.0.2",
    "@vitest/coverage-v8": "^4.0.18",
    "@vue/test-utils": "^2.4.6",
    "@vue/tsconfig": "^0.8.1",
    "concurrently": "^9.2.1",
    "eventsource": "^4.1.0",
    "happy-dom": "^20.7.0",
    "playwright": "^1.58.2",
    "typescript": "~5.9.3",
    "vite": "^7.3.1",
    "vite-plugin-static-copy": "^4.0.0",
    "vitest": "^4.0.18",
    "vue-tsc": "^3.1.5"
  }
}


path: public/vite.svg
content: <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

path: README.en.md
content: # OpenClaw Admin - AI Agent Management Platform

<p align="center">
  <strong>Modern AI Agent Gateway Management Console</strong>
</p>

<p align="center">
  <a href="https://github.com/itq5/OpenClaw-Admin">GitHub</a> •
  <a href="#features">Features</a> •
  <a href="#tech-stack">Tech Stack</a> •
  <a href="#quick-start">Quick Start</a> •
  <a href="#project-structure">Project Structure</a> •
  <a href="#development-guide">Development Guide</a> •
  <a href="README.md">中文</a>
</p>

***

## Introduction

OpenClaw Admin is a modern AI agent management platform built with Vue 3, providing a complete web management interface for OpenClaw Gateway. Through intuitive visual operations, users can easily manage AI agents, sessions, models, channels, skills, and other core features.

### Version Compatibility

| OpenClaw Admin | OpenClaw Gateway | Status     |
| -------------- | ---------------- | ---------- |
| 0.2.6          | 2026.4.5         | ✅ Verified |

> 💡 **Tip**: It is recommended to use the latest version of OpenClaw Gateway for the best experience. If you encounter connection issues, please check if the Gateway version is compatible, or view the [OpenClaw Changelog](https://github.com/openclaw/openclaw/releases).

### Highlights

- 🎯 **All-in-One Management**: Integrated agent management, session monitoring, model configuration, channel management, and more
- 🌐 **Multi-Channel Support**: Supports QQ, Feishu, DingTalk, WeCom, and other message channels
- 🤖 **Multi-Agent Collaboration**: Create and manage multiple AI agents for complex task collaboration
- 📊 **Real-time Monitoring**: System resources, session status, token usage, and more
- 🌍 **Internationalization**: Built-in Chinese and English support, seamless switching
- 🎨 **Modern UI**: Responsive design based on Naive UI, supports light/dark themes

***

## Features

### Login

![Login](docs/images/登录.png)

- Username/password authentication
- Secure session management

### Dashboard

![Dashboard](docs/images/仪表盘.png)

- Runtime overview and key metrics
- Token usage trend charts
- Session activity statistics
- Real-time event stream monitoring
- Top models/channels/tools distribution

### Chat

![Chat](docs/images/在线对话.png)

- Real-time chat interface
- Support for slash commands (`/new`, `/skill`, `/model`, `/status`, `/subagents`)
- Message filtering and search
- Quick replies
- Real-time token usage statistics

### Sessions

![Sessions](docs/images/会话管理.png)

- Session list and details
- Create, reset, delete sessions
- Multi-dimensional filtering and sorting
- Message history export

### Memory

![Memory](docs/images/记忆管理.png)

- Agent document management
- Edit AGENTS, SOUL, IDENTITY, USER, and other core documents
- Markdown editor
- Quick template snippets

### Cron

![Cron](docs/images/计划任务.png)

- Scheduled task creation and management
- Support for Cron expressions, fixed intervals, specific times
- Task execution history
- Quick templates (morning report, health check, etc.)

### Models

![Models](docs/images/模型管理.png)

- Multi-model channel configuration
- API Key security management (masked display)
- Model probing
- Default model settings
- Coding package quick configuration

### Channels

![Channels](docs/images/频道管理.png)

- QQ, Feishu, DingTalk, WeCom channel configuration
- Channel status monitoring
- Credential security management
- One-click installation and configuration

### Skills

![Skills](docs/images/技能管理.png)

- Skill plugin list
- Built-in/user skill classification
- Skill installation and updates
- Chat visibility control

### Agents

![Agents](docs/images/多智能体.png)

- Agent creation and management
- Identity, model, tool permission configuration
- Session statistics and token usage
- Workspace file management

### Office

![Office-1](docs/images/智能体工坊-1.png)
![Office-2](docs/images/智能体工坊-2.png)

- Multi-agent collaboration space
- Scene creation wizard
- Task delegation and execution
- Inter-agent communication
- Team management

### MyWorld

![MyWorld](docs/images/虚拟公司.png)

- Visual office scenes
- Character movement and interaction
- Area interaction features
- Real-time communication

### Terminal

![Terminal](docs/images/远程终端.png)

- SSE protocol remote terminal
- Multi-node support
- Full-screen mode
- Custom shell and working directory

### Remote Desktop

![Remote Desktop](docs/images/远程桌面.png)

- Linux/Windows remote desktop
- Real-time screen transmission
- Mouse and keyboard operations
- Clipboard synchronization

### Files

![Files](docs/images/文件浏览器.png)

- Workspace file browsing
- File editing and preview
- File upload and download
- Directory management

### System

![System](docs/images/系统监视器.png)

- CPU, memory, disk usage
- Network connection status
- Instance online status
- Runtime statistics

### Settings

![Settings](docs/images/系统设置.png)

- Connection configuration management
- Appearance and theme settings
- Environment variable configuration

***

## Tech Stack

### Frontend Framework

| Technology | Version | Description                         |
| ---------- | ------- | ----------------------------------- |
| Vue        | 3.5.x   | Progressive JavaScript Framework    |
| Vue Router | 4.x     | Official Router                     |
| Pinia      | 3.x     | State Management                    |
| TypeScript | 5.x     | Type-safe JavaScript Superset       |
| Vite       | 7.x     | Next-generation Frontend Build Tool |

### UI Components

| Technology        | Version | Description             |
| ----------------- | ------- | ----------------------- |
| Naive UI          | 2.43.x  | Vue 3 Component Library |
| @vicons/ionicons5 | 0.13.x  | Icon Library            |
| @fortawesome      | 7.x     | Font Awesome Icons      |

### Communication & Data

| Technology  | Version | Description                           |
| ----------- | ------- | ------------------------------------- |
| WebSocket   | -       | Real-time Bidirectional Communication |
| SSE         | -       | Server-Sent Events                    |
| markdown-it | 14.x    | Markdown Parser                       |

### Backend Services

| Technology     | Version | Description              |
| -------------- | ------- | ------------------------ |
| Express        | 5.x     | Node.js Web Framework    |
| ws             | 8.x     | WebSocket Implementation |
| better-sqlite3 | 12.x    | SQLite Database          |
| node-pty       | 1.x     | Pseudo Terminal Support  |
| ssh2           | 1.x     | SSH Client               |

### Terminal

| Technology             | Version | Description       |
| ---------------------- | ------- | ----------------- |
| @xterm/xterm           | 6.x     | Terminal Emulator |
| @xterm/addon-fit       | 0.11.x  | Terminal Fit      |
| @xterm/addon-web-links | 0.12.x  | Link Support      |

***

## Quick Start

### Requirements

- Node.js >= 18.0.0
- npm >= 9.0.0

### Install Dependencies

```bash
npm install
```

### Initialize Environment Variables

```bash
cp .env.example .env
```

### Development Mode

Start frontend development server:

```bash
npm run dev
```

Start backend server:

```bash
npm run dev:server
```

Start both frontend and backend:

```bash
npm run dev:all
```

Visit `http://localhost:3000` to access the management interface.

### Production Build

```bash
npm run build
```

### Preview Build

```bash
npm run preview
```

***

## Project Structure

```
openclaw-admin/
├── src/
│   ├── api/                    # API Layer
│   │   ├── types/              # TypeScript Type Definitions
│   │   ├── connect.ts          # Connection Management
│   │   ├── rpc-client.ts       # RPC Client
│   │   └── websocket.ts        # WebSocket Wrapper
│   │
│   ├── assets/                 # Static Assets
│   │   └── styles/
│   │       └── main.css        # Global Styles
│   │
│   ├── components/             # Components
│   │   ├── common/             # Common Components
│   │   ├── layout/             # Layout Components
│   │   └── office/             # Office Scene Components
│   │
│   ├── composables/            # Composables
│   │   ├── useEventStream.ts   # Event Stream
│   │   ├── useResizable.ts     # Resize Handling
│   │   └── useTheme.ts         # Theme Management
│   │
│   ├── i18n/                   # Internationalization
│   │   ├── messages/
│   │   │   ├── zh-CN.ts        # Chinese
│   │   │   └── en-US.ts        # English
│   │   └── index.ts
│   │
│   ├── layouts/                # Layouts
│   │   └── DefaultLayout.vue
│   │
│   ├── router/                 # Router
│   │   ├── index.ts
│   │   └── routes.ts
│   │
│   ├── stores/                 # Pinia State Management
│   │   ├── agent.ts            # Agent
│   │   ├── auth.ts             # Authentication
│   │   ├── channel.ts          # Channel
│   │   ├── chat.ts             # Chat
│   │   ├── config.ts           # Configuration
│   │   ├── cron.ts             # Scheduled Tasks
│   │   ├── memory.ts           # Memory
│   │   ├── model.ts            # Model
│   │   ├── session.ts          # Session
│   │   ├── skill.ts            # Skill
│   │   ├── terminal.ts         # Terminal
│   │   ├── theme.ts            # Theme
│   │   ├── websocket.ts        # WebSocket
│   │   └── ...
│   │
│   ├── utils/                  # Utilities
│   │   ├── channel-config.ts
│   │   ├── format.ts
│   │   ├── markdown.ts
│   │   └── secret-mask.ts
│   │
│   ├── views/                  # Page Views
│   │   ├── agents/             # Multi-Agent
│   │   ├── channels/           # Channel Management
│   │   ├── chat/               # Online Chat
│   │   ├── cron/               # Scheduled Tasks
│   │   ├── memory/             # Memory Management
│   │   ├── models/             # Model Management
│   │   ├── sessions/           # Session Management
│   │   ├── skills/             # Skill Management
│   │   ├── system/             # System Monitoring
│   │   ├── terminal/           # Remote Terminal
│   │   ├── remote-desktop/     # Remote Desktop
│   │   ├── files/              # File Browser
│   │   ├── office/             # Agent Workshop
│   │   ├── myworld/            # Virtual Company
│   │   ├── monitor/            # Operations Center
│   │   ├── settings/           # System Settings
│   │   ├── Dashboard.vue       # Dashboard
│   │   └── Login.vue           # Login Page
│   │
│   ├── App.vue                 # Root Component
│   ├── main.ts                 # Entry File
│   └── env.d.ts                # Environment Type Declarations
│
├── server/                     # Backend Services
│   ├── index.js                # Server Entry
│   ├── gateway.js              # Gateway Connection
│   └── database.js             # Database Operations
│
├── public/                     # Public Static Assets
├── dist/                       # Build Output
├── data/                       # Data Storage
│
├── vite.config.ts              # Vite Configuration
├── tsconfig.json               # TypeScript Configuration
├── package.json                # Project Configuration
├── .env.example                # Environment Variables Example
└── .env                        # Local Environment Variables (copied from .env.example)
```

***

## Development Guide

### Code Style

- Use Vue 3 Composition API + `<script setup lang="ts">`
- Follow 2-space indentation, single quotes, trailing commas, no semicolons
- Use `@/` alias for `src` path imports

### Naming Conventions

| Type        | Convention     | Example                |
| ----------- | -------------- | ---------------------- |
| Components  | PascalCase.vue | `ConnectionStatus.vue` |
| Route Pages | \*Page.vue     | `SessionsPage.vue`     |
| Store       | camelCase.ts   | `session.ts`           |
| Composable  | use\*.ts       | `useTheme.ts`          |

### Build Verification

Before submitting, ensure:

```bash
npm run build
```

Build passes with no type errors.

### Environment Variables

First copy the example file, then fill in according to your local environment:

```bash
cp .env.example .env
```

Then configure in `.env` file:

```env
VITE_APP_TITLE=OpenClaw Admin
OPENCLAW_WS_URL=ws://localhost:18789
OPENCLAW_AUTH_TOKEN=
OPENCLAW_AUTH_PASSWORD=      # Gateway password, either one with Token is enough
PORT=3001
DEV_PORT=3000
DEV_FRONTEND_URL=http://localhost:3000
AUTH_USERNAME=
AUTH_PASSWORD=
MEDIA_DIR=
# OPENCLAW_HOME=/path/to/.openclaw
```

***

## API Reference

### WebSocket RPC Methods

The project communicates with OpenClaw Gateway via WebSocket, supporting the following RPC methods:

#### Configuration Management

- `config.get` - Get configuration
- `config.patch` - Update configuration
- `config.set` - Set configuration
- `config.apply` - Apply configuration

#### Session Management

- `sessions.list` - List sessions
- `sessions.get` - Get session details
- `sessions.reset` - Reset session
- `sessions.delete` - Delete session
- `sessions.spawn` - Create session
- `sessions.history` - Get history
- `sessions.usage` - Get usage statistics

#### Channel Management

- `channels.status` - Get channel status
- `channel.auth` - Channel authentication
- `channel.pair` - Channel pairing

#### Agent Management

- `agents.list` - List agents
- `agents.create` - Create agent
- `agents.update` - Update agent
- `agents.delete` - Delete agent
- `agents.files.list` - List files
- `agents.files.get` - Get file
- `agents.files.set` - Set file

#### Model Management

- `models.list` - List models

#### Scheduled Tasks

- `cron.list` - List tasks
- `cron.add` - Add task
- `cron.update` - Update task
- `cron.delete` - Delete task
- `cron.run` - Execute task

#### System Monitoring

- `health` - Health check
- `status` - Status query
- `system-presence` - Instance status
- `logs.tail` - View logs

***

## Security Notes

- ⚠️ **Never commit** real Gateway tokens, credentials, or other sensitive information
- Credential fields use masked display, plain text is never echoed
- API Keys are only submitted when a new value is entered, otherwise the original value is kept

***

## License

[MIT License](LICENSE)

***

## Contributing

Issues and Pull Requests are welcome!

**GitHub Repository**: <https://github.com/itq5/OpenClaw-Admin>

1. Fork this repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'feat: add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Create a Pull Request

***

## Contact

### Author Email

📧 <[REDACTED_SECRET]>

### WeChat Group

Welcome to join our WeChat group for latest updates and technical support:

![WeChat Group](docs/images/微信群.png)

***

<p align="center">
  Made with ❤️ by <a href="https://github.com/itq5/OpenClaw-Admin">OpenClaw Admin</a> Team
</p>


path: README.md
content: # OpenClaw Admin - AI 智能体管理平台

<p align="center">
  <strong>现代化的 AI 智能体网关管理控制台</strong>
</p>

<p align="center">
  <a href="https://github.com/itq5/OpenClaw-Admin">GitHub</a> •
  <a href="#功能特性">功能特性</a> •
  <a href="#技术栈">技术栈</a> •
  <a href="#快速开始">快速开始</a> •
  <a href="#项目结构">项目结构</a> •
  <a href="#开发指南">开发指南</a> •
  <a href="README.en.md">English</a>
</p>

***

## 项目简介

OpenClaw Admin 是一个基于 Vue 3 构建的现代化 AI 智能体管理平台，为 OpenClaw Gateway 提供完整的 Web 管理界面。通过直观的可视化操作，用户可以轻松管理 AI 智能体、会话、模型、频道、技能等核心功能。

### 版本兼容性

| OpenClaw Admin | OpenClaw Gateway | 状态    |
| -------------- | ---------------- | ----- |
| 0.2.6          | 2026.4.5         | ✅ 已验证 |

> 💡 **提示**：建议使用最新版本的 OpenClaw Gateway 以获得最佳体验。如遇到连接问题，请检查 Gateway 版本是否兼容，或查看 [OpenClaw 更新日志](https://github.com/openclaw/openclaw/releases)。

### 核心亮点

- 🎯 **一站式管理**：集成智能体管理、会话监控、模型配置、频道管理等完整功能
- 🌐 **多渠道支持**：支持 QQ、飞书、钉钉、企业微信等多种消息渠道
- 🤖 **多智能体协作**：支持创建和管理多个 AI 智能体，实现复杂任务协作
- 📊 **实时监控**：提供系统资源、会话状态、Token 使用量等实时监控
- 🌍 **国际化支持**：内置中英文双语支持，无缝切换
- 🎨 **现代 UI**：基于 Naive UI 的响应式设计，支持亮色/暗色主题

***

## 功能特性

### 登录 (Login)

![登录](docs/images/登录.png)

- 用户名密码认证
- 安全的会话管理

### 仪表盘 (Dashboard)

![仪表盘](docs/images/仪表盘.png)

- 运行总览与关键指标展示
- Token 使用趋势图表
- 会话活跃度统计
- 实时事件流监控
- Top 模型/渠道/工具分布

### 在线对话 (Chat)

![在线对话](docs/images/在线对话.png)

- 实时聊天交互界面
- 支持斜杠命令 (`/new`, `/skill`, `/model`, `/status`, `/subagents`)
- 消息筛选与搜索
- 常用语快捷回复
- Token 使用量实时统计

### 会话管理 (Sessions)

![会话管理](docs/images/会话管理.png)

- 会话列表与详情查看
- 会话创建、重置、删除
- 多维度筛选与排序
- 消息记录导出

### 记忆管理 (Memory)

![记忆管理](docs/images/记忆管理.png)

- 智能体文档管理
- 支持编辑 AGENTS、SOUL、IDENTITY、USER 等核心文档
- Markdown 编辑器
- 模板片段快速插入

### 任务计划 (Cron)

![计划任务](docs/images/计划任务.png)

- 定时任务创建与管理
- 支持 Cron 表达式、固定间隔、指定时间
- 任务执行历史查看
- 快捷模板（晨报、健康巡检等）

### 模型管理 (Models)

![模型管理](docs/images/模型管理.png)

- 多模型渠道配置
- API Key 安全管理（脱敏显示）
- 模型探测功能
- 默认模型设置
- Coding 套餐快捷配置

### 频道管理 (Channels)

![频道管理](docs/images/频道管理.png)

- QQ、飞书、钉钉、企业微信渠道配置
- 渠道状态监控
- 凭证安全管理
- 一键安装与配置

### 技能管理 (Skills)

![技能管理](docs/images/技能管理.png)

- 技能插件列表
- 内置/用户技能分类
- 技能安装与更新
- Chat 可见性控制

### 多智能体 (Agents)

![多智能体](docs/images/多智能体.png)

- 智能体创建与管理
- 身份、模型、工具权限配置
- 会话统计与 Token 用量
- 工作区文件管理

### 智能体工坊 (Office)

![智能体工坊-1](docs/images/智能体工坊-1.png)
![智能体工坊-2](docs/images/智能体工坊-2.png)

- 多智能体协作空间
- 场景创建向导
- 任务委派与执行
- 智能体间通信
- 团队管理

### 虚拟公司 (MyWorld)

![虚拟公司](docs/images/虚拟公司.png)

- 可视化办公场景
- 角色移动与交互
- 区域互动功能
- 实时通信

### 远程终端 (Terminal)

![远程终端](docs/images/远程终端.png)

- SSE 协议远程终端
- 多节点支持
- 全屏模式
- 自定义 Shell 和工作目录

### 远程桌面 (Remote Desktop)

![远程桌面](docs/images/远程桌面.png)

- Linux/Windows 远程桌面
- 实时画面传输
- 鼠标键盘操作
- 剪贴板同步

### 文件浏览器 (Files)

![文件浏览器](docs/images/文件浏览器.png)

- 工作区文件浏览
- 文件编辑与预览
- 文件上传下载
- 目录管理

### 系统监控 (System)

![系统监视器](docs/images/系统监视器.png)

- CPU、内存、磁盘使用率
- 网络连接状态
- 实例在线状态
- 运行时间统计

### 系统设置 (Settings)

![系统设置](docs/images/系统设置.png)

- 连接配置管理
- 外观主题设置
- 环境变量配置

***

## 技术栈

### 前端框架

| 技术         | 版本    | 说明                  |
| ---------- | ----- | ------------------- |
| Vue        | 3.5.x | 渐进式 JavaScript 框架   |
| Vue Router | 4.x   | 官方路由管理器             |
| Pinia      | 3.x   | 状态管理库               |
| TypeScript | 5.x   | 类型安全的 JavaScript 超集 |
| Vite       | 7.x   | 下一代前端构建工具           |

### UI 组件库

| 技术                | 版本     | 说明              |
| ----------------- | ------ | --------------- |
| Naive UI          | 2.43.x | Vue 3 组件库       |
| @vicons/ionicons5 | 0.13.x | 图标库             |
| @fortawesome      | 7.x    | Font Awesome 图标 |

### 通信与数据

| 技术          | 版本   | 说明           |
| ----------- | ---- | ------------ |
| WebSocket   | -    | 实时双向通信       |
| SSE         | -    | 服务器推送事件      |
| markdown-it | 14.x | Markdown 解析器 |

### 后端服务

| 技术             | 版本   | 说明             |
| -------------- | ---- | -------------- |
| Express        | 5.x  | Node.js Web 框架 |
| ws             | 8.x  | WebSocket 实现   |
| better-sqlite3 | 12.x | SQLite 数据库     |
| node-pty       | 1.x  | 伪终端支持          |
| ssh2           | 1.x  | SSH 客户端        |

### 终端相关

| 技术                     | 版本     | 说明    |
| ---------------------- | ------ | ----- |
| @xterm/xterm           | 6.x    | 终端模拟器 |
| @xterm/addon-fit       | 0.11.x | 终端自适应 |
| @xterm/addon-web-links | 0.12.x | 链接支持  |

***

## 快速开始

### 环境要求

- Node.js >= 18.0.0
- npm >= 9.0.0

### 安装依赖

```bash
npm install
```

### 初始化环境变量

```bash
cp .env.example .env
```

### 开发模式

启动前端开发服务器：

```bash
npm run dev
```

启动后端服务：

```bash
npm run dev:server
```

同时启动前后端：

```bash
npm run dev:all
```

访问 `http://localhost:3000` 进入管理界面。

### 生产构建

```bash
npm run build
```

### 预览构建结果

```bash
npm run preview
```

***

## 项目结构

```
openclaw-admin/
├── src/
│   ├── api/                    # API 层
│   │   ├── types/              # TypeScript 类型定义
│   │   ├── connect.ts          # 连接管理
│   │   ├── rpc-client.ts       # RPC 客户端
│   │   └── websocket.ts        # WebSocket 封装
│   │
│   ├── assets/                 # 静态资源
│   │   └── styles/
│   │       └── main.css        # 全局样式
│   │
│   ├── components/             # 组件
│   │   ├── common/             # 通用组件
│   │   ├── layout/             # 布局组件
│   │   └── office/             # 办公场景组件
│   │
│   ├── composables/            # 组合式函数
│   │   ├── useEventStream.ts   # 事件流
│   │   ├── useResizable.ts     # 尺寸调整
│   │   └── useTheme.ts         # 主题管理
│   │
│   ├── i18n/                   # 国际化
│   │   ├── messages/
│   │   │   ├── zh-CN.ts        # 中文
│   │   │   └── en-US.ts        # 英文
│   │   └── index.ts
│   │
│   ├── layouts/                # 布局
│   │   └── DefaultLayout.vue
│   │
│   ├── router/                 # 路由
│   │   ├── index.ts
│   │   └── routes.ts
│   │
│   ├── stores/                 # Pinia 状态管理
│   │   ├── agent.ts            # 智能体
│   │   ├── auth.ts             # 认证
│   │   ├── channel.ts          # 频道
│   │   ├── chat.ts             # 聊天
│   │   ├── config.ts           # 配置
│   │   ├── cron.ts             # 定时任务
│   │   ├── memory.ts           # 记忆
│   │   ├── model.ts            # 模型
│   │   ├── session.ts          # 会话
│   │   ├── skill.ts            # 技能
│   │   ├── terminal.ts         # 终端
│   │   ├── theme.ts            # 主题
│   │   ├── websocket.ts        # WebSocket
│   │   └── ...
│   │
│   ├── utils/                  # 工具函数
│   │   ├── channel-config.ts
│   │   ├── format.ts
│   │   ├── markdown.ts
│   │   └── secret-mask.ts
│   │
│   ├── views/                  # 页面视图
│   │   ├── agents/             # 多智能体
│   │   ├── channels/           # 频道管理
│   │   ├── chat/               # 在线对话
│   │   ├── cron/               # 任务计划
│   │   ├── memory/             # 记忆管理
│   │   ├── models/             # 模型管理
│   │   ├── sessions/           # 会话管理
│   │   ├── skills/             # 技能管理
│   │   ├── system/             # 系统监控
│   │   ├── terminal/           # 远程终端
│   │   ├── remote-desktop/     # 远程桌面
│   │   ├── files/              # 文件浏览
│   │   ├── office/             # 智能体工坊
│   │   ├── myworld/            # 虚拟公司
│   │   ├── monitor/            # 运维中心
│   │   ├── settings/           # 系统设置
│   │   ├── Dashboard.vue       # 仪表盘
│   │   └── Login.vue           # 登录页
│   │
│   ├── App.vue                 # 根组件
│   ├── main.ts                 # 入口文件
│   └── env.d.ts                # 环境类型声明
│
├── server/                     # 后端服务
│   ├── index.js                # 服务入口
│   ├── gateway.js              # Gateway 连接
│   └── database.js             # 数据库操作
│
├── public/                     # 公共静态资源
├── dist/                       # 构建输出
├── data/                       # 数据存储
│
├── vite.config.ts              # Vite 配置
├── tsconfig.json               # TypeScript 配置
├── package.json                # 项目配置
├── .env.example                # 环境变量示例
└── .env                        # 本地环境变量（由 .env.example 复制）
```

***

## 开发指南

### 代码风格

- 使用 Vue 3 Composition API + `<script setup lang="ts">`
- 遵循 2 空格缩进、单引号、尾随逗号、无分号
- 使用 `@/` 别名导入 `src` 路径

### 命名约定

| 类型         | 命名规范           | 示例                     |
| ---------- | -------------- | ---------------------- |
| 组件         | PascalCase.vue | `ConnectionStatus.vue` |
| 路由页面       | \*Page.vue     | `SessionsPage.vue`     |
| Store      | camelCase.ts   | `session.ts`           |
| Composable | use\*.ts       | `useTheme.ts`          |

### 构建验证

提交前请确保：

```bash
npm run build
```

构建通过，无类型错误。

### 环境变量

先复制示例文件，再按本地环境填写：

```bash
cp .env.example .env
```

然后在 `.env` 文件中配置：

```env
VITE_APP_TITLE=OpenClaw Admin
OPENCLAW_WS_URL=ws://localhost:18789
OPENCLAW_AUTH_TOKEN=
OPENCLAW_AUTH_PASSWORD=      # Gateway 密码，与 Token 二选一即可
PORT=3001
DEV_PORT=3000
DEV_FRONTEND_URL=http://localhost:3000
AUTH_USERNAME=
AUTH_PASSWORD=
MEDIA_DIR=
# OPENCLAW_HOME=/path/to/.openclaw
```

***

## API 参考

### WebSocket RPC 方法

项目通过 WebSocket 与 OpenClaw Gateway 通信，支持以下 RPC 方法：

#### 配置管理

- `config.get` - 获取配置
- `config.patch` - 更新配置
- `config.set` - 设置配置
- `config.apply` - 应用配置

#### 会话管理

- `sessions.list` - 列出会话
- `sessions.get` - 获取会话详情
- `sessions.reset` - 重置会话
- `sessions.delete` - 删除会话
- `sessions.spawn` - 创建会话
- `sessions.history` - 获取历史记录
- `sessions.usage` - 获取用量统计

#### 频道管理

- `channels.status` - 获取频道状态
- `channel.auth` - 频道认证
- `channel.pair` - 频道配对

#### 智能体管理

- `agents.list` - 列出智能体
- `agents.create` - 创建智能体
- `agents.update` - 更新智能体
- `agents.delete` - 删除智能体
- `agents.files.list` - 列出文件
- `agents.files.get` - 获取文件
- `agents.files.set` - 设置文件

#### 模型管理

- `models.list` - 列出模型

#### 定时任务

- `cron.list` - 列出任务
- `cron.add` - 添加任务
- `cron.update` - 更新任务
- `cron.delete` - 删除任务
- `cron.run` - 执行任务

#### 系统监控

- `health` - 健康检查
- `status` - 状态查询
- `system-presence` - 实例状态
- `logs.tail` - 日志查看

***

## 安全说明

- ⚠️ **禁止提交**真实 Gateway Token、凭证或其他敏感信息
- 凭证字段采用掩码显示，不回显明文
- API Key 仅在输入新值时提交，未输入则保持原值

***

## 许可证

[MIT License](LICENSE)

***

## 贡献指南

欢迎提交 Issue 和 Pull Request！

**GitHub 仓库**: <https://github.com/itq5/OpenClaw-Admin>

1. Fork 本仓库
2. 创建功能分支 (`git checkout -b feature/amazing-feature`)
3. 提交更改 (`git commit -m 'feat: add amazing feature'`)
4. 推送到分支 (`git push origin feature/amazing-feature`)
5. 创建 Pull Request

***

## 联系方式

### 作者邮箱

📧 <[REDACTED_SECRET]>

### 微信交流群

欢迎加入微信交流群，获取最新动态和技术支持：

![微信群](docs/images/微信群.png)

***

<p align="center">
  Made with ❤️ by <a href="https://github.com/itq5/OpenClaw-Admin">OpenClaw Admin</a> Team
</p>


path: server/database.js
content: import Database from 'better-sqlite3'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

const dbPath = join(__dirname, '../data/wizard.db')

const db = new Database(dbPath)

db.pragma('journal_mode = WAL')

db.exec(`
  CREATE TABLE IF NOT EXISTS scenarios (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    description TEXT,
    status TEXT DEFAULT 'draft',
    agent_selection_mode TEXT DEFAULT 'existing',
    selected_agents TEXT DEFAULT '[]',
    generated_agents TEXT DEFAULT '[]',
    bindings TEXT DEFAULT '[]',
    tasks TEXT DEFAULT '[]',
    execution_log TEXT DEFAULT '[]',
    created_at INTEGER DEFAULT (strftime('%s', 'now') * 1000),
    updated_at INTEGER DEFAULT (strftime('%s', 'now') * 1000)
  );

  CREATE TABLE IF NOT EXISTS tasks (
    id TEXT PRIMARY KEY,
    scenario_id TEXT,
    title TEXT NOT NULL,
    description TEXT,
    status TEXT DEFAULT 'pending',
    assigned_agents TEXT DEFAULT '[]',
    priority TEXT DEFAULT 'medium',
    mode TEXT DEFAULT 'default',
    conversation_history TEXT DEFAULT '[]',
    execution_history TEXT DEFAULT '[]',
    created_at INTEGER DEFAULT (strftime('%s', 'now') * 1000),
    updated_at INTEGER DEFAULT (strftime('%s', 'now') * 1000),
    FOREIGN KEY (scenario_id) REFERENCES scenarios(id) ON DELETE CASCADE
  );

  CREATE TABLE IF NOT EXISTS backup_records (
    id TEXT PRIMARY KEY,
    type TEXT NOT NULL,
    filename TEXT,
    status TEXT DEFAULT 'pending',
    progress INTEGER DEFAULT 0,
    message TEXT,
    stage TEXT,
    error TEXT,
    result TEXT,
    created_at INTEGER DEFAULT (strftime('%s', 'now') * 1000),
    completed_at INTEGER,
    size INTEGER
  );

  CREATE INDEX IF NOT EXISTS idx_tasks_scenario_id ON tasks(scenario_id);
  CREATE INDEX IF NOT EXISTS idx_scenarios_status ON scenarios(status);
  CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
  CREATE INDEX IF NOT EXISTS idx_backup_records_created_at ON backup_records(created_at);
`)

try {
  db.exec('ALTER TABLE scenarios ADD COLUMN execution_log TEXT DEFAULT \'[]\'')
} catch (e) {
  if (!e.message.includes('duplicate column name')) {
    console.error('[Database] Failed to add execution_log column:', e.message)
  }
}

try {
  db.exec('ALTER TABLE tasks ADD COLUMN execution_history TEXT DEFAULT \'[]\'')
} catch (e) {
  if (!e.message.includes('duplicate column name')) {
    console.error('[Database] Failed to add execution_history column:', e.message)
  }
}

export function createBackupRecord(id, type, filename = null) {
  const stmt = db.prepare(`
    INSERT INTO backup_records (id, type, filename, status, progress, message, created_at)
    VALUES (?, ?, ?, 'pending', 0, 'Task created', ?)
  `)
  stmt.run(id, type, filename, Date.now())
  return id
}

export function updateBackupRecord(id, updates) {
  const fields = []
  const values = []
  
  for (let [key, value] of Object.entries(updates)) {
    if (key === 'completedAt') key = 'completed_at'
    fields.push(`${key} = ?`)
    values.push(typeof value === 'object' ? JSON.stringify(value) : value)
  }
  
  values.push(id)
  
  const stmt = db.prepare(`UPDATE backup_records SET ${fields.join(', ')} WHERE id = ?`)
  stmt.run(...values)
}

export function getBackupRecord(id) {
  const stmt = db.prepare('SELECT * FROM backup_records WHERE id = ?')
  const record = stmt.get(id)
  if (record && record.result) {
    record.result = JSON.parse(record.result)
  }
  return record
}

export function getBackupRecords(limit = 20, offset = 0) {
  const stmt = db.prepare('SELECT * FROM backup_records ORDER BY created_at DESC LIMIT ? OFFSET ?')
  const records = stmt.all(limit, offset)
  return records.map(r => {
    if (r.result) {
      r.result = JSON.parse(r.result)
    }
    return r
  })
}

export function getBackupRecordsCount() {
  const stmt = db.prepare('SELECT COUNT(*) as count FROM backup_records')
  return stmt.get().count
}

export function deleteBackupRecord(id) {
  const stmt = db.prepare('DELETE FROM backup_records WHERE id = ?')
  stmt.run(id)
}

console.log('[Database] Initialized at:', dbPath)

export default db


path: server/gateway.js
content: import { readFileSync } from 'fs'
import WebSocket from 'ws'
import { randomUUID } from 'crypto'
import EventEmitter from 'events'
import { createHash, generateKeyPairSync, sign } from 'crypto'

const APP_VERSION = JSON.parse(
  readFileSync(new URL('../package.json', import.meta.url), 'utf-8'),
).version || ''

export class OpenClawGateway extends EventEmitter {
  constructor(url, authToken, authPassword, logLevel = 'INFO') {
    super()
    this.url = url
    this.authToken = authToken
    this.authPassword = authPassword
    this.logLevel = logLevel
    this.isDebug = logLevel === 'DEBUG'
    this.ws = null
    this.isConnected = false
    this.pendingCalls = new Map()
    this.connectId = null
    this.nonce = null
    this.connectSent = false
    this.reconnectTimer = null
    this.heartbeatTimer = null
    this.deviceIdentity = null
  }

  debug(...args) {
    if (this.isDebug) {
      console.log('[Gateway]', ...args)
    }
  }

  async connect() {
    if (this.ws) {
      this.ws.close()
    }

    const authParam = this.authToken || this.authPassword || ''
    const wsUrl = authParam ? `${this.url}?auth=${authParam}` : this.url
    
    this.debug('Connecting to:', this.url)
    this.debug('Auth configured:', {
      hasToken: !!this.authToken,
      hasPassword: !!this.authPassword,
      tokenLength: this.authToken?.length || 0,
      passwordLength: this.authPassword?.length || 0
    })
    
    try {
      this.ws = new WebSocket(wsUrl)
      
      this.ws.on('open', () => {
        this.debug('WebSocket opened, preparing connect frame...')
        this.connectId = `connect-${Date.now()}`
        this.nonce = null
        this.connectSent = false
        this.emit('stateChange', 'connecting')
        
        setTimeout(() => {
          if (!this.connectSent) {
            this.debug('Sending connect frame (no challenge received)')
            this.sendConnect()
          }
        }, 500)
      })

      this.ws.on('message', (data) => {
        this.debug('Received message:', data.toString().substring(0, 500))
        this.handleMessage(data)
      })

      this.ws.on('close', (code, reason) => {
        this.debug('WebSocket closed:', { code, reason: reason.toString() })
        this.handleDisconnect(code, reason.toString())
      })

      this.ws.on('error', (err) => {
        console.error('[Gateway] WebSocket error:', err.message)
        this.emit('error', err)
      })
    } catch (err) {
      console.error('[Gateway] Connection failed:', err.message)
      this.emit('error', err)
      this.scheduleReconnect()
    }
  }

  async sendConnect() {
    if (this.connectSent || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
      this.debug('sendConnect skipped:', { 
        connectSent: this.connectSent, 
        wsReady: this.ws?.readyState, 
        wsOpen: this.ws?.readyState === WebSocket.OPEN 
      })
      return
    }
    
    this.connectSent = true

    const connectParams = await this.buildConnectParams()
    
    this.debug('Sending connect frame with params:', {
      clientId: connectParams.client?.id,
      clientMode: connectParams.client?.mode,
      role: connectParams.role,
      scopes: connectParams.scopes,
      hasDevice: !!connectParams.device,
      hasToken: !!connectParams.auth?.token,
      hasPassword: !!connectParams.auth?.password,
      caps: connectParams.caps
    })

    const frame = {
      type: 'req',
      id: this.connectId,
      method: 'connect',
      params: connectParams,
    }

    this.ws.send(JSON.stringify(frame))
  }

  async buildConnectParams() {
    const params = {
      minProtocol: 3,
      maxProtocol: 3,
      client: {
        id: 'cli',
        displayName: 'OpenClaw Web Backend',
        version: APP_VERSION,
        platform: process.platform,
        mode: 'cli',
      },
      role: 'operator',
      scopes: ['operator.read', 'operator.write', 'operator.admin'],
      caps: ['tool-events'],
      commands: [],
      permissions: {},
      auth: {
        [REDACTED_SECRET] || '',
        [REDACTED_SECRET] || '', // 支持 password 认证
      },
      locale: 'zh-CN',
      userAgent: `OpenClaw-Web-Backend/${APP_VERSION}`,
    }

    try {
      if (!this.deviceIdentity) {
        this.deviceIdentity = await this.generateDeviceIdentity()
      }

      const signedAtMs = Date.now()
      const payload = this.buildDeviceAuthPayload({
        deviceId: this.deviceIdentity.deviceId,
        clientId: params.client.id,
        clientMode: params.client.mode,
        role: params.role,
        scopes: params.scopes,
        signedAtMs,
        [REDACTED_SECRET] ?? null,
        nonce: this.nonce || null,
      })

      const signature = await this.signDevicePayload(
        this.deviceIdentity.privateKey,
        payload
      )

      params.device = {
        id: this.deviceIdentity.deviceId,
        publicKey: this.deviceIdentity.publicKey,
        signature,
        signedAt: signedAtMs,
        ...(this.nonce ? { nonce: this.nonce } : {}),
      }
    } catch (e) {
      this.debug('Device identity generation failed, using legacy mode:', e.message)
    }

    return params
  }

  async generateDeviceIdentity() {
    const { publicKey, privateKey } = this.generateEd25519KeyPair()
    const publicKeyRaw = Buffer.from(publicKey, 'base64')
    const deviceId = this.bytesToHex(createHash('sha256').update(publicKeyRaw).digest())

    return {
      deviceId,
      publicKey,
      privateKey,
    }
  }

  generateEd25519KeyPair() {
    const { publicKey, privateKey } = generateKeyPairSync('ed25519')
    
    const publicKeyRaw = publicKey.export({ type: 'spki', format: 'der' }).slice(-32)
    const privateKeyRaw = privateKey.export({ type: 'pkcs8', format: 'der' }).slice(-32)
    
    return {
      publicKey: this.base64UrlEncode(publicKeyRaw),
      privateKey: this.base64UrlEncode(privateKeyRaw),
    }
  }

  buildDeviceAuthPayload(params) {
    const version = params.nonce ? 'v2' : 'v1'
    const scopes = params.scopes.join(',')
    const [REDACTED_SECRET] ?? ''
    const base = [
      version,
      params.deviceId,
      params.clientId,
      params.clientMode,
      params.role,
      scopes,
      String(params.signedAtMs),
      token,
    ]
    if (version === 'v2') {
      base.push(params.nonce ?? '')
    }
    return base.join('|')
  }

  async signDevicePayload(privateKeyBase64Url, payload) {
    const privateKeyRaw = Buffer.from(
      privateKeyBase64Url.replace(/-/g, '+').replace(/_/g, '/'),
      'base64'
    )
    const data = Buffer.from(payload, 'utf-8')

    const signature = sign(null, data, {
      key: Buffer.concat([
        Buffer.from('302e020100300506032b657004220420', 'hex'),
        privateKeyRaw,
      ]),
      format: 'der',
      type: 'pkcs8',
    })

    return this.base64UrlEncode(signature.slice(-64))
  }

  base64UrlEncode(buffer) {
    return buffer.toString('base64')
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/g, '')
  }

  bytesToHex(bytes) {
    return Array.from(bytes)
      .map((b) => b.toString(16).padStart(2, '0'))
      .join('')
  }

  handleMessage(data) {
    try {
      const frame = JSON.parse(data.toString())

      if (frame.type === 'event') {
        this.debug('Received event:', frame.event, 'payload keys:', frame.payload ? Object.keys(frame.payload) : null)
        
        if (frame.event === 'connect.challenge') {
          this.debug('Received connect.challenge, nonce:', frame.payload?.nonce?.substring(0, 16) + '...')
          this.nonce = frame.payload?.nonce
          if (this.nonce && !this.connectSent) {
            this.debug('Sending connect frame after challenge')
            this.sendConnect()
          }
        } else {
          this.emit('event', frame.event, frame.payload)
        }
        return
      }

      if (frame.type === 'res') {
        if (frame.id === this.connectId) {
          this.handleConnectResponse(frame)
          return
        }

        const pending = this.pendingCalls.get(frame.id)
        if (pending) {
          this.pendingCalls.delete(frame.id)
          clearTimeout(pending.timer)
          if (frame.ok) {
            pending.resolve(frame.payload)
          } else {
            console.error('[Gateway] RPC call failed:', frame.id, frame.error)
            pending.reject(new Error(frame.error?.message || 'RPC call failed'))
          }
        }
      }
    } catch (e) {
      console.error('[Gateway] Failed to handle message:', e.message)
    }
  }

  handleConnectResponse(frame) {
    this.debug('Connect response received:', {
      ok: frame.ok,
      error: frame.error,
      payloadKeys: frame.payload ? Object.keys(frame.payload) : null
    })
    
    this.connectId = null

    if (frame.ok) {
      this.isConnected = true
      this.emit('stateChange', 'connected')
      this.emit('connected', frame.payload)
      this.startHeartbeat()
      
      this.debug('Connected payload keys:', Object.keys(frame.payload || {}))
      
      const snapshot = frame.payload?.snapshot
      this.debug('Snapshot keys:', snapshot ? Object.keys(snapshot) : 'No snapshot')
      
      const updateInfo = snapshot?.updateAvailable
      const serverVersion = frame.payload?.server?.version
      this.debug('Update info:', updateInfo)
      this.debug('Server version:', serverVersion)
      
      if (updateInfo) {
        this.debug('Emitting version event with:', updateInfo)
        this.emit('version', updateInfo)
      } else if (serverVersion) {
        this.debug('Emitting version event with server version:', serverVersion)
        this.emit('version', { currentVersion: serverVersion, latestVersion: serverVersion, channel: 'latest' })
      } else {
        this.debug('No version info found')
      }
    } else {
      const error = frame.error?.message || 'Connection failed'
      console.error('[Gateway] Connect failed:', error)
      this.debug('Full error response:', JSON.stringify(frame.error, null, 2))
      this.emit('error', new Error(error))
      this.emit('stateChange', 'failed')
      this.ws?.close()
    }
  }

  handleDisconnect(code, reason) {
    this.isConnected = false
    this.connectSent = false
    this.connectId = null
    this.clearTimers()
    
    this.emit('stateChange', 'disconnected')
    this.emit('disconnected', code, reason)

    for (const [id, pending] of this.pendingCalls) {
      pending.reject(new Error('Connection closed'))
    }
    this.pendingCalls.clear()

    this.scheduleReconnect()
  }

  scheduleReconnect() {
    if (this.reconnectTimer) return
    
    this.reconnectTimer = setTimeout(() => {
      this.reconnectTimer = null
      this.connect()
    }, 3000)
  }

  startHeartbeat() {
    this.heartbeatTimer = setInterval(() => {
      if (this.isConnected && this.ws?.readyState === WebSocket.OPEN) {
        this.call('health').catch(() => {})
      }
    }, 30000)
  }

  clearTimers() {
    if (this.reconnectTimer) {
      clearTimeout(this.reconnectTimer)
      this.reconnectTimer = null
    }
    if (this.heartbeatTimer) {
      clearInterval(this.heartbeatTimer)
      this.heartbeatTimer = null
    }
  }

  call(method, params, timeout = 160000) {
    return new Promise((resolve, reject) => {
      if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
        reject(new Error('WebSocket not connected'))
        return
      }

      const id = `rpc-${randomUUID()}`
      const frame = { type: 'req', id, method, params }

      const timer = setTimeout(() => {
        this.pendingCalls.delete(id)
        reject(new Error(`RPC call "${method}" timed out`))
      }, timeout)

      this.pendingCalls.set(id, { resolve, reject, timer })
      this.ws.send(JSON.stringify(frame))
    })
  }

  disconnect() {
    this.clearTimers()
    if (this.ws) {
      this.ws.close()
      this.ws = null
    }
    this.isConnected = false
  }
}


path: server/index.js
content: import express from 'express'
import cors from 'cors'
import { createServer } from 'http'
import { randomUUID } from 'crypto'
import { fileURLToPath } from 'url'
import { dirname, join, resolve, basename, extname, sep } from 'path'
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, mkdirSync, rmSync, unlinkSync, stat, promises as fsPromises, createReadStream, createWriteStream, copyFileSync, readlinkSync, symlinkSync, renameSync } from 'fs'
import { OpenClawGateway } from './gateway.js'
import { parse } from 'dotenv'
import os from 'os'
import multer from 'multer'
import checkDiskSpace from 'check-disk-space'
import { execSync } from 'child_process'
import pty from 'node-pty'
import db, { createBackupRecord, updateBackupRecord, getBackupRecord, getBackupRecords, getBackupRecordsCount, deleteBackupRecord } from './database.js'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

const envPath = join(__dirname, '../.env')

function loadEnvConfig() {
  if (!existsSync(envPath)) {
    return {
      PORT: 3001,
      OPENCLAW_WS_URL: 'ws://localhost:18789',
      OPENCLAW_AUTH_TOKEN: '',
      OPENCLAW_AUTH_PASSWORD: '',
      DEV_FRONTEND_URL: 'http://localhost:3000',
      AUTH_USERNAME: '',
      AUTH_PASSWORD: '',
      MEDIA_DIR: '',
      LOG_LEVEL: 'INFO',
    }
  }
  const content = readFileSync(envPath, 'utf-8')
  const parsed = parse(content)
  return {
    PORT: parsed.PORT || 3001,
    OPENCLAW_WS_URL: parsed.OPENCLAW_WS_URL || 'ws://localhost:18789',
    OPENCLAW_AUTH_TOKEN: parsed.OPENCLAW_AUTH_TOKEN || '',
    OPENCLAW_AUTH_PASSWORD: parsed.OPENCLAW_AUTH_PASSWORD || '',
    DEV_FRONTEND_URL: parsed.DEV_FRONTEND_URL || 'http://localhost:3000',
    AUTH_USERNAME: parsed.AUTH_USERNAME || '',
    AUTH_PASSWORD: parsed.AUTH_PASSWORD || '',
    MEDIA_DIR: parsed.MEDIA_DIR || '',
    LOG_LEVEL: parsed.LOG_LEVEL || 'INFO',
  }
}

let envConfig = loadEnvConfig()

const isDebug = envConfig.LOG_LEVEL === 'DEBUG'

function debug(...args) {
  if (isDebug) {
    console.log('[DEBUG]', ...args)
  }
}

const app = express()
const server = createServer(app)

const distPath = join(__dirname, '../dist')
const hasDist = existsSync(join(distPath, 'index.html'))

const sessions = new Map()

app.use(cors())
app.use(express.json())

let gateway = new OpenClawGateway(envConfig.OPENCLAW_WS_URL, envConfig.OPENCLAW_AUTH_TOKEN, envConfig.OPENCLAW_AUTH_PASSWORD, envConfig.LOG_LEVEL)

const sseClients = new Map()

const terminalSessions = new Map()
const desktopSessions = new Map()

function cleanupTerminalSession(sessionId) {
  const session = terminalSessions.get(sessionId)
  if (!session) return false
  
  try {
    if (session.ptyProcess) {
      session.ptyProcess.kill()
    }
  } catch (e) {
    debug('[Terminal] Error killing PTY process:', e.message)
  }
  
  terminalSessions.delete(sessionId)
  console.log(`[Terminal] Session ${sessionId} cleaned up`)
  return true
}

function cleanupAllTerminalSessions() {
  const sessionIds = [...terminalSessions.keys()]
  console.log(`[Terminal] Cleaning up ${sessionIds.length} terminal sessions...`)
  for (const sessionId of sessionIds) {
    cleanupTerminalSession(sessionId)
  }
}

function cleanupOrphanedSessions() {
  const now = Date.now()
  const STALE_THRESHOLD = 30 * 60 * 1000
  
  for (const [sessionId, session] of terminalSessions) {
    const isStale = session.lastHeartbeat && (now - session.lastHeartbeat) > STALE_THRESHOLD
    const hasDeadResponse = !session.res || session.res.writableEnded || session.res.destroyed
    
    if (isStale || hasDeadResponse) {
      console.log(`[Terminal] Cleaning up orphaned session ${sessionId} (stale: ${isStale}, dead response: ${hasDeadResponse})`)
      cleanupTerminalSession(sessionId)
    }
  }
}

setInterval(cleanupOrphanedSessions, 5 * 60 * 1000)

let gatewayVersion = null
let updateInfo = null

gateway.on('connected', () => {
  console.log('[Gateway] Connected to OpenClaw')
})

gateway.on('version', (info) => {
  debug('Gateway version info:', info)
  updateInfo = info
  gatewayVersion = info.currentVersion
  broadcastSSE({ type: 'gatewayState', state: 'connected', version: info.currentVersion, updateAvailable: info })
})

gateway.on('disconnected', () => {
  console.log('[Gateway] Disconnected from OpenClaw')
  gatewayVersion = null
  broadcastSSE({ type: 'gatewayState', state: 'disconnected' })
})

gateway.on('error', (err) => {
  console.error('[Gateway] Error:', err.message)
  debug('Error stack:', err.stack)
})

gateway.on('event', (event, payload) => {
  debug('Gateway event:', event, 'payload keys:', payload ? Object.keys(payload) : null)
  broadcastSSE({ type: 'event', event, payload })
})

gateway.on('stateChange', (state) => {
  debug('Gateway state changed to:', state)
  broadcastSSE({ type: 'gatewayState', state })
})

debug('Connecting to Gateway at:', envConfig.OPENCLAW_WS_URL)
gateway.connect()

function broadcastSSE(data) {
  const message = `data: ${JSON.stringify(data)}\n\n`
  for (const [id, client] of sseClients) {
    try {
      client.res.write(message)
    } catch (e) {
      sseClients.delete(id)
    }
  }
}

function isAuthEnabled() {
  return envConfig.AUTH_USERNAME && envConfig.AUTH_PASSWORD
}

function checkAuth(req) {
  if (!isAuthEnabled()) return true
  let [REDACTED_SECRET]'Bearer ', '') || req.cookies?.session
  if (!token && req.query && req.query.token) {
    [REDACTED_SECRET]
  }
  if (!token) return false
  const session = sessions.get(token)
  if (!session) return false
  if (session.expires < Date.now()) {
    sessions.delete(token)
    return false
  }
  return true
}

function authMiddleware(req, res, next) {
  if (!isAuthEnabled()) return next()
  if (!checkAuth(req)) {
    return res.status(401).json({ error: 'Unauthorized' })
  }
  next()
}

app.get('/api/auth/config', (req, res) => {
  res.json({
    enabled: isAuthEnabled(),
  })
})

app.post('/api/auth/login', (req, res) => {
  if (!isAuthEnabled()) {
    return res.json({ ok: true, message: 'Auth disabled' })
  }

  const { username, password } = req.body

  if (!username || !password) {
    return res.status(400).json({ ok: false, error: 'Username and password required' })
  }

  if (username !== envConfig.AUTH_USERNAME || password !== envConfig.AUTH_PASSWORD) {
    return res.status(401).json({ ok: false, error: 'Invalid credentials' })
  }

  const [REDACTED_SECRET])
  const expires = Date.now() + 24 * 60 * 60 * 1000

  sessions.set(token, { username, expires })

  res.json({ ok: true, token })
})

app.post('/api/auth/logout', (req, res) => {
  const [REDACTED_SECRET]'Bearer ', '')
  if (token) {
    sessions.delete(token)
  }
  res.json({ ok: true })
})

app.get('/api/auth/check', authMiddleware, (req, res) => {
  res.json({ ok: true, authenticated: true })
})

function parseEnvFile(content) {
  const result = {}
  const lines = content.split('\n')
  for (const line of lines) {
    const trimmed = line.trim()
    if (!trimmed || trimmed.startsWith('#')) continue
    const eqIndex = trimmed.indexOf('=')
    if (eqIndex === -1) continue
    const key = trimmed.slice(0, eqIndex).trim()
    let value = trimmed.slice(eqIndex + 1).trim()
    if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
      value = value.slice(1, -1)
    }
    result[key] = value
  }
  return result
}

function stringifyEnvFile(data) {
  const lines = []
  for (const [key, value] of Object.entries(data)) {
    const escaped = value.includes('\n') || value.includes('"') || value.includes("'")
      ? `"${value.replace(/"/g, '\\"')}"`
      : value
    lines.push(`${key}=${escaped}`)
  }
  return lines.join('\n') + '\n'
}

app.get('/api/config', authMiddleware, (req, res) => {
  try {
    if (!existsSync(envPath)) {
      return res.json({ ok: true, config: {} })
    }
    const content = readFileSync(envPath, 'utf-8')
    const config = parseEnvFile(content)
    res.json({ ok: true, config })
  } catch (err) {
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

app.post('/api/config', authMiddleware, (req, res) => {
  try {
    const { AUTH_USERNAME, AUTH_PASSWORD, OPENCLAW_WS_URL, OPENCLAW_AUTH_TOKEN, OPENCLAW_AUTH_PASSWORD } = req.body
    
    const existingContent = existsSync(envPath) ? readFileSync(envPath, 'utf-8') : ''
    const existing = parseEnvFile(existingContent)
    
    if (AUTH_USERNAME !== undefined) existing.AUTH_USERNAME = AUTH_USERNAME
    if (AUTH_PASSWORD !== undefined) existing.AUTH_PASSWORD = AUTH_PASSWORD
    if (OPENCLAW_WS_URL !== undefined) existing.OPENCLAW_WS_URL = OPENCLAW_WS_URL
    if (OPENCLAW_AUTH_TOKEN !== undefined) existing.OPENCLAW_AUTH_TOKEN = OPENCLAW_AUTH_TOKEN
    if (OPENCLAW_AUTH_PASSWORD !== undefined) existing.OPENCLAW_AUTH_PASSWORD = OPENCLAW_AUTH_PASSWORD
    
    const newContent = stringifyEnvFile(existing)
    writeFileSync(envPath, newContent, 'utf-8')
    
    const oldConfig = { ...envConfig }
    envConfig = loadEnvConfig()
    
    const wsUrlChanged = oldConfig.OPENCLAW_WS_URL !== envConfig.OPENCLAW_WS_URL
    const tokenChanged = oldConfig.OPENCLAW_AUTH_TOKEN !== envConfig.OPENCLAW_AUTH_TOKEN
    const passwordChanged = oldConfig.OPENCLAW_AUTH_PASSWORD !== envConfig.OPENCLAW_AUTH_PASSWORD
    
    if (wsUrlChanged || tokenChanged || passwordChanged) {
      console.log('[Config] Gateway config changed, reconnecting...')
      gateway.disconnect()
      gateway = new OpenClawGateway(envConfig.OPENCLAW_WS_URL, envConfig.OPENCLAW_AUTH_TOKEN, envConfig.OPENCLAW_AUTH_PASSWORD)
      
      gateway.on('connected', (info) => {
        console.log('[Gateway] Connected to OpenClaw:', info?.server?.version)
        broadcastSSE({ type: 'gatewayState', state: 'connected' })
      })
      gateway.on('disconnected', () => {
        console.log('[Gateway] Disconnected from OpenClaw')
        broadcastSSE({ type: 'gatewayState', state: 'disconnected' })
      })
      gateway.on('error', (err) => {
        console.error('[Gateway] Error:', err.message)
      })
      gateway.on('event', (event, payload) => {
        broadcastSSE({ type: 'event', event, payload })
      })
      gateway.on('stateChange', (state) => {
        broadcastSSE({ type: 'gatewayState', state })
      })
      gateway.connect()
    }
    
    console.log('[Config] Configuration reloaded')
    res.json({ ok: true, message: 'Configuration saved and reloaded.' })
  } catch (err) {
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

app.get('/api/health', (req, res) => {
  res.json({
    ok: true,
    gateway: gateway.isConnected ? 'connected' : 'disconnected',
    clients: sseClients.size,
  })
})

app.get('/api/npm/versions', async (req, res) => {
  try {
    const response = await fetch('https://registry.npmjs.org/openclaw')
    if (!response.ok) {
      throw new Error('Failed to fetch versions from npm')
    }
    const data = await response.json()
    const versions = Object.keys(data.versions || {})
    
    // 过滤并排序版本号
    const validVersions = versions.filter(version => /^\d+\.\d+\.\d+/.test(version))
    validVersions.sort((a, b) => {
      const aParts = a.split('.').map(Number)
      const bParts = b.split('.').map(Number)
      
      for (let i = 0; i < 3; i++) {
        const aVal = aParts[i] || 0
        const bVal = bParts[i] || 0
        if (aVal !== bVal) {
          return bVal - aVal
        }
      }
      return 0
    })
    
    res.json({ versions: validVersions })
  } catch (error) {
    console.error('[Server] Failed to fetch npm versions:', error.message)
    res.status(500).json({ error: 'Failed to fetch versions from npm' })
  }
})

app.post('/api/npm/update', async (req, res) => {
  try {
    const { version } = req.body
    const packageSpec = version ? `openclaw@${version}` : 'openclaw@latest'
    
    console.log(`[Server] Updating OpenClaw via npm: ${packageSpec}`)
    
    // 执行npm更新命令
    const { execSync } = await import('child_process')
    const output = execSync(`npm install -g ${packageSpec}`, {
      encoding: 'utf8',
      timeout: 120000 // 2分钟超时
    })
    
    console.log('[Server] npm update output:', output)
    
    res.json({ 
      ok: true, 
      message: `Successfully updated to ${packageSpec}`,
      output: output
    })
  } catch (error) {
    console.error('[Server] Failed to update OpenClaw via npm:', error.message)
    res.status(500).json({ 
      ok: false, 
      error: error.message,
      stdout: error.stdout,
      stderr: error.stderr
    })
  }
})

let lastNetworkStats = null
let lastNetworkTime = null

function getNetworkStats() {
  const platform = os.platform()
  let bytesReceived = 0
  let bytesSent = 0

  try {
    if (platform === 'win32') {
      const output = execSync(
        'powershell -NoProfile -Command "Get-NetAdapterStatistics | ConvertTo-Json"',
        { encoding: 'utf8', timeout: 5000 }
      )
      const stats = JSON.parse(output || '[]')
      const adapters = Array.isArray(stats) ? stats : [stats]
      for (const adapter of adapters) {
        if (adapter) {
          bytesReceived += Number(adapter.ReceivedBytes) || 0
          bytesSent += Number(adapter.SentBytes) || 0
        }
      }
    } else {
      const netDev = readFileSync('/proc/net/dev', 'utf8')
      const lines = netDev.trim().split('\n').slice(2)
      for (const line of lines) {
        const parts = line.trim().split(/\s+/)
        if (parts.length >= 10) {
          const iface = parts[0].replace(':', '')
          if (iface === 'lo') continue
          bytesReceived += parseInt(parts[1], 10) || 0
          bytesSent += parseInt(parts[9], 10) || 0
        }
      }
    }
  } catch {
    bytesReceived = 0
    bytesSent = 0
  }

  return { bytesReceived, bytesSent }
}

async function getDiskSpace() {
  try {
    const platform = os.platform()
    let checkPath = '/'
    if (platform === 'win32') {
      checkPath = process.env.SystemDrive || 'C:\\'
    }
    const diskSpace = await checkDiskSpace(checkPath)
    return {
      total: diskSpace.size,
      free: diskSpace.free,
      used: diskSpace.size - diskSpace.free,
    }
  } catch {
    return { total: 0, free: 0, used: 0 }
  }
}

app.get('/api/system/metrics', authMiddleware, async (req, res) => {
  try {
    const cpus = os.cpus()
    const totalMem = os.totalmem()
    const freeMem = os.freemem()
    const usedMem = totalMem - freeMem

    const diskInfo = await getDiskSpace()
    const diskTotal = diskInfo.total
    const diskFree = diskInfo.free

    let cpuUsage = 0
    for (const cpu of cpus) {
      const total = Object.values(cpu.times).reduce((a, b) => a + b, 0)
      const idle = cpu.times.idle
      cpuUsage += ((total - idle) / total) * 100
    }
    cpuUsage = cpuUsage / cpus.length

    let presence = []
    try {
      if (gateway.isConnected) {
        presence = await gateway.call('system-presence')
        if (!Array.isArray(presence)) {
          presence = presence?.presence || presence?.items || presence?.list || []
        }
      }
    } catch {
      presence = []
    }

    let uptime = os.uptime()
    try {
      if (gateway.isConnected) {
        const health = await gateway.call('health')
        uptime = health?.uptime || uptime
      }
    } catch {
      // use os uptime
    }

    const networkStats = getNetworkStats()

    res.json({
      ok: true,
      metrics: {
        cpu: {
          usage: Math.round(cpuUsage * 10) / 10,
          cores: cpus.length,
          model: cpus[0]?.model || 'Unknown',
        },
        memory: {
          total: totalMem,
          used: usedMem,
          free: freeMem,
          usagePercent: Math.round((usedMem / totalMem) * 1000) / 10,
        },
        disk: {
          total: diskTotal,
          used: diskTotal - diskFree,
          free: diskFree,
          usagePercent: diskTotal > 0 ? Math.round(((diskTotal - diskFree) / diskTotal) * 1000) / 10 : 0,
        },
        network: {
          bytesReceived: networkStats.bytesReceived,
          bytesSent: networkStats.bytesSent,
        },
        uptime,
        loadAverage: os.loadavg(),
        platform: os.platform(),
        hostname: os.hostname(),
      },
      presence,
    })
  } catch (err) {
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

const WORKSPACE_BASE = join(__dirname, '..')

const agentWorkspaceCache = new Map()

function expandHomePath(path) {
  if (!path) return path
  if (path.startsWith('~')) {
    return join(os.homedir(), path.slice(1))
  }
  return path
}

function safePath(userPath, workspaceBase) {
  if (!workspaceBase) return null
  
  const expandedBase = resolve(expandHomePath(workspaceBase))
  const targetPath = resolve(expandedBase, userPath || '')
  
  const normalizedBase = expandedBase.toLowerCase()
  const normalizedTarget = targetPath.toLowerCase()
  
  if (!normalizedTarget.startsWith(normalizedBase)) {
    console.log('[Files] Path escape detected:', { 
      base: expandedBase, 
      target: targetPath,
      userPath 
    })
    return null
  }
  
  return targetPath
}

async function getAgentWorkspace(agentId) {
  if (agentWorkspaceCache.has(agentId)) {
    const cached = agentWorkspaceCache.get(agentId)
    if (Date.now() - cached.timestamp < 60000) {
      return cached.workspace
    }
  }
  
  if (!gateway.isConnected) {
    return null
  }
  
  try {
    const result = await gateway.call('agents.files.list', { agentId })
    const workspace = result?.workspace || result?.dir || result?.path
    if (workspace) {
      agentWorkspaceCache.set(agentId, { workspace, timestamp: Date.now() })
    }
    return workspace
  } catch (e) {
    console.error('[Files] Failed to get agent workspace:', e.message)
    return null
  }
}

app.get('/api/agents/workspace', authMiddleware, async (req, res) => {
  try {
    const agentId = req.query.agentId || 'main'
    
    if (!gateway.isConnected) {
      return res.status(503).json({ ok: false, error: { message: 'Gateway not connected' } })
    }
    
    const workspace = await getAgentWorkspace(agentId)
    
    if (!workspace) {
      return res.status(404).json({ ok: false, error: { message: 'Could not determine agent workspace' } })
    }
    
    res.json({
      ok: true,
      agentId,
      workspace,
      expandedPath: expandHomePath(workspace)
    })
  } catch (err) {
    console.error('[Agents] Workspace error:', err)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

function formatFileSize(bytes) {
  if (bytes === 0) return '0 B'
  const k = 1024
  const sizes = ['B', 'KB', 'MB', 'GB']
  const i = Math.floor(Math.log(bytes) / Math.log(k))
  return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}

app.get('/api/files/list', authMiddleware, async (req, res) => {
  try {
    const relPath = req.query.path || ''
    const workspaceParam = req.query.workspace || ''
    
    if (!workspaceParam) {
      return res.status(400).json({ ok: false, error: { message: 'Workspace parameter is required' } })
    }
    
    const workspaceBase = expandHomePath(workspaceParam)
    const absPath = safePath(relPath, workspaceBase)
    
    console.log('[Files] List:', { relPath, workspaceParam, workspaceBase, absPath })
    
    if (!absPath) {
      return res.status(400).json({ ok: false, error: { message: 'Invalid path' } })
    }
    
    if (!existsSync(absPath)) {
      return res.json({ ok: true, files: [], path: relPath, workspaceRoot: workspaceBase })
    }
    
    const stats = statSync(absPath)
    if (!stats.isDirectory()) {
      return res.json({ ok: true, files: [], path: relPath, workspaceRoot: workspaceBase })
    }
    
    const entries = readdirSync(absPath, { withFileTypes: true })
    const files = entries.map(entry => {
      const fullPath = join(absPath, entry.name)
      let size = 0
      let mtime = 0
      
      try {
        const s = statSync(fullPath)
        size = s.size
        mtime = s.mtimeMs
      } catch {}
      
      const entryRelPath = relPath ? `${relPath}/${entry.name}` : entry.name
      
      return {
        name: entry.name,
        path: entryRelPath.replace(/\\/g, '/'),
        type: entry.isDirectory() ? 'directory' : 'file',
        isDirectory: entry.isDirectory(),
        size: entry.isFile() ? size : undefined,
        updatedAtMs: mtime,
        extension: entry.isFile() ? extname(entry.name).slice(1).toLowerCase() : undefined,
      }
    })
    
    files.sort((a, b) => {
      if (a.type !== b.type) return a.type === 'directory' ? -1 : 1
      return a.name.localeCompare(b.name)
    })
    
    res.json({ 
      ok: true, 
      files,
      path: relPath,
      workspaceRoot: workspaceBase
    })
  } catch (err) {
    console.error('[Files] List error:', err)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

app.get('/api/files/get', authMiddleware, async (req, res) => {
  try {
    let relPath = req.query.path || req.query.name
    const workspaceParam = req.query.workspace || ''
    const binary = req.query.binary === 'true'
    
    if (!relPath) {
      return res.status(400).json({ ok: false, error: { message: 'Path is required' } })
    }
    
    if (!workspaceParam) {
      return res.status(400).json({ ok: false, error: { message: 'Workspace parameter is required' } })
    }
    
    // Handle double URL encoding from img src
    try {
      let decoded = decodeURIComponent(relPath)
      // Check if it was double-encoded
      if (decoded.includes('%')) {
        decoded = decodeURIComponent(decoded)
      }
      relPath = decoded
    } catch (e) {
      // If decode fails, use original
    }
    
    const workspaceBase = expandHomePath(workspaceParam)
    const absPath = safePath(relPath, workspaceBase)
    
    console.log('[Files] Get:', { relPath, workspaceParam, absPath, binary })
    
    if (!absPath) {
      return res.status(400).json({ ok: false, error: { message: 'Invalid path' } })
    }
    
    if (!existsSync(absPath)) {
      return res.status(404).json({ ok: false, error: { message: 'File not found' } })
    }
    
    const stats = statSync(absPath)
    if (stats.isDirectory()) {
      return res.status(400).json({ ok: false, error: { message: 'Cannot read directory' } })
    }
    
    const ext = extname(absPath).slice(1).toLowerCase()
    const imgExts = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp']
    const pdfExts = ['pdf']
    
    if (binary && imgExts.includes(ext)) {
      const contentTypeMap = {
        png: 'image/png',
        jpg: 'image/jpeg',
        jpeg: 'image/jpeg',
        gif: 'image/gif',
        svg: 'image/svg+xml',
        webp: 'image/webp',
        ico: 'image/x-icon',
        bmp: 'image/bmp',
      }
      
      const contentType = contentTypeMap[ext] || 'application/octet-stream'
      res.setHeader('Content-Type', contentType)
      res.setHeader('Content-Length', stats.size)
      
      const stream = createReadStream(absPath)
      stream.pipe(res)
      
      stream.on('error', (err) => {
        console.error('[Files] Stream error:', err.message)
        if (!res.headersSent) {
          res.status(500).json({ ok: false, error: { message: err.message } })
        }
      })
      return
    }
    
    if (binary && pdfExts.includes(ext)) {
      res.setHeader('Content-Type', 'application/pdf')
      res.setHeader('Content-Length', stats.size)
      
      const stream = createReadStream(absPath)
      stream.pipe(res)
      
      stream.on('error', (err) => {
        console.error('[Files] PDF stream error:', err.message)
        if (!res.headersSent) {
          res.status(500).json({ ok: false, error: { message: err.message } })
        }
      })
      return
    }
    
    if (imgExts.includes(ext)) {
      const buffer = readFileSync(absPath)
      const base64 = buffer.toString('base64')
      res.json({
        ok: true,
        file: {
          name: basename(absPath),
          path: relPath,
          content: base64,
          isBase64: true,
          size: stats.size,
          updatedAtMs: stats.mtimeMs,
          extension: ext,
        }
      })
    } else {
      const content = readFileSync(absPath, 'utf-8')
      res.json({
        ok: true,
        file: {
          name: basename(absPath),
          path: relPath,
          content,
          size: stats.size,
          updatedAtMs: stats.mtimeMs,
          extension: ext,
        }
      })
    }
  } catch (err) {
    console.error('[Files] Get error:', err)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

app.post('/api/files/set', authMiddleware, async (req, res) => {
  try {
    const { path: relPath, name, content, workspace: workspaceParam } = req.body
    const filePath = relPath || name
    
    if (!filePath) {
      return res.status(400).json({ ok: false, error: { message: 'Path is required' } })
    }
    
    if (!workspaceParam) {
      return res.status(400).json({ ok: false, error: { message: 'Workspace parameter is required' } })
    }
    
    const workspaceBase = expandHomePath(workspaceParam)
    const absPath = safePath(filePath, workspaceBase)
    
    console.log('[Files] Set:', { filePath, workspaceParam, absPath })
    
    if (!absPath) {
      return res.status(400).json({ ok: false, error: { message: 'Invalid path' } })
    }
    
    const parentDir = dirname(absPath)
    if (!existsSync(parentDir)) {
      mkdirSync(parentDir, { recursive: true })
    }
    
    if (content === null || content === undefined) {
      if (existsSync(absPath)) {
        unlinkSync(absPath)
      }
      res.json({ ok: true, deleted: true })
    } else {
      writeFileSync(absPath, content, 'utf-8')
      const stats = statSync(absPath)
      res.json({
        ok: true,
        file: {
          name: basename(absPath),
          path: filePath,
          size: stats.size,
          updatedAtMs: stats.mtimeMs,
        }
      })
    }
  } catch (err) {
    console.error('[Files] Set error:', err)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

app.post('/api/files/mkdir', authMiddleware, async (req, res) => {
  try {
    const { path: relPath, name, workspace: workspaceParam } = req.body
    const dirPath = relPath || name
    
    if (!dirPath) {
      return res.status(400).json({ ok: false, error: { message: 'Path is required' } })
    }
    
    if (!workspaceParam) {
      return res.status(400).json({ ok: false, error: { message: 'Workspace parameter is required' } })
    }
    
    const workspaceBase = expandHomePath(workspaceParam)
    const absPath = safePath(dirPath, workspaceBase)
    
    console.log('[Files] Mkdir:', { dirPath, workspaceParam, absPath })
    
    if (!absPath) {
      return res.status(400).json({ ok: false, error: { message: 'Invalid path' } })
    }
    
    if (existsSync(absPath)) {
      return res.status(400).json({ ok: false, error: { message: 'Already exists' } })
    }
    
    mkdirSync(absPath, { recursive: true })
    
    res.json({
      ok: true,
      directory: {
        name: basename(absPath),
        path: dirPath,
      }
    })
  } catch (err) {
    console.error('[Files] Mkdir error:', err)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

app.post('/api/files/delete', authMiddleware, async (req, res) => {
  try {
    const { path: relPath, name, workspace: workspaceParam } = req.body
    const filePath = relPath || name
    
    if (!filePath) {
      return res.status(400).json({ ok: false, error: { message: 'Path is required' } })
    }
    
    if (!workspaceParam) {
      return res.status(400).json({ ok: false, error: { message: 'Workspace parameter is required' } })
    }
    
    const workspaceBase = expandHomePath(workspaceParam)
    const absPath = safePath(filePath, workspaceBase)
    
    console.log('[Files] Delete:', { filePath, workspaceParam, absPath })
    
    if (!absPath) {
      return res.status(400).json({ ok: false, error: { message: 'Invalid path' } })
    }
    
    if (!existsSync(absPath)) {
      return res.status(404).json({ ok: false, error: { message: 'Not found' } })
    }
    
    rmSync(absPath, { recursive: true, force: true })
    
    res.json({ ok: true, deleted: true })
  } catch (err) {
    console.error('[Files] Delete error:', err)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

app.post('/api/files/rename', authMiddleware, async (req, res) => {
  try {
    const { oldPath, newPath, workspace: workspaceParam } = req.body
    
    if (!oldPath || !newPath) {
      return res.status(400).json({ ok: false, error: { message: 'Old path and new path are required' } })
    }
    
    if (!workspaceParam) {
      return res.status(400).json({ ok: false, error: { message: 'Workspace parameter is required' } })
    }
    
    const workspaceBase = expandHomePath(workspaceParam)
    const absOldPath = safePath(oldPath, workspaceBase)
    const absNewPath = safePath(newPath, workspaceBase)
    
    console.log('[Files] Rename:', { oldPath, newPath, absOldPath, absNewPath })
    
    if (!absOldPath || !absNewPath) {
      return res.status(400).json({ ok: false, error: { message: 'Invalid path' } })
    }
    
    if (!existsSync(absOldPath)) {
      return res.status(404).json({ ok: false, error: { message: 'Source not found' } })
    }
    
    if (existsSync(absNewPath)) {
      return res.status(400).json({ ok: false, error: { message: 'Target already exists' } })
    }
    
    const { renameSync } = await import('fs')
    renameSync(absOldPath, absNewPath)
    
    res.json({ 
      ok: true, 
      renamed: true,
      oldPath,
      newPath 
    })
  } catch (err) {
    console.error('[Files] Rename error:', err)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

const upload = multer({ 
  storage: multer.memoryStorage(),
  limits: { fileSize: 100 * 1024 * 1024 }
})

app.post('/api/files/upload', authMiddleware, upload.single('file'), async (req, res) => {
  try {
    const file = req.file
    const relPath = req.body.path
    const workspaceParam = req.body.workspace
    
    if (!file) {
      return res.status(400).json({ ok: false, error: { message: 'No file uploaded' } })
    }
    
    if (!relPath) {
      return res.status(400).json({ ok: false, error: { message: 'Path is required' } })
    }
    
    if (!workspaceParam) {
      return res.status(400).json({ ok: false, error: { message: 'Workspace parameter is required' } })
    }
    
    const workspaceBase = expandHomePath(workspaceParam)
    const absPath = safePath(relPath, workspaceBase)
    
    console.log('[Files] Upload:', { relPath, workspaceParam, absPath, size: file.size })
    
    if (!absPath) {
      return res.status(400).json({ ok: false, error: { message: 'Invalid path' } })
    }
    
    const parentDir = dirname(absPath)
    if (!existsSync(parentDir)) {
      mkdirSync(parentDir, { recursive: true })
    }
    
    await fsPromises.writeFile(absPath, file.buffer)
    
    const stats = statSync(absPath)
    res.json({
      ok: true,
      file: {
        name: basename(absPath),
        path: relPath,
        size: stats.size,
        updatedAtMs: stats.mtimeMs,
      }
    })
  } catch (err) {
    console.error('[Files] Upload error:', err)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

app.get('/api/status', authMiddleware, async (req, res) => {
  try {
    if (!gateway.isConnected) {
      return res.status(503).json({ error: 'Gateway not connected' })
    }
    const status = await gateway.call('status')
    res.json({ ok: true, payload: status })
  } catch (err) {
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

app.post('/api/rpc', authMiddleware, async (req, res) => {
  const { method, params } = req.body

  if (!method) {
    return res.status(400).json({ error: 'Method is required' })
  }

  if (!gateway.isConnected) {
    return res.status(503).json({ error: 'Gateway not connected' })
  }

  try {
    const result = await gateway.call(method, params)
    res.json({ ok: true, payload: result })
  } catch (err) {
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

app.get('/api/events', authMiddleware, (req, res) => {
  debug('[SSE] New client connecting, auth check passed')
  
  res.setHeader('Content-Type', 'text/event-stream')
  res.setHeader('Cache-Control', 'no-cache')
  res.setHeader('Connection', 'keep-alive')
  res.setHeader('X-Accel-Buffering', 'no')
  res.flushHeaders()

  const clientId = randomUUID()
  sseClients.set(clientId, { res, subscriptions: new Set(['*']) })
  debug('[SSE] Client connected:', clientId, 'total clients:', sseClients.size)

  res.write(`data: ${JSON.stringify({ type: 'connected', clientId })}\n\n`)

  const initialState = gateway.isConnected ? 'connected' : 'disconnected'
  debug('[SSE] Sending initial state to client:', clientId, 'state:', initialState, 'gatewayVersion:', gatewayVersion)
  res.write(`data: ${JSON.stringify({ 
    type: 'gatewayState', 
    state: initialState,
    version: initialState === 'connected' ? gatewayVersion : null,
    updateAvailable: initialState === 'connected' ? updateInfo : null
  })}\n\n`)

  req.on('close', () => {
    sseClients.delete(clientId)
    debug('[SSE] Client disconnected:', clientId, 'remaining clients:', sseClients.size)
  })
})

app.get('/api/terminal/stream', authMiddleware, (req, res) => {
  const cols = parseInt(req.query.cols) || 120
  const rows = parseInt(req.query.rows) || 36
  const nodeId = req.query.nodeId || 'local'

  res.setHeader('Content-Type', 'text/event-stream')
  res.setHeader('Cache-Control', 'no-cache')
  res.setHeader('Connection', 'keep-alive')
  res.setHeader('X-Accel-Buffering', 'no')
  res.flushHeaders()

  const sessionId = randomUUID()
  const now = Date.now()
  
  const sendEvent = (type, data = {}) => {
    try {
      const event = { type, sessionId, ...data }
      res.write(`data: ${JSON.stringify(event)}\n\n`)
      return true
    } catch (e) {
      console.error('[Terminal] Error sending event:', e.message)
      return false
    }
  }

  try {
    const shell = process.platform === 'win32' ? 'powershell.exe' : process.env.SHELL || '/bin/bash'
    
    const ptyProcess = pty.spawn(shell, [], {
      name: 'xterm-256color',
      cols,
      rows,
      cwd: process.env.HOME || process.cwd(),
      env: { ...process.env, TERM: 'xterm-256color' }
    })

    terminalSessions.set(sessionId, { 
      ptyProcess, 
      nodeId, 
      res, 
      createdAt: now,
      lastHeartbeat: now 
    })

    ptyProcess.onData((data) => {
      try {
        const sent = sendEvent('output', { data })
        if (!sent) {
          console.log(`[Terminal] Failed to send output for session ${sessionId}, cleaning up`)
          cleanupTerminalSession(sessionId)
        }
      } catch (e) {
        console.error('[Terminal] Error sending output:', e.message)
        cleanupTerminalSession(sessionId)
      }
    })

    ptyProcess.onExit(({ exitCode }) => {
      console.log(`[Terminal] Session ${sessionId} exited with code ${exitCode}`)
      sendEvent('disconnected', { message: `Process exited with code ${exitCode}` })
      cleanupTerminalSession(sessionId)
    })

    console.log(`[Terminal] Session ${sessionId} created (shell: ${shell}, size: ${cols}x${rows})`)
    sendEvent('connected', { cols, rows })

    req.on('close', () => {
      console.log(`[Terminal] Client disconnected, cleaning up session ${sessionId}`)
      cleanupTerminalSession(sessionId)
    })

    req.on('error', (err) => {
      console.error(`[Terminal] Request error for session ${sessionId}:`, err.message)
      cleanupTerminalSession(sessionId)
    })

  } catch (err) {
    console.error('[Terminal] Failed to create PTY:', err.message)
    sendEvent('error', { message: `Failed to create terminal: ${err.message}` })
    res.end()
  }
})

app.post('/api/terminal/input', authMiddleware, (req, res) => {
  const { sessionId, data } = req.body

  if (!sessionId || !data) {
    return res.status(400).json({ ok: false, error: { message: 'sessionId and data are required' } })
  }

  const session = terminalSessions.get(sessionId)
  if (!session) {
    return res.status(404).json({ ok: false, error: { message: 'Session not found' } })
  }

  try {
    session.ptyProcess.write(data)
    res.json({ ok: true })
  } catch (err) {
    console.error('[Terminal] Error writing to PTY:', err.message)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

app.post('/api/terminal/resize', authMiddleware, (req, res) => {
  const { sessionId, cols, rows } = req.body

  if (!sessionId || cols === undefined || rows === undefined) {
    return res.status(400).json({ ok: false, error: { message: 'sessionId, cols, and rows are required' } })
  }

  const session = terminalSessions.get(sessionId)
  if (!session) {
    return res.status(404).json({ ok: false, error: { message: 'Session not found' } })
  }

  try {
    session.ptyProcess.resize(cols, rows)
    res.json({ ok: true, cols, rows })
  } catch (err) {
    console.error('[Terminal] Error resizing PTY:', err.message)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

app.post('/api/terminal/destroy', authMiddleware, (req, res) => {
  const { sessionId } = req.body

  if (!sessionId) {
    return res.status(400).json({ ok: false, error: { message: 'sessionId is required' } })
  }

  const cleaned = cleanupTerminalSession(sessionId)
  if (cleaned) {
    console.log(`[Terminal] Session ${sessionId} destroyed via API`)
  }
  res.json({ ok: true, message: cleaned ? 'Session destroyed' : 'Session already destroyed' })
})

app.post('/api/terminal/heartbeat', authMiddleware, (req, res) => {
  const { sessionId } = req.body

  if (!sessionId) {
    return res.status(400).json({ ok: false, error: { message: 'sessionId is required' } })
  }

  const session = terminalSessions.get(sessionId)
  if (!session) {
    return res.status(404).json({ ok: false, error: { message: 'Session not found' } })
  }

  session.lastHeartbeat = Date.now()
  res.json({ ok: true })
})

// ============ Remote Desktop API ============

const { spawn } = await import('child_process')

async function findFreeDisplay() {
  for (let i = 99; i >= 1; i--) {
    const lockFile = `/tmp/.X${i}-lock`
    if (!existsSync(lockFile)) {
      return i
    }
  }
  return 99
}

async function startXvfbDisplay(width, height, depth = 24) {
  const displayNum = await findFreeDisplay()
  const display = `:${displayNum}`
  
  const xvfb = spawn('Xvfb', [
    display,
    '-screen', '0', `${width}x${height}x${depth}`,
    '-ac',
    '-nolisten', 'tcp',
  ], {
    stdio: ['ignore', 'pipe', 'pipe'],
  })
  
  await new Promise((resolve, reject) => {
    const timeout = setTimeout(() => {
      reject(new Error('Xvfb startup timeout'))
    }, 5000)
    
    xvfb.on('error', (err) => {
      clearTimeout(timeout)
      reject(err)
    })
    
    setTimeout(() => {
      clearTimeout(timeout)
      resolve(true)
    }, 500)
  })
  
  return { display, process: xvfb }
}

async function startX11vnc(display, password) {
  const args = [
    '-display', display,
    '-forever',
    '-shared',
    '-rfbport', '0',
    '-nopw',
  ]
  
  if (password) {
    args.push('-passwd', password)
  }
  
  const vnc = spawn('x11vnc', args, {
    stdio: ['ignore', 'pipe', 'pipe'],
  })
  
  let port = null
  await new Promise((resolve, reject) => {
    const timeout = setTimeout(() => {
      reject(new Error('x11vnc startup timeout'))
    }, 10000)
    
    vnc.stderr.on('data', (data) => {
      const str = data.toString()
      const portMatch = str.match(/PORT=(\d+)/)
      if (portMatch) {
        port = parseInt(portMatch[1], 10)
        clearTimeout(timeout)
        resolve(true)
      }
    })
    
    vnc.on('error', (err) => {
      clearTimeout(timeout)
      reject(err)
    })
  })
  
  return { process: vnc, port }
}

function startFFmpegCapture(display, width, height, fps = 15, quality = 5) {
  const args = [
    '-f', 'x11grab',
    '-draw_mouse', '1',
    '-video_size', `${width}x${height}`,
    '-framerate', String(fps),
    '-i', display,
    '-vf', `scale=${width}:${height}`,
    '-f', 'image2pipe',
    '-vcodec', 'mjpeg',
    '-q:v', String(quality),
    '-',
  ]
  
  const ffmpeg = spawn('ffmpeg', args, {
    stdio: ['ignore', 'pipe', 'pipe'],
  })
  
  return ffmpeg
}

function parseMJPEGFrames(ffmpeg, onFrame) {
  let buffer = Buffer.alloc(0)
  const SOI = Buffer.from([0xFF, 0xD8])
  const EOI = Buffer.from([0xFF, 0xD9])
  
  ffmpeg.stdout.on('data', (chunk) => {
    buffer = Buffer.concat([buffer, chunk])
    
    let start = 0
    while (true) {
      const soiIndex = buffer.indexOf(SOI, start)
      if (soiIndex === -1) break
      
      const eoiIndex = buffer.indexOf(EOI, soiIndex)
      if (eoiIndex === -1) break
      
      const frame = buffer.slice(soiIndex, eoiIndex + 2)
      onFrame(frame)
      start = eoiIndex + 2
    }
    
    if (start > 0) {
      buffer = buffer.slice(start)
    }
  })
  
  return ffmpeg
}

async function captureLinuxDesktopFast(display, width, height, quality = 30) {
  return new Promise((resolve) => {
    const args = [
      '-f', 'x11grab',
      '-draw_mouse', '1',
      '-video_size', `${width}x${height}`,
      '-i', display,
      '-vframes', '1',
      '-f', 'image2pipe',
      '-vcodec', 'mjpeg',
      '-q:v', String(quality),
      '-',
    ]
    
    const proc = spawn('ffmpeg', args, {
      stdio: ['ignore', 'pipe', 'pipe'],
    })
    
    const chunks = []
    proc.stdout.on('data', (chunk) => chunks.push(chunk))
    proc.on('close', () => {
      if (chunks.length > 0) {
        resolve(Buffer.concat(chunks))
      } else {
        resolve(null)
      }
    })
    proc.on('error', () => resolve(null))
  })
}

async function captureLinuxDesktop(display, width, height) {
  return captureLinuxDesktopFast(display, width, height, 30)
}

async function captureWindowsDesktop() {
  return new Promise((resolve) => {
    const script = `
      Add-Type -AssemblyName System.Windows.Forms
      Add-Type -AssemblyName System.Drawing
      $screen = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
      $bitmap = New-Object System.Drawing.Bitmap($screen.Width, $screen.Height)
      $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
      $graphics.CopyFromScreen($screen.Location, [System.Drawing.Point]::Empty, $screen.Size)
      $encoder = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq 'image/jpeg' }
      $params = New-Object System.Drawing.Imaging.EncoderParameters(1)
      $params.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter([System.Drawing.Imaging.Encoder]::Quality, 50L)
      $stream = New-Object System.IO.MemoryStream
      $bitmap.Save($stream, $encoder, $params)
      [Convert]::ToBase64String($stream.ToArray())
    `
    
    const proc = spawn('powershell', ['-NoProfile', '-Command', script], {
      stdio: ['ignore', 'pipe', 'pipe'],
    })
    
    let output = ''
    proc.stdout.on('data', (data) => output += data.toString())
    proc.on('close', () => {
      if (output) {
        try {
          resolve(Buffer.from(output.trim(), 'base64'))
        } catch {
          resolve(null)
        }
      } else {
        resolve(null)
      }
    })
    proc.on('error', () => resolve(null))
  })
}

app.get('/api/desktop/displays', authMiddleware, (req, res) => {
  if (process.platform === 'win32') {
    return res.json({ ok: true, displays: [], platform: 'windows' })
  }
  
  const displays = []
  const tmpDir = '/tmp'
  
  try {
    const files = readdirSync(tmpDir)
    const lockPattern = /^\.X(\d+)-lock$/
    
    for (const file of files) {
      const match = file.match(lockPattern)
      if (match) {
        const displayNum = match[1]
        displays.push({
          display: `:${displayNum}`,
          number: parseInt(displayNum, 10),
        })
      }
    }
    
    displays.sort((a, b) => b.number - a.number)
  } catch (e) {
    console.error('[Desktop] Failed to list displays:', e.message)
  }
  
  res.json({ ok: true, displays, platform: 'linux' })
})

app.get('/api/desktop/list', authMiddleware, (req, res) => {
  const sessions = []
  for (const [id, session] of desktopSessions) {
    sessions.push({
      id,
      nodeId: session.nodeId,
      nodeName: session.nodeName,
      platform: session.platform,
      status: session.status,
      width: session.width,
      height: session.height,
      createdAt: session.createdAt,
      lastActivityAt: session.lastActivityAt,
    })
  }
  res.json({ ok: true, sessions })
})

app.post('/api/desktop/create', authMiddleware, async (req, res) => {
  const { nodeId, width, height, host, port, password, display: inputDisplay } = req.body
  
  const sessionId = randomUUID()
  const platform = process.platform === 'win32' ? 'windows' : 'linux'
  
  const session = {
    id: sessionId,
    nodeId: nodeId || 'local',
    nodeName: nodeId || 'local',
    platform,
    status: 'creating',
    width: width || 1024,
    height: height || 768,
    host: host || 'localhost',
    port: port || 5900,
    [REDACTED_SECRET] || '',
    createdAt: Date.now(),
    lastActivityAt: Date.now(),
    clients: new Set(),
    xvfbProcess: null,
    vncProcess: null,
    ffmpegProcess: null,
    display: null,
    frameBuffer: null,
    isExternalDisplay: false,
  }
  
  desktopSessions.set(sessionId, session)
  
  try {
    if (platform === 'linux') {
      if (inputDisplay) {
        console.log(`[Desktop] Using existing display ${inputDisplay} for session ${sessionId}...`)
        session.display = inputDisplay
        session.isExternalDisplay = true
      } else {
        console.log(`[Desktop] Starting Xvfb for session ${sessionId}...`)
        const { display, process: xvfb } = await startXvfbDisplay(session.width, session.height)
        session.display = display
        session.xvfbProcess = xvfb
        console.log(`[Desktop] Xvfb started on display ${display}`)
        
        try {
          console.log(`[Desktop] Starting x11vnc for session ${sessionId}...`)
          const { process: vnc, port: vncPort } = await startX11vnc(display, session.password)
          session.vncProcess = vnc
          session.port = vncPort
          console.log(`[Desktop] x11vnc started on port ${vncPort}`)
        } catch (vncErr) {
          console.log(`[Desktop] x11vnc not available, using screen capture: ${vncErr.message}`)
        }
        
        xvfb.on('exit', (code) => {
          console.log(`[Desktop] Xvfb exited for session ${sessionId} with code ${code}`)
          session.status = 'error'
        })
      }
    }
    
    session.status = 'ready'
    res.json({
      ok: true,
      sessionId,
      message: 'Desktop session created. Connect via SSE stream.',
      width: session.width,
      height: session.height,
      platform: session.platform,
      display: session.display,
      vncPort: session.port,
      isExternalDisplay: session.isExternalDisplay,
    })
  } catch (err) {
    console.error(`[Desktop] Failed to create session ${sessionId}:`, err.message)
    session.status = 'error'
    res.status(500).json({
      ok: false,
      error: { message: `Failed to create desktop session: ${err.message}` },
    })
  }
})

app.get('/api/desktop/stream', authMiddleware, (req, res) => {
  const sessionId = req.query.sessionId
  
  if (!sessionId) {
    return res.status(400).json({ ok: false, error: { message: 'sessionId is required' } })
  }
  
  const session = desktopSessions.get(sessionId)
  if (!session) {
    return res.status(404).json({ ok: false, error: { message: 'Session not found' } })
  }
  
  res.setHeader('Content-Type', 'text/event-stream')
  res.setHeader('Cache-Control', 'no-cache')
  res.setHeader('Connection', 'keep-alive')
  res.setHeader('X-Accel-Buffering', 'no')
  res.flushHeaders()
  
  const sendEvent = (type, data = {}) => {
    const event = { type, sessionId, ...data }
    res.write(`data: ${JSON.stringify(event)}\n\n`)
  }
  
  session.clients.add(res)
  session.status = 'connected'
  session.lastActivityAt = Date.now()
  
  console.log(`[Desktop] Client connected to session ${sessionId}`)
  sendEvent('connected', { width: session.width, height: session.height })
  
  let ffmpegProcess = null
  let frameCount = 0
  let lastFrameTime = Date.now()
  
  if (session.platform === 'linux' && session.display) {
    ffmpegProcess = startFFmpegCapture(session.display, session.width, session.height, 15, 5)
    
    parseMJPEGFrames(ffmpegProcess, (frame) => {
      frameCount++
      const now = Date.now()
      if (now - lastFrameTime >= 100) {
        session.frameBuffer = frame
        sendEvent('frame', { 
          data: frame.toString('base64'),
          width: session.width,
          height: session.height,
          fps: Math.round(frameCount * 1000 / (now - lastFrameTime)),
        })
        frameCount = 0
        lastFrameTime = now
      }
    })
    
    ffmpegProcess.on('error', (err) => {
      console.error(`[Desktop] FFmpeg error for session ${sessionId}:`, err.message)
    })
    
    ffmpegProcess.on('exit', (code) => {
      console.log(`[Desktop] FFmpeg exited for session ${sessionId} with code ${code}`)
    })
  } else if (session.platform === 'windows') {
    const captureFrame = async () => {
      try {
        const frameBuffer = await captureWindowsDesktop()
        if (frameBuffer) {
          session.frameBuffer = frameBuffer
          sendEvent('frame', { 
            data: frameBuffer.toString('base64'),
            width: session.width,
            height: session.height,
          })
        }
      } catch (err) {
        console.error(`[Desktop] Frame capture error for session ${sessionId}:`, err.message)
      }
    }
    
    const frameInterval = setInterval(captureFrame, 100)
    
    req.on('close', () => {
      clearInterval(frameInterval)
      session.clients.delete(res)
      console.log(`[Desktop] Client disconnected from session ${sessionId}`)
      if (session.clients.size === 0) {
        session.status = 'disconnected'
      }
    })
    
    return
  }
  
  req.on('close', () => {
    if (ffmpegProcess) {
      ffmpegProcess.kill()
    }
    session.clients.delete(res)
    console.log(`[Desktop] Client disconnected from session ${sessionId}`)
    if (session.clients.size === 0) {
      session.status = 'disconnected'
    }
  })
})

app.post('/api/desktop/input/mouse', authMiddleware, (req, res) => {
  const { sessionId, x, y, button, buttons, type, wheelDeltaX, wheelDeltaY } = req.body
  
  if (!sessionId) {
    return res.status(400).json({ ok: false, error: { message: 'sessionId is required' } })
  }
  
  const session = desktopSessions.get(sessionId)
  if (!session) {
    return res.status(404).json({ ok: false, error: { message: 'Session not found' } })
  }
  
  session.lastActivityAt = Date.now()
  
  if (session.platform === 'linux' && session.display) {
    try {
      let cmd = ''
      if (type === 'mousemove') {
        cmd = `DISPLAY=${session.display} xdotool mousemove ${x} ${y}`
      } else if (type === 'click') {
        const btn = button === 2 ? 3 : button === 3 ? 2 : button
        cmd = `DISPLAY=${session.display} xdotool mousemove ${x} ${y} click ${btn + 1}`
      } else if (type === 'wheel') {
        const deltaY = wheelDeltaY || 0
        const btn = deltaY > 0 ? 5 : 4
        const clicks = Math.abs(deltaY) > 50 ? 3 : 1
        cmd = `DISPLAY=${session.display} xdotool click --repeat ${clicks} ${btn}`
      }
      
      if (cmd) {
        spawn('sh', ['-c', cmd])
      }
    } catch (e) {
      console.error('[Desktop] Mouse input error:', e.message)
    }
  }
  
  res.json({ ok: true })
})

app.post('/api/desktop/input/keyboard', authMiddleware, (req, res) => {
  const { sessionId, key, code, keyCode, shiftKey, ctrlKey, altKey, metaKey, type } = req.body
  
  if (!sessionId) {
    return res.status(400).json({ ok: false, error: { message: 'sessionId is required' } })
  }
  
  const session = desktopSessions.get(sessionId)
  if (!session) {
    return res.status(404).json({ ok: false, error: { message: 'Session not found' } })
  }
  
  session.lastActivityAt = Date.now()
  
  if (session.platform === 'linux' && session.display) {
    try {
      let keyName = key
      const specialKeys = {
        'Enter': 'Return',
        'Escape': 'Escape',
        'Backspace': 'BackSpace',
        'Tab': 'Tab',
        'ArrowUp': 'Up',
        'ArrowDown': 'Down',
        'ArrowLeft': 'Left',
        'ArrowRight': 'Right',
        'Control': 'Control_L',
        'Shift': 'Shift_L',
        'Alt': 'Alt_L',
        'Meta': 'Super_L',
        ' ': 'space',
      }
      
      if (specialKeys[key]) {
        keyName = specialKeys[key]
      }
      
      const modifiers = []
      if (ctrlKey) modifiers.push('ctrl')
      if (shiftKey) modifiers.push('shift')
      if (altKey) modifiers.push('alt')
      if (metaKey) modifiers.push('super')
      
      let cmd = ''
      if (type === 'keydown') {
        if (modifiers.length > 0) {
          cmd = `DISPLAY=${session.display} xdotool keydown ${modifiers.map(m => m).join(' ')} ${keyName}`
        } else {
          cmd = `DISPLAY=${session.display} xdotool keydown ${keyName}`
        }
      } else if (type === 'keyup') {
        cmd = `DISPLAY=${session.display} xdotool keyup ${keyName}`
      }
      
      if (cmd) {
        spawn('sh', ['-c', cmd])
      }
    } catch (e) {
      console.error('[Desktop] Keyboard input error:', e.message)
    }
  }
  
  res.json({ ok: true })
})

app.post('/api/desktop/input/clipboard', authMiddleware, (req, res) => {
  const { sessionId, text } = req.body
  
  if (!sessionId) {
    return res.status(400).json({ ok: false, error: { message: 'sessionId is required' } })
  }
  
  const session = desktopSessions.get(sessionId)
  if (!session) {
    return res.status(404).json({ ok: false, error: { message: 'Session not found' } })
  }
  
  session.lastActivityAt = Date.now()
  
  res.json({ ok: true })
})

app.post('/api/desktop/resize', authMiddleware, (req, res) => {
  const { sessionId, width, height } = req.body
  
  if (!sessionId) {
    return res.status(400).json({ ok: false, error: { message: 'sessionId is required' } })
  }
  
  const session = desktopSessions.get(sessionId)
  if (!session) {
    return res.status(404).json({ ok: false, error: { message: 'Session not found' } })
  }
  
  session.width = width || session.width
  session.height = height || session.height
  session.lastActivityAt = Date.now()
  
  for (const client of session.clients) {
    try {
      client.write(`data: ${JSON.stringify({ type: 'resized', sessionId, width: session.width, height: session.height })}\n\n`)
    } catch (e) {}
  }
  
  res.json({ ok: true, width: session.width, height: session.height })
})

app.post('/api/desktop/destroy', authMiddleware, (req, res) => {
  const { sessionId } = req.body
  
  if (!sessionId) {
    return res.status(400).json({ ok: false, error: { message: 'sessionId is required' } })
  }
  
  const session = desktopSessions.get(sessionId)
  if (!session) {
    return res.json({ ok: true, message: 'Session already destroyed' })
  }
  
  for (const client of session.clients) {
    try {
      client.write(`data: ${JSON.stringify({ type: 'disconnected', sessionId, message: 'Session destroyed' })}\n\n`)
      client.end()
    } catch (e) {}
  }
  
  if (session.ffmpegProcess) {
    try {
      session.ffmpegProcess.kill()
      console.log(`[Desktop] FFmpeg killed for session ${sessionId}`)
    } catch (e) {}
  }
  
  if (session.vncProcess) {
    try {
      session.vncProcess.kill()
      console.log(`[Desktop] x11vnc killed for session ${sessionId}`)
    } catch (e) {}
  }
  
  if (session.xvfbProcess) {
    try {
      session.xvfbProcess.kill()
      console.log(`[Desktop] Xvfb killed for session ${sessionId}`)
    } catch (e) {}
  }
  
  desktopSessions.delete(sessionId)
  console.log(`[Desktop] Session ${sessionId} destroyed`)
  res.json({ ok: true })
})

app.post('/api/desktop/heartbeat', authMiddleware, (req, res) => {
  const { sessionId } = req.body
  
  if (!sessionId) {
    return res.status(400).json({ ok: false, error: { message: 'sessionId is required' } })
  }
  
  const session = desktopSessions.get(sessionId)
  if (!session) {
    return res.status(404).json({ ok: false, error: { message: 'Session not found' } })
  }
  
  session.lastHeartbeat = Date.now()
  res.json({ ok: true })
})

function keyCodeToX11KeySym(keyCode, key) {
  const keyMap = {
    8: 0xFF08,
    9: 0xFF09,
    13: 0xFF0D,
    16: 0xFFE1,
    17: 0xFFE3,
    18: 0xFFE9,
    19: 0xFF13,
    20: 0xFFE5,
    27: 0xFF1B,
    32: 0x0020,
    33: 0xFF55,
    34: 0xFF56,
    35: 0xFF57,
    36: 0xFF50,
    37: 0xFF51,
    38: 0xFF52,
    39: 0xFF53,
    40: 0xFF54,
    45: 0xFF63,
    46: 0xFFFF,
    91: 0xFFEB,
    92: 0xFF67,
    93: 0xFF67,
    112: 0xFFBE,
    113: 0xFFBF,
    114: 0xFFC0,
    115: 0xFFC1,
    116: 0xFFC2,
    117: 0xFFC3,
    118: 0xFFC4,
    119: 0xFFC5,
    120: 0xFFC6,
    121: 0xFFC7,
    122: 0xFFC8,
    123: 0xFFC9,
    144: 0xFF7F,
    145: 0xFF14,
    186: 0x003B,
    187: 0x003D,
    188: 0x002C,
    189: 0x002D,
    190: 0x002E,
    191: 0x002F,
    192: 0x0060,
    219: 0x005B,
    220: 0x005C,
    221: 0x005D,
    222: 0x0027,
  }
  
  if (keyMap[keyCode]) {
    return keyMap[keyCode]
  }
  
  if (key && key.length === 1) {
    return key.charCodeAt(0)
  }
  
  return keyCode
}

// ============ Wizard API (Scenarios & Tasks) ============

// Scenarios API
app.get('/api/wizard/scenarios', authMiddleware, (req, res) => {
  try {
    const rows = db.prepare('SELECT * FROM scenarios ORDER BY updated_at DESC').all()
    const scenarios = rows.map(row => ({
      id: row.id,
      name: row.name,
      description: row.description,
      status: row.status,
      agentSelectionMode: row.agent_selection_mode,
      selectedAgents: JSON.parse(row.selected_agents || '[]'),
      generatedAgents: JSON.parse(row.generated_agents || '[]'),
      bindings: JSON.parse(row.bindings || '[]'),
      tasks: JSON.parse(row.tasks || '[]'),
      executionLog: JSON.parse(row.execution_log || '[]'),
      createdAt: row.created_at,
      updatedAt: row.updated_at,
    }))
    res.json({ ok: true, scenarios })
  } catch (err) {
    console.error('[Wizard] Get scenarios error:', err)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

app.get('/api/wizard/scenarios/:id', authMiddleware, (req, res) => {
  try {
    const row = db.prepare('SELECT * FROM scenarios WHERE id = ?').get(req.params.id)
    if (!row) {
      return res.status(404).json({ ok: false, error: { message: 'Scenario not found' } })
    }
    const scenario = {
      id: row.id,
      name: row.name,
      description: row.description,
      status: row.status,
      agentSelectionMode: row.agent_selection_mode,
      selectedAgents: JSON.parse(row.selected_agents || '[]'),
      generatedAgents: JSON.parse(row.generated_agents || '[]'),
      bindings: JSON.parse(row.bindings || '[]'),
      tasks: JSON.parse(row.tasks || '[]'),
      executionLog: JSON.parse(row.execution_log || '[]'),
      createdAt: row.created_at,
      updatedAt: row.updated_at,
    }
    res.json({ ok: true, scenario })
  } catch (err) {
    console.error('[Wizard] Get scenario error:', err)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

app.post('/api/wizard/scenarios', authMiddleware, (req, res) => {
  try {
    const id = randomUUID()
    const now = Date.now()
    const { name, description, agentSelectionMode, selectedAgents, generatedAgents, bindings, tasks, status, executionLog } = req.body
    
    db.prepare(`
      INSERT INTO scenarios (id, name, description, status, agent_selection_mode, selected_agents, generated_agents, bindings, tasks, execution_log, created_at, updated_at)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    `).run(
      id,
      name || '',
      description || '',
      status || 'draft',
      agentSelectionMode || 'existing',
      JSON.stringify(selectedAgents || []),
      JSON.stringify(generatedAgents || []),
      JSON.stringify(bindings || []),
      JSON.stringify(tasks || []),
      JSON.stringify(executionLog || []),
      now,
      now
    )
    
    const scenario = {
      id,
      name: name || '',
      description: description || '',
      status: status || 'draft',
      agentSelectionMode: agentSelectionMode || 'existing',
      selectedAgents: selectedAgents || [],
      generatedAgents: generatedAgents || [],
      bindings: bindings || [],
      tasks: tasks || [],
      executionLog: executionLog || [],
      createdAt: now,
      updatedAt: now,
    }
    
    console.log('[Wizard] Created scenario:', id, name)
    res.json({ ok: true, scenario })
  } catch (err) {
    console.error('[Wizard] Create scenario error:', err)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

app.put('/api/wizard/scenarios/:id', authMiddleware, (req, res) => {
  try {
    const existing = db.prepare('SELECT id FROM scenarios WHERE id = ?').get(req.params.id)
    if (!existing) {
      return res.status(404).json({ ok: false, error: { message: 'Scenario not found' } })
    }
    
    const now = Date.now()
    const { name, description, status, agentSelectionMode, selectedAgents, generatedAgents, bindings, tasks, executionLog } = req.body
    
    db.prepare(`
      UPDATE scenarios 
      SET name = ?, description = ?, status = ?, agent_selection_mode = ?, selected_agents = ?, generated_agents = ?, bindings = ?, tasks = ?, execution_log = ?, updated_at = ?
      WHERE id = ?
    `).run(
      name,
      description,
      status,
      agentSelectionMode,
      JSON.stringify(selectedAgents || []),
      JSON.stringify(generatedAgents || []),
      JSON.stringify(bindings || []),
      JSON.stringify(tasks || []),
      JSON.stringify(executionLog || []),
      now,
      req.params.id
    )
    
    console.log('[Wizard] Updated scenario:', req.params.id)
    res.json({ ok: true, updatedAt: now })
  } catch (err) {
    console.error('[Wizard] Update scenario error:', err)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

app.delete('/api/wizard/scenarios/:id', authMiddleware, (req, res) => {
  try {
    db.prepare('DELETE FROM tasks WHERE scenario_id = ?').run(req.params.id)
    db.prepare('DELETE FROM scenarios WHERE id = ?').run(req.params.id)
    
    console.log('[Wizard] Deleted scenario:', req.params.id)
    res.json({ ok: true })
  } catch (err) {
    console.error('[Wizard] Delete scenario error:', err)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

// Tasks API
app.get('/api/wizard/tasks', authMiddleware, (req, res) => {
  try {
    const scenarioId = req.query.scenarioId
    let rows
    if (scenarioId) {
      rows = db.prepare('SELECT * FROM tasks WHERE scenario_id = ? ORDER BY updated_at DESC').all(scenarioId)
    } else {
      rows = db.prepare('SELECT * FROM tasks ORDER BY updated_at DESC').all()
    }
    
    const tasks = rows.map(row => ({
      id: row.id,
      scenarioId: row.scenario_id,
      title: row.title,
      description: row.description,
      status: row.status,
      assignedAgents: JSON.parse(row.assigned_agents || '[]'),
      priority: row.priority,
      mode: row.mode,
      conversationHistory: JSON.parse(row.conversation_history || '[]'),
      executionHistory: JSON.parse(row.execution_history || '[]'),
      createdAt: row.created_at,
      updatedAt: row.updated_at,
    }))
    res.json({ ok: true, tasks })
  } catch (err) {
    console.error('[Wizard] Get tasks error:', err)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

app.get('/api/wizard/tasks/:id', authMiddleware, (req, res) => {
  try {
    const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id)
    if (!row) {
      return res.status(404).json({ ok: false, error: { message: 'Task not found' } })
    }
    const task = {
      id: row.id,
      scenarioId: row.scenario_id,
      title: row.title,
      description: row.description,
      status: row.status,
      assignedAgents: JSON.parse(row.assigned_agents || '[]'),
      priority: row.priority,
      mode: row.mode,
      conversationHistory: JSON.parse(row.conversation_history || '[]'),
      executionHistory: JSON.parse(row.execution_history || '[]'),
      createdAt: row.created_at,
      updatedAt: row.updated_at,
    }
    res.json({ ok: true, task })
  } catch (err) {
    console.error('[Wizard] Get task error:', err)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

app.post('/api/wizard/tasks', authMiddleware, (req, res) => {
  try {
    const id = randomUUID()
    const now = Date.now()
    const { scenarioId, title, description, status, assignedAgents, priority, mode, conversationHistory, executionHistory } = req.body
    
    db.prepare(`
      INSERT INTO tasks (id, scenario_id, title, description, status, assigned_agents, priority, mode, conversation_history, execution_history, created_at, updated_at)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    `).run(
      id,
      scenarioId || null,
      title || '',
      description || '',
      status || 'pending',
      JSON.stringify(assignedAgents || []),
      priority || 'medium',
      mode || 'default',
      JSON.stringify(conversationHistory || []),
      JSON.stringify(executionHistory || []),
      now,
      now
    )
    
    const task = {
      id,
      scenarioId: scenarioId || null,
      title: title || '',
      description: description || '',
      status: status || 'pending',
      assignedAgents: assignedAgents || [],
      priority: priority || 'medium',
      mode: mode || 'default',
      conversationHistory: conversationHistory || [],
      executionHistory: executionHistory || [],
      createdAt: now,
      updatedAt: now,
    }
    
    console.log('[Wizard] Created task:', id, title)
    res.json({ ok: true, task })
  } catch (err) {
    console.error('[Wizard] Create task error:', err)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

app.put('/api/wizard/tasks/:id', authMiddleware, (req, res) => {
  try {
    const existing = db.prepare('SELECT id FROM tasks WHERE id = ?').get(req.params.id)
    if (!existing) {
      return res.status(404).json({ ok: false, error: { message: 'Task not found' } })
    }
    
    const now = Date.now()
    const { scenarioId, title, description, status, assignedAgents, priority, mode, conversationHistory, executionHistory } = req.body
    
    db.prepare(`
      UPDATE tasks 
      SET scenario_id = ?, title = ?, description = ?, status = ?, assigned_agents = ?, priority = ?, mode = ?, conversation_history = ?, execution_history = ?, updated_at = ?
      WHERE id = ?
    `).run(
      scenarioId,
      title,
      description,
      status,
      JSON.stringify(assignedAgents || []),
      priority,
      mode,
      JSON.stringify(conversationHistory || []),
      JSON.stringify(executionHistory || []),
      now,
      req.params.id
    )
    
    console.log('[Wizard] Updated task:', req.params.id)
    res.json({ ok: true, updatedAt: now })
  } catch (err) {
    console.error('[Wizard] Update task error:', err)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

app.delete('/api/wizard/tasks/:id', authMiddleware, (req, res) => {
  try {
    db.prepare('DELETE FROM tasks WHERE id = ?').run(req.params.id)
    
    console.log('[Wizard] Deleted task:', req.params.id)
    res.json({ ok: true })
  } catch (err) {
    console.error('[Wizard] Delete task error:', err)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

// Media API endpoint
app.get('/api/media', (req, res) => {
  try {
    const path = req.query.path
    if (!path) {
      return res.status(400).json({ ok: false, error: { message: 'Path parameter is required' } })
    }
    
    console.log('[Media] Request path:', path)
    
    // Prevent directory traversal
    const safePath = path.replace(/\.\./g, '').replace(/\//g, sep)
    console.log('[Media] Safe path:', safePath)
    
    // 支持多个可能的媒体目录，按优先级搜索
    const possibleMediaDirs = []
    
    // 1. .env 文件中的 MEDIA_DIR（最高优先级）
    if (envConfig.MEDIA_DIR) {
      possibleMediaDirs.push(envConfig.MEDIA_DIR)
    }
    
    // 2. 系统环境变量 MEDIA_DIR
    if (process.env.MEDIA_DIR) {
      possibleMediaDirs.push(process.env.MEDIA_DIR)
    }
    
    // 3. OPENCLAW_HOME 推导的媒体目录
    const openclawHome = process.env.OPENCLAW_HOME
    if (openclawHome) {
      possibleMediaDirs.push(join(openclawHome, '.openclaw', 'media'))
    }
    
    // 4. 当前用户主目录
    possibleMediaDirs.push(join(os.homedir(), '.openclaw', 'media'))
    
    // 5. 常见的其他用户目录（适用于 root 运行但文件在 ubuntu 用户目录的情况）
    if (process.platform !== 'win32') {
      possibleMediaDirs.push('/home/ubuntu/.openclaw/media')
      possibleMediaDirs.push('/home/user/.openclaw/media')
    }
    
    // 去重
    const uniqueMediaDirs = [...new Set(possibleMediaDirs)]
    console.log('[Media] Searching in dirs:', uniqueMediaDirs)
    
    let foundFile = null
    let usedMediaDir = null
    
    for (const mediaDir of uniqueMediaDirs) {
      const fullPath = resolve(mediaDir, safePath)
      
      // 安全检查：确保路径在媒体目录内
      if (!fullPath.startsWith(mediaDir)) {
        continue
      }
      
      if (existsSync(fullPath)) {
        const stats = statSync(fullPath)
        if (stats.isFile()) {
          foundFile = fullPath
          usedMediaDir = mediaDir
          break
        }
      }
    }
    
    if (!foundFile) {
      console.log('[Media] File not found in any media dir:', safePath)
      return res.status(404).json({ ok: false, error: { message: 'File not found' } })
    }
    
    console.log('[Media] File found:', foundFile, '| Media dir:', usedMediaDir)
    
    const stats = statSync(foundFile)
    if (!stats.isFile()) {
      return res.status(400).json({ ok: false, error: { message: 'Not a file' } })
    }
    
    // Set appropriate content type based on file extension
    const ext = extname(foundFile).toLowerCase()
    const contentTypeMap = {
      '.png': 'image/png',
      '.jpg': 'image/jpeg',
      '.jpeg': 'image/jpeg',
      '.gif': 'image/gif',
      '.webp': 'image/webp',
      '.svg': 'image/svg+xml'
    }
    
    const contentType = contentTypeMap[ext] || 'application/octet-stream'
    res.setHeader('Content-Type', contentType)
    res.setHeader('Content-Length', stats.size)
    
    // Stream the file
    const stream = createReadStream(foundFile)
    stream.pipe(res)
    
    stream.on('error', (err) => {
      console.error('[Media] Error streaming file:', err.message)
      res.status(500).json({ ok: false, error: { message: 'Internal server error' } })
    })
  } catch (err) {
    console.error('[Media] Error:', err.message)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

// ============================================================
// Backup & Restore API
// ============================================================

import { createGzip, createGunzip } from 'zlib'
import { pipeline, Readable } from 'stream'
import { pipeline as pipelinePromises } from 'stream/promises'
import { createHash } from 'crypto'
import archiver from 'archiver'
import unzipper from 'unzipper'
import AdmZip from 'adm-zip'

const BACKUP_DIR = join(__dirname, '../backups')
const DATA_DIR = join(__dirname, '../data')
const PROJECT_ROOT = join(__dirname, '..')
const WIZARD_DB_PATH = join(DATA_DIR, 'wizard.db')
const ENV_PATH = join(PROJECT_ROOT, '.env')

const backupTasks = new Map()

function ensureBackupDir() {
  if (!existsSync(BACKUP_DIR)) {
    mkdirSync(BACKUP_DIR, { recursive: true })
  }
}

function generateTaskId() {
  return `backup-task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}

function broadcastBackupProgress(taskId, progress) {
  broadcastSSE({
    type: 'backupProgress',
    taskId,
    ...progress
  })
}

async function executeOpenClawBackup(outputPath) {
  return new Promise((resolve, reject) => {
    const openclawHome = process.env.OPENCLAW_HOME
    
    let command, args, spawnOptions
    
    if (openclawHome && process.platform !== 'win32') {
      const username = openclawHome.split('/').pop()
      console.log(`[Backup] Running as user '${username}' with full environment`)
      
      command = 'su'
      args = ['-', username, '-c', `openclaw backup create --output ${outputPath}`]
      spawnOptions = { stdio: ['ignore', 'pipe', 'pipe'] }
    } else {
      command = 'openclaw'
      args = ['backup', 'create', '--output', outputPath]
      spawnOptions = {
        stdio: ['ignore', 'pipe', 'pipe'],
        shell: process.platform === 'win32',
        env: openclawHome 
          ? { ...process.env, HOME: openclawHome }
          : process.env
      }
      if (openclawHome) {
        console.log(`[Backup] Using OPENCLAW_HOME: ${openclawHome}`)
      }
    }
    
    const proc = spawn(command, args, spawnOptions)

    let stdout = ''
    let stderr = ''

    proc.stdout.on('data', (data) => {
      stdout += data.toString()
    })

    proc.stderr.on('data', (data) => {
      stderr += data.toString()
    })

    proc.on('close', (code) => {
      if (code === 0) {
        resolve({ success: true, stdout, stderr })
      } else {
        reject(new Error(`OpenClaw backup failed with code ${code}: ${stderr || stdout}`))
      }
    })

    proc.on('error', (err) => {
      reject(new Error(`Failed to execute openclaw backup: ${err.message}`))
    })
  })
}

async function extractOpenClawBackup(backupPath, tempDir) {
  return new Promise((resolve, reject) => {
    const homeDir = process.env.OPENCLAW_HOME || os.homedir()
    const openclawDir = join(homeDir, '.openclaw')
    console.log('[Restore] Target OpenClaw directory:', openclawDir)

    if (!existsSync(backupPath)) {
      return reject(new Error(`OpenClaw backup file not found: ${backupPath}`))
    }

    const stat = statSync(backupPath)
    if (stat.size === 0) {
      return reject(new Error('OpenClaw backup file is empty'))
    }

    console.log('[Restore] Extracting OpenClaw backup:', backupPath, 'size:', stat.size)

    const proc = spawn('tar', ['-xzf', backupPath, '-C', tempDir, '--ignore-zeros'], {
      stdio: ['ignore', 'pipe', 'pipe']
    })

    let stdout = ''
    let stderr = ''
    let timeout = null

    const timeoutMs = Math.max(300000, Math.ceil(stat.size / 1024 / 1024) * 1000)
    console.log('[Restore] Setting timeout to', timeoutMs / 1000, 'seconds for', Math.ceil(stat.size / 1024 / 1024), 'MB file')

    timeout = setTimeout(() => {
      proc.kill()
      reject(new Error('OpenClaw backup extraction timed out'))
    }, timeoutMs)

    proc.stdout.on('data', (data) => {
      stdout += data.toString()
    })

    proc.stderr.on('data', (data) => {
      stderr += data.toString()
    })

    proc.on('close', async (code) => {
      clearTimeout(timeout)
      try {
        if (code !== 0 && stderr.includes('unexpected end of file')) {
          return reject(new Error('OpenClaw backup file is corrupted or incomplete'))
        }

        const items = readdirSync(tempDir, { withFileTypes: true })
        const backupRoot = items.find(item => item.isDirectory() && item.name.includes('openclaw-backup'))

        if (!backupRoot) {
          if (code !== 0 && stderr) {
            console.warn('[Restore] tar warnings:', stderr)
          }
          return reject(new Error('Invalid OpenClaw backup: no backup root directory found'))
        }

        const payloadPath = join(tempDir, backupRoot.name, 'payload')
        if (!existsSync(payloadPath)) {
          return reject(new Error('Invalid OpenClaw backup: no payload directory found'))
        }

        function findOpenClawDir(dir) {
          try {
            const items = readdirSync(dir, { withFileTypes: true })
            for (const item of items) {
              if (item.isDirectory()) {
                if (item.name === '.openclaw') {
                  return join(dir, item.name)
                }
                const found = findOpenClawDir(join(dir, item.name))
                if (found) return found
              }
            }
          } catch (e) {
            console.warn('[Restore] Error reading directory:', dir, e.message)
          }
          return null
        }

        const extractedOpenClawDir = findOpenClawDir(payloadPath)
        if (!extractedOpenClawDir) {
          return reject(new Error('Invalid OpenClaw backup: .openclaw directory not found in payload'))
        }

        function copyDirSync(src, dest, overwrite = false) {
          if (!existsSync(dest)) {
            mkdirSync(dest, { recursive: true })
          }
          const entries = readdirSync(src, { withFileTypes: true })
          for (const entry of entries) {
            const srcPath = join(src, entry.name)
            const destPath = join(dest, entry.name)
            try {
              if (entry.isDirectory()) {
                copyDirSync(srcPath, destPath, overwrite)
              } else if (entry.isSymbolicLink()) {
                if (overwrite) {
                  try { unlinkSync(destPath) } catch (e) { }
                } else if (existsSync(destPath)) {
                  continue
                }
                const linkTarget = readlinkSync(srcPath)
                symlinkSync(linkTarget, destPath)
              } else {
                if (existsSync(destPath)) {
                  if (overwrite) {
                    try {
                      unlinkSync(destPath)
                      copyFileSync(srcPath, destPath)
                    } catch (e) {
                      console.warn('[Restore] Could not overwrite (file may be locked):', destPath)
                    }
                  }
                } else {
                  copyFileSync(srcPath, destPath)
                }
              }
            } catch (e) {
              console.warn('[Restore] Error copying:', srcPath, e.message)
            }
          }
        }

        copyDirSync(extractedOpenClawDir, openclawDir, true)

        console.log('[Restore] OpenClaw backup restored to:', openclawDir)
        resolve({ success: true, stdout, stderr, warnings: code !== 0 ? stderr : null })
      } catch (err) {
        reject(new Error(`Failed to move extracted files: ${err.message}`))
      }
    })

    proc.on('error', (err) => {
      clearTimeout(timeout)
      reject(new Error(`Failed to extract OpenClaw backup: ${err.message}`))
    })
  })
}

async function createZipArchive(outputPath, files) {
  return new Promise((resolve, reject) => {
    const output = createWriteStream(outputPath)
    const archive = archiver('zip', { zlib: { level: 9 } })

    output.on('close', () => {
      const size = archive.pointer()
      console.log('[Backup] ZIP archive created:', outputPath, 'size:', size)
      resolve({ size })
    })

    output.on('error', (err) => {
      console.error('[Backup] ZIP output error:', err.message)
      reject(err)
    })

    archive.on('error', (err) => {
      console.error('[Backup] ZIP archive error:', err.message)
      reject(err)
    })

    archive.on('warning', (err) => {
      console.warn('[Backup] ZIP archive warning:', err.message)
    })

    archive.pipe(output)

    let addedFiles = 0
    let totalSize = 0
    for (const file of files) {
      if (existsSync(file.path)) {
        const stat = statSync(file.path)
        console.log('[Backup] Adding to ZIP:', file.name, 'size:', stat.size)
        archive.file(file.path, { name: file.name })
        addedFiles++
        totalSize += stat.size
      } else {
        console.warn('[Backup] File not found, skipping:', file.path)
      }
    }

    console.log('[Backup] ZIP contains', addedFiles, 'files, total size:', totalSize)
    archive.finalize()
  })
}

async function extractZipArchive(zipPath, targetDir) {
  return new Promise((resolve, reject) => {
    try {
      if (!existsSync(zipPath)) {
        return reject(new Error(`ZIP file not found: ${zipPath}`))
      }

      const stat = statSync(zipPath)
      if (stat.size === 0) {
        return reject(new Error('ZIP file is empty'))
      }

      console.log('[Restore] Extracting ZIP:', zipPath, 'size:', stat.size)

      const zip = new AdmZip(zipPath)
      const zipEntries = zip.getEntries()

      console.log('[Restore] ZIP contains', zipEntries.length, 'entries')

      zip.extractAllTo(targetDir, true)

      console.log('[Restore] ZIP extraction completed to:', targetDir)
      resolve()
    } catch (err) {
      console.error('[Restore] ZIP extraction failed:', err.message)
      reject(new Error(`Failed to extract ZIP: ${err.message}`))
    }
  })
}

async function executeBackupTask(taskId, params = {}) {
  const task = backupTasks.get(taskId)
  if (!task) return

  createBackupRecord(taskId, 'create')
  const tempDir = join(os.tmpdir(), `.openclaw_backup_${taskId}`)

  try {
    task.status = 'running'
    task.progress = 0
    task.message = 'Starting backup...'
    broadcastBackupProgress(taskId, { status: 'running', progress: 0, message: task.message })
    updateBackupRecord(taskId, { status: 'running', progress: 0, message: task.message })

    ensureBackupDir()

    if (!existsSync(tempDir)) {
      mkdirSync(tempDir, { recursive: true })
    }

    const timestamp = new Date().toISOString()
      .replace(/T/, '_')
      .replace(/:/g, '-')
      .replace(/\..+/, '')

    const backupFilename = `backup_${timestamp}.zip`
    const backupPath = join(BACKUP_DIR, backupFilename)

    task.message = 'Creating OpenClaw backup...'
    task.progress = 10
    broadcastBackupProgress(taskId, { status: 'running', progress: 10, message: task.message, stage: 'openclaw_backup' })
    updateBackupRecord(taskId, { status: 'running', progress: 10, message: task.message, stage: 'openclaw_backup' })

    const openclawBackupPath = join(os.tmpdir(), `openclaw_backup_${taskId}.tar.gz`)
    const openclawFinalPath = join(tempDir, 'openclaw_backup.tar.gz')
    try {
      await executeOpenClawBackup(openclawBackupPath)
      console.log('[Backup] OpenClaw backup created')
      
      if (existsSync(openclawBackupPath)) {
        copyFileSync(openclawBackupPath, openclawFinalPath)
        unlinkSync(openclawBackupPath)
      }
    } catch (err) {
      console.warn('[Backup] OpenClaw backup skipped:', err.message)
      if (existsSync(openclawBackupPath)) {
        try { unlinkSync(openclawBackupPath) } catch (e) {}
      }
    }

    task.message = 'Backing up project database...'
    task.progress = 30
    broadcastBackupProgress(taskId, { status: 'running', progress: 30, message: task.message, stage: 'project_database' })
    updateBackupRecord(taskId, { status: 'running', progress: 30, message: task.message, stage: 'project_database' })

    const filesToArchive = []

    if (existsSync(openclawFinalPath)) {
      filesToArchive.push({ path: openclawFinalPath, name: 'openclaw_backup.tar.gz' })
      console.log('[Backup] OpenClaw backup added')
    }

    if (existsSync(WIZARD_DB_PATH)) {
      filesToArchive.push({ path: WIZARD_DB_PATH, name: 'data/wizard.db' })
      console.log('[Backup] Wizard database added')
    }

    task.message = 'Backing up environment config...'
    task.progress = 50
    broadcastBackupProgress(taskId, { status: 'running', progress: 50, message: task.message, stage: 'env_config' })
    updateBackupRecord(taskId, { status: 'running', progress: 50, message: task.message, stage: 'env_config' })

    if (existsSync(ENV_PATH)) {
      const tempEnvPath = join(tempDir, '.env')
      copyFileSync(ENV_PATH, tempEnvPath)
      filesToArchive.push({ path: tempEnvPath, name: '.env' })
      console.log('[Backup] Environment config added')
    }

    const manifest = {
      version: '3.0',
      createdAt: new Date().toISOString(),
      platform: process.platform,
      nodeVersion: process.version,
      source: 'openclaw-admin',
      components: {
        openclaw: existsSync(openclawBackupPath),
        wizardDb: existsSync(WIZARD_DB_PATH),
        env: existsSync(ENV_PATH)
      }
    }
    const manifestPath = join(tempDir, 'manifest.json')
    writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
    filesToArchive.push({ path: manifestPath, name: 'manifest.json' })

    task.message = 'Creating archive...'
    task.progress = 70
    broadcastBackupProgress(taskId, { status: 'running', progress: 70, message: task.message, stage: 'archiving' })
    updateBackupRecord(taskId, { status: 'running', progress: 70, message: task.message, stage: 'archiving' })

    const archiveResult = await createZipArchive(backupPath, filesToArchive)

    task.message = 'Cleaning up...'
    task.progress = 90
    broadcastBackupProgress(taskId, { status: 'running', progress: 90, message: task.message, stage: 'cleanup' })
    updateBackupRecord(taskId, { status: 'running', progress: 90, message: task.message, stage: 'cleanup' })

    rmSync(tempDir, { recursive: true, force: true })

    task.status = 'completed'
    task.progress = 100
    task.message = 'Backup completed successfully'
    task.completedAt = Date.now()
    task.result = {
      filename: backupFilename,
      size: archiveResult.size
    }

    updateBackupRecord(taskId, {
      status: 'completed',
      progress: 100,
      message: task.message,
      filename: backupFilename,
      size: archiveResult.size,
      completedAt: task.completedAt,
      result: task.result
    })

    broadcastBackupProgress(taskId, {
      status: 'completed',
      progress: 100,
      message: task.message,
      result: task.result
    })

    console.log(`[Backup] Created: ${backupFilename}`)
  } catch (err) {
    console.error('[Backup] Create error:', err.message)
    task.status = 'failed'
    task.error = err.message
    task.message = `Backup failed: ${err.message}`
    task.completedAt = Date.now()

    updateBackupRecord(taskId, {
      status: 'failed',
      error: err.message,
      message: task.message,
      completedAt: task.completedAt
    })

    if (existsSync(tempDir)) {
      rmSync(tempDir, { recursive: true, force: true })
    }

    broadcastBackupProgress(taskId, {
      status: 'failed',
      progress: task.progress,
      message: task.message,
      error: err.message
    })
  }
}

async function executeRestoreTask(taskId, filename) {
  const task = backupTasks.get(taskId)
  if (!task) return

  const tempDir = join(os.tmpdir(), `.openclaw_restore_${taskId}`)

  try {
    task.status = 'running'
    task.progress = 0
    task.message = 'Starting restore...'
    broadcastBackupProgress(taskId, { status: 'running', progress: 0, message: task.message })
    updateBackupRecord(taskId, { status: 'running', progress: 0, message: task.message })

    const safeName = basename(filename)
    if (safeName !== filename || (!filename.endsWith('.zip') && !filename.endsWith('.json.gz'))) {
      throw new Error('Invalid filename. Expected .zip or .json.gz file.')
    }

    const backupPath = join(BACKUP_DIR, safeName)
    if (!existsSync(backupPath)) {
      throw new Error('Backup not found')
    }

    if (!existsSync(tempDir)) {
      mkdirSync(tempDir, { recursive: true })
    }

    let manifest = { version: '1.0' }
    let backupData = null

    if (filename.endsWith('.zip')) {
      task.message = 'Extracting backup archive...'
      task.progress = 10
      broadcastBackupProgress(taskId, { status: 'running', progress: 10, message: task.message, stage: 'extracting' })

      await extractZipArchive(backupPath, tempDir)

      const manifestPath = join(tempDir, 'manifest.json')
      if (existsSync(manifestPath)) {
        manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'))
      }
      console.log('[Restore] Manifest:', manifest)
    } else if (filename.endsWith('.json.gz')) {
      task.message = 'Reading legacy backup file...'
      task.progress = 10
      broadcastBackupProgress(taskId, { status: 'running', progress: 10, message: task.message, stage: 'reading' })

      const gzip = createGunzip()
      const readStream = createReadStream(backupPath)
      const chunks = []

      await pipelinePromises(
        readStream,
        gzip,
        async function* (source) {
          for await (const chunk of source) {
            chunks.push(chunk)
            yield chunk
          }
        }
      )

      const jsonContent = Buffer.concat(chunks).toString('utf-8')
      backupData = JSON.parse(jsonContent)
      manifest = { version: backupData.version || '1.0', legacy: true }
      console.log('[Restore] Legacy backup version:', manifest.version)

      if (backupData.data) {
        if (backupData.data.wizardDatabase) {
          const wizardDbDir = join(tempDir, 'data')
          mkdirSync(wizardDbDir, { recursive: true })
          const wizardDbContent = Buffer.from(backupData.data.wizardDatabase.content, 'base64')
          writeFileSync(join(wizardDbDir, 'wizard.db'), wizardDbContent)
        }
        if (backupData.data.envConfig) {
          writeFileSync(join(tempDir, '.env'), backupData.data.envConfig)
        }
      }
    }

    const results = {
      wizardDb: false,
      env: false,
      openclaw: false,
      errors: []
    }

    task.message = 'Restoring OpenClaw data...'
    task.progress = 30
    broadcastBackupProgress(taskId, { status: 'running', progress: 30, message: task.message, stage: 'openclaw_restore' })
    updateBackupRecord(taskId, { status: 'running', progress: 30, message: task.message, stage: 'openclaw_restore' })

    const extractedOpenClawBackup = join(tempDir, 'openclaw_backup.tar.gz')
    if (existsSync(extractedOpenClawBackup)) {
      try {
        const openclawTempDir = join(os.tmpdir(), `.openclaw_extract_${taskId}`)
        mkdirSync(openclawTempDir, { recursive: true })
        await extractOpenClawBackup(extractedOpenClawBackup, openclawTempDir)
        rmSync(openclawTempDir, { recursive: true, force: true })
        results.openclaw = true
        console.log('[Restore] OpenClaw data restored')
      } catch (e) {
        results.errors.push(`OpenClaw restore failed: ${e.message}`)
        console.warn('[Restore] OpenClaw restore error:', e.message)
      }
    }

    task.message = 'Restoring project database...'
    task.progress = 50
    broadcastBackupProgress(taskId, { status: 'running', progress: 50, message: task.message, stage: 'project_database' })
    updateBackupRecord(taskId, { status: 'running', progress: 50, message: task.message, stage: 'project_database' })

    let backupDbPath = null
    const extractedWizardDb = join(tempDir, 'data', 'wizard.db')
    if (existsSync(extractedWizardDb)) {
      try {
        if (existsSync(WIZARD_DB_PATH)) {
          backupDbPath = `${WIZARD_DB_PATH}.bak-${Date.now()}`
          writeFileSync(backupDbPath, readFileSync(WIZARD_DB_PATH))
        }
        writeFileSync(WIZARD_DB_PATH, readFileSync(extractedWizardDb))
        results.wizardDb = true
        console.log('[Restore] Wizard database restored')
      } catch (e) {
        results.errors.push(`Wizard DB restore failed: ${e.message}`)
      }
    }

    let backupEnvPath = null
    task.message = 'Restoring environment config...'
    task.progress = 70
    broadcastBackupProgress(taskId, { status: 'running', progress: 70, message: task.message, stage: 'env_config' })
    updateBackupRecord(taskId, { status: 'running', progress: 70, message: task.message, stage: 'env_config' })

    const extractedEnv = join(tempDir, '.env')
    if (existsSync(extractedEnv)) {
      try {
        if (existsSync(ENV_PATH)) {
          backupEnvPath = `${ENV_PATH}.bak-${Date.now()}`
          copyFileSync(ENV_PATH, backupEnvPath)
        }
        copyFileSync(extractedEnv, ENV_PATH)
        results.env = true
        console.log('[Restore] Environment config restored')
      } catch (e) {
        results.errors.push(`Env restore failed: ${e.message}`)
      }
    }

    task.message = 'Cleaning up...'
    task.progress = 90
    broadcastBackupProgress(taskId, { status: 'running', progress: 90, message: task.message, stage: 'cleanup' })

    rmSync(tempDir, { recursive: true, force: true })

    if (backupDbPath && existsSync(backupDbPath)) {
      try {
        unlinkSync(backupDbPath)
        console.log('[Restore] Cleaned up database backup file')
      } catch (e) {
        console.warn('[Restore] Failed to cleanup database backup file:', e.message)
      }
    }
    if (backupEnvPath && existsSync(backupEnvPath)) {
      try {
        unlinkSync(backupEnvPath)
        console.log('[Restore] Cleaned up env backup file')
      } catch (e) {
        console.warn('[Restore] Failed to cleanup env backup file:', e.message)
      }
    }

    task.status = 'completed'
    task.progress = 100
    task.message = 'Restore completed successfully'
    task.completedAt = Date.now()
    task.result = results

    broadcastBackupProgress(taskId, {
      status: 'completed',
      progress: 100,
      message: task.message,
      result: results
    })

    console.log(`[Restore] Completed: ${safeName}`)
  } catch (err) {
    console.error('[Restore] Error:', err.message)
    task.status = 'failed'
    task.error = err.message
    task.message = `Restore failed: ${err.message}`
    task.completedAt = Date.now()

    if (existsSync(tempDir)) {
      rmSync(tempDir, { recursive: true, force: true })
    }

    broadcastBackupProgress(taskId, {
      status: 'failed',
      progress: task.progress,
      message: task.message,
      error: err.message
    })
  }
}

app.get('/api/backup/list', authMiddleware, (req, res) => {
  try {
    ensureBackupDir()
    const files = readdirSync(BACKUP_DIR)
      .filter(f => f.endsWith('.zip') || f.endsWith('.json.gz'))
      .map(f => {
        const filePath = join(BACKUP_DIR, f)
        const stats = statSync(filePath)
        const zipMatch = f.match(/backup_(\d{4}-\d{2}-\d{2})_(\d{2}-\d{2}-\d{2})\.zip/)
        const gzMatch = f.match(/backup_(\d{4}-\d{2}-\d{2})_(\d{2}-\d{2}-\d{2})\.json\.gz/)
        const uploadedZipMatch = f.match(/uploaded_(\d{4}-\d{2}-\d{2})_(\d{2}-\d{2}-\d{2})\.zip/)
        const uploadedGzMatch = f.match(/uploaded_(\d{4}-\d{2}-\d{2})_(\d{2}-\d{2}-\d{2})\.json\.gz/)
        const match = zipMatch || gzMatch || uploadedZipMatch || uploadedGzMatch
        return {
          filename: f,
          createdAt: stats.birthtime,
          size: stats.size,
          date: match ? `${match[1]} ${match[2].replace(/-/g, ':')}` : f,
          format: f.endsWith('.zip') ? 'zip' : 'json.gz'
        }
      })
      .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))

    res.json({ ok: true, backups: files })
  } catch (err) {
    console.error('[Backup] List error:', err.message)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

app.get('/api/backup/tasks', authMiddleware, (req, res) => {
  try {
    const dbTasks = getBackupRecords(20)
    const tasks = dbTasks.map(t => ({
      id: t.id,
      type: t.type,
      status: t.status,
      progress: t.progress,
      message: t.message,
      filename: t.filename,
      error: t.error,
      startedAt: t.created_at,
      completedAt: t.completed_at,
      result: t.filename ? { filename: t.filename } : null
    }))
    res.json({ ok: true, tasks })
  } catch (err) {
    console.error('[Backup] Failed to get tasks:', err.message)
    res.json({ ok: true, tasks: [] })
  }
})

app.get('/api/backup/tasks/:taskId', authMiddleware, (req, res) => {
  const task = backupTasks.get(req.params.taskId)
  if (!task) {
    return res.status(404).json({ ok: false, error: { message: 'Task not found' } })
  }
  res.json({
    ok: true,
    task: {
      id: task.id,
      type: task.type,
      status: task.status,
      progress: task.progress,
      message: task.message,
      filename: task.filename,
      error: task.error,
      startedAt: task.startedAt,
      completedAt: task.completedAt,
      result: task.result
    }
  })
})

app.delete('/api/backup/tasks/completed', authMiddleware, (req, res) => {
  try {
    const result = db.prepare('DELETE FROM backup_records WHERE status IN (?, ?)').run('completed', 'failed')
    console.log(`[Backup] Cleared ${result.changes} completed/failed tasks`)
    res.json({ ok: true, deleted: result.changes })
  } catch (err) {
    console.error('[Backup] Failed to clear completed tasks:', err.message)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

app.post('/api/backup/create', authMiddleware, async (req, res) => {
  try {
    const taskId = generateTaskId()
    const task = {
      id: taskId,
      type: 'create',
      status: 'pending',
      progress: 0,
      message: 'Backup task created',
      startedAt: Date.now(),
      params: req.body || {}
    }
    backupTasks.set(taskId, task)

    res.json({
      ok: true,
      taskId,
      message: 'Backup task created'
    })

    setImmediate(() => executeBackupTask(taskId, req.body))
  } catch (err) {
    console.error('[Backup] Create error:', err.message)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

// 下载备份
app.get('/api/backup/download', authMiddleware, (req, res) => {
  try {
    const filename = req.query.filename
    if (!filename) {
      return res.status(400).json({ ok: false, error: { message: 'Filename is required' } })
    }
    
    // 安全检查：防止路径遍历
    const safeName = basename(filename)
    if (safeName !== filename || (!filename.endsWith('.zip') && !filename.endsWith('.json.gz'))) {
      return res.status(400).json({ ok: false, error: { message: 'Invalid filename. Expected .zip or .json.gz file.' } })
    }
    
    const backupPath = join(BACKUP_DIR, safeName)
    if (!existsSync(backupPath)) {
      return res.status(404).json({ ok: false, error: { message: 'Backup not found' } })
    }
    
    const contentType = filename.endsWith('.zip') ? 'application/zip' : 'application/gzip'
    res.setHeader('Content-Type', contentType)
    res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`)
    
    const stream = createReadStream(backupPath)
    stream.pipe(res)
  } catch (err) {
    console.error('[Backup] Download error:', err.message)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

// 恢复备份
app.post('/api/backup/restore', authMiddleware, async (req, res) => {
  try {
    const { filename } = req.body
    if (!filename) {
      return res.status(400).json({ ok: false, error: { message: 'Filename is required' } })
    }

    const safeName = basename(filename)
    if (safeName !== filename || (!filename.endsWith('.zip') && !filename.endsWith('.json.gz'))) {
      return res.status(400).json({ ok: false, error: { message: 'Invalid filename. Expected .zip or .json.gz file.' } })
    }

    const backupPath = join(BACKUP_DIR, safeName)
    if (!existsSync(backupPath)) {
      return res.status(404).json({ ok: false, error: { message: 'Backup not found' } })
    }

    const taskId = generateTaskId()
    const task = {
      id: taskId,
      type: 'restore',
      status: 'pending',
      progress: 0,
      message: 'Restore task created',
      filename: safeName,
      startedAt: Date.now()
    }
    backupTasks.set(taskId, task)

    res.json({
      ok: true,
      taskId,
      message: 'Restore task created'
    })

    setImmediate(() => executeRestoreTask(taskId, filename))
  } catch (err) {
    console.error('[Restore] Error:', err.message)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

// 删除备份
app.delete('/api/backup/delete', authMiddleware, (req, res) => {
  try {
    const filename = req.query.filename
    if (!filename) {
      return res.status(400).json({ ok: false, error: { message: 'Filename is required' } })
    }

    const safeName = basename(filename)
    if (safeName !== filename || (!filename.endsWith('.zip') && !filename.endsWith('.json.gz'))) {
      return res.status(400).json({ ok: false, error: { message: 'Invalid filename. Expected .zip or .json.gz file.' } })
    }

    const backupPath = join(BACKUP_DIR, safeName)
    if (!existsSync(backupPath)) {
      return res.status(404).json({ ok: false, error: { message: 'Backup not found' } })
    }

    unlinkSync(backupPath)
    res.json({ ok: true, message: 'Backup deleted' })
    console.log(`[Backup] Deleted: ${safeName}`)
  } catch (err) {
    console.error('[Backup] Delete error:', err.message)
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

// 上传并恢复备份
const backupUpload = multer({ 
  dest: join(os.tmpdir(), 'openclaw-backup-uploads'),
  limits: { fileSize: 100 * 1024 * 1024 } // 100MB 限制
})

app.post('/api/backup/upload', authMiddleware, backupUpload.single('backup'), async (req, res) => {
  try {
    if (!req.file) {
      return res.status(400).json({ ok: false, error: { message: 'No backup file uploaded' } })
    }

    const tempPath = req.file.path

    const taskId = generateTaskId()
    const task = {
      id: taskId,
      type: 'upload',
      status: 'pending',
      progress: 0,
      message: 'Upload task created',
      filename: req.file.originalname,
      startedAt: Date.now(),
      tempPath
    }
    backupTasks.set(taskId, task)

    res.json({
      ok: true,
      taskId,
      message: 'Upload task created'
    })

    setImmediate(async () => {
      try {
        task.status = 'running'
        task.message = 'Validating uploaded file...'
        task.progress = 10
        broadcastBackupProgress(taskId, { status: 'running', progress: 10, message: task.message })

        const isZip = req.file.originalname.endsWith('.zip')
        const isGz = req.file.originalname.endsWith('.json.gz')

        if (!isZip && !isGz) {
          throw new Error('Invalid backup file format. Expected .zip or .json.gz file.')
        }

        ensureBackupDir()
        const timestamp = new Date().toISOString()
          .replace(/T/, '_')
          .replace(/:/g, '-')
          .replace(/\..+/, '')

        if (isZip) {
          const tempExtractDir = join(os.tmpdir(), `backup_extract_${taskId}`)
          mkdirSync(tempExtractDir, { recursive: true })

          await extractZipArchive(tempPath, tempExtractDir)

          const manifestPath = join(tempExtractDir, 'manifest.json')
          if (!existsSync(manifestPath)) {
            rmSync(tempExtractDir, { recursive: true, force: true })
            throw new Error('Invalid backup file: missing manifest.json')
          }

          task.message = 'Saving backup...'
          task.progress = 50
          broadcastBackupProgress(taskId, { status: 'running', progress: 50, message: task.message })

          const newFilename = `uploaded_${timestamp}.zip`
          const newPath = join(BACKUP_DIR, newFilename)

          const filesToArchive = []
          const items = readdirSync(tempExtractDir, { recursive: true, withFileTypes: true })
          for (const item of items) {
            if (item.isFile()) {
              const relativePath = item.path.replace(tempExtractDir, '').replace(/^[/\\]/, '')
              const itemName = relativePath ? `${relativePath}/${item.name}` : item.name
              filesToArchive.push({ path: join(item.path, item.name), name: itemName })
            }
          }

          const archiveResult = await createZipArchive(newPath, filesToArchive)

          rmSync(tempExtractDir, { recursive: true, force: true })
          unlinkSync(tempPath)

          task.status = 'completed'
          task.progress = 100
          task.message = 'Backup uploaded successfully'
          task.completedAt = Date.now()
          task.result = {
            filename: newFilename,
            size: archiveResult.size
          }

          broadcastBackupProgress(taskId, {
            status: 'completed',
            progress: 100,
            message: task.message,
            result: task.result
          })

          console.log(`[Backup] Uploaded and saved: ${newFilename}`)
        } else if (isGz) {
          task.message = 'Validating legacy backup...'
          task.progress = 30
          broadcastBackupProgress(taskId, { status: 'running', progress: 30, message: task.message })

          const gzip = createGunzip()
          const readStream = createReadStream(tempPath)
          const chunks = []

          await pipelinePromises(
            readStream,
            gzip,
            async function* (source) {
              for await (const chunk of source) {
                chunks.push(chunk)
                yield chunk
              }
            }
          )

          const jsonContent = Buffer.concat(chunks).toString('utf-8')
          const backupData = JSON.parse(jsonContent)

          if (!backupData.version || !backupData.data) {
            unlinkSync(tempPath)
            throw new Error('Invalid legacy backup file format')
          }

          task.message = 'Saving backup...'
          task.progress = 50
          broadcastBackupProgress(taskId, { status: 'running', progress: 50, message: task.message })

          const newFilename = `uploaded_${timestamp}.json.gz`
          const newPath = join(BACKUP_DIR, newFilename)

          const newGzip = createGzip()
          const writeStream = createWriteStream(newPath)
          await pipelinePromises(
            Readable.from([jsonContent]),
            newGzip,
            writeStream
          )

          unlinkSync(tempPath)

          task.status = 'completed'
          task.progress = 100
          task.message = 'Backup uploaded successfully'
          task.completedAt = Date.now()
          task.result = {
            filename: newFilename,
            size: statSync(newPath).size
          }

          broadcastBackupProgress(taskId, {
            status: 'completed',
            progress: 100,
            message: task.message,
            result: task.result
          })

          console.log(`[Backup] Uploaded and saved: ${newFilename}`)
        }
      } catch (err) {
        console.error('[Backup] Upload error:', err.message)
        if (existsSync(tempPath)) {
          unlinkSync(tempPath)
        }
        task.status = 'failed'
        task.error = err.message
        task.message = `Upload failed: ${err.message}`
        task.completedAt = Date.now()

        broadcastBackupProgress(taskId, {
          status: 'failed',
          progress: task.progress,
          message: task.message,
          error: err.message
        })
      }
    })
  } catch (err) {
    console.error('[Backup] Upload error:', err.message)
    if (req.file && existsSync(req.file.path)) {
      unlinkSync(req.file.path)
    }
    res.status(500).json({ ok: false, error: { message: err.message } })
  }
})

if (hasDist) {
  app.use(express.static(distPath))

  app.use((req, res, next) => {
    if (!req.path.startsWith('/api')) {
      res.sendFile(join(distPath, 'index.html'))
    } else {
      next()
    }
  })
} else {
  app.use((req, res, next) => {
    if (req.path.startsWith('/api')) {
      return next()
    }
    res.status(404).json({
      error: 'Frontend not built',
      message: `This is the backend API server. Please visit ${envConfig.DEV_FRONTEND_URL} for the frontend during development, or run 'npm run build' first.`,
      frontendUrl: envConfig.DEV_FRONTEND_URL,
    })
  })
}

server.listen(envConfig.PORT, () => {
  console.log(`Server running on http://localhost:${envConfig.PORT}`)
  console.log(`OpenClaw Gateway: ${envConfig.OPENCLAW_WS_URL}`)
  if (isAuthEnabled()) {
    console.log(`Auth enabled: user "${envConfig.AUTH_USERNAME}"`)
  } else {
    console.log('Auth disabled (no AUTH_USERNAME/AUTH_PASSWORD configured)')
  }
  if (!hasDist) {
    console.log(`Development mode: Frontend at ${envConfig.DEV_FRONTEND_URL}`)
  }

  try {
    const runningTasks = db.prepare('SELECT id FROM backup_records WHERE status = ?').all('running')
    if (runningTasks.length > 0) {
      console.log(`[Backup] Marking ${runningTasks.length} interrupted tasks as failed`)
      db.prepare('UPDATE backup_records SET status = ?, error = ?, message = ? WHERE status = ?').run(
        'failed',
        'Server restarted during task execution',
        'Task interrupted by server restart',
        'running'
      )
    }
  } catch (err) {
    console.error('[Backup] Failed to cleanup interrupted tasks:', err.message)
  }
})

process.on('SIGINT', () => {
  console.log('\nShutting down...')
  cleanupAllTerminalSessions()
  gateway.disconnect()
  server.close(() => {
    console.log('Server closed')
    process.exit(0)
  })
})

process.on('SIGTERM', () => {
  console.log('\nShutting down (SIGTERM)...')
  cleanupAllTerminalSessions()
  gateway.disconnect()
  server.close(() => {
    console.log('Server closed')
    process.exit(0)
  })
})


path: src/api/connect.ts
content: import { getPreferredLocale } from '@/i18n/locale'
import { buildDeviceAuthPayload } from './device-auth-payload'
import { loadOrCreateDeviceIdentity, signDevicePayload } from './device-identity'

export interface ConnectParams {
  minProtocol: number
  maxProtocol: number
  client: {
    id: string
    version: string
    platform: string
    mode: 'cli' | 'ui' | 'webchat' | 'operator' | 'node' | string
    displayName?: string
    instanceId?: string
  }
  role: 'operator' | 'node' | string
  scopes: string[]
  caps: string[]
  commands: string[]
  permissions: Record<string, boolean>
  device?: {
    id: string
    publicKey: string
    signature: string
    signedAt: number
    nonce?: string
  }
  auth: {
    [REDACTED_SECRET]
  }
  locale?: string
  userAgent?: string
}

const DEFAULT_MIN_PROTOCOL = 3
const DEFAULT_MAX_PROTOCOL = 3
const DEFAULT_CLIENT_ID: ConnectParams['client']['id'] = 'cli'
const DEFAULT_CLIENT_MODE: ConnectParams['client']['mode'] = 'cli'

function getClientVersion(): string {
  return import.meta.env.VITE_APP_VERSION || ''
}

function getClientPlatform(): string {
  if (typeof navigator === 'undefined') return 'web'
  const nav = navigator as Navigator & { userAgentData?: { platform?: string } }
  const raw = nav.userAgentData?.platform || navigator.platform || 'web'
  return String(raw).toLowerCase() || 'web'
}

function getClientLocale(): string | undefined {
  if (typeof window === 'undefined') return undefined
  return getPreferredLocale()
}

function getClientUserAgent(): string | undefined {
  if (typeof navigator === 'undefined') return undefined
  return navigator.userAgent || undefined
}

export function buildConnectParamsLegacy([REDACTED_SECRET]): ConnectParams {
  return {
    minProtocol: DEFAULT_MIN_PROTOCOL,
    maxProtocol: DEFAULT_MAX_PROTOCOL,
    client: {
      id: DEFAULT_CLIENT_ID,
      displayName: 'OpenClaw Admin',
      version: getClientVersion(),
      platform: getClientPlatform(),
      mode: DEFAULT_CLIENT_MODE,
    },
    role: 'operator',
    scopes: ['operator.read', 'operator.write', 'operator.admin'],
    // 请求 tool-events 能力以接收 agent 工具流事件（用于前端实时状态提示/工具调用可视化）
    caps: ['tool-events'],
    commands: [],
    permissions: {},
    auth: {
      token: token || '',
    },
    locale: getClientLocale(),
    userAgent: getClientUserAgent(),
  }
}

export async function buildConnectParams(
  [REDACTED_SECRET],
  opts?: { nonce?: string | null },
): Promise<ConnectParams> {
  const params = buildConnectParamsLegacy(token)
  const nonce = typeof opts?.nonce === 'string' ? opts.nonce.trim() : ''

  try {
    const deviceIdentity = await loadOrCreateDeviceIdentity()
    const signedAtMs = Date.now()
    const payload = buildDeviceAuthPayload({
      deviceId: deviceIdentity.deviceId,
      clientId: params.client.id,
      clientMode: params.client.mode,
      role: params.role,
      scopes: params.scopes,
      signedAtMs,
      [REDACTED_SECRET] ?? null,
      nonce: nonce || null,
    })
    const signature = await signDevicePayload(deviceIdentity.privateKey, payload)
    params.device = {
      id: deviceIdentity.deviceId,
      publicKey: deviceIdentity.publicKey,
      signature,
      signedAt: signedAtMs,
      ...(nonce ? { nonce } : {}),
    }
  } catch {
    // 非安全上下文或设备身份生成失败时：回退为 legacy connect（兼容旧 Gateway，但在 v2026.2.14+ 会被清空 scopes）
  }

  return params
}


path: src/api/device-auth-payload.ts
content: export type DeviceAuthPayloadParams = {
  deviceId: string
  clientId: string
  clientMode: string
  role: string
  scopes: string[]
  signedAtMs: number
  token?: string | null
  nonce?: string | null
  version?: 'v1' | 'v2'
}

export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string {
  const version = params.version ?? (params.nonce ? 'v2' : 'v1')
  const scopes = params.scopes.join(',')
  const [REDACTED_SECRET] ?? ''
  const base = [
    version,
    params.deviceId,
    params.clientId,
    params.clientMode,
    params.role,
    scopes,
    String(params.signedAtMs),
    token,
  ]
  if (version === 'v2') {
    base.push(params.nonce ?? '')
  }
  return base.join('|')
}



path: src/api/device-identity.ts
content: import { getPublicKeyAsync, signAsync, utils } from '@noble/ed25519'

type StoredIdentity = {
  version: 1
  deviceId: string
  publicKey: string
  privateKey: string
  createdAtMs: number
}

export type DeviceIdentity = {
  deviceId: string
  publicKey: string
  privateKey: string
}

const STORAGE_KEY = 'openclaw-device-identity-v1'

function base64UrlEncode(bytes: Uint8Array): string {
  let binary = ''
  for (const byte of bytes) {
    binary += String.fromCharCode(byte)
  }
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
}

function base64UrlDecode(input: string): Uint8Array {
  const normalized = input.replace(/-/g, '+').replace(/_/g, '/')
  const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
  const binary = atob(padded)
  const out = new Uint8Array(binary.length)
  for (let i = 0; i < binary.length; i += 1) {
    out[i] = binary.charCodeAt(i)
  }
  return out
}

function bytesToHex(bytes: Uint8Array): string {
  return Array.from(bytes)
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('')
}

async function fingerprintPublicKey(publicKeyRaw: Uint8Array): Promise<string> {
  if (!crypto?.subtle) {
    throw new Error('crypto.subtle unavailable (secure context required)')
  }
  const hash = await crypto.subtle.digest('SHA-256', publicKeyRaw.slice().buffer)
  return bytesToHex(new Uint8Array(hash))
}

async function generateIdentity(): Promise<DeviceIdentity> {
  const privateKeyRaw = utils.randomSecretKey()
  const publicKeyRaw = await getPublicKeyAsync(privateKeyRaw)
  const deviceId = await fingerprintPublicKey(publicKeyRaw)
  return {
    deviceId,
    publicKey: base64UrlEncode(publicKeyRaw),
    privateKey: base64UrlEncode(privateKeyRaw),
  }
}

export async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
  try {
    const raw = localStorage.getItem(STORAGE_KEY)
    if (raw) {
      const parsed = JSON.parse(raw) as StoredIdentity
      if (
        parsed?.version === 1 &&
        typeof parsed.deviceId === 'string' &&
        typeof parsed.publicKey === 'string' &&
        typeof parsed.privateKey === 'string'
      ) {
        const derivedId = await fingerprintPublicKey(base64UrlDecode(parsed.publicKey))
        if (derivedId !== parsed.deviceId) {
          const updated: StoredIdentity = {
            ...parsed,
            deviceId: derivedId,
          }
          localStorage.setItem(STORAGE_KEY, JSON.stringify(updated))
          return {
            deviceId: derivedId,
            publicKey: parsed.publicKey,
            privateKey: parsed.privateKey,
          }
        }
        return {
          deviceId: parsed.deviceId,
          publicKey: parsed.publicKey,
          privateKey: parsed.privateKey,
        }
      }
    }
  } catch {
    // ignore and regenerate
  }

  const identity = await generateIdentity()
  const stored: StoredIdentity = {
    version: 1,
    deviceId: identity.deviceId,
    publicKey: identity.publicKey,
    privateKey: identity.privateKey,
    createdAtMs: Date.now(),
  }
  localStorage.setItem(STORAGE_KEY, JSON.stringify(stored))
  return identity
}

export async function signDevicePayload(privateKeyBase64Url: string, payload: string): Promise<string> {
  const key = base64UrlDecode(privateKeyBase64Url)
  const data = new TextEncoder().encode(payload)
  const sig = await signAsync(data, key)
  return base64UrlEncode(sig)
}


path: src/api/http-client.ts
content: import { ConnectionState, type RPCResponse, type RPCEvent } from './types'

type EventHandler = (...args: unknown[]) => void

export interface ApiClientConfig {
  baseUrl?: string
  reconnectInterval?: number
  maxReconnectAttempts?: number
  getToken?: () => string | null
}

const DEFAULT_CONFIG: Required<ApiClientConfig> = {
  baseUrl: '',
  reconnectInterval: 3000,
  maxReconnectAttempts: 20,
  getToken: () => null,
}

export class ApiClient {
  private config: Required<ApiClientConfig>
  private listeners = new Map<string, Set<EventHandler>>()
  private eventSource: EventSource | null = null
  private reconnectAttempts = 0
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null
  private _state: ConnectionState = ConnectionState.DISCONNECTED
  private clientId: string | null = null

  get state(): ConnectionState {
    return this._state
  }

  constructor(config?: Partial<ApiClientConfig>) {
    this.config = { ...DEFAULT_CONFIG, ...config }
  }

  connect(): void {
    this._state = ConnectionState.CONNECTING
    this.emit('stateChange', ConnectionState.CONNECTING)
    this.reconnectAttempts = 0
    this.createEventSource()
  }

  disconnect(): void {
    this.clearTimers()
    this._state = ConnectionState.DISCONNECTED
    if (this.eventSource) {
      this.eventSource.close()
      this.eventSource = null
    }
    this.emit('stateChange', ConnectionState.DISCONNECTED)
  }

  private createEventSource(): void {
    try {
      let url = this.config.baseUrl
        ? `${this.config.baseUrl}/api/events`
        : '/api/events'

      const [REDACTED_SECRET])
      if (token) {
        url += `?[REDACTED_SECRET])}`
      }

      console.log('[ApiClient] Creating EventSource:', url)

      this.eventSource = new EventSource(url)

      this.eventSource.onopen = () => {
        console.log('[ApiClient] EventSource opened')
        this.reconnectAttempts = 0
      }

      this.eventSource.onmessage = (event: MessageEvent) => {
        this.handleMessage(event.data)
      }

      this.eventSource.onerror = (error) => {
        console.error('[ApiClient] EventSource error:', error)
        console.error('[ApiClient] EventSource readyState:', this.eventSource?.readyState)
        this.handleDisconnect()
      }
    } catch (e) {
      console.error('[ApiClient] Connection failed:', e)
      this.scheduleReconnect()
    }
  }

  private handleMessage(data: string): void {
    try {
      const message = JSON.parse(data)
      console.log('[ApiClient] Received message type:', message.type, 'data:', JSON.stringify(message).substring(0, 200))

      switch (message.type) {
        case 'connected':
          this.clientId = message.clientId
          console.log('[ApiClient] Connected with clientId:', this.clientId)
          break

        case 'gatewayState':
          console.log('[ApiClient] Gateway state:', message.state, 'version:', message.version)
          if (message.state === 'connected') {
            this._state = ConnectionState.CONNECTED
            this.emit('stateChange', ConnectionState.CONNECTED)
            this.emit('connected', { version: message.version, updateAvailable: message.updateAvailable })
          } else if (message.state === 'disconnected' || message.state === 'failed') {
            if (this._state === ConnectionState.CONNECTED) {
              this._state = ConnectionState.RECONNECTING
              this.emit('stateChange', ConnectionState.RECONNECTING)
            }
          } else if (message.state === 'connecting') {
            this._state = ConnectionState.CONNECTING
            this.emit('stateChange', ConnectionState.CONNECTING)
          }
          if (message.version || message.updateAvailable) {
            this.emit('connected', { version: message.version, updateAvailable: message.updateAvailable })
          }
          break

        case 'event':
          const evt = message as { event: string; payload: unknown }
          queueMicrotask(() => {
            this.emit('event', { type: 'event', event: evt.event, payload: evt.payload } as RPCEvent)
            this.emit(`event:${evt.event}`, evt.payload)
          })
          break

        case 'backupProgress':
          queueMicrotask(() => {
            this.emit('backupProgress', message)
          })
          break
      }
    } catch (e) {
      console.error('[ApiClient] Failed to parse message:', e)
    }
  }

  private handleDisconnect(): void {
    console.log('[ApiClient] Disconnected, current state:', this._state)
    
    if (this.eventSource) {
      this.eventSource.close()
      this.eventSource = null
    }

    this.emit('disconnected', 0, 'SSE disconnected')

    const shouldReconnect =
      this._state !== ConnectionState.DISCONNECTED &&
      this._state !== ConnectionState.FAILED

    if (shouldReconnect) {
      console.log('[ApiClient] Scheduling reconnect...')
      this.scheduleReconnect()
    } else {
      console.log('[ApiClient] Not reconnecting, state:', this._state)
    }
  }

  private scheduleReconnect(): void {
    if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
      console.error('[ApiClient] Max reconnect attempts reached:', this.reconnectAttempts)
      this._state = ConnectionState.FAILED
      this.emit('stateChange', ConnectionState.FAILED)
      this.emit('failed', 'Max reconnect attempts reached')
      return
    }

    this._state = ConnectionState.RECONNECTING
    this.emit('stateChange', ConnectionState.RECONNECTING)

    const delay = Math.min(
      this.config.reconnectInterval * Math.pow(1.5, this.reconnectAttempts),
      30000
    )
    this.reconnectAttempts++
    
    console.log('[ApiClient] Reconnecting in', delay, 'ms, attempt', this.reconnectAttempts)

    this.reconnectTimer = setTimeout(() => {
      this.createEventSource()
    }, delay)

    this.emit('reconnecting', this.reconnectAttempts, delay)
  }

  private clearTimers(): void {
    if (this.reconnectTimer) {
      clearTimeout(this.reconnectTimer)
      this.reconnectTimer = null
    }
  }

  async rpc<T = unknown>(method: string, params?: unknown): Promise<T> {
    const url = this.config.baseUrl
      ? `${this.config.baseUrl}/api/rpc`
      : '/api/rpc'

    const headers: Record<string, string> = {
      'Content-Type': 'application/json',
    }

    const [REDACTED_SECRET])
    if (token) {
      headers['Authorization'] = `Bearer ${token}`
    }

    const response = await fetch(url, {
      method: 'POST',
      headers,
      body: JSON.stringify({ method, params }),
    })

    if (response.status === 401) {
      throw new Error('Unauthorized')
    }

    const result: RPCResponse<T> = await response.json()

    if (!result.ok) {
      throw new Error(result.error?.message || 'RPC call failed')
    }

    return result.payload as T
  }

  async health(): Promise<{ ok: boolean; gateway: string; clients: number }> {
    const url = this.config.baseUrl
      ? `${this.config.baseUrl}/api/health`
      : '/api/health'

    const response = await fetch(url)
    return response.json()
  }

  on(event: string, handler: EventHandler): () => void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set())
    }
    this.listeners.get(event)!.add(handler)
    return () => this.off(event, handler)
  }

  off(event: string, handler: EventHandler): void {
    this.listeners.get(event)?.delete(handler)
  }

  private emit(event: string, ...args: unknown[]): void {
    const handlers = this.listeners.get(event)
    if (!handlers || handlers.size === 0) return
    
    handlers.forEach((handler) => {
      try {
        handler(...args)
      } catch (e) {
        console.error(`[ApiClient] Event handler error for "${event}":`, e)
      }
    })
  }
}


path: src/api/types/backup.ts
content: export interface BackupItem {
  filename: string
  createdAt: string
  size: number
  date: string
}

export type BackupTaskStatus = 'pending' | 'running' | 'completed' | 'failed'

export interface BackupTask {
  id: string
  type: 'create' | 'restore' | 'upload'
  status: BackupTaskStatus
  progress: number
  message: string
  filename?: string
  error?: string
  startedAt: number
  completedAt?: number
  result?: BackupTaskResult
}

export interface BackupTaskResult {
  filename?: string
  size?: number
  database?: boolean
  config?: boolean
  sessions?: number
  errors?: string[]
}

export interface BackupCreateParams {
  includeDatabase?: boolean
  includeConfig?: boolean
  includeSessions?: boolean
  includeEnv?: boolean
}

export interface BackupRestoreParams {
  filename: string
}

export interface BackupListResult {
  backups: BackupItem[]
  total: number
}

export interface BackupTaskProgress {
  taskId: string
  status: BackupTaskStatus
  progress: number
  message: string
  stage?: string
}


path: src/api/types/channel.ts
content: export interface Channel {
  id: string
  platform: ChannelPlatform
  channelKey?: string
  accountId?: string
  enabled: boolean
  status: ChannelStatus
  accountName?: string
  memberCount?: number
  dmPolicy: DMPolicy
  groupPolicy?: string
  requireMention?: boolean
  groupAllowFrom?: string[]
  groups?: ChannelGroup[]
  [key: string]: unknown
}

export type ChannelPlatform =
  | 'whatsapp'
  | 'telegram'
  | 'discord'
  | 'slack'
  | 'signal'
  | 'imessage'
  | 'webchat'
  | 'matrix'
  | string

export type ChannelStatus = 'connected' | 'disconnected' | 'authenticating' | 'error'

export type DMPolicy = 'pairing' | 'allowlist' | 'open' | 'disabled' | string

export interface ChannelGroup {
  id: string
  name: string
  requireMention: boolean
  allowFrom?: string[]
  [key: string]: unknown
}

export interface ChannelAuthParams {
  channelId: string
  channelKey?: string
  accountId?: string
  method?: string
}

export interface PairParams {
  channelId: string
  channelKey?: string
  accountId?: string
  code: string
}


path: src/api/types/config.ts
content: export interface OpenClawConfig {
  agents?: AgentConfig
  channels?: Record<string, ChannelConfig>
  plugins?: PluginsConfig
  bindings?: Binding[]
  tools?: ToolsConfig
  session?: SessionConfig
  gateway?: GatewayConfig
  models?: ModelsConfig
}

export interface PluginsConfig {
  enabled?: boolean
  allow?: string[]
  deny?: string[]
  load?: {
    paths?: string[]
  }
  entries?: Record<string, PluginEntryConfig>
  installs?: Record<string, PluginInstallRecord>
}

export interface PluginEntryConfig {
  enabled?: boolean
  config?: Record<string, unknown>
  [key: string]: unknown
}

export interface PluginInstallRecord {
  source?: 'npm' | 'archive' | 'path' | string
  spec?: string
  sourcePath?: string
  installPath?: string
  version?: string
  installedAt?: string
  [key: string]: unknown
}

export interface AgentConfig {
  defaults?: AgentDefaults
  list?: AgentInstance[]
}

export interface AgentDefaults {
  workspace?: string
  model?: ModelConfig
  tools?: ToolPolicyConfig
  models?: Record<string, { alias?: string }>
}

export interface AgentInstance {
  id: string
  name?: string
  workspace?: string
  model?: ModelConfig
  tools?: ToolPolicyConfig
  sandbox?: SandboxConfig
  subagents?: {
    allowAgents?: string[]
    denyAgents?: string[]
  }
  identity?: {
    name?: string
    theme?: string
    emoji?: string
    avatar?: string
  }
}

export interface ModelConfig {
  primary?: string
  fallback?: string[]
}

export interface ToolPolicyConfig {
  allow?: string[]
  deny?: string[]
  profile?: 'minimal' | 'coding' | 'full'
}

export interface ChannelSecretField {
  path: string
  label?: string
  required?: boolean
  masked?: string
}

export interface ChannelAccountConfig {
  enabled?: boolean
  dmPolicy?: string
  allowFrom?: string[]
  groupPolicy?: string
  requireMention?: boolean
  groupAllowFrom?: string[]
  groups?: Record<string, unknown>
  [key: string]: unknown
}

export interface ChannelConfig {
  enabled?: boolean
  dmPolicy?: string
  allowFrom?: string[]
  groupPolicy?: string
  requireMention?: boolean
  groupAllowFrom?: string[]
  groups?: Record<string, unknown>
  accounts?: Record<string, ChannelAccountConfig>
  secretFields?: ChannelSecretField[]
  [key: string]: unknown
}

export interface Binding {
  channel?: string
  agentId?: string
  match?: {
    channel: string
    accountId?: string
    peer?: {
      kind: 'direct' | 'group' | 'channel' | 'dm' | 'acp'
      id: string
    }
  }
}

export interface ToolsConfig {
  allow?: string[]
  deny?: string[]
  profile?: string
  sandbox?: SandboxConfig
  sessions?: {
    visibility?: string
  }
  agentToAgent?: {
    enabled?: boolean
    allow?: string[]
  }
}

export interface SandboxConfig {
  enabled: boolean
  docker?: Record<string, unknown>
}

export interface SessionConfig {
  reset?: { mode: string; hour?: number; idleMinutes?: number }
  queue?: { mode: string }
}

export interface GatewayConfig {
  port?: number
  bind?: string
  auth?: { token?: string; allowTailscale?: boolean }
  controlUi?: { basePath?: string }
}

export interface ModelsConfig {
  primary?: string
  fallback?: string[]
  mode?: 'merge' | 'replace' | string
  providers?: Record<string, ModelProviderConfig>
}

export interface ModelProviderConfig {
  api?: 'openai-completions' | 'openai-responses' | 'anthropic-messages' | 'google-generative-ai' | string
  baseUrl?: string
  apiKey?: string
  authHeader?: boolean
  headers?: Record<string, string>
  modelPrefix?: string
  models?: ModelProviderModel[]
}

export interface ModelProviderModel {
  id: string
  name?: string
  enabled?: boolean
  contextWindow?: number
  tags?: string[]
}

export interface ConfigPatch {
  path: string
  value: unknown
}


path: src/api/types/index.ts
content: export * from './rpc'
export * from './session'
export * from './channel'
export * from './config'
export * from './terminal'
export * from './remote-desktop'
export * from './backup'
import type { ModelConfig, ToolPolicyConfig } from './config'

export interface Skill {
  name: string
  description?: string
  version?: string
  source: 'bundled' | 'managed' | 'workspace' | 'extra'
  installed: boolean
  eligible?: boolean
  disabled?: boolean
  bundled?: boolean
  skillKey?: string
  hasUpdate?: boolean
}

export interface PluginPackage {
  name: string
  installed: boolean
  version?: string
  enabled?: boolean
  status?: string
}

export interface Tool {
  name: string
  description: string
  category: string
  enabled: boolean
}

export interface DeviceNode {
  id: string
  name: string
  platform: string
  connected: boolean
  capabilities: string[]
  lastSeen?: string
}

export interface NodeInvokeParams {
  nodeId: string
  action: string
  params?: Record<string, unknown>
}

export interface AgentParams {
  sessionKey: string
  message: string
  idempotencyKey?: string
  model?: string
  elevated?: boolean
  thinkingLevel?: 'off' | 'low' | 'high'
}

export interface AgentIdentity {
  name?: string
  theme?: string
  emoji?: string
  avatar?: string
  avatarUrl?: string
}

export interface AgentInfo {
  id: string
  name?: string
  identity?: AgentIdentity
  model?: string
  tools?: ToolPolicyConfig
}

export interface AgentsListResult {
  defaultId?: string
  mainKey?: string
  scope?: string
  agents: AgentInfo[]
}

export interface AgentFileEntry {
  name: string
  path: string
  missing: boolean
  size?: number
  updatedAtMs?: number
  content?: string
  type?: 'file' | 'directory'
  isDirectory?: boolean
}

export interface AgentFilesListResult {
  agentId: string
  workspace: string
  files: AgentFileEntry[]
}

export interface AgentFilesGetResult {
  agentId: string
  workspace: string
  file: AgentFileEntry
}

export interface AgentFilesSetResult {
  ok: boolean
  agentId: string
  workspace: string
  file: AgentFileEntry
}

export interface AgentEvent {
  event: string
  payload: unknown
  seq?: number
  timestamp: number
}

export interface SendParams {
  channelId: string
  recipient: string
  content: string
}

export interface ChatMessageContent {
  type: 'text' | 'thinking' | 'tool_call' | 'tool_result' | 'image'
  text?: string
  thinking?: string
  id?: string
  name?: string
  arguments?: Record<string, unknown>
  content?: unknown
  isError?: boolean
  mimeType?: string
  bytes?: number
  data?: string
  mediaPath?: string
}

export interface ChatMessage {
  id?: string
  role: 'user' | 'assistant' | 'tool' | 'system'
  content: string
  timestamp?: string
  name?: string
  model?: string
  provider?: string
  stopReason?: string
  toolCallId?: string
  toolName?: string
  isError?: boolean
  rawContent?: ChatMessageContent[]
}

export interface ChatSendParams {
  sessionKey: string
  message: string
  model?: string
  idempotencyKey?: string
}

export type CronSchedule =
  | {
      kind: 'at'
      at: string
    }
  | {
      kind: 'every'
      everyMs: number
      anchorMs?: number
    }
  | {
      kind: 'cron'
      expr: string
      tz?: string
    }

export type CronPayload =
  | {
      kind: 'systemEvent'
      text: string
    }
  | {
      kind: 'agentTurn'
      message: string
      model?: string
      thinking?: string
      timeoutSeconds?: number
      allowUnsafeExternalContent?: boolean
      deliver?: boolean
      channel?: string
      to?: string
      bestEffortDeliver?: boolean
    }

export interface CronDelivery {
  mode: 'none' | 'announce'
  channel?: string
  to?: string
  bestEffort?: boolean
}

export interface CronJobState {
  nextRunAtMs?: number
  runningAtMs?: number
  lastRunAtMs?: number
  lastStatus?: 'ok' | 'error' | 'skipped'
  lastError?: string
  lastDurationMs?: number
  consecutiveErrors?: number
}

export interface CronJob {
  id: string
  agentId?: string
  name: string
  description?: string
  enabled: boolean
  deleteAfterRun?: boolean
  createdAtMs?: number
  updatedAtMs?: number
  scheduleObj?: CronSchedule
  sessionTarget?: 'main' | 'isolated'
  wakeMode?: 'next-heartbeat' | 'now'
  payload?: CronPayload
  delivery?: CronDelivery
  state?: CronJobState

  // Backward-compatible display fields
  schedule: string
  command?: string
  timezone?: string
  nextRun?: string
  lastRun?: string
}

export interface CronStatus {
  enabled: boolean
  jobs: number
  running?: number
  nextWakeAtMs?: number
}

export interface CronRunLogEntry {
  ts: number
  jobId: string
  action?: 'finished'
  status?: 'ok' | 'error' | 'skipped'
  error?: string
  summary?: string
  sessionId?: string
  sessionKey?: string
  runAtMs?: number
  durationMs?: number
  nextRunAtMs?: number
}

export interface CronUpsertParams {
  id?: string
  name?: string
  agentId?: string | null
  description?: string
  enabled?: boolean
  deleteAfterRun?: boolean
  schedule?: CronSchedule
  sessionTarget?: 'main' | 'isolated'
  wakeMode?: 'next-heartbeat' | 'now'
  payload?: CronPayload
  delivery?: CronDelivery
  // Legacy compatibility
  command?: string
  timezone?: string
  scheduleText?: string
}

export interface ModelInfo {
  id: string
  label?: string
  provider?: string
  family?: string
  enabled?: boolean
  available?: boolean
  description?: string
  contextWindow?: number
  capabilities?: string[]
}

export interface SessionsUsageParams {
  key?: string
  startDate?: string
  endDate?: string
  limit?: number
  includeContextWeight?: boolean
}

export interface SessionsUsageTotals {
  input: number
  output: number
  cacheRead: number
  cacheWrite: number
  totalTokens: number
  totalCost: number
  inputCost: number
  outputCost: number
  cacheReadCost: number
  cacheWriteCost: number
  missingCostEntries: number
}

export interface SessionsUsageModelItem {
  provider?: string
  model?: string
  count: number
  totals: SessionsUsageTotals
}

export interface SessionsUsageDailyItem {
  date: string
  tokens: number
  cost: number
  messages: number
  toolCalls: number
  errors: number
}

export interface SessionsUsageSession {
  key: string
  label?: string
  sessionId?: string
  updatedAt?: number
  agentId?: string
  channel?: string
  chatType?: string
  modelProvider?: string
  model?: string
  usage: {
    input: number
    output: number
    cacheRead: number
    cacheWrite: number
    totalTokens: number
    totalCost: number
    messageCounts?: {
      total: number
      user: number
      assistant: number
      toolCalls: number
      toolResults: number
      errors: number
    }
    toolUsage?: {
      totalCalls: number
      uniqueTools: number
      tools: Array<{ name: string; count: number }>
    }
    dailyBreakdown?: Array<{ date: string; tokens: number; cost: number }>
  } | null
}

export interface SessionsUsageResult {
  updatedAt: number
  startDate: string
  endDate: string
  sessions: SessionsUsageSession[]
  totals: SessionsUsageTotals
  aggregates: {
    messages: {
      total: number
      user: number
      assistant: number
      toolCalls: number
      toolResults: number
      errors: number
    }
    tools: {
      totalCalls: number
      uniqueTools: number
      tools: Array<{ name: string; count: number }>
    }
    byModel: SessionsUsageModelItem[]
    byProvider: SessionsUsageModelItem[]
    byAgent: Array<{ agentId: string; totals: SessionsUsageTotals }>
    byChannel: Array<{ channel: string; totals: SessionsUsageTotals }>
    daily: SessionsUsageDailyItem[]
  }
}

export interface CostUsageDailyEntry extends SessionsUsageTotals {
  date: string
}

export interface CostUsageSummary {
  updatedAt: number
  days: number
  daily: CostUsageDailyEntry[]
  totals: SessionsUsageTotals
}

export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'

export interface LogEntry {
  raw: string
  time?: string | null
  level?: LogLevel | null
  subsystem?: string | null
  message?: string | null
  meta?: Record<string, unknown> | null
}

export interface LogsTailParams {
  cursor?: number
  limit?: number
  maxBytes?: number
}

export interface LogsTailResult {
  file: string
  cursor: number
  size: number
  lines: string[]
  truncated?: boolean
  reset?: boolean
}

export interface SystemPresenceEntry {
  instanceId?: string | null
  host?: string | null
  ip?: string | null
  version?: string | null
  platform?: string | null
  deviceFamily?: string | null
  modelIdentifier?: string | null
  roles?: string[] | null
  scopes?: string[] | null
  mode?: string | null
  lastInputSeconds?: number | null
  reason?: string | null
  tags?: string[] | null
  text?: string | null
  ts?: number | null
  deviceId?: string | null
}

export type ExecApprovalsSecurity = 'deny' | 'allowlist' | 'full'
export type ExecApprovalsAsk = 'off' | 'on-miss' | 'always'

export interface ExecApprovalsDefaults {
  security?: ExecApprovalsSecurity
  ask?: ExecApprovalsAsk
  askFallback?: ExecApprovalsSecurity
  autoAllowSkills?: boolean
}

export interface ExecApprovalsAllowlistEntry {
  id?: string
  pattern: string
  lastUsedAt?: number
  lastUsedCommand?: string
  lastResolvedPath?: string
}

export interface ExecApprovalsAgent extends ExecApprovalsDefaults {
  allowlist?: ExecApprovalsAllowlistEntry[]
}

export interface ExecApprovalsFile {
  version?: 1
  socket?: {
    path?: string
    token?: string
  }
  defaults?: ExecApprovalsDefaults
  agents?: Record<string, ExecApprovalsAgent>
}

export interface ExecApprovalsSnapshot {
  path: string
  exists: boolean
  hash: string
  file: ExecApprovalsFile
}

export interface UpdateRunStepResult {
  name: string
  command: string
  cwd?: string
  durationMs?: number
  exitCode?: number | null
  stdoutTail?: string | null
  stderrTail?: string | null
}

export interface UpdateRunResult {
  status: 'ok' | 'error' | 'skipped'
  mode: 'git' | 'pnpm' | 'bun' | 'npm' | 'unknown'
  root?: string
  reason?: string
  before?: {
    sha?: string | null
    version?: string | null
  } | null
  after?: {
    sha?: string | null
    version?: string | null
  } | null
  steps: UpdateRunStepResult[]
  durationMs: number
}

export interface UpdateRunResponse {
  ok: boolean
  result?: UpdateRunResult
  restart?: {
    ok?: boolean
    delayMs?: number
    pid?: number
    reason?: string
    error?: string
  } | null
  sentinel?: {
    path?: string | null
    payload?: Record<string, unknown> | null
  } | null
}

export interface HealthChannelAccountSummary {
  accountId: string
  configured?: boolean
  linked?: boolean
  authAgeMs?: number | null
  probe?: unknown
  lastProbeAt?: number | null
  [key: string]: unknown
}

export interface HealthChannelSummary extends HealthChannelAccountSummary {
  accounts?: Record<string, HealthChannelAccountSummary>
}

export interface HealthSessionsSummary {
  path: string
  count: number
  recent: Array<{
    key: string
    updatedAt: number | null
    age: number | null
  }>
}

export interface HealthAgentSummary {
  agentId: string
  name?: string
  isDefault: boolean
  heartbeat: Record<string, unknown>
  sessions: HealthSessionsSummary
}

export interface HealthSummary {
  ok: true
  ts: number
  durationMs: number
  channels: Record<string, HealthChannelSummary>
  channelOrder: string[]
  channelLabels: Record<string, string>
  heartbeatSeconds: number
  defaultAgentId: string
  agents: HealthAgentSummary[]
  sessions: HealthSessionsSummary
}

export interface StatusSessionStatus {
  agentId?: string
  key: string
  kind: 'direct' | 'group' | 'global' | 'unknown'
  sessionId?: string
  updatedAt: number | null
  age: number | null
  thinkingLevel?: string
  verboseLevel?: string
  reasoningLevel?: string
  elevatedLevel?: string
  systemSent?: boolean
  abortedLastRun?: boolean
  inputTokens?: number
  outputTokens?: number
  totalTokens: number | null
  totalTokensFresh: boolean
  remainingTokens: number | null
  percentUsed: number | null
  model: string | null
  contextTokens: number | null
  flags: string[]
}

export interface HeartbeatStatus {
  agentId: string
  enabled: boolean
  every: string
  everyMs: number | null
}

export interface StatusSummary {
  linkChannel?: {
    id: string
    label: string
    linked: boolean
    authAgeMs: number | null
  }
  heartbeat: {
    defaultAgentId: string
    agents: HeartbeatStatus[]
  }
  channelSummary: string[]
  queuedSystemEvents: string[]
  sessions: {
    paths: string[]
    count: number
    defaults: { model: string | null; contextTokens: number | null }
    recent: StatusSessionStatus[]
    byAgent: Array<{
      agentId: string
      path: string
      count: number
      recent: StatusSessionStatus[]
    }>
  }
}

export interface SystemMetrics {
  cpu: {
    usage: number
    cores: number
    model?: string
  }
  memory: {
    total: number
    used: number
    free: number
    usagePercent: number
  }
  disk: {
    total: number
    used: number
    free: number
    usagePercent: number
  }
  network: {
    bytesReceived: number
    bytesSent: number
    connections?: number
  }
  uptime: number
  loadAverage?: number[]
  platform?: string
  hostname?: string
}

export interface FileEntry {
  name: string
  path: string
  type: 'file' | 'directory' | 'symlink'
  size?: number
  modifiedAt?: string
  createdAt?: string
  permissions?: string
  isReadable?: boolean
  isWritable?: boolean
  extension?: string
}

export interface FileBrowseResult {
  path: string
  entries: FileEntry[]
  parentPath?: string
}

export interface FileReadResult {
  path: string
  content: string
  encoding: string
  size: number
  modifiedAt?: string
}

export interface AgentWithStats {
  id: string
  name?: string
  identity?: AgentIdentity
  workspace?: string
  model?: string
  status?: 'active' | 'idle' | 'offline'
  sessionCount?: number
  tokenUsage?: {
    input: number
    output: number
    total: number
  }
  lastActivity?: string
}

export interface AgentAddParams {
  id: string
  workspace?: string
  model?: ModelConfig
  tools?: ToolPolicyConfig
  identity?: AgentIdentity
}

export interface AgentSetIdentityParams {
  agentId?: string
  workspace?: string
  identityFile?: string
  fromIdentity?: boolean
  name?: string
  theme?: string
  emoji?: string
  avatar?: string
}

export interface AgentSetModelParams {
  agentId?: string
  workspace?: string
  model?: string
  fallback?: string[]
}

export interface AgentDeleteParams {
  agentId: string
}


path: src/api/types/remote-desktop.ts
content: export type RemoteDesktopConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'

export type RemoteDesktopPlatform = 'linux' | 'windows' | 'unknown'

export type RemoteDesktopStatus = 'creating' | 'ready' | 'connected' | 'disconnected' | 'error' | 'destroyed'

export interface RemoteDesktopSession {
  id: string
  nodeId?: string
  nodeName?: string
  platform: RemoteDesktopPlatform
  status: RemoteDesktopStatus
  width: number
  height: number
  depth: number
  vncPort?: number
  websocketPort?: number
  password?: string
  createdAt: number
  lastActivityAt: number
  error?: string
  frameData?: string
}

export interface RemoteDesktopConfig {
  nodeId?: string
  width: number
  height: number
  depth?: number
  compression?: number
  quality?: number
  timeout?: number
  shared?: boolean
  viewOnly?: boolean
  scale?: boolean
}

export interface RemoteDesktopCreateParams {
  nodeId?: string
  width?: number
  height?: number
  depth?: number
  timeout?: number
}

export interface RemoteDesktopCreateResult {
  sessionId: string
  success: boolean
  message?: string
  vncPort?: number
  websocketPort?: number
  password?: string
}

export interface RemoteDesktopResizeParams {
  sessionId: string
  width: number
  height: number
}

export interface RemoteDesktopDestroyParams {
  sessionId: string
}

export interface RemoteDesktopListResult {
  sessions: RemoteDesktopSession[]
}

export interface RemoteDesktopInputEvent {
  sessionId: string
  type: 'mouse' | 'keyboard' | 'clipboard'
  data: RemoteDesktopMouseData | RemoteDesktopKeyboardData | RemoteDesktopClipboardData
}

export interface RemoteDesktopMouseData {
  action: 'move' | 'click' | 'dblclick' | 'down' | 'up' | 'wheel'
  x: number
  y: number
  button?: number
  buttons?: number
  wheelDeltaX?: number
  wheelDeltaY?: number
}

export interface RemoteDesktopKeyboardData {
  action: 'down' | 'up' | 'press'
  key: string
  code: string
  keyCode: number
  shiftKey?: boolean
  ctrlKey?: boolean
  altKey?: boolean
  metaKey?: boolean
}

export interface RemoteDesktopClipboardData {
  text: string
}

export interface RemoteDesktopSSEEvent {
  type: 'connected' | 'disconnected' | 'error' | 'resized' | 'frame'
  sessionId: string
  data?: string
  width?: number
  height?: number
  message?: string
}

export interface RemoteDesktopNode {
  id: string
  name: string
  platform: RemoteDesktopPlatform
  connected: boolean
  capabilities: string[]
  hasDesktop: boolean
  lastSeen?: string
}


path: src/api/types/rpc.ts
content: export interface RPCRequest {
  type: 'req'
  id: string
  method: string
  params?: unknown
}

export interface RPCResponse<T = unknown> {
  type: 'res'
  id: string
  ok: boolean
  payload?: T
  error?: RPCError
}

export interface RPCEvent {
  type: 'event'
  event: string
  payload: unknown
  seq?: number
}

export interface RPCError {
  code?: string | number
  message: string
  details?: unknown
  data?: unknown
  retryable?: boolean
  retryAfterMs?: number
}

export type RPCFrame = RPCRequest | RPCResponse | RPCEvent

export const ConnectionState = {
  DISCONNECTED: 'disconnected',
  CONNECTING: 'connecting',
  CONNECTED: 'connected',
  RECONNECTING: 'reconnecting',
  FAILED: 'failed',
} as const

export type ConnectionState = (typeof ConnectionState)[keyof typeof ConnectionState]


path: src/api/types/session.ts
content: export interface Session {
  key: string
  agentId: string
  channel: string
  peer: string
  messageCount: number
  lastActivity: string
  model?: string
  tokenUsage?: TokenUsage
  label?: string
}

export interface SessionDetail extends Session {
  transcript: TranscriptMessage[]
  tokenUsage?: TokenUsage
  directives?: string[]
}

export interface TranscriptMessage {
  role: 'user' | 'assistant' | 'tool' | 'system'
  content: string
  timestamp: string
  name?: string
  input?: unknown
  output?: unknown
  tokens?: { input: number; output: number }
}

export interface TokenUsage {
  totalInput: number
  totalOutput: number
}

export interface SessionExport {
  key: string
  transcript: TranscriptMessage[]
  exportedAt: string
}


path: src/api/types/terminal.ts
content: export type TerminalConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'

export interface TerminalSession {
  id: string
  nodeId?: string
  nodeName?: string
  createdAt: number
  lastActivityAt: number
}

export interface TerminalOutputLine {
  id: string
  type: 'stdout' | 'stderr' | 'stdin' | 'system'
  content: string
  timestamp: number
}

export interface TerminalConfig {
  nodeId?: string
  cols: number
  rows: number
  cwd?: string
  env?: Record<string, string>
  shell?: string
  timeout?: number
}

export interface TerminalCreateParams {
  nodeId?: string
  cols?: number
  rows?: number
  cwd?: string
  env?: Record<string, string>
  shell?: string
}

export interface TerminalCreateResult {
  sessionId: string
  success: boolean
  message?: string
}

export interface TerminalWriteParams {
  sessionId: string
  input: string
}

export interface TerminalResizeParams {
  sessionId: string
  cols: number
  rows: number
}

export interface TerminalDestroyParams {
  sessionId: string
}

export interface TerminalListResult {
  sessions: TerminalSession[]
}

export interface TerminalSSEEvent {
  type: 'output' | 'connected' | 'disconnected' | 'error' | 'resized'
  sessionId: string
  data?: string
  cols?: number
  rows?: number
  message?: string
}


path: src/api/websocket.ts
content: import { ConnectionState, type RPCFrame, type RPCEvent, type RPCResponse } from './types'
import { ApiClient } from './http-client'

type EventHandler = (...args: unknown[]) => void

export interface WebSocketConfig {
  url?: string
  auth?: string
  reconnect?: boolean
  reconnectInterval?: number
  maxReconnectAttempts?: number
  getToken?: () => string | null
}

const DEFAULT_CONFIG: Required<Omit<WebSocketConfig, 'auth' | 'getToken'>> & { auth?: string; getToken?: () => string | null } = {
  url: '',
  reconnect: true,
  reconnectInterval: 3000,
  maxReconnectAttempts: 20,
  auth: undefined,
  getToken: undefined,
}

export class OpenClawWebSocket {
  private apiClient: ApiClient
  private config: Required<Omit<WebSocketConfig, 'auth' | 'getToken'>> & { auth?: string; getToken?: () => string | null }
  private listeners = new Map<string, Set<EventHandler>>()
  private _state: ConnectionState = ConnectionState.DISCONNECTED

  get state(): ConnectionState {
    return this._state
  }

  constructor(config?: Partial<WebSocketConfig>) {
    this.config = { ...DEFAULT_CONFIG, ...config }
    this.apiClient = new ApiClient({
      baseUrl: this.config.url || '',
      reconnectInterval: this.config.reconnectInterval,
      maxReconnectAttempts: this.config.maxReconnectAttempts,
      getToken: this.config.getToken,
    })

    this.bindApiClientEvents()
  }

  private bindApiClientEvents(): void {
    this.apiClient.on('stateChange', (state: unknown) => {
      this._state = state as ConnectionState
      this.emit('stateChange', state)
    })

    this.apiClient.on('reconnecting', (attempts: unknown, delay: unknown) => {
      this.emit('reconnecting', attempts, delay)
    })

    this.apiClient.on('error', (error: unknown) => {
      this.emit('error', error)
    })

    this.apiClient.on('failed', (reason: unknown) => {
      this.emit('failed', reason)
    })

    this.apiClient.on('connected', (payload: unknown) => {
      this.emit('connected', payload)
    })

    this.apiClient.on('disconnected', (code: unknown, reason: unknown) => {
      this.emit('disconnected', code, reason)
    })

    this.apiClient.on('event', (evt: unknown) => {
      const event = evt as RPCEvent
      this.emit('event', event)
      this.emit(`event:${event.event}`, event.payload)
    })

    this.apiClient.on('backupProgress', (data: unknown) => {
      this.emit('backupProgress', data)
    })
  }

  connect(url?: string, auth?: string): void {
    if (url) this.config.url = url
    if (auth !== undefined) this.config.auth = auth

    this._state = ConnectionState.CONNECTING
    this.emit('stateChange', ConnectionState.CONNECTING)

    this.apiClient.connect()
  }

  disconnect(): void {
    this._state = ConnectionState.DISCONNECTED
    this.apiClient.disconnect()
    this.emit('stateChange', ConnectionState.DISCONNECTED)
  }

  async send(data: RPCFrame): Promise<void> {
    if (data.type !== 'req') {
      return
    }

    try {
      const payload = await this.apiClient.rpc(data.method, data.params)
      const response: RPCResponse<unknown> = {
        type: 'res',
        id: data.id,
        ok: true,
        payload,
      }
      this.emit(`rpc:${data.id}`, response)
    } catch (err) {
      const response: RPCResponse<unknown> = {
        type: 'res',
        id: data.id,
        ok: false,
        error: {
          message: err instanceof Error ? err.message : 'RPC call failed',
        },
      }
      this.emit(`rpc:${data.id}`, response)
    }
  }

  on(event: string, handler: EventHandler): () => void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set())
    }
    this.listeners.get(event)!.add(handler)
    return () => this.off(event, handler)
  }

  off(event: string, handler: EventHandler): void {
    this.listeners.get(event)?.delete(handler)
  }

  private emit(event: string, ...args: unknown[]): void {
    this.listeners.get(event)?.forEach((handler) => {
      try {
        handler(...args)
      } catch (e) {
        console.error(`[WebSocket] Event handler error for "${event}":`, e)
      }
    })
  }
}


path: src/assets/styles/main.css
content: :root {
  --bg-primary: #ffffff;
  --bg-secondary: #f5f7fa;
  --bg-card: #ffffff;
  --text-primary: #1a1a2e;
  --text-secondary: #64748b;
  --border-color: #e2e8f0;
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
  --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07);
  --radius: 8px;
  --radius-lg: 12px;
  --card-radius-xl: 16px;
  --toolbar-btn-height: 38px;
  --toolbar-btn-radius: 11px;
  --toolbar-refresh-bg: var(--bg-primary);
  --toolbar-refresh-bg-hover: var(--bg-secondary);
  --toolbar-refresh-border: var(--border-color);
  --toolbar-refresh-text: var(--text-primary);
  --toolbar-refresh-shadow: 0 6px 14px rgba(15, 23, 42, 0.1);
  --toolbar-refresh-shadow-hover: 0 10px 18px rgba(15, 23, 42, 0.14);
  --toolbar-save-shadow: 0 8px 18px rgba(5, 150, 105, 0.26);
  --toolbar-save-shadow-hover: 0 12px 22px rgba(5, 150, 105, 0.32);
  --toolbar-apply-shadow: 0 8px 18px rgba(245, 158, 11, 0.26);
  --toolbar-apply-shadow-hover: 0 12px 22px rgba(245, 158, 11, 0.32);
  --sidebar-width: 240px;
  --sidebar-collapsed: 64px;
  --header-height: 56px;
  --link-color: #16a34a;
  --link-color-hover: #15803d;
  --link-underline: rgba(22, 163, 74, 0.28);
  --md-bullet-color: #9ca3af;
  --md-bullet-nested-color: #c4c9d2;
  --md-blockquote-bg: rgba(100, 116, 139, 0.06);
  --md-blockquote-border: #cbd5e1;
  --md-code-bg: rgba(100, 116, 139, 0.08);
  --md-code-border: rgba(100, 116, 139, 0.13);
  --md-pre-bg: rgba(100, 116, 139, 0.05);
}

[data-theme='dark'] {
  --bg-primary: #101014;
  --bg-secondary: #18181c;
  --bg-card: #1e1e22;
  --text-primary: #ffffffde;
  --text-secondary: #a0a0b0;
  --border-color: #2c2c32;
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
  --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
  --toolbar-refresh-shadow: 0 6px 14px rgba(0, 0, 0, 0.3);
  --toolbar-refresh-shadow-hover: 0 10px 18px rgba(0, 0, 0, 0.4);
  --toolbar-save-shadow: 0 8px 16px rgba(5, 150, 105, 0.22);
  --toolbar-save-shadow-hover: 0 10px 20px rgba(5, 150, 105, 0.28);
  --toolbar-apply-shadow: 0 8px 16px rgba(245, 158, 11, 0.22);
  --toolbar-apply-shadow-hover: 0 10px 20px rgba(245, 158, 11, 0.28);
  --link-color: #34d399;
  --link-color-hover: #6ee7b7;
  --link-underline: rgba(52, 211, 153, 0.3);
  --md-bullet-color: #6b7280;
  --md-bullet-nested-color: #4b5563;
  --md-blockquote-bg: rgba(160, 160, 176, 0.07);
  --md-blockquote-border: #3f3f46;
  --md-code-bg: rgba(160, 160, 176, 0.1);
  --md-code-border: rgba(160, 160, 176, 0.12);
  --md-pre-bg: rgba(0, 0, 0, 0.2);
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html, body, #app {
  height: 100%;
  width: 100%;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
    'Noto Sans', sans-serif;
  background-color: var(--bg-secondary);
  color: var(--text-primary);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

/* Scrollbar */
::-webkit-scrollbar {
  width: 6px;
  height: 6px;
}

::-webkit-scrollbar-track {
  background: transparent;
}

::-webkit-scrollbar-thumb {
  background: var(--border-color);
  border-radius: 3px;
}

::-webkit-scrollbar-thumb:hover {
  background: var(--text-secondary);
}

/* Page transitions */
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.15s ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}

/* Utility */
.page-container {
  max-width: 1400px;
  margin: 0 auto;
}

body.wide-mode .page-container {
  max-width: 100%;
  padding-left: 12px;
  padding-right: 12px;
}

.app-card.n-card {
  border-radius: var(--card-radius-xl);
  border-color: var(--border-color);
  background: var(--bg-card);
  box-shadow: var(--shadow-sm);
}

.app-toolbar {
  align-items: center;
  flex-wrap: wrap;
}

.app-toolbar-btn.n-button {
  min-width: 112px;
  height: var(--toolbar-btn-height);
  border-radius: var(--toolbar-btn-radius);
  padding: 0 16px;
  font-weight: 700;
  letter-spacing: 0.2px;
  transition: transform 0.14s ease, box-shadow 0.2s ease, filter 0.2s ease;
}

.app-toolbar-btn .n-icon {
  font-size: 16px;
}

.app-toolbar-btn:not(.n-button--disabled):hover {
  transform: translateY(-1px);
}

.app-toolbar-btn:not(.n-button--disabled):active {
  transform: translateY(0);
}

.app-toolbar-btn--refresh.n-button {
  background: var(--toolbar-refresh-bg) !important;
  border: 1px solid var(--toolbar-refresh-border) !important;
  color: var(--toolbar-refresh-text) !important;
  box-shadow: var(--toolbar-refresh-shadow);
}

.app-toolbar-btn--refresh.n-button:not(.n-button--disabled):hover {
  border-color: var(--toolbar-refresh-border) !important;
  background: var(--toolbar-refresh-bg-hover) !important;
  box-shadow: var(--toolbar-refresh-shadow-hover);
}

.app-toolbar-btn--save.n-button {
  background: linear-gradient(135deg, #16a34a 0%, #059669 100%) !important;
  border: none !important;
  color: #ffffff !important;
  box-shadow: var(--toolbar-save-shadow);
}

.app-toolbar-btn--save.n-button:not(.n-button--disabled):hover {
  filter: brightness(1.04);
  box-shadow: var(--toolbar-save-shadow-hover);
}

.app-toolbar-btn--apply.n-button {
  background: linear-gradient(135deg, #f59e0b 0%, #f97316 100%) !important;
  border: none !important;
  color: #ffffff !important;
  box-shadow: var(--toolbar-apply-shadow);
}

.app-toolbar-btn--apply.n-button:not(.n-button--disabled):hover {
  filter: brightness(1.04);
  box-shadow: var(--toolbar-apply-shadow-hover);
}

.app-toolbar-btn.n-button.n-button--disabled {
  opacity: 0.7;
  box-shadow: none !important;
}


path: src/composables/useEventStream.ts
content: import { computed, onMounted, onUnmounted } from 'vue'
import { useWebSocketStore } from '@/stores/websocket'
import { useMonitorStore } from '@/stores/monitor'
import type { RPCEvent, AgentEvent } from '@/api/types'

export function useEventStream(eventTypes?: string[]) {
  const wsStore = useWebSocketStore()
  const monitorStore = useMonitorStore()
  let cleanup: (() => void) | null = null

  const filteredEvents = computed(() => {
    if (!eventTypes || eventTypes.length === 0) return monitorStore.events
    return monitorStore.events.filter((e) => eventTypes.includes(e.event))
  })

  onMounted(() => {
    cleanup = wsStore.subscribe('event', (evt: unknown) => {
      const rpcEvent = evt as RPCEvent
      const agentEvent: AgentEvent = {
        event: rpcEvent.event,
        payload: rpcEvent.payload,
        seq: rpcEvent.seq,
        timestamp: Date.now(),
      }
      monitorStore.addEvent(agentEvent)
    })
  })

  onUnmounted(() => {
    cleanup?.()
  })

  return {
    events: filteredEvents,
    clear: monitorStore.clearEvents,
    paused: monitorStore.paused,
  }
}


path: src/composables/useResizable.ts
content: import { onUnmounted, ref, watch, type Ref } from 'vue'

export interface UseResizableOptions {
  minWidth?: number
  maxWidth?: number
  defaultWidth?: number
  storageKey?: string
  direction?: 'left' | 'right'
}

export function useResizable(
  containerRef: Ref<HTMLElement | null>,
  options: UseResizableOptions = {}
) {
  const {
    minWidth = 300,
    maxWidth = 800,
    defaultWidth = 420,
    storageKey,
    direction = 'left',
  } = options

  const width = ref(defaultWidth)
  const isResizing = ref(false)

  if (storageKey) {
    const stored = localStorage.getItem(storageKey)
    if (stored) {
      const parsed = parseInt(stored, 10)
      if (!isNaN(parsed) && parsed >= minWidth && parsed <= maxWidth) {
        width.value = parsed
      }
    }
  }

  let startX = 0
  let startWidth = 0
  let isListenerAdded = false

  function handleMouseDown(e: MouseEvent) {
    if (!containerRef.value) return

    const rect = containerRef.value.getBoundingClientRect()
    const resizeZone = 8

    const isInResizeZone =
      direction === 'left'
        ? e.clientX >= rect.left && e.clientX <= rect.left + resizeZone
        : e.clientX <= rect.right && e.clientX >= rect.right - resizeZone

    if (!isInResizeZone) return

    e.preventDefault()
    isResizing.value = true
    startX = e.clientX
    startWidth = width.value

    document.addEventListener('mousemove', handleMouseMove)
    document.addEventListener('mouseup', handleMouseUp)
    document.body.style.cursor = 'ew-resize'
    document.body.style.userSelect = 'none'
  }

  function handleMouseMove(e: MouseEvent) {
    if (!isResizing.value) return

    const deltaX = direction === 'left' ? startX - e.clientX : e.clientX - startX
    let newWidth = startWidth + deltaX

    newWidth = Math.max(minWidth, Math.min(maxWidth, newWidth))
    width.value = newWidth
  }

  function handleMouseUp() {
    isResizing.value = false
    document.removeEventListener('mousemove', handleMouseMove)
    document.removeEventListener('mouseup', handleMouseUp)
    document.body.style.cursor = ''
    document.body.style.userSelect = ''

    if (storageKey) {
      localStorage.setItem(storageKey, String(width.value))
    }
  }

  function handleMouseOver(e: MouseEvent) {
    if (!containerRef.value || isResizing.value) return

    const rect = containerRef.value.getBoundingClientRect()
    const resizeZone = 8

    const isInResizeZone =
      direction === 'left'
        ? e.clientX >= rect.left && e.clientX <= rect.left + resizeZone
        : e.clientX <= rect.right && e.clientX >= rect.right - resizeZone

    if (isInResizeZone) {
      document.body.style.cursor = 'ew-resize'
    }
  }

  function handleMouseLeave() {
    if (!isResizing.value) {
      document.body.style.cursor = ''
    }
  }

  function addListeners() {
    if (containerRef.value && !isListenerAdded) {
      containerRef.value.addEventListener('mousedown', handleMouseDown)
      containerRef.value.addEventListener('mouseover', handleMouseOver)
      containerRef.value.addEventListener('mouseleave', handleMouseLeave)
      isListenerAdded = true
    }
  }

  function removeListeners() {
    if (containerRef.value && isListenerAdded) {
      containerRef.value.removeEventListener('mousedown', handleMouseDown)
      containerRef.value.removeEventListener('mouseover', handleMouseOver)
      containerRef.value.removeEventListener('mouseleave', handleMouseLeave)
      isListenerAdded = false
    }
  }

  watch(containerRef, (newVal) => {
    if (newVal) {
      addListeners()
    }
  }, { immediate: true })

  onUnmounted(() => {
    removeListeners()
    document.removeEventListener('mousemove', handleMouseMove)
    document.removeEventListener('mouseup', handleMouseUp)
    document.body.style.cursor = ''
    document.body.style.userSelect = ''
  })

  return {
    width,
    isResizing,
  }
}


path: src/composables/useTheme.ts
content: import { computed } from 'vue'
import { darkTheme } from 'naive-ui'
import { useThemeStore } from '@/stores/theme'

export function useTheme() {
  const themeStore = useThemeStore()

  const naiveTheme = computed(() =>
    themeStore.mode === 'dark' ? darkTheme : null
  )

  const isDark = computed(() => themeStore.mode === 'dark')

  return {
    theme: naiveTheme,
    mode: computed(() => themeStore.mode),
    isDark,
    toggle: themeStore.toggle,
  }
}


path: src/env.d.ts
content: /// <reference types="vite/client" />

declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

declare module 'vue-pdf-embed' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, any>
  export default component
}

interface ImportMetaEnv {
  readonly VITE_WS_URL: string
  readonly VITE_APP_TITLE: string
  readonly VITE_APP_VERSION?: string
  readonly VITE_OPENCLAW_CLIENT_ID?: string
  readonly VITE_OPENCLAW_CLIENT_MODE?: string
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}


path: src/i18n/index.ts
content: import { createI18n } from 'vue-i18n'
import zhCN from './messages/zh-CN'
import enUS from './messages/en-US'
import { getPreferredLocale, type AppLocale } from './locale'

const DEFAULT_LOCALE: AppLocale = 'en-US'

export const i18n = createI18n({
  legacy: false,
  globalInjection: true,
  locale: getPreferredLocale(),
  fallbackLocale: DEFAULT_LOCALE,
  messages: {
    'zh-CN': zhCN,
    'en-US': enUS,
  },
})



path: src/i18n/locale.ts
content: export type AppLocale = 'zh-CN' | 'en-US'

const STORAGE_KEY = 'openclaw_locale'

export function normalizeLocale(input?: string | null): AppLocale | null {
  if (!input) return null
  const raw = String(input).trim()
  if (!raw) return null

  const lower = raw.toLowerCase()
  if (lower === 'zh-cn') return 'zh-CN'
  if (lower === 'en-us') return 'en-US'

  if (lower.startsWith('zh')) return 'zh-CN'
  if (lower.startsWith('en')) return 'en-US'

  return null
}

export function getSystemLocale(): AppLocale {
  if (typeof navigator === 'undefined') return 'en-US'

  const candidates = (navigator.languages?.length ? navigator.languages : [navigator.language]).filter(Boolean)
  for (const candidate of candidates) {
    const normalized = normalizeLocale(candidate)
    if (normalized) return normalized
  }

  return 'en-US'
}

export function getStoredLocale(): AppLocale | null {
  if (typeof window === 'undefined') return null
  return normalizeLocale(window.localStorage.getItem(STORAGE_KEY))
}

export function getPreferredLocale(): AppLocale {
  return getStoredLocale() || getSystemLocale()
}

export function saveLocale(locale: AppLocale): void {
  if (typeof window === 'undefined') return
  window.localStorage.setItem(STORAGE_KEY, locale)
}

export const LOCALE_STORAGE_KEY = STORAGE_KEY



path: src/i18n/text.ts
content: import { getPreferredLocale, type AppLocale } from './locale'

export function getActiveLocale(): AppLocale {
  return getPreferredLocale()
}

export function byLocale(zhCN: string, enUS: string, locale: AppLocale = getPreferredLocale()): string {
  return locale === 'zh-CN' ? zhCN : enUS
}



path: src/main.ts
content: import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { i18n } from '@/i18n'
import './assets/styles/main.css'
import 'katex/dist/katex.min.css'
import 'highlight.js/styles/github-dark.css'

const app = createApp(App)
app.use(createPinia())
app.use(i18n)
app.use(router)
app.mount('#app')


path: src/router/index.ts
content: import { createRouter, createWebHistory } from 'vue-router'
import { routes } from './routes'
import { useAuthStore } from '@/stores/auth'

const router = createRouter({
  history: createWebHistory(),
  routes,
})

router.beforeEach(async (to, _from, next) => {
  const authStore = useAuthStore()

  const authEnabled = await authStore.checkAuthConfig()

  if (!authEnabled) {
    if (to.name === 'Login') {
      next({ name: 'Dashboard' })
      return
    }
    next()
    return
  }

  if (to.meta.public) {
    if (to.name === 'Login' && authStore.isAuthenticated) {
      const valid = await authStore.checkAuth()
      if (valid) {
        const redirect = typeof to.query.redirect === 'string' ? to.query.redirect : '/'
        next(redirect)
        return
      }
    }
    next()
    return
  }

  if (!authStore.isAuthenticated) {
    next({ name: 'Login', query: { redirect: to.fullPath } })
    return
  }

  const valid = await authStore.checkAuth()
  if (!valid) {
    next({ name: 'Login', query: { redirect: to.fullPath } })
    return
  }

  next()
})

export default router


path: src/router/routes.ts
content: import type { RouteRecordRaw } from 'vue-router'

export const routes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/Login.vue'),
    meta: { titleKey: 'routes.login', public: true },
  },
  {
    path: '/',
    component: () => import('@/layouts/DefaultLayout.vue'),
    meta: { requiresAuth: true },
    children: [
      {
        path: '',
        name: 'Dashboard',
        component: () => import('@/views/Dashboard.vue'),
        meta: { titleKey: 'routes.dashboard', icon: 'GridOutline' },
      },
      {
        path: 'chat',
        name: 'Chat',
        component: () => import('@/views/chat/ChatPage.vue'),
        meta: { titleKey: 'routes.chat', icon: 'ChatboxEllipsesOutline' },
      },
      {
        path: 'sessions',
        name: 'Sessions',
        component: () => import('@/views/sessions/SessionsPage.vue'),
        meta: { titleKey: 'routes.sessions', icon: 'ChatbubblesOutline' },
      },
      {
        path: 'sessions/:key',
        name: 'SessionDetail',
        component: () => import('@/views/sessions/SessionDetailPage.vue'),
        meta: { titleKey: 'routes.sessionDetail', hidden: true },
      },
      {
        path: 'memory',
        name: 'Memory',
        component: () => import('@/views/memory/MemoryPage.vue'),
        meta: { titleKey: 'routes.memory', icon: 'BookOutline' },
      },
      {
        path: 'cron',
        name: 'Cron',
        component: () => import('@/views/cron/CronPage.vue'),
        meta: { titleKey: 'routes.cron', icon: 'CalendarOutline' },
      },
      {
        path: 'models',
        name: 'Models',
        component: () => import('@/views/models/ModelsPage.vue'),
        meta: { titleKey: 'routes.models', icon: 'SparklesOutline' },
      },
      {
        path: 'channels',
        name: 'Channels',
        component: () => import('@/views/channels/ChannelsPage.vue'),
        meta: { titleKey: 'routes.channels', icon: 'GitNetworkOutline' },
      },
      {
        path: 'config',
        redirect: { name: 'Models' },
        meta: { hidden: true },
      },
      {
        path: 'skills',
        name: 'Skills',
        component: () => import('@/views/skills/SkillsPage.vue'),
        meta: { titleKey: 'routes.skills', icon: 'ExtensionPuzzleOutline' },
      },
      {
        path: 'tools',
        redirect: { name: 'Skills' },
        meta: { hidden: true },
      },
      {
        path: 'system',
        name: 'System',
        component: () => import('@/views/system/SystemPage.vue'),
        meta: { titleKey: 'routes.system', icon: 'PulseOutline' },
      },
      {
        path: 'terminal',
        name: 'Terminal',
        component: () => import('@/views/terminal/TerminalPage.vue'),
        meta: { titleKey: 'routes.terminal', icon: 'TerminalOutline' },
      },
      {
        path: 'remote-desktop',
        name: 'RemoteDesktop',
        component: () => import('@/views/remote-desktop/RemoteDesktopPage.vue'),
        meta: { titleKey: 'routes.remoteDesktop', icon: 'DesktopOutline' },
      },
      {
        path: 'files',
        name: 'Files',
        component: () => import('@/views/files/FilesPage.vue'),
        meta: { titleKey: 'routes.files', icon: 'FolderOutline' },
      },
      {
        path: 'agents',
        name: 'Agents',
        component: () => import('@/views/agents/AgentsPage.vue'),
        meta: { titleKey: 'routes.agents', icon: 'PeopleOutline' },
      },
      {
        path: 'office',
        name: 'Office',
        component: () => import('@/views/office/OfficePage.vue'),
        meta: { titleKey: 'routes.office', icon: 'ConstructOutline' },
      },
      {
        path: 'myworld',
        name: 'MyWorld',
        component: () => import('@/views/myworld/MyWorldPage.vue'),
        meta: { titleKey: 'routes.myworld', icon: 'StorefrontOutline' },
      },
      {
        path: 'backup',
        name: 'Backup',
        component: () => import('@/views/backup/BackupPage.vue'),
        meta: { titleKey: 'routes.backup', icon: 'ArchiveOutline' },
      },
      {
        path: 'monitor',
        name: 'Monitor',
        component: () => import('@/views/monitor/MonitorPage.vue'),
        meta: { titleKey: 'routes.monitor', icon: 'PulseOutline', hidden: true },
      },
      {
        path: 'settings',
        name: 'Settings',
        component: () => import('@/views/settings/SettingsPage.vue'),
        meta: { titleKey: 'routes.settings', icon: 'CogOutline' },
      },
    ],
  },
]


path: src/stores/auth.ts
content: import { ref, computed } from 'vue'
import { defineStore } from 'pinia'

const AUTH_TOKEN_KEY = 'auth_token'

export const useAuthStore = defineStore('auth', () => {
  const [REDACTED_SECRET] | null>(localStorage.getItem(AUTH_TOKEN_KEY))
  const authEnabled = ref(true)
  const loading = ref(false)
  const error = ref<string | null>(null)

  const isAuthenticated = computed(() => !!token.value)

  function setToken(newToken: string | null) {
    token.value = newToken
    if (newToken) {
      localStorage.setItem(AUTH_TOKEN_KEY, newToken)
    } else {
      localStorage.removeItem(AUTH_TOKEN_KEY)
    }
  }

  async function checkAuthConfig() {
    try {
      const response = await fetch('/api/auth/config')
      const data = await response.json()
      authEnabled.value = data.enabled
      return data.enabled
    } catch {
      authEnabled.value = false
      return false
    }
  }

  async function checkAuth(): Promise<boolean> {
    if (!token.value) return false
    
    try {
      const response = await fetch('/api/auth/check', {
        headers: {
          'Authorization': `Bearer ${token.value}`,
        },
      })
      
      if (response.ok) {
        return true
      } else {
        setToken(null)
        return false
      }
    } catch {
      return false
    }
  }

  async function login(username: string, [REDACTED_SECRET]): Promise<boolean> {
    loading.value = true
    error.value = null
    
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ username, password }),
      })
      
      const data = await response.json()
      
      if (response.ok && data.ok) {
        if (data.token) {
          setToken(data.token)
        }
        loading.value = false
        return true
      } else {
        error.value = data.error || 'Login failed'
        loading.value = false
        return false
      }
    } catch (e) {
      error.value = e instanceof Error ? e.message : 'Network error'
      loading.value = false
      return false
    }
  }

  async function logout() {
    try {
      if (token.value) {
        await fetch('/api/auth/logout', {
          method: 'POST',
          headers: {
            'Authorization': `Bearer ${token.value}`,
          },
        })
      }
    } catch {
      // ignore
    }
    setToken(null)
  }

  function getToken(): string | null {
    return token.value
  }

  return {
    token,
    authEnabled,
    loading,
    error,
    isAuthenticated,
    checkAuthConfig,
    checkAuth,
    login,
    logout,
    getToken,
  }
})


path: src/stores/channel.ts
content: import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useWebSocketStore } from './websocket'
import type { Channel, ChannelAuthParams, PairParams } from '@/api/types'

export const useChannelStore = defineStore('channel', () => {
  const channels = ref<Channel[]>([])
  const loading = ref(false)

  const wsStore = useWebSocketStore()

  async function fetchChannels() {
    loading.value = true
    try {
      channels.value = await wsStore.rpc.listChannels()
    } catch (error) {
      channels.value = []
      console.error('[ChannelStore] fetchChannels failed:', error)
    } finally {
      loading.value = false
    }
  }

  async function authChannel(params: ChannelAuthParams) {
    return await wsStore.rpc.authChannel(params)
  }

  async function pairChannel(params: PairParams) {
    await wsStore.rpc.pairChannel(params)
    await fetchChannels()
  }

  return {
    channels,
    loading,
    fetchChannels,
    authChannel,
    pairChannel,
  }
})


path: src/stores/config.ts
content: import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useWebSocketStore } from './websocket'
import type { OpenClawConfig, ConfigPatch } from '@/api/types'

export const useConfigStore = defineStore('config', () => {
  const config = ref<OpenClawConfig | null>(null)
  const loading = ref(false)
  const saving = ref(false)
  const lastError = ref<string | null>(null)

  const wsStore = useWebSocketStore()

  async function fetchConfig() {
    loading.value = true
    lastError.value = null
    try {
      config.value = await wsStore.rpc.getConfig()
    } catch (error) {
      config.value = null
      lastError.value = error instanceof Error ? error.message : String(error)
      console.error('[ConfigStore] fetchConfig failed:', error)
    } finally {
      loading.value = false
    }
  }

  async function patchConfig(patches: ConfigPatch[]) {
    saving.value = true
    lastError.value = null
    try {
      await wsStore.rpc.patchConfig(patches)
      await fetchConfig()
    } catch (error) {
      lastError.value = error instanceof Error ? error.message : String(error)
      throw error
    } finally {
      saving.value = false
    }
  }

  async function setConfig(newConfig: OpenClawConfig) {
    saving.value = true
    lastError.value = null
    try {
      await wsStore.rpc.setConfig(newConfig)
      await fetchConfig()
    } catch (error) {
      lastError.value = error instanceof Error ? error.message : String(error)
      throw error
    } finally {
      saving.value = false
    }
  }

  async function applyConfig() {
    saving.value = true
    lastError.value = null
    try {
      await wsStore.rpc.applyConfig()
    } catch (error) {
      lastError.value = error instanceof Error ? error.message : String(error)
      throw error
    } finally {
      saving.value = false
    }
  }

  return {
    config,
    loading,
    saving,
    lastError,
    fetchConfig,
    patchConfig,
    setConfig,
    applyConfig,
  }
})


path: src/stores/cron.ts
content: import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useWebSocketStore } from './websocket'
import type { CronJob, CronRunLogEntry, CronStatus, CronUpsertParams } from '@/api/types'

export const useCronStore = defineStore('cron', () => {
  const jobs = ref<CronJob[]>([])
  const status = ref<CronStatus | null>(null)
  const selectedJobId = ref<string | null>(null)
  const runs = ref<CronRunLogEntry[]>([])
  const loading = ref(false)
  const statusLoading = ref(false)
  const runsLoading = ref(false)
  const saving = ref(false)
  const lastError = ref<string | null>(null)

  const wsStore = useWebSocketStore()

  async function fetchJobs() {
    loading.value = true
    lastError.value = null
    try {
      jobs.value = await wsStore.rpc.listCrons()
    } catch (error) {
      jobs.value = []
      lastError.value = error instanceof Error ? error.message : String(error)
      console.error('[CronStore] fetchJobs failed:', error)
    } finally {
      loading.value = false
    }
  }

  async function fetchStatus() {
    statusLoading.value = true
    lastError.value = null
    try {
      status.value = await wsStore.rpc.getCronStatus()
    } catch (error) {
      status.value = null
      lastError.value = error instanceof Error ? error.message : String(error)
      console.error('[CronStore] fetchStatus failed:', error)
    } finally {
      statusLoading.value = false
    }
  }

  async function fetchOverview() {
    await Promise.all([fetchJobs(), fetchStatus()])
  }

  async function fetchRuns(jobId: string, limit = 50) {
    selectedJobId.value = jobId
    runsLoading.value = true
    lastError.value = null
    try {
      runs.value = await wsStore.rpc.listCronRuns(jobId, limit)
    } catch (error) {
      runs.value = []
      lastError.value = error instanceof Error ? error.message : String(error)
      console.error('[CronStore] fetchRuns failed:', error)
    } finally {
      runsLoading.value = false
    }
  }

  function clearRuns() {
    selectedJobId.value = null
    runs.value = []
  }

  async function createJob(params: CronUpsertParams) {
    saving.value = true
    lastError.value = null
    try {
      await wsStore.rpc.createCron(params)
      await fetchOverview()
    } catch (error) {
      lastError.value = error instanceof Error ? error.message : String(error)
      throw error
    } finally {
      saving.value = false
    }
  }

  async function updateJob(id: string, params: Partial<CronUpsertParams>) {
    saving.value = true
    lastError.value = null
    try {
      await wsStore.rpc.updateCron(id, params)
      await fetchOverview()
      if (selectedJobId.value === id) {
        await fetchRuns(id)
      }
    } catch (error) {
      lastError.value = error instanceof Error ? error.message : String(error)
      throw error
    } finally {
      saving.value = false
    }
  }

  async function deleteJob(id: string) {
    saving.value = true
    lastError.value = null
    try {
      await wsStore.rpc.deleteCron(id)
      if (selectedJobId.value === id) {
        clearRuns()
      }
      await fetchOverview()
    } catch (error) {
      lastError.value = error instanceof Error ? error.message : String(error)
      throw error
    } finally {
      saving.value = false
    }
  }

  async function runJob(id: string, mode: 'force' | 'due' = 'force') {
    saving.value = true
    lastError.value = null
    try {
      await wsStore.rpc.runCron(id, mode)
      await fetchOverview()
      await fetchRuns(id)
    } catch (error) {
      lastError.value = error instanceof Error ? error.message : String(error)
      throw error
    } finally {
      saving.value = false
    }
  }

  return {
    jobs,
    status,
    selectedJobId,
    runs,
    loading,
    statusLoading,
    runsLoading,
    saving,
    lastError,
    fetchJobs,
    fetchStatus,
    fetchOverview,
    fetchRuns,
    clearRuns,
    createJob,
    updateJob,
    deleteJob,
    runJob,
  }
})


path: src/stores/locale.ts
content: import { ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { i18n } from '@/i18n'
import { getStoredLocale, getSystemLocale, saveLocale, type AppLocale } from '@/i18n/locale'

function applyLocale(locale: AppLocale) {
  i18n.global.locale.value = locale
  if (typeof document === 'undefined') return
  document.documentElement.setAttribute('lang', locale)
}

export const useLocaleStore = defineStore('locale', () => {
  const stored = getStoredLocale()
  const locale = ref<AppLocale>(stored || getSystemLocale())

  watch(locale, (val) => {
    applyLocale(val)
  }, { immediate: true })

  function setLocale(next: AppLocale, persist = true) {
    locale.value = next
    if (persist) saveLocale(next)
  }

  function toggle() {
    const next: AppLocale = locale.value === 'zh-CN' ? 'en-US' : 'zh-CN'
    setLocale(next, true)
  }

  return { locale, setLocale, toggle }
})



Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)