Files
lvmh/PROTOCOL.md
T

258 lines
10 KiB
Markdown

# lvmh Protocol
Version: 1 (envelope field `v`). All components build against this file.
Changes here require updating plugin, daemon, and web together.
## Overview
```
┌─────────────┐ WS /agent/ws ┌────────────┐ docker API ┌────────────┐
│ pi plugin │◄───────────────►│ daemon │◄──────────────►│ containers │
│ (lvmh-agent)│ agent events │ (golang) │ pi in worker │ each with │
└─────────────┘ + prompts └────────────┘ image + plugin│ lvmh plugin│
▲ ▲
REST /api/* │ │ serves web/dist
┌────┴────┴───┐
│ web UI │
│ (React+Vite)│
└─────────────┘
```
Every pi process (local TUI sessions and daemon-spawned container sessions)
loads the lvmh plugin. The plugin dials the daemon over websocket. Prompts from
the web are delivered into the live session via the plugin. Container sessions
use the exact same protocol — spawn env just points them at the daemon.
## Auth
All requests (WS upgrade and REST) carry:
```
Authorization: Bearer $LVMH_TOKEN
```
Daemon rejects with 401 (REST) or closes the WS (upgrade) on mismatch.
The web UI asks for the token once, stores it in localStorage.
## Transport 1 — agent websocket (plugin → daemon)
Endpoint: `GET /agent/ws` (websocket upgrade).
Direction: bidirectional, JSON text frames. **One JSON object per frame.**
### Envelope (both directions)
```jsonc
{
"v": 1,
"sessionId": "string", // pi session id (from session_start event)
"seq": 123, // monotonic per-session event number, plugin-assigned
"ts": 1699999999999, // unix ms, sender clock
"type": "..." // see below
}
```
`seq` is assigned by the **plugin** to every daemon-bound event, monotonically
increasing per session, starting at 1, persisted for the session lifetime.
Daemon persists every event keyed `(sessionId, seq)`. On reconnect the plugin
resends everything after the daemon's last persisted seq (see Handshake).
### Handshake
After WS open, plugin sends:
```jsonc
{ "v":1, "type":"hello", "sessionId":"...", "seq":0, "ts":...,
"session": { // snapshot; re-sent on session_info_changed
"id": "string",
"name": "string|null",
"cwd": "string",
"model": "glm-5.3",
"provider": "zai-renaud",
"agent": true, // true if spawned-by-daemon container session
"repo": "group/project|null", // best effort from cwd or spawn metadata
"startedAt": 1699999999999
}
}
```
Daemon replies:
```jsonc
{ "v":1, "type":"welcome", "sessionId":"...", "seq":0, "ts":...,
"lastSeq": 42 } // highest persisted seq for this session, 0 if none
```
Plugin then replays buffered/persisted events with `seq > lastSeq` and
continues streaming live ones.
### Plugin → daemon events
All carry the envelope. `type` values mirror pi extension events:
| type | payload fields | notes |
| --- | --- | --- |
| `hello` | `session` | see handshake |
| `message_start` | `message: {role, id}` | user/assistant/toolResult msg begins |
| `message_update` | `delta: string` | streaming text delta (assistant only) |
| `message_end` | `message` | full message (see Message shape) |
| `tool_execution_start` | `toolCallId, toolName, args` | |
| `tool_execution_update` | `toolCallId, toolName, partial: string` | |
| `tool_execution_end` | `toolCallId, toolName, isError` | `resultPreview: string` (truncated 2KB) |
| `agent_start` | — | LLM run begins |
| `agent_end` | `usage: {inputTokens?, outputTokens?, totalCost?}` | |
| `agent_settled` | — | agent fully idle; web shows "done" |
| `session_info` | `session` (same shape as hello) | on rename/model change |
| `bye` | `reason: "shutdown"` | plugin graceful disconnect |
Notes:
- `message_update` deltas are the streaming path: daemon fans them out to web
subscribers immediately, **without** persisting them. On web page load the UI
reconstructs current text from persisted `message_end` events only.
- Everything except `message_update` is persisted.
### Message shape (in `message_end`)
```jsonc
{
"role": "user" | "assistant" | "toolResult" | "system",
"id": "string",
"text": "string", // concatenated text content
"thinking": "string|null", // reasoning content if any
"toolCalls": [ // assistant message tool calls
{ "id": "string", "name": "string", "argsJson": "string" }
],
"toolCallId": "string|null" // toolResult messages: which call this answers
}
```
### Daemon → plugin
| type | payload | notes |
| --- | --- | --- |
| `welcome` | `lastSeq` | see handshake |
| `prompt` | `promptId: string, message: string` | deliver into session |
| `abort` | — | abort current run |
On `prompt` the plugin calls
`pi.sendUserMessage(message, { deliverAs: "steer" })` — the message queues
into the TUI session (TUI-first: if user is mid-conversation in the terminal,
web messages wait like typed-ahead input). The plugin sends nothing back;
the resulting `message_start`/`message_end` events mirror the prompt to the web.
### Reconnect & buffering (plugin side, hard requirement)
- Never block pi's event loop: all WS I/O through a bounded async queue.
- Reconnect with exponential backoff + jitter, capped 30s, forever.
- Daemon down at startup ⇒ pi fully functional, plugin silent.
- Buffer unpersisted events in memory (cap 10k, drop oldest with a
`buffer_overflow` notice event on reconnect) and replay after `welcome.lastSeq`.
- Any plugin error is caught and logged to a file (`~/.pi/lvmh-agent.log`),
never surfaced as an extension failure.
## Transport 2 — REST API (web → daemon)
Base: `/api`. JSON bodies/responses. Bearer auth as above.
Errors: `{"error": "message"}` with appropriate status.
### Sessions
| method & path | body → response | notes |
| --- | --- | --- |
| `GET /api/sessions` | → `[{id, name, cwd, model, provider, agent, repo, startedAt, online, lastEventAt}]` | `online` = WS currently connected |
| `GET /api/sessions/:id/events?after=0&limit=1000` | → `[{...envelope+payload}]` | persisted events, seq ascending |
| `POST /api/sessions/:id/prompt` | `{message}``{ok: true}` | routed to plugin WS if online, else 409 |
| `POST /api/sessions/:id/abort` | → `{ok}` | routed to plugin |
| `DELETE /api/sessions/:id/container` | → `{ok}` | stop spawned container |
### Spawn
| `POST /api/spawn` | `{repo: "group/project", branch?: "main"}``{sessionId, containerId}` | clone (per-repo volume) → run container |
| `GET /api/spawn/status` | → `[{repo, state, containerId, sessionId?}]` | in-flight clone/spawn jobs |
`POST /api/spawn` semantics:
1. Clone/pull repo into volume `lvmh-repo-<slug>` (slug = repo path with `/`
`-`), default branch unless `branch` given. Concurrent spawns on same
volume serialize.
2. Create container from image `lvmh-worker:latest` (build from repo's
`docker/worker.Dockerfile` if missing), env: `ZAI_RENAUD_API_KEY`,
`LVMH_TOKEN`, `LVMH_URL`, provider/models.json mounted read-only,
repo volume at `/workspace`, session volume at `/pi-sessions`.
3. Container runs `node /bridge/index.mjs` (bridge: `createAgentSession` +
lvmh plugin dialing the daemon). The resulting `hello` (with
`session.agent=true`, `session.repo`) registers the session.
4. Response returns immediately after container create; status via
`/api/spawn/status` and session list.
### GitLab
| `GET /api/gitlab/status` | → `{connected: bool, baseUrl, username?}` | |
| `POST /api/gitlab/connect` | `{token}``{username}` | validates PAT, stores it |
| `DELETE /api/gitlab/connect` | → `{ok}` | |
| `GET /api/gitlab/repos` | → `[{path, name, namespace, lastActivityAt, webUrl, defaultBranch}]` | member projects, sorted by activity |
PAT stored daemon-side (SQLite `settings` table), never returned by any API.
## Transport 3 — web websocket (browser → daemon)
Endpoint: `GET /ws` (websocket upgrade, bearer token as query param `?token=`
because browsers cannot set headers on WS).
Daemon → browser frames:
```jsonc
{"type":"session_list", "sessions":[...]} // full list, on change
{"type":"events", "sessionId":"...", "after":42, // batched events (persisted
"events":[{...envelope+payload}]} // kinds + live message_update)
{"type":"spawn_status", "jobs":[...]}
```
Browser → daemon frames:
```jsonc
{"type":"subscribe", "sessionId":"..."} // events stream (one at a time)
{"type":"unsubscribe", "sessionId":"..."}
```
Reconnect: on open, re-`subscribe` and refetch `GET /api/sessions/:id/events`
from last seen seq (UI tracks seq per session).
## Task list & subagents (derived)
Todo lists and subagent runs are visible because pi implements them as tool
calls (`todo` tool; `subagent` tool with async runs). The daemon derives, from
persisted `tool_execution_*` + `message_end` events:
- **Todos**: state from latest `todo` tool call args/result per session
(exposed as pseudo-event type `todos` in `/api/sessions/:id/events`? No —
derived on demand, see REST below).
Simpler: the **web UI derives** todos/subagents client-side from the event
stream (it already receives all tool calls; `todo` tool args contain the full
task list snapshot; `subagent` calls carry agent/run info in args). Daemon
does no derivation. This keeps PROTOCOL minimal.
## Daemon persistence (SQLite schema, informational)
```sql
CREATE TABLE events(sessionId TEXT NOT NULL, seq INTEGER NOT NULL,
ts INTEGER NOT NULL, type TEXT NOT NULL, payload TEXT NOT NULL,
PRIMARY KEY(sessionId, seq));
CREATE TABLE sessions(id TEXT PRIMARY KEY, info TEXT NOT NULL, -- session JSON
lastSeq INTEGER NOT NULL DEFAULT 0, lastEventAt INTEGER, online INTEGER DEFAULT 0);
CREATE TABLE settings(key TEXT PRIMARY KEY, value TEXT NOT NULL); -- gitlab PAT etc
```
File `lvmh.db` on volume `lvmh-data`.
## Environment variables
| var | where | meaning |
| --- | --- | --- |
| `LVMH_URL` | plugin | `ws://alarm:8686/agent/ws` (ws:// or wss://) |
| `LVMH_TOKEN` | plugin, daemon, web UI storage | shared secret |
| `ZAI_RENAUD_API_KEY` | daemon, containers | LLM provider key |
| `LVMH_CONTAINER_DOCKER_SOCK` | daemon container | `/var/run/docker.sock` |