12 KiB
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)
{
"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:
{ "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:
{ "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_updatedeltas 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 persistedmessage_endevents only.- Everything except
message_updateis persisted.
Message shape (in message_end)
{
"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_overflownotice event on reconnect) and replay afterwelcome.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 |
| GET /api/repos | → [{repo, image}] | repo→custom image registrations, sorted by repo |
| PUT /api/repos/{repo}/image | {image} → {ok} | register custom image; repo must pass repo validation, image must match lvmh-worker-* |
| DELETE /api/repos/{repo}/image | → {ok} | drop registration (idempotent); repo spawns fall back to lvmh-worker:latest |
POST /api/spawn semantics:
- Clone/pull repo into volume
lvmh-repo-<slug>(slug = repo path with/→--, plus-+ first 6 hex of sha256(repo path) so distinct paths can never collide), default branch unlessbranchgiven. Concurrent spawns on same volume serialize. - Create container from image
lvmh-worker:latest(build from repo'sdocker/worker.Dockerfileif 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. - Container runs
node /bridge/index.mjs(bridge:createAgentSession+ lvmh plugin dialing the daemon). The resultinghello(withsession.agent=true,session.repo) registers the session. - Response returns immediately after container create; status via
/api/spawn/statusand session list.
Repo→image resolution: before creating the container the daemon looks up
repo in the repo-images registry (repo_images table, managed by the
/api/repos routes). A registered image replaces lvmh-worker:latest in
the created container; the daemon does NOT build custom images — if the
registered image is missing on the docker host the job errors with
custom image <name> not built (ask the ops agent to build it). Image names
are pinned to the lvmh-worker-* namespace by validation. Without a
registration (or after DELETE) spawns use the default worker image.
Ops container. The daemon keeps one long-lived control container,
lvmh-ops (image lvmh-ops:latest, built from docker/control.Dockerfile
when missing): an agent pi with the docker socket mounted, its own
lvmh-ops-work:/ops workspace volume and the shared session/pi-cache
volumes, on the daemon network with a fixed session id lvmh-ops-control.
It prepares repos, builds per-repo worker images (docker CLI via the socket)
and registers them through /api/repos. A loop re-checks every 30s: stopped
→ started, missing → built+created+started; DELETE /api/sessions/lvmh-ops-control/container removes it and the loop recreates
it on the next tick with a fresh session.
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:
{"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:
{"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
todotool call args/result per session (exposed as pseudo-event typetodosin/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)
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
CREATE TABLE repo_images(repo TEXT PRIMARY KEY, image TEXT NOT NULL); -- repo→custom worker image
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 |
LVMH_GITEA_TOKEN |
ops container | PAT passed through from the daemon store, for repo clone/push |
LVMH_API |
ops container | http://lvmh:8686 — daemon REST base for curl |