# Loomi Protocol (LMP) v1

Android 手机作为 Hermes 的原生控制与通知入口。手机端**不是**独立客户端，
而是注册进 Hermes Gateway 的一个平台适配器（`loomi-plugin`）。

```
Hermes Gateway ──hooks/adapters──▶ Android Platform Plugin ──WSS──▶ Android App
                                                                      │
Hermes Gateway ◀──resolve_gateway_approval / handle_message───────────┘
```

本协议只描述 **Plugin ⇄ App** 这一跳。Plugin 与 Gateway 之间全部复用 Hermes 现有接口，
不新增任何 core 行为。

---

## 1. 传输

- 单条 WebSocket，路径 `/ws`。
- 同一端口上另有 `GET /health`（无鉴权，仅返回 `{"ok":true,"v":1}`），供反代/探活使用。
- 文本帧，JSON，UTF-8。二进制帧保留（v1 未使用）。
- 服务端每 25s 发 `ping`，客户端必须在 10s 内回 `pong`；连续 3 次未回则服务端断开。
- 客户端也可主动发 `ping`。
- 所有时间戳为 **float epoch 秒（UTC）**。客户端与服务端时间不一致时，客户端以
  `server_time` 字段为准做偏移校正，绝不用本地时间做排序。

## 2. 信封（所有帧）

```json
{
  "v": 1,
  "seq": 10231,
  "ts": 1759123456.789,
  "type": "run.delta",
  "task_id": "task_a1b2c3d4",
  "run_id": "run_9f8e7d6c",
  "event_id": "e_10231",
  "payload": { }
}
```

| 字段 | 说明 |
|---|---|
| `v` | 协议版本，整数。当前 `1`。 |
| `seq` | 服务端单调递增序号，**每设备**独立。断线重连的唯一排序/去重依据。 |
| `ts` | 服务端时间（float epoch 秒）。 |
| `type` | 事件类型，见 §4。 |
| `task_id` | 任务（对应一个 Hermes session）稳定 ID。无归属时为 `null`。 |
| `run_id` | 一次 Agent 回合（turn）的 ID。无归属时为 `null`。 |
| `event_id` | `"e_<seq>"`，便于日志与回执引用。 |
| `payload` | 类型相关载荷。**永不包含 Hermes 内部对象**，只含已归一化的字面量。 |

规则：
- App 必须按 `seq` 排序，`seq` 小于已处理的则丢弃（幂等去重）。
- 服务端保留最近 512 条已发送事件；App 重连时在 `hello` 里带 `last_seq`，服务端补发缺口。
- 缺口超出保留窗口时，服务端在 `hello.ok` 里置 `truncated: true`，App 应重新拉 `task.list`。

## 3. 配对与设备身份

**禁止用户手输 API Key。** 流程：

1. 用户在服务器执行 CLI：
   ```
   hermes loomi pair
   ```
   该命令走 **loopback-only** 的内部端点 `POST http://127.0.0.1:<port>/internal/pair/new`，
   拿到一次性配对令牌，在终端渲染二维码。二维码内容是 JSON：
   ```json
   {"v":1,"url":"wss://hermes.example.com/ws","token":"<一次性令牌>","name":"<主机名>","fp":"<证书指纹或空>"}
   ```
2. App 扫码 → 连 `url` → 首帧发 `hello` 带 `token`。
3. 服务端校验：**单次使用**（用后立即作废）、**TTL 默认 300 秒**、来源必须是新设备。
   通过后生成 `device_id` + `credential`（32 字节 urlsafe），随 `hello.ok` 下发。App 落盘保存。
4. 之后每次连接发 `hello` 带 `device_id` + `credential` + `last_seq`。

安全约束（实现已在 adapter 中强制）：
- 令牌与凭据**永不写入日志**（日志里一律用 `<redacted>` / 前 8 位指纹）。
- 默认绑定 `127.0.0.1`。绑定到非回环地址时：若未配置 TLS 且未显式设
  `LOOMI_ALLOW_INSECURE_BIND=true`，**适配器拒绝启动**（fail closed）。
- 无鉴权的 WS 连接一律在 10 秒内被服务端关闭。
- 设备可撤销：`hermes loomi devices` / `hermes loomi revoke <device_id>`。
- 支持 LAN / 反向代理 / 公网部署；反向代理必须支持 WebSocket Upgrade
  （nginx 需要 `proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";`
  并把 `proxy_read_timeout` 调到 ≥ 600s）。
- 多设备同时登录受支持：事件广播到所有在线设备，审批**先到先得**，
  被解决的审批会向其余设备推送 `approval.resolved`。

## 4. 事件类型

### 服务端 → 客户端

| type | payload | 来源（Hermes 真实接口） |
|---|---|---|
| `hello.ok` | `{device_id, credential?, server_name, server_version, protocol_version, server_time, truncated, home_channel}` | 适配器 |
| `error` | `{code, message, fatal}` | 适配器 |
| `run.started` | `{run_id, task_id, session_id, surface, model, provider, started_at, title}` | hook `on_stream_start` |
| `run.delta` | `{run_id, text, kind:"text"\|"reasoning", iteration}` | hook `on_stream_delta` |
| `run.interim` | `{run_id, text, already_streamed}` | hook `on_interim_message` |
| `run.tool_started` | `{run_id, iteration, tool, preview, index, call_id}` | hook `pre_tool_call` |
| `run.tool_finished` | `{run_id, iteration, tool, ok, index, call_id, duration}` | hook `post_tool_call` |
| `run.completed` | `{run_id, status:"completed"\|"failed"\|"cancelled", text, error, finished}` | hook `on_stream_end` + 适配器 `send()` 兜底 |
| `run.failed` | `{run_id, error, status:"failed"}` | 同上，`error` 非空时 |
| `approval.required` | `{approval_id, run_id, task_id, command, description, choices:[{label,choice,style}], smart_denied, session_key_ref, deadline_ts}` | 适配器 `_send_exec_approval_prompt` |
| `approval.resolved` | `{approval_id, choice, decided_by:"local"\|"remote"\|"timeout"\|"agent"}` | hook `post_approval_response` |
| `notification` | `{category:"result"\|"diagnostic", title, text, source, requires_attention}` | 适配器 `send()`（cron / 后台任务 / 看板 / 网关通告） |
| `task.list.result` | `{tasks:[...]}` | 适配器（读 SessionDB，只读） |
| `pong` | `{}` | 适配器 |
| `ping` | `{}` | 适配器 |

`tool_started` / `tool_finished` 的 `index` 来自 hook 载荷，用于把 start 与 finish 配对；
若同一迭代内出现多个同名工具，以 `call_id` 优先配对。

### 客户端 → 服务端

| type | payload | 说明 |
|---|---|---|
| `hello` | `{protocol_version, token?\|device_id+credential, last_seq?, device_info:{model, os, app_version}}` | 首帧，必须 |
| `pong` | `{}` | 心跳应答 |
| `ping` | `{}` | 心跳发起 |
| `session.send` | `{task_id?, text, request_id}` | 注入一条用户消息。`request_id` 由客户端生成，服务端保证幂等 |
| `run.cancel` | `{run_id}` | 中断当前回合（等价网关 `/stop`） |
| `run.pause` | `{run_id}` | v1 语义 = **软中断**（保留会话，可继续）。非暂停/恢复对 |
| `run.resume` | `{run_id}` | v1 语义 = 向该会话注入「继续」消息 |
| `approval.resolve` | `{approval_id, choice, reason?, request_id}` | `choice ∈ once/session/always/deny`，原样交给 `resolve_gateway_approval` |
| `task.list` | `{request_id, limit?, status_filter?}` | 拉任务列表 |

`run.pause` / `run.resume` 的 v1 语义是**明确的近似**，不是真正的进程暂停：
Hermes 的回合不可挂起，只可中断。协议字段保留，行为在文档与 UI 文案里如实标注。

## 5. 幂等与顺序

- 服务端 → 客户端：靠 `seq`。
- 客户端 → 服务端：靠 `request_id`。服务端对 `session.send` / `approval.resolve`
  保留最近 256 个 `request_id` 的结果，重复请求直接返回上次结果，不重复执行。
- 审批解析额外靠 `approval_id`：已解决的审批再次解析返回 `error{code:"already_resolved"}`。

## 6. 版本与兼容

- `v` 只在**破坏性**变更时递增。
- 新增事件类型、新增 payload 字段 = **非破坏性**，`v` 不变；App 必须忽略未知 `type` 与未知字段。
- 服务端在 `hello.ok` 里回 `protocol_version`；App 若不支持则给出一句明确提示并停止，
  不做猜测性降级。

## 7. 事件来源对照（实现依据，均为 Hermes 已存在接口）

| 协议事件 | Hermes 接口 | 位置 |
|---|---|---|
| `run.started/delta/interim/completed` | plugin hooks `on_stream_start` / `on_stream_delta` / `on_interim_message` / `on_stream_end` | `agent/stream_delivery.py` → `agent/plugin_stream_hooks.py` |
| `run.tool_started/finished` | plugin hooks `pre_tool_call` / `post_tool_call` | `agent/shell_hooks.py` 事件表 |
| `approval.required` | `BasePlatformAdapter.send_exec_approval` → 覆写 `_send_exec_approval_prompt` | `gateway/platforms/base.py:2821` |
| `approval.resolved` | plugin hooks `pre_approval_request` / `post_approval_response` | `tools/approval.py` |
| `notification` | `BasePlatformAdapter.send()`（网关出站投递） | `gateway/delivery.py` |
| 审批放行 | `tools.approval.resolve_gateway_approval(session_key, choice, resolve_all, reason, request_id)` | `tools/approval.py:138` |
| 注入用户消息 | `BasePlatformAdapter.handle_message(MessageEvent)` + `build_source()` | `gateway/platforms/base.py:3960` |
| 中断回合 | 网关 `/stop` 路径（`interrupt_session_activity`） | `gateway/run.py` |

**没有用到、也不允许用到的**：任何对 Hermes core 文件的修改。
