From 631aaf060b568769d38715b0fd3780170b8f2fa6 Mon Sep 17 00:00:00 2001 From: Raphael Westphal Date: Tue, 18 Aug 2026 12:59:50 +0200 Subject: [PATCH] chart: lvmh map, tickets, PROTOCOL.md wire contract --- .gitignore | 5 + PROTOCOL.md | 257 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 .gitignore create mode 100644 PROTOCOL.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..727ec79 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.scratch +node_modules +dist +*.db +.pi/ diff --git a/PROTOCOL.md b/PROTOCOL.md new file mode 100644 index 0000000..41052ec --- /dev/null +++ b/PROTOCOL.md @@ -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 = 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` |