plugin: lvmh-agent extension — mirror, replay, steer prompts; smoke 25/25, e2e 13/13 vs real pi
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user