# 运行时状态

记录所有运行时目录的结构、每个状态文件的格式定义、哪些数据重启会丢/保留。这里描述的是 `forge-hub install` 后的运行时形态；手动部署可以只部署其中一部分。

---

## Hub Server 运行时（`~/.forge-hub/`）

> [!IMPORTANT]
> **同步源代码不会覆盖运行时数据。** `~/.forge-hub/` 里混着两种东西：源代码文件（`.ts`，从 OSS 复制过来的）和运行时数据（`.jsonl`、`.json` 配置、`.db`、`.log`、`state/`、`evidence/` 等）。它们的文件名完全不重叠——`forge-hub sync` 或手动 `cp` 源码时，只会覆盖 `.ts` 文件，不会碰你的聊天历史、allowlist、审计日志、心跳配置等任何运行时数据。

### 目录结构

```
~/.forge-hub/
├── hub.ts                    ← 入口（从源代码 cp）
├── router.ts                 ← 路由（从源代码 cp）
├── channel-loader.ts         ← 通道加载器（从源代码 cp）
├── instance-manager.ts       ← 实例管理（从源代码 cp）
├── config.ts                 ← 配置工具（从源代码 cp）
├── state.ts                  ← 持久化工具（从源代码 cp）
├── types.ts                  ← 类型定义（从源代码 cp）
├── package.json / tsconfig.json / bun.lock
│
├── hub-config.json           ← ★ 主配置（热加载）
├── hub.pid                   ← PID 文件（启动时写入）
├── hub.log                   ← 日志（rotation: 1MB×3）
├── hub.log.1                 ← 上一个 rotation
├── hub.log.2 / hub.log.3      ← 更早的 rotation
├── hub-client.log            ← hub-channel MCP client 日志（rotation: 1MB×2）
├── hub-stderr.log            ← launchd stderr 输出
├── api-token                 ← 可选 API token 文件（chmod 600）
├── lock.json                 ← 锁定状态（locked/at/by）
├── lock-phrase.json          ← 锁定暗号（敏感！不要读！）
├── channel-presets.json      ← 通道预设模板
├── search.db                 ← 可选：FTS5 全文搜索索引（search_index: true 时创建）
├── queue.db                  ← 消息投递队列（SQLite WAL，无在线实例时缓存，24h 过期）
├── sendable/                 ← 允许 `/send-file` 读取的本地文件沙箱
├── package/                  ← install staging 的包源码 snapshot（fh symlink 指向这里）
├── hub-dashboard/            ← Dashboard runtime 源码 + build 产物
│   └── dist/                 ← hub-server 静态托管的 Dashboard
│
├── channels/                 ← 通道插件（从源代码 cp；生产启动时加载，`FORGE_HUB_DEV=1` 才热加载）
│   ├── wechat.ts
│   ├── wechat-ilink.ts       ← 微信 API 封装
│   ├── wechat-media.ts       ← 微信媒体处理
│   ├── wechat-types.ts       ← 微信类型定义
│   ├── telegram.ts
│   ├── feishu.ts
│   ├── imessage.ts
│   └── homeland.ts
│
├── state/                    ← 持久化状态（重启保留）
│   ├── _hub/                 ← Hub 自身状态
│   │   ├── instance-identities.json
│   │   └── pending.json      ← 审批 pending 持久化（重启恢复）
│   ├── wechat/
│   │   ├── account.json
│   │   ├── sync.json
│   │   ├── context-tokens.json
│   │   ├── allowlist.json
│   │   ├── chat-history.jsonl
│   │   └── media/
│   ├── telegram/
│   │   ├── config.json
│   │   ├── allowlist.json
│   │   ├── chat-history.jsonl
│   │   └── media/
│   ├── feishu/
│   │   ├── allowlist.json
│   │   ├── chat-history.jsonl
│   │   └── media/
│   ├── imessage/
│   │   ├── allowlist.json
│   │   └── chat-history.jsonl
│   └── homeland/
│       ├── allowlist.json
│       └── chat-history.jsonl
│
└── audit.jsonl               ← 审计日志（append-only）
```

### 配置文件 Schema

#### `hub-config.json`

```json
{
  "port": 9900,                    // Hub 监听端口
  "host": "127.0.0.1",            // 绑定地址（localhost only）
  "primary_instance": "",          // 保留兼容（无@时已默认广播，不再依赖）
  "show_instance_tag": true,       // 回复时是否加实例标识
  "mention_mode": "direct",       // @消息分发："direct"=只发被@的（默认）/ "broadcast"=广播+标handler
  "approval_channels": ["wechat"], // 远程审批推送通道列表
  "approval_push_mode": "parallel", // "parallel"（默认）或 "sequential"
  "auto_replay_on_ready": true,    // instance WS ready 时是否自动推近期历史（默认 true）
  "auto_replay_count": 5,          // 自动 replay 每通道条数（默认 5；搭配 forge-launcher 可按通道精细控制）
  "asr_plugin_override": {         // 可选：每通道 ASR 覆盖
    "feishu": "hook",
    "imessage": "none"
  },
  "agent_name": "Forge",           // 出站消息发送者名称（可用 HUB_AGENT_NAME env 覆盖）
  "search_index": false            // 启用 FTS5 全文搜索索引（启用后写入 search.db）
}
```

热加载——修改后自动生效，不需要重启 Hub。

#### `channel-presets.json`

```json
[
  {
    "name": "日常",              // 预设名称
    "subscribe": ["wechat", "telegram"],  // 订阅的通道
    "history": {                 // 每个通道回放历史条数
      "wechat": 100,
      "telegram": 50
    }
  }
]
```

外部启动器（如菜单栏 App 或等价工具）读取此文件，提供通道会话的预设模板选择。

### 状态文件 Schema

#### `state/_hub/instance-identities.json`

实例身份持久化。key 是 `forge-${ppid}`（Claude Code 父进程 pid，hub-client 和 CLI 的 Bash sibling 都用同一个 ppid 保持一致）。

```json
{
  "forge-68528": {
    "description": "三通道测试",   // 实例描述（字符串）
    "tag": "G",                    // 实例标签（字符串，可选）
    "channels": ["wechat", "telegram"], // 订阅通道（数组，可选）
    "isChannel": true,              // 是否为 channel session（布尔，可选）
    "lastSeenAt": "2026-04-24T12:00:00.000Z" // 最近连接/断开时间（字符串，可选）
  }
}
```

`isChannel` 是 Hub Server 的 roster / 展示字段：`true` 表示当前或最近一次连接是 channel session，`false` 表示仅工具实例。它不再作为 Hub Client 的 crash recovery 模式判定依据；Hub Client 是否进入 channel mode 只由本次启动的 `next-session.json` / legacy `next-name.txt` / Claude Code channel handler auto-detect 决定。

**重启保留**：是。Hub 重启后从此文件恢复实例展示信息；不从这里恢复 Hub Client 的 channel/tool 模式。

#### `state/{channel}/allowlist.json`

每个通道的授权名单。

```json
{
  "allowed": [
    {
      "id": "<YOUR_WECHAT_SENDER_ID>@im.wechat",  // 平台原生 ID
      "nickname": "用户"                                    // 昵称（用于 hub_reply 的 to 参数自动解析）
    }
  ],
  "auto_allow_next": false,
  "approval_owner_id": "<YOUR_WECHAT_SENDER_ID>@im.wechat"
}
```

通过 `fh hub allow/revoke` + 二次确认管理（默认 `FORGE_HUB_AUTH_MODE=prompt`，也可设为 `touchid` / `none`）。普通聊天是整个 allowlist 都可通过；审批回复只接受显式 `approval_owner_id`，用 `fh hub owner <channel> <id|昵称>` 设置。旧文件可能没有 `auto_allow_next` 或 `approval_owner_id`，代码会按缺省值处理；新写入会保留这些字段。

#### `state/wechat/account.json`

```json
{
  "token": "<YOUR_ILINK_BOT_TOKEN>...",  // ilink API token
  "baseUrl": "https://ilinkai.weixin.qq.com",
  "accountId": "<YOUR_ILINK_BOT_ACCOUNT>",    // Bot 账号 ID
  "userId": "<YOUR_WECHAT_SENDER_ID>@im.wechat",  // 用户的微信 ID
  "savedAt": "2026-03-22T12:00:24.419Z"
}
```

#### `state/wechat/sync.json`

```json
{
  "buf": "ChEI..."  // ilink 同步 buffer（Base64），用于增量拉取消息
}
```

**重启保留**：是。丢了的话会重新全量拉取。

#### `state/wechat/context-tokens.json`

微信消息的 context token 映射。用于回复时关联原始消息。

#### `state/telegram/config.json`

```json
{
  "bot_token": "8795..."  // Telegram Bot API token
}
```

#### `state/{channel}/chat-history.jsonl`

所有通道的聊天历史。JSON Lines 格式，每行一条消息。Hub 默认在实例 WebSocket ready 时自动推每通道最近 `auto_replay_count` 条；也可通过 `hub_replay_history` / `fh hub replay` 主动拉取。`auto_replay_on_ready=false` 时不自动推。

**重启保留**：是。

### 日志文件

#### `hub.log`

Hub 运行日志。Rotation: 超过 1MB 时重命名为 `hub.log.1`，创建新 `hub.log`。最多保留 3 个。

**重启保留**：是。

#### `hub-stderr.log`

launchd 的 stderr 重定向。通常只有启动错误和 unhandled exception。

#### `audit.jsonl`

审计日志。代码按 append-only 方式追加写入；它不是文件系统层面的防篡改日志。每条记录包含：时间、操作类型、操作者、结果。

授权操作（allow/revoke/owner）在审计日志写入失败时会报错并中止后续流程，不会静默跳过。当前实现不是完整事务回滚：allowlist 文件可能已经落盘，但 CLI 会返回错误提醒管理员介入。

**重启保留**：是。

### 数据库文件

#### `search.db`

FTS5 全文搜索索引。仅在 `hub-config.json` 设置 `search_index: true` 时创建。包含一张主表 `messages` 和一张 FTS5 虚拟表 `messages_fts`（trigram tokenizer，支持中文搜索）。`appendHistory` 写 JSONL 的同时同步写入此库。通过 `GET /search?q=...` 端点查询。

删除此文件不影响聊天历史（历史仍在各通道的 `chat-history.jsonl`），下次 Hub 启动时会重建空索引（但已有历史不会自动回填）。

**重启保留**：是。

#### `queue.db`

消息投递队列（SQLite WAL 模式）。当入站消息无法投递（无在线实例 / WebSocket 推送失败）时入队，实例上线后自动 drain。队列条目 24 小时自动过期。

删除此文件会丢失未投递的排队消息，但不影响已投递的历史。

**重启保留**：是。

### 进程文件

#### `hub.pid`

Hub 进程 PID。启动时写入，用于进程管理。

**重启保留**：会被新进程覆盖。

### 内存状态与持久化镜像

除了磁盘文件，Hub Server 还维护一些内存状态。部分状态有磁盘镜像并会在重启时恢复；没有磁盘镜像的状态会在重启后丢失。

#### `pendingPermissions` Map



远程审批的挂起队列。key 是 MCP 协议的 request_id（由 Claude Code 生成），value 是：

```typescript
{
  request_id: string;           // Claude Code 生成的 5 小写字母（不含 l），内部标识，不展示给用户
  yes_id: string;               // Hub 生成的"批准 ID"，展示文案里配 yes 用，强绑定 allow
  no_id: string;                // Hub 生成的"拒绝 ID"，展示文案里配 no 用，强绑定 deny
  tool_name: string;            // 比如 "bash"
  description: string;          // 操作描述
  input_preview: string;        // 参数预览（截到 200 字符）
  from_instance: string;        // 发起请求的 instance ID
  created_at: number;           // ms epoch，用于 TTL 清理
  pushed_channels: string[];    // 实际成功推送的通道（用于调试/日志）
}
```

**强绑定设计**：yes_id 和 no_id 是两个独立的 5 字母 id，分别绑定 allow / deny 语义——不共用同一个 request_id。用户回复 `yes {yes_id}` 才通过；回复 `yes {no_id}` 会被拒（verdict 和 id 语义不符），pending 保持等待让他重新发。双防线：verdict word 要对 + id 要对，任一错失都不生效——防手滑和 autocorrect。

配套的 `idLookup` Map（`Map<string, {request_id, behavior}>`）是反查索引：每个 pending 登记时同时写 `yes_id → (request_id, "allow")` 和 `no_id → (request_id, "deny")`。用户回复时先走 idLookup，拿到 request_id + 预期 verdict，再和回复里的 word 比对。

**磁盘镜像**：`state/_hub/pending.json` 保存的是 pending 数组，用来重启后恢复 `pendingPermissions` + `idLookup`。出于安全考虑，写盘时会把 `input_preview` 清空；内存里仍保留当前请求的 preview，重启后不会重新推送这段 preview。

**反馈回路**：`sendApprovalAck` 在四个场景给用户回 ack，消除"用户回复后 Hub 黑箱"：
- 成功：`✅ 已批准/拒绝 {tool_name} ({description})`——用户知道送达且生效
- verdict/id 不一致：`⚠️ 不一致 + 提示正确的 yes {yes_id} / no {no_id}`——用户知道要重试，pending 保持
- id 无登记（TTL 过期/Hub 重启/输错）：`⚠️ 编号不存在，建议去终端 Esc`——用户知道老会话卡了要救
- TTL 自动超时：`⏰ 已超时自动拒绝`——用户事后也能知道有过这么一回

ack 发到**用户回复的那个通道 + 该通道的回复目标 id**（通常就是 fromId；像 iMessage 这类通道会保留独立的鉴权 sender id 和 reply target id）——因为他在那儿看到我的回话才直观。失败不 throw，只 logError，不影响主路径。

#### `idLookup` Map



审批 display id 反查表。key 是 `yes_id` 或 `no_id`（都是 5 字母），value 是 `{request_id, behavior: "allow" | "deny"}`。每个 pending 登记时同时写 2 条；清理时（成功/TTL/全通道失败）同步清 2 条。

为什么要独立这个 Map 而不是在 pendingPermissions 里扫？—— O(1) 查找 vs O(n) 扫描。且"收到主人回复"是 Hub 对延迟最敏感的路径，O(1) 必须。

**生命周期**：
- 创建：`/permission-request` 登记时——同时写 pendingPermissions + 两条 idLookup + `savePendingToDisk()`
- 删除：收到匹配且一致的 `yes/no xxxxx` 回复时 / 240 分钟 TTL 自动清理时 / 全通道推送失败时 / orphan 清理时——每种都**同时清 pending 一条 + idLookup 两条 + savePendingToDisk()**
- **Hub 重启保留 pending**：启动时 `loadPendingFromDisk()` 从 `state/_hub/pending.json` 还原，过期的丢弃并写 `approval_restart_timeout` 审计

**TTL 清理**：`setInterval(cleanup, 60_000)`——每 60 秒扫一次，对超过 `240 * 60 * 1000` ms 的条目做两件事：
1. **先通知 client**：给 `pending.from_instance` 发 `permission_response`（`behavior="deny"`），让 client 的 MCP permission handler 返回，避免 Claude Code 永久挂起
2. **再删除 map 条目**

如果 instance 已离线就只 log 不发（client 不在，发也没人收）。

**为什么 TTL 选 240 分钟**：覆盖"用户睡眠中几小时后才回审批"的实际场景。更短 TTL（如 10 分钟）会静默丢弃延迟回复；**且清理时必须同时给 client 发 `behavior="deny"`**——否则 Claude Code 的 MCP permission handler 会永久挂起，整个会话卡死。

### 哪些数据重启会丢

| 数据 | 重启后 |
|------|--------|
| 在线实例列表 | 丢失（实例重连后恢复） |
| 消息投递队列 | **保留**（持久化到 `queue.db`，实例上线后 drain，24 小时自动过期） |
| pending 的审批请求（`pendingPermissions` Map） | **保留**（持久化到 `state/_hub/pending.json`，启动时 `loadPendingFromDisk()` 还原 pending + 重建 idLookup，超 TTL 的丢弃 audit） |
| 锁定状态 | **保留**（`lock.json` 持久化） |
| 通道健康统计 | 丢失 |
| 所有 state/ 下的文件 | 保留 |
| 聊天历史 | 保留 |
| 审计日志 | 保留 |

---


## Hub Client 运行时（`~/.claude/channels/hub/`）

### 目录结构

```
~/.claude/channels/hub/
├── hub-channel.ts         ← MCP channel 入口（从源代码 cp）
├── .mcp.json              ← MCP channel 配置
├── package.json / tsconfig.json / bun.lock
└── node_modules/
```

Hub Client 没有自己的持久化状态。身份信息优先读自 `~/.forge-hub/next-session.json`（由外部启动器写入，或手动创建），再读 legacy `next-name.txt`，最后只从 `~/.forge-hub/state/_hub/instance-identities.json` 恢复 tag / description。channel/tool 模式不从 `instance-identities.json` 恢复，避免 PID 复用把 tools 窗口误判成 channel session；没有 session config 时，Hub Client 会检测本次 Claude Code 是否注册了 channel handler。

### 相关的外部文件

| 文件 | 位置 | 写入者 | 读取者 |
|------|------|--------|--------|
| `next-session.json` | `~/.forge-hub/` | 外部启动器（可选） | hub-channel.ts |
| `next-name.txt` | `~/.forge-hub/` | 外部启动器（legacy） | hub-channel.ts（fallback） |
| `instance-identities.json` | `~/.forge-hub/state/_hub/` | Hub Server | hub-channel.ts（只恢复 tag / description） |
