Files

101 lines
4.7 KiB
Markdown

# lvmh pi plugin
A [pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) extension
that mirrors live session events to the lvmh daemon over WebSocket and delivers
web-side prompts back into the running session. Wire contract: `PROTOCOL.md`
(envelope `v: 1`) at the repo root.
Single file, zero npm dependencies (Node built-in global `WebSocket` +
`node:fs`). TypeScript is loaded directly by pi's extension loader.
## Install
Copy `lvmh-agent.ts` into one of pi's auto-discovery locations:
```sh
# global (all projects)
cp plugin/lvmh-agent.ts ~/.pi/agent/extensions/lvmh-agent.ts
# or project-local
mkdir -p .pi/extensions
cp plugin/lvmh-agent.ts .pi/extensions/lvmh-agent.ts
```
Quick test without installing:
```sh
pi -e ./plugin/lvmh-agent.ts
```
## Environment variables
| var | required | meaning |
| --- | --- | --- |
| `LVMH_URL` | yes | Full agent websocket URL, e.g. `ws://alarm:8686/agent/ws` (`ws://` or `wss://`) |
| `LVMH_TOKEN` | yes | Shared bearer secret; sent as `Authorization: Bearer …` on the WS upgrade |
| `LVMH_AGENT` | no | Set to `1` by the daemon when spawning container sessions → `session.agent: true` in the hello snapshot |
| `LVMH_REPO` | no | `group/project` spawn metadata → `session.repo` in the hello snapshot |
If `LVMH_URL` or `LVMH_TOKEN` is unset the extension does nothing at all.
## Behavior
- **Handshake** — on `session_start` the plugin connects, sends `hello` with a
full session snapshot (id, name, cwd, model, provider, agent, repo,
startedAt), waits for `welcome.lastSeq`, then replays every buffered
persisted event with `seq > lastSeq` and continues streaming live.
- **Event mirroring** — `message_start`, `message_update` (text deltas only),
`message_end` (full protocol Message shape incl. `toolCalls[].argsJson`,
`thinking`, `toolCallId`), `tool_execution_start/update/end`
(`partial`/`resultPreview` truncated to 2000 chars), `agent_start`,
`agent_end` (usage: `inputTokens`/`outputTokens`/`totalCost` summed over the
run's assistant messages), `agent_settled`, and `session_info` on rename
(`session_info_changed`) or model change (`model_select`).
- **Seq** — monotonically increasing per session, assigned plugin-side,
survives reconnects within the process. Post-restart, the counter is bumped
past `welcome.lastSeq` so seq numbers are never reused.
- **Inbound** — `prompt``pi.sendUserMessage(message, { deliverAs: "steer" })`
(TUI-first: web prompts queue like typed-ahead input while you are mid-turn);
`abort``pi.abort()` (falls back to `ctx.abort()`). Unknown frame types are
ignored.
- **Reconnect** — exponential backoff 1s → 30s cap (+ up to 1s jitter), forever.
Daemon down at startup ⇒ pi runs perfectly, plugin retries silently.
- **Bounded queues** — send queue caps at 1000 frames (drop-oldest), replay
buffer at 10000 events (drop-oldest). Dropped frames are never silent: a
`buffer_overflow` notice (`{dropped: n}`) is emitted right after the next
successful handshake. Persisted-kind events dropped from the send queue are
recovered via the replay buffer; only unpersisted `message_update` deltas can
be lost.
- **Shutdown** — `session_shutdown` sends `bye {reason: "shutdown"}` (best
effort), closes the socket, cancels all timers. Nothing is flushed; exit is
immediate. No reconnect is scheduled afterwards (pi re-instantiates the
extension for the next session; seq counters are module-level and carry over).
## Bulletproofing
The plugin must never crash, hang, or slow down pi:
- every event handler is wrapped by `sub()` (lvmh-agent.ts): sync throws are
caught, async rejections are swallowed, handlers always return `undefined`;
- all WS I/O is non-blocking; WS send/construct failures are caught and treated
as disconnects;
- logging is append-only to `~/.pi/lvmh-agent.log` (async, best-effort, never
throws); the plugin never writes to stdout/stderr;
- no npm dependencies, no timers left running after `session_shutdown`
(reconnect timer is `unref()`'d anyway).
## Development
```sh
cd plugin
npx --package typescript@5.9 --package @types/node tsc --noEmit -p . # typecheck (strict)
node smoke.ts # 25 offline checks vs mini-daemon
node e2e.ts # real pi vs mini-daemon (1 small LLM call)
```
`tsconfig.json` points `paths`/`typeRoots` at the installed pi package; if that
moves, `pi-types.d.ts` contains a commented minimal fallback declaration.
`mini-daemon.ts`, `smoke.ts`, `e2e.ts` are dev-only harnesses (hand-rolled WS
server, no dependencies) and are not loaded by pi. See `selftest.md` for the
full constraint-by-constraint verification.