Files

8.6 KiB
Raw Permalink Blame History

lvmh-agent selftest

How the "BULLETPROOF" constraints were verified for lvmh-agent.ts. Line numbers refer to lvmh-agent.ts in this directory.

Validation runs (all executed, 2026-08-18)

check command result
Typecheck (strict, real pi types) npx --package typescript@5.9 tsc --noEmit -p . in plugin/ exit 0, no errors
Offline behavior vs mini-daemon node plugin/smoke.ts 25/25 ok (run 3× consecutive, all green)
Real pi end-to-end vs mini-daemon node plugin/e2e.ts 13/13 ok (one real LLM call via -p)
Real pi + dead host LVMH_URL=ws://127.0.0.1:9/agent/ws LVMH_TOKEN=x pi -e ./plugin/lvmh-agent.ts -p "Reply with exactly: ok" replies ok, exit 0, plugin silently retries (log only, growing backoff), stdout clean

Environment: Node v26.7.0, pi 0.84.1, TypeScript 5.9.

Constraint checklist

1. Never crash pi / never throw from a handler

Every pi.on subscription goes through sub() (L183204): the handler body runs inside try/catch; if it returns a promise, rejections are swallowed via .then(undefined, …); the wrapper itself always returns undefined. pi can therefore never observe a throw or an unhandled rejection from this extension, regardless of which of the 13 registered handlers fails (L459617).

Handler-internal risk points and their guards:

code path guard
log() (L7582) try/catch around fs.appendFile; callback discards errors
nextSeq/map/emit payload building pure data code; mapMessage (L113) and textOfContent (L91) treat every field as unknown and type-check before use
enqueue/flush (L206240) JSON.stringify in try/catch (circular args → skip frame, L224); ws.send in try/catch → treated as disconnect + reconnect scheduled (L230236)
connect() (L288) constructor in try/catch (invalid URL → backoff, L293); all four socket callbacks (onopen L297, onmessage L312, onclose L325) wrap their bodies in try/catch; stale-socket identity guard if (ws !== socket) return so an old socket's late callbacks can never clobber the new socket's state
onMessage (L382) JSON.parse in try/catch (L385); non-object frames, wrong v, unknown types ignored
deliverPrompt (L400) pi.sendUserMessage(..., {deliverAs:"steer"}) in try/catch (L406); empty or non-string message ignored; foreign sessionId ignored
doAbort (L411) pi.abort (if present) else lastCtx.abort() in try/catch
buildSnapshot (L421) all sessionManager reads in try/catch with fallbacks (L433: ctx.cwd / Date.now)
session_start (L459) getSessionId() in try/catch; no id ⇒ mirroring disabled for that session, no crash
session_shutdown (L490) clearTimeout and bye-send/close both in try/catch (L494, L500)
onWelcome (L349) validates lastSeq (Number.isFinite, else 0, L355); foreign-session welcome ignored (L350)

That is 14 explicit try/catch blocks plus the universal sub() wrapper. Static pass over every function: no code path can throw to pi; nothing awaits — all I/O is fire-and-forget async (fs.appendFile, undici WS), so nothing can block or hang the event loop either. The only synchronous work is JSON serialization of outbound frames (bounded by message size).

2. Zero npm dependencies

Imports: node:fs, node:os, node:path (L2931), type-only import of @earendil-works/pi-coding-agent (L33, erased at runtime), and the Node ≥ 22 global WebSocket (undici). Verified by grep import and by the e2e run against real pi. typeof WebSocket === "undefined" guard at L156 makes the extension inert (not broken) on older Node.

3. Bounded queue + drop-oldest + buffer_overflow

  • Send queue cap 1000: enqueue() L206215 (while shift-oldest, every drop counted and logged).
  • Replay buffer cap 10000 (persisted-kind events only; message_update is excluded per protocol): emit() L241252.
  • Drops are never silent: droppedEvents > 0emit("buffer_overflow", {dropped}) immediately after the next welcome is processed (L373376).
  • Persisted-kind events dropped from the send queue are still in the replay buffer and get recovered by replay; only message_update deltas can be lost. onWelcome additionally filters persisted frames out of the stale queue before merging replay (L367368) so replay + queue can never double-send an event.
  • Smoke scenario 4 proves it: 10 050 persisted events fired while disconnected → reconnect → buffer_overflow {dropped: n>0} observed on the wire, replay delivered, no duplicate seq.

4. WS send failures never propagate

flush() L230236: ws.send in try/catch → log, handleDisconnect(), scheduleReconnect(). Same treatment in session_shutdown bye-send (L500). Proven by smoke scenario 4 (server destroys sockets under load) and e2e.

5. Reconnect: backoff + jitter, cap 30s, forever, never throws

scheduleReconnect() L274286: min(1000·2^attempt, 30000) + jitter[0,1000); attempt counter reset only on a successful welcome (L356). Timer is unref()'d (L283) so it can never keep pi's event loop alive. Dead-host run above shows pi fully functional with the plugin retrying silently (~/.pi/lvmh-agent.log: attempts #0,#1,#2 at ~1s/~2s/~3s spacing).

6. Log file only, console clean

Single sink ~/.pi/lvmh-agent.log via log() L7582 (async append, truncated to 500 chars/line, swallows all errors). grep -n "console\." lvmh-agent.ts returns nothing; e2e asserts the plugin adds nothing to pi's stdout ("e2e plugin keeps stdout clean").

7. session_shutdown / unload: close, flush nothing, exit fast

session_shutdown handler L490519: synchronous, sends best-effort bye {reason:"shutdown"} only if the socket is OPEN, close()s it, clears the timer, sets stopped (blocks emit, connect, scheduleReconnect, and flush). No flush, no awaits. Smoke scenario 6: connection count → 0, no reconnect for the following 2.6s. E2E: bye frame observed on the wire, no reconnect after.

8. Daemon down at startup ⇒ pi starts and runs perfectly

Proven twice: (a) smoke scenario 5 — factory loads against a closed port, handlers never throw, event loop stays responsive (100ms timer completes <500ms); (b) real pi run above — prompt answered, exit 0.

9. Protocol conformance details

  • Envelope v/sessionId/seq/ts/type on every frame; hello/bye/welcome use seq: 0 (L339, L505).
  • Per-session monotonic seq survives reconnects (module-level seqCounters, L70); after a process restart, the counter is pushed past welcome.lastSeq so seqs are never reused against a warm daemon (L360364).
  • Message mapping (mapMessage L113): role, deterministic id (role-timestamp), concatenated text, thinking or null, toolCalls[{id,name,argsJson}], toolCallId for toolResults — verified by smoke "2 message_end mapping" and e2e user/assistant message_end.
  • message_update carries only the text delta (diffed against the previously sent prefix, L547556); non-prefix text (model retry) resends full text.
  • Truncation to 2000 chars for partial and resultPreview (L576, L584).
  • agent_end.usage sums input/output/cost.total across the run's assistant messages; omitted entirely if none had usage (L595613).
  • Multi-session: extension instance is re-created per session bind; on session_start with a new session id the old socket/timers/queues are reset (L475488) and seq counters stay per-session (module map).

Known non-issues (documented, not plugin bugs)

  • pi print-mode exit hang: pi -p with any extension loaded (reproduced with an empty noop extension, no lvmh involvement) sometimes does not exit after answering — a pi quirk, independent of this plugin. The plugin releases all of its resources on session_shutdown (proven by bye, socket close, and no reconnect in e2e). The e2e harness treats this as informational.
  • Sync flush of a large replay burst (~10k frames ≈ 1MB) takes single-digit milliseconds of JSON serialization; it cannot hang the loop but is the largest synchronous chunk the plugin can produce.

Files

file role
lvmh-agent.ts the extension (only file pi loads)
README.md install / env / behavior
tsconfig.json strict typecheck against real pi types (paths, typeRoots)
pi-types.d.ts commented minimal fallback if pi's dist types move
mini-daemon.ts dev-only hand-rolled WS daemon for tests
smoke.ts dev-only offline test (25 checks)
e2e.ts dev-only real-pi test (13 checks)