Files
hermes-skills/hermes-vscode-remote/SKILL.md

173 lines
8.9 KiB
Markdown
Raw Normal View History

---
name: hermes-vscode-remote
description: Connect Hermes Agent to VS Code when they run on different machines (LXC/remote server + local laptop). Covers ACP stdio-to-WebSocket bridging, API server vs ACP distinction, and profile selection.
version: 1.0.0
author: agent
tags: [hermes, vscode, acp, remote, websocket, bridge]
---
# Hermes to VS Code — Remote Connection
When Hermes runs on a remote machine (LXC, VPS, Docker) and VS Code runs on your local laptop, ACP's stdio transport doesn't work directly. This skill covers the bridge setup.
## Key Concepts
- **ACP (Agent Client Protocol)** — stdio-based JSON-RPC protocol. VS Code launches the agent as a child process. This is the correct path for VS Code integration.
- **API Server** — HTTP/OpenAI-compatible endpoint (port 8642). For Open WebUI, LobeChat, etc. NOT for VS Code.
- **Profile selection** — per-process, not per-request. No `?profile=xxx` parameter. Each profile needs its own gateway/ACP process.
## Prerequisites (Hermes Side)
```bash
# Install ACP extra
cd ~/.hermes/hermes-agent
pip install -e '.[acp]'
# Verify
hermes acp --check # should exit 0
hermes acp --version
```
## Architecture
```
VS Code (laptop) → websocat (stdio↔ws) → LAN → stdio-to-ws (ws↔stdio) → hermes acp (LXC)
```
## Step 1: Start the Bridge on Hermes Host
```bash
# Install the bridge
npm install -g @rebornix/stdio-to-ws --prefix ~/.local
# Start it (persistent, survives disconnects)
~/.local/bin/stdio-to-ws -p 8643 --persist --grace-period -1 "hermes -p <profile> acp"
```
Verify: `ss -tlnp | grep 8643` should show listening.
### Auto-Start with systemd (Recommended)
Create a user service so the bridge survives reboots and crashes:
```bash
# Ensure linger is enabled (so user services survive logout)
sudo loginctl enable-linger $USER
```
Create `~/.config/systemd/user/hermes-acp-bridge.service`:
```ini
[Unit]
Description=Hermes ACP stdio-to-ws bridge for VS Code
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/home/<user>/.local/bin/stdio-to-ws -p 8643 --persist --grace-period -1 "/home/<user>/.local/bin/hermes -p <profile> acp"
Restart=always
RestartSec=5
Environment=HOME=/home/<user>
Environment=HERMES_HOME=/home/<user>/.hermes
Environment=PATH=/home/<user>/.local/bin:/usr/local/bin:/usr/bin:/bin
[Install]
WantedBy=default.target
```
Then enable and start:
```bash
systemctl --user daemon-reload
systemctl --user enable --now hermes-acp-bridge.service
systemctl --user status hermes-acp-bridge.service # verify
```
A ready-to-copy template is at `templates/hermes-acp-bridge.service`.
### Files
- `templates/hermes-acp-bridge.service` — copy-paste systemd unit (fill in `<user>`)
- `scripts/acp_repro.py` — JSON-RPC traffic probe (see Debugging section below)
## Step 2: Configure VS Code on Laptop
1. Install **ACP Client** extension (`formulahendry.acp-client`)
2. Install `websocat` (WebSocket ↔ stdio bridge):
- macOS: `brew install websocat`
- Or download from https://github.com/vi/websocat/releases
3. Add to VS Code `settings.json`:
```json
{
"acp.agents": {
"Hermes (<profile>)": {
"command": "websocat",
"args": ["ws://<hermes-host>:8643"]
}
}
}
```
4. Open ACP Client panel, select the agent, connect.
## Pitfalls
- **ACP is stdio-only.** There is no `--host`/`--port` flag on `hermes acp`. The bridge is mandatory for remote use.
- **API server is the wrong tool for VS Code.** Port 8642 serves OpenAI-format HTTP — VS Code ACP Client doesn't speak it.
- **Profile is locked at process start.** `hermes -p vscode acp` serves the vscode profile. To switch profiles, stop and restart with a different `-p`.
- **`API_SERVER_KEY` is required** when binding the API server to `0.0.0.0`. ACP has no such requirement.
- **npm global install may need `--prefix ~/.local`** if the user lacks root on the Hermes host.
- **`pip install -e '.[acp]'` may downgrade packages** (openai, requests, etc.) to match Hermes's pinned versions. The dependency conflicts are cosmetic — ACP still works.
- **EADDRINUSE on systemd restart.** If the bridge was previously started manually (background process) and you switch to systemd, the old process still holds port 8643. Kill it first: `ss -tlnp | grep 8643` to find the PID, then `kill <pid>`. The systemd service will auto-restart and bind successfully.
- **`spawn hermes ENOENT` in systemd.** systemd's default PATH does not include `~/.local/bin`. Use the full path `/home/<user>/.local/bin/hermes` in `ExecStart=` and add `Environment=PATH=/home/<user>/.local/bin:/usr/local/bin:/usr/bin:/bin`. The symptom is the bridge accepting WebSocket connections but immediately dropping them — check `journalctl --user -u hermes-acp-bridge.service` for the error. The bridge log will show `[Child process error: Error: spawn hermes ENOENT]` right when a client connects.
- **`systemctl --user` needs linger.** Without `loginctl enable-linger $USER`, user services stop on logout. Check with `loginctl show-user $USER | grep Linger`.
- **`stdio-to-ws` takes ONLY the first positional arg as the command string.** It uses `parseArgsStringToArgv()` to split that single string. Passing `["hermes", "-p", "vscode", "acp"]` as separate args to the bridge will silently drop everything after `hermes`. Always pass the command as one quoted string: `"hermes -p vscode acp"`. This bites when scripting the bridge from Python (`asyncio.create_subprocess_exec`).
- **`stdio-to-ws` sends a `{"type":"connected","clientId":"..."}` handshake** before any ACP frames. Custom WebSocket clients must read past this handshake before expecting JSON-RPC responses.
- **`stdio-to-ws` strips LSP `Content-Length:` headers** from incoming messages (`message.replace(/^Content-Length: \d+\r?\n\r?\n/, "")`). This is a no-op for ACP (newline-delimited JSON, no LSP framing) but worth knowing if you ever wire it to an LSP server.
- **`acp` library `MessageSender.drain()` blocks the response send.** The `acp` Python SDK (v0.9.0) sends all ACP frames — notifications AND the final JSON-RPC response — through a single `MessageSender` queue. The send loop does `await self._writer.drain()` after every write. When the WebSocket transport is slow, `drain()` blocks indefinitely, queuing every subsequent frame behind it. The final `session/prompt` response (with `stopReason`) gets stuck behind the streaming notifications, so VS Code receives all the text but never gets the signal to clear the "thinking" spinner. Fix: patch `MessageSender._loop()` in the installed `acp` package to bound `drain()` with `asyncio.wait_for(timeout=0.5)`. The payload is already in the writer's internal buffer after `write()`; the timeout just prevents the send loop from waiting forever for the OS pipe to drain. The bytes are still delivered when the consumer reads. File: `/home/<user>/.local/lib/python3.13/site-packages/acp/task/sender.py`. This patch gets overwritten on `pip install --upgrade agent-client-protocol` — re-apply or upstream a fix.
## Verification
```bash
# On Hermes host
ss -tlnp | grep 8643 # bridge listening
hermes acp --check # ACP deps OK
# On laptop
curl -s http://<hermes-host>:8643/ -H "Upgrade: websocket" -H "Connection: Upgrade" -o /dev/null -w "%{http_code}"
# Should return 400 (WebSocket upgrade attempted, not a plain HTTP endpoint)
```
## Debugging ACP Behavior ("spinner never ends", "stopReason missing")
When VS Code reports weird ACP behavior (stuck "thinking" spinner, missing
final response, no stopReason), the bug could live in three layers:
1. **Agent code**`acp_adapter/server.py::prompt()` returning `PromptResponse`
2. **Transport** — stdio-to-ws bridge, websocat, or the WebSocket itself
3. **VS Code client** — the `formulahendry.acp-client` extension
Before suspecting the agent code, isolate the transport. Run the repro script
that spawns `hermes acp` directly and traces every JSON-RPC frame:
```bash
python3 scripts/acp_repro.py
python3 scripts/acp_repro.py --prompt "say hi"
```
The script sends `initialize → session/new → session/prompt` and prints every
notification and response. Look for the final `[RESP id=3]` line — if it shows
`"stopReason": "end_turn"`, the agent is correct and the bug is in the
transport or VS Code extension. If it's missing entirely, look at the agent's
stderr (printed by the script) for stack traces.
This probe was the decisive tool when investigating a reported "missing
stopReason" bug — it proved the agent was sending the response correctly and
the issue was elsewhere (most likely the WebSocket bridge or the client).
## Files
- `scripts/acp_repro.py` — ACP JSON-RPC traffic trace (initialize → session/new → session/prompt)
- `templates/hermes-acp-bridge.service` — systemd user unit for auto-start
- `references/acp-hang-investigation.md` — root cause and fix for the "ACP spinner never ends" bug: `MessageSender.drain()` in the `acp` SDK blocks indefinitely, queuing the final response behind stuck writes. Includes the `asyncio.wait_for(timeout=0.5)` patch.