Metadata-Version: 2.4
Name: wishee-hermes-connector
Version: 0.14.0
Summary: Connect a Hermes agent to the Wishee bridge via reverse WebSocket
License: MIT
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: websockets>=12.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"

# wishee-hermes-connector

Connect a [Hermes](https://github.com/NousResearch/hermes) agent to the Wishee bridge
(`openclaw-bridge`) over a reverse WebSocket — the Hermes counterpart of
`@wishee-ai/openclaw-connector`. The bridge drives the agent (`chat.send` /
`chat.history` / `sessions.reset`) and receives its replies (`task-result`).

Requires no changes to the bridge or to hermes-agent core.

**Requires Hermes >= 0.14.0.** The connector checks this in `register()` and refuses
to load below it — Hermes surfaces the error in the gateway log and in
`hermes plugins list`. See `version_gate.py` for how that floor was derived
(the binding constraint is `PlatformEntry.is_connected`, 2026-04-20).

## How it works

The connector is a Hermes **platform adapter** (runs inside the `hermes gateway`
process). It dials the bridge, does the `hello`/`welcome` handshake, then:

- `chat.send` → mints a `runId`, answers immediately, runs the agent turn, and
  emits a `task-result` event when the turn finishes (the bridge's device-push path).
- `chat.history` / `sessions.reset` → reads / clears the Hermes session store.

From the bridge's perspective a Hermes agent is indistinguishable from an
OpenClaw instance: same `/api/bind`, same `hello`, same RPCs, same event frames.

### Activity (usage duration)

The connector installs a Hermes hook at `~/.hermes/hooks/wishee-activity/` that
listens for `agent:start` / `agent:end` — the two events that bracket one
`_run_agent` call, i.e. the real boundary of a turn, **for every platform**. So
turns the user starts locally count too, not just the ones the bridge dispatches.

The hook handler only appends one self-contained interval per finished run to a
local journal (`~/.hermes/wishee-connector/wishee-activity-YYYYMMDD.jsonl`); it
never imports the connector, touches the network, or starts a timer. The
connector reads that journal by byte watermark and ships batches as
`agent-activity` frames, advancing the watermark **only after a successful
send** — the journal is what makes this survive a disconnect or a restart
(unlike an in-memory queue).

On first run it also seeds the journal once from `state.db`, deriving past turns
from message timestamps, so history predating the hook isn't lost.

> The gateway only scans `hooks/` at startup, and the connector writes the hook
> when it connects — later than that scan. Up to 0.13.0 a fresh hook therefore
> took a *second* restart to start recording. Since 0.14.0 the connector also
> registers it into the running gateway's hook registry when it connects (both
> at startup and after a hot attach, see below), skipping it when the gateway
> already loaded it by name. A hook whose handler changed in an upgrade still
> switches over on the next restart.

### 阻塞式提示（询问选项 / 危险命令审批）

Hermes 的两类**会卡住 agent 线程等用户**的提示——`clarify`（提问 + 选项）与
危险命令审批——都经 bridge 现成的**输入类审批**通道推到
设备：`approval-request{kind:"user_input", inputSpec}` → 设备渲染 →
`approval.respond{approvalId, decision, input}` → `approval-resolved`。不新开
帧，所以 bridge 和 profile-service 无需改动。

提问卡的 `inputSpec` 与 codex 连接器的 `requestUserInput` **同构**（`questions[]`，
契约 v2），这样主机端只做一种渲染——见
`docs/superpowers/specs/2026-09-11-user-input-card-contract.md`。危险命令审批那半
（`kind:"command"`）不变，见
`docs/superpowers/specs/2026-09-10-approval-roundtrip-contract.md`。

> 不实现这两个方法的话都会落到 Hermes 的文本兜底：问题被拼成编号文本走
> `send()`，而 `send()` 只往 `_pending_reply` 里塞，要等整轮跑完才发——可这一轮
> 正卡在等回答上（默认 3600 秒）。主机端因此整整一小时收不到任何一帧，日志里
> 还什么都不报，因为兜底照样回 `success=True`。

一轮结束时还挂着的问题会回一条 `approval-resolved{outcome:"canceled"}`，设备
据此撤掉卡片——超时之后 agent 早就自己往下跑了，那张卡再答也没有去处。0.10.0 起
不必等到一轮结束：连接器每 3 秒看一眼 Hermes 还等不等设备上挂着的提示（审批超时、
clarify 超时、在同一会话里打 `/approve` `/deny` 答掉），不等了就立刻撤卡。

危险命令审批卡的按钮由 bridge 按 `rawParams.availableDecisions` 出（codex 的词汇），
连接器据 Hermes 的 `allow_session` 带上 `acceptForSession`，设备上就有「允许本次会话」；
「始终允许」故意不给（设备那页永远不发 `approve_always`）。Hermes 0.21 起每条待批有
`request_id`，连接器按它批，同一会话里同时挂着两条时不会批错（老版本只能 FIFO）。
Hermes 0.21.5 起网关只给声明了 `supports_exec_approval_buttons()` 的平台发审批卡，
连接器声明了。

0.11.0 起：

- Hermes 0.21.x 收掉一个没答的问题时会调 `retire_clarify_card`，设备上的卡当场撤掉
  （老版本仍靠上面那个 3 秒巡检）；提问卡发失败后网关补发的纯文本版如实报失败，
  agent 不会按"已送达"干等一小时。
- 推出去还没收尾的卡记在 `<HERMES_HOME>/wishee-connector/pending-cards.json`：Hermes
  重启（升级、崩溃、OOM）后那几轮已经没了，或者收尾那一刻正好和 bridge 断着，下次连上
  bridge 时补发 `canceled` 把作废的卡撤掉。

### 执行过程（0.10.0）

与 codex 连接器同一套契约，设备上「正在执行任务…」那一行、锁屏卡、过程播报都靠它：

- `agent-progress{sessionKey, itemId, ts}` —— agent 说完一段话、**后面还要调工具**时发，
  只带信号不带正文。来源是 `post_api_request` 插件钩子（0.14.0 起就有）：带工具调用
  的那次模型返回一定不是结论，所以一到就报，不受 `display.*` 配置影响；正文按 Hermes
  自己的 `strip_think_blocks` 洗掉推理块。不用 `send()` 当来源：流式 / previewed 时
  结论也走它，0.14 上心跳和中间消息又分不出来。只报**人话**：去掉代码之后一个字都不剩
  的不算过程（口径同 codex 连接器）。Hermes 0.21.x + Codex 类 provider（`openai-codex`）
  的 `content` 是空的，那句话在 `codex_message_items` 的 commentary 里，按 Hermes 自己界面
  那套（`visible_commentary`：去推理块 + 脱敏）取。
- `chat.turns{sessionKey, limit}` —— 这条会话最近若干**轮**（一条用户消息 + 它之后的
  agent 回复），每条消息带 `id`、每轮带 `status`（`inProgress` / `completed` / `failed`）。
  跑完的轮次读 state.db；**在跑的那一轮只认内存**——0.14–0.16 整轮跑完才落盘、0.18 起
  边跑边写，盘上那份要么没有要么只有半截。开跑时记下全库 `messages.id` 的水位，比它
  大的行就是这一轮的；跑完后按正文把过程的 itemId 对回去，推送晚到也找得到。
  `chat.history` 原样保留给老设备。

The transport **auto-reconnects**: an unexpected socket close (bridge restart,
network drop) re-dials with capped exponential backoff and re-runs the
`hello`/`welcome` handshake. A deliberate shutdown never reconnects.

### 装完不用重启（0.14.0）

Hermes 只在网关启动时给平台建适配器，所以以前装完必须请用户发 `/restart`（agent 自己
重启不了网关，Hermes 硬拦）。Hermes **0.21.5** 起，`hermes plugins enable/install` 会经
控制 socket 让正在跑的网关重新扫描插件，`register()` 因此在网关进程里被执行——但网关
只给已有适配器重新接线，不建新平台。连接器在这时把 wishee 塞进网关自己的平台重连队列
（`live_gateway.py`），由重连巡检按启动时同一套流程建适配器、接 handler、连 bridge，一两
秒内就连上，待认领的绑定码也在这时认领掉。

- 只在 `config.yaml` 里有 `platforms.wishee.enabled: true` 时才挂——和重启后的行为一致，
  免得现在能用、下次重启悄悄不见。
- 网关启动阶段加载插件时什么都不做（`_running` 要等所有平台连完才置上），交给启动流程。
- 已经在线或已在队列里就不动：装别的插件也会触发强制重新扫描，不能挂出第二个适配器。
- 碰的是 Hermes 的私有属性（`_gateway_runner_ref`、`_failed_platforms`、
  `_reconnect_watcher_task`），每样先探测，缺了就打一行日志、退回"请重启"，不让插件加载失败。
- `register()` 是在插件发现锁里被调的，**不能**用 `load_gateway_config()` 读配置——它会
  `discover_plugins()`，等的就是这把锁；0.21.5 真网关上实测 register() 卡满 10 秒被判加载
  失败。所以直接读 `config.yaml`，用网关同一个 `PlatformConfig.from_dict` 解析。

做不到的：Hermes 0.21.4 及以下没有运行中重新扫描，照旧要重启；**升级**（`pip install -U`）
也照旧要重启——pip 装的插件重新扫描时不会重新 import，跑的还是旧代码。

## Install

```bash
pip install wishee-hermes-connector   # into your Hermes environment
```

## Enable

1. Set `platforms.wishee.enabled: true` in your Hermes `config.yaml` (platform
   adapters are off by default), and write the bind code if there is one (item 5).
   Do both **before** item 2: on Hermes ≥ 0.21.5 item 2 starts the connector at once.
2. Run `hermes plugins enable wishee` (entry-point plugins are opt-in). It adds
   `wishee` to `plugins.enabled`; on Hermes ≥ 0.21.5 with a running gateway it also
   loads the plugin into that gateway and the connector attaches itself — **no
   restart** (see 装完不用重启 above). Hermes 0.14's CLI does not know entry-point
   plugins ("is not installed or bundled"): add `wishee` to `plugins.enabled` by hand.
3. Optionally set `WISHEE_BRIDGE_URL` to override the default
   (`wss://ai-bridge.wishee.com.cn/ws`).
4. If the connector did not start in item 2 (older Hermes, or no gateway was
   running), restart / run `hermes gateway`. On first run it creates this instance's identity; while
   unbound it logs a hint telling the user to scan the QR on the WISHEE device
   (「My Agent」→ Hermes) and send the resulting message to the agent. Up to
   0.12.0 it also printed a QR for the bridge's scan-to-bind web page; 0.13.0
   drops that — binding goes only through the bind code below. Identity is stored at
   `<HERMES_HOME>/wishee-connector/binding.json`, i.e. `~/.hermes/...` unless
   `HERMES_HOME` says otherwise — the same directory the activity journal uses.
   Up to 0.8.0 this one path instead read `HOME` and fell back to `/tmp`, which
   on Windows (where a gateway cold start usually has no `HOME`) put the
   identity in `C:\tmp` and made the agent look unbound after an upgrade.
   0.9.0 adopts an identity left at that old location on first run, copying it
   rather than moving it so a rollback still finds one.
5. Bind code (0.12.0+). When the user has an 8-letter WISHEE bind code (sent
   by the WeChat account after scanning the QR on the WISHEE device), write it to
   `<HERMES_HOME>/wishee-connector/pending-bind-code` before the connector
   starts. When it starts (hot attach or restart) it claims the code itself via the bridge's
   `POST /api/bind/claim` (same request the onboarding doc's curl sends:
   body = `binding.json`), so on Hermes ≥ 0.21.5 nothing else is needed and on
   older ones the user only has to send `/restart` — an agent
   cannot restart its own gateway (Hermes blocks it). The outcome is written to
   `claim-result.json` in the same directory (no secret in it). Definitive
   failures (code used / expired / unknown / identity conflict) delete the code
   file; transient ones (bridge unavailable, rate limited, network) keep it and
   retry while waiting to be bound. Local split-port dev setups can point the
   claim at the HTTP port with `WISHEE_CLAIM_URL`.

## Scope

Emits `task-result` on each completed turn (the bridge's device-push path), and since
0.10.0 `agent-progress` signals + `chat.turns` for the in-turn progress (see above).
Token-level streaming is still out of scope: the device only needs whole lines.

## Development

Tested against Hermes 0.14.0 (tag `v2026.5.16`, what the oldest supported installs run),
0.21.0 (tag `v2026.8.31`) and 0.21.5 (tag `v2026.9.24`, the device end-to-end run of
0.11.0). A few tests skip themselves on 0.14 where the feature does not exist there
(multi-select clarify, approval `request_id`).

The hot attach (0.14.0) was also run end to end against real gateways with a fake
bridge: on 0.21.5, installing + `hermes plugins enable wishee` into a running gateway
connected and claimed the bind code with the gateway PID unchanged, and a second
forced plugin rescan left the one adapter alone; on 0.14.0, `enable` refuses the
entry-point plugin, nothing attaches, and after a restart it connects and claims
as before.

```bash
uv venv --python 3.13 .venv
uv pip install --python .venv/bin/python -e /path/to/hermes-agent
uv pip install --python .venv/bin/python -e ".[dev]"
.venv/bin/python -m pytest -q
```
