8.6 KiB
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() (L183–204): 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 (L459–617).
Handler-internal risk points and their guards:
| code path | guard |
|---|---|
log() (L75–82) |
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 (L206–240) |
JSON.stringify in try/catch (circular args → skip frame, L224); ws.send in try/catch → treated as disconnect + reconnect scheduled (L230–236) |
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 (L29–31), 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()L206–215 (whileshift-oldest, every drop counted and logged). - Replay buffer cap 10000 (persisted-kind events only;
message_updateis excluded per protocol):emit()L241–252. - Drops are never silent:
droppedEvents > 0⇒emit("buffer_overflow", {dropped})immediately after the nextwelcomeis processed (L373–376). - Persisted-kind events dropped from the send queue are still in the replay
buffer and get recovered by replay; only
message_updatedeltas can be lost.onWelcomeadditionally filters persisted frames out of the stale queue before merging replay (L367–368) 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() L230–236: 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() L274–286: 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() L75–82 (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 L490–519: 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/typeon every frame;hello/bye/welcomeuseseq: 0(L339, L505). - Per-session monotonic seq survives reconnects (module-level
seqCounters, L70); after a process restart, the counter is pushed pastwelcome.lastSeqso seqs are never reused against a warm daemon (L360–364). - Message mapping (
mapMessageL113):role, deterministicid(role-timestamp), concatenatedtext,thinkingor null,toolCalls[{id,name,argsJson}],toolCallIdfor toolResults — verified by smoke "2 message_end mapping" and e2e user/assistant message_end. message_updatecarries only the text delta (diffed against the previously sent prefix, L547–556); non-prefix text (model retry) resends full text.- Truncation to 2000 chars for
partialandresultPreview(L576, L584). agent_end.usagesumsinput/output/cost.totalacross the run's assistant messages; omitted entirely if none had usage (L595–613).- Multi-session: extension instance is re-created per session bind; on
session_startwith a new session id the old socket/timers/queues are reset (L475–488) and seq counters stay per-session (module map).
Known non-issues (documented, not plugin bugs)
- pi print-mode exit hang:
pi -pwith 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 onsession_shutdown(proven bybye, 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) |