Files
lvmh/PROTOCOL.md
T

10 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_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)

{
  "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 /--, plus - + first 6 hex of sha256(repo path) so distinct paths can never collide), 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:

{"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 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)

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