chart: lvmh map, tickets, PROTOCOL.md wire contract
This commit is contained in:
+257
@@ -0,0 +1,257 @@
|
||||
# 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` |
|
||||
Reference in New Issue
Block a user