/** * Shared IPC envelope types + JSONL framing for the Pi Verifier Agent. * * Wire protocol — JSONL, one JSON object per line, terminated by `\n`. * * IMPORTANT — DO NOT use Node's `readline` module to consume these frames. * `readline` splits on the Unicode line separators U+2028 (LINE SEPARATOR) * and U+2029 (PARAGRAPH SEPARATOR) in addition to `\n`. Both code points * are valid characters inside JSON strings (JSON.parse will accept them * literally), so `readline`-based framing silently corrupts any envelope * whose payload — for example, a builder report containing source code, * UTF-8 prose, or pasted text — happens to include one of those code * points. We split on the byte `0x0A` (`\n`) ONLY. Period. * * Direction matrix (architecturally enforced — see assertDirection): * * verifier ──► builder builder ──► verifier * ───────────────────── ────────────────────── * hello hello_ack * prompt (correction text) prompt_ack * report (for inline render) (no reply needed) * event ← unified lifecycle channel * * Bidirectional (allowed on either side): * ping / pong — both verifier and builder run 10s liveness intervals. * bye — either side may close the channel cleanly. * * Round-trip examples (all of these survive JSON.stringify → \n-split → JSON.parse): * * {"type":"hello","role":"verifier","sessionId":"a1b2","pid":4242} * {"type":"event","name":"stop","sessionId":"a1b2","turnIndex":3,"timestamp":1714312800000} * {"type":"prompt","sessionId":"a1b2","message":"Add NOT NULL to email","correlationId":"c-1"} * {"type":"report","sessionId":"a1b2","turnIndex":3,"status":"verified", * "summary":"schema ok","sections":{"verified":"- users: PASS"},"raw":"## Report\n..."} * {"type":"event","name":"error","sessionId":"a1b2","detail":"tool execute failed", * "timestamp":1714312800123} * * Embedded special characters that must NOT corrupt the frame: * - escaped newline inside a string → "line1\nline2" (JSON-escaped, no real \n byte on the wire) * - U+2028 / U+2029 inside a string → emitted as the literal 3-byte UTF-8 sequence * (E2 80 A8 / E2 80 A9). `readline` would split on these; we don't. * - quote characters → escaped as \" by JSON.stringify * - embedded null → escaped as * - multi-megabyte payloads → fine; we buffer until \n is seen */ import type { Readable } from "node:stream"; // ─── Envelope types ────────────────────────────────────────────────────────── export interface Hello { type: "hello"; role: "verifier" | "verifiable"; sessionId: string; pid: number; } export interface HelloAck { type: "hello_ack"; sessionId: string; } export interface Prompt { type: "prompt"; sessionId: string; message: string; deliverAs?: "followUp" | "steer"; correlationId: string; } export interface PromptAck { type: "prompt_ack"; sessionId: string; correlationId: string; ok: boolean; error?: string; } /** * Confidence ladder, highest → lowest. Drives the verifier's status-bar color * (green / orange / red) and is parsed from the verifier's `CONFIDENCE:` line. * * PERFECT — every claim verified, no gaps, no feedback (green) * VERIFIED — all checked passed, minor non-blocking gaps allowed (green) * PARTIAL — no failures but significant unverifiable gaps (orange) * FEEDBACK — verifier_prompt fired; builder will correct (orange) * FAILED — could not verify at all; escalating to human (red) * * Note: FAILED here means "the verifier itself was unable to do its job" * (no oracle, no fixture, ambiguous claims), NOT "the work failed * verification." Work-failed-with-feedback is FEEDBACK — that's the system * working as designed; the loop closes and the builder fixes it. */ export type Confidence = "perfect" | "verified" | "partial" | "feedback" | "failed"; export interface Report { type: "report"; sessionId: string; turnIndex: number; status: "verified" | "failed" | "unsure"; /** Per-cycle confidence grade — see Confidence type docs. */ confidence: Confidence; summary: string; sections: Record; raw: string; } export interface Event { type: "event"; name: "start" | "stop" | "error"; sessionId: string; /** Monotonic counter — present for start / stop. NOT a JSONL row index. */ turnIndex?: number; /** present for error events */ detail?: string; timestamp: number; /** * The original user prompt that triggered this turn. Captured in the * builder's `input` event handler for non-extension-source inputs. * Present on `start` and `stop` events for the same turn so the verifier * can reference it without parsing the session JSONL. Omitted on error. */ userPrompt?: string; /** * Line number in the builder's session JSONL where this turn's content * begins (1-indexed; matches pi's `read` tool `offset` parameter). * Captured at `before_agent_start` (line count BEFORE pi appends the * user message). Lets the verifier `read` directly into the slice * instead of scanning the whole file. * * Present on `start` and `stop` events. */ sessionFileStartLine?: number; /** * Line number in the builder's session JSONL where this turn's content * ends (1-indexed; inclusive). Captured at `agent_end` (line count * AFTER pi has flushed the assistant response). Slice [start..end] is * exactly this turn. The verifier reads with * offset = sessionFileStartLine, * limit = sessionFileEndLine - sessionFileStartLine + 1. * * Present only on `stop` events. */ sessionFileEndLine?: number; } export interface Ping { type: "ping"; nonce: string; } export interface Pong { type: "pong"; nonce: string; } export interface Bye { type: "bye"; reason: string; } export type Envelope = | Hello | HelloAck | Prompt | PromptAck | Report | Event | Ping | Pong | Bye; export type EnvelopeType = Envelope["type"]; // ─── Type guards ───────────────────────────────────────────────────────────── export const isHello = (e: Envelope): e is Hello => e.type === "hello"; export const isHelloAck = (e: Envelope): e is HelloAck => e.type === "hello_ack"; export const isPrompt = (e: Envelope): e is Prompt => e.type === "prompt"; export const isPromptAck = (e: Envelope): e is PromptAck => e.type === "prompt_ack"; export const isReport = (e: Envelope): e is Report => e.type === "report"; export const isEvent = (e: Envelope): e is Event => e.type === "event"; export const isPing = (e: Envelope): e is Ping => e.type === "ping"; export const isPong = (e: Envelope): e is Pong => e.type === "pong"; export const isBye = (e: Envelope): e is Bye => e.type === "bye"; // ─── Direction matrix enforcement ──────────────────────────────────────────── export type Side = "verifier-to-builder" | "builder-to-verifier"; /** * Bidirectional envelope types. Both sides legitimately send these: * - `ping` / `pong`: each side runs its own 10s liveness interval and * answers the peer's pings. * - `bye`: either side may initiate a clean teardown. */ const BIDIRECTIONAL: ReadonlySet = new Set([ "ping", "pong", "bye", ]); const VERIFIER_TO_BUILDER: ReadonlySet = new Set([ "hello", "prompt", "report", ...BIDIRECTIONAL, ]); const BUILDER_TO_VERIFIER: ReadonlySet = new Set([ "hello_ack", "prompt_ack", "event", ...BIDIRECTIONAL, ]); /** * Throws if `envelope.type` is not allowed in the given direction. * * Typed envelopes (hello / hello_ack / prompt / prompt_ack / report / event) * are strictly one-directional per the matrix above. `ping`, `pong`, and * `bye` are bidirectional — both sides run liveness intervals and either * side may initiate a clean teardown. This exists so the socket * read/write paths can fail loudly on programming errors instead of * silently corrupting the protocol. */ export function assertDirection(envelope: Envelope, side: Side): void { const allowed = side === "verifier-to-builder" ? VERIFIER_TO_BUILDER : BUILDER_TO_VERIFIER; if (!allowed.has(envelope.type)) { throw new Error( `Envelope direction violation: type "${envelope.type}" is not allowed on the ` + `${side} channel. Allowed for this side: ${[...allowed].join(", ")}.`, ); } } // ─── JSONL framing ─────────────────────────────────────────────────────────── /** * Serialize an envelope as a single JSONL frame (terminated by `\n`). * * `JSON.stringify` already escapes any literal `\n` / `\r` inside string * values, so the only `\n` byte in the result is the framing terminator * we append here. `U+2028` / `U+2029` survive as their literal UTF-8 * bytes — that is fine for us because we split on `\n` only. */ export function encodeEnvelope(envelope: Envelope): string { return `${JSON.stringify(envelope)}\n`; } /** * Async iterator over JSONL envelopes from a Readable byte stream. * * Buffers raw bytes and splits on `\n` ONLY. Does not use `readline`. * Yields each parsed `Envelope`. Invalid JSON or unknown envelope types * surface as thrown errors — callers decide whether to log + drop or * tear down the connection. * * On stream end, any trailing partial frame (no terminating `\n`) is * discarded. This matches well-behaved peers that always end with a * `bye\n` before closing. */ export async function* readEnvelopes(stream: Readable): AsyncIterable { let buffer = ""; stream.setEncoding("utf8"); for await (const chunk of stream as AsyncIterable) { buffer += chunk; let newlineIndex = buffer.indexOf("\n"); while (newlineIndex !== -1) { const line = buffer.slice(0, newlineIndex); buffer = buffer.slice(newlineIndex + 1); if (line.length > 0) { yield parseEnvelope(line); } newlineIndex = buffer.indexOf("\n"); } } // Trailing partial frame (no `\n`) is dropped on stream end. } /** * Parse a single JSONL line into a typed Envelope. * * Throws with a clear message if the line isn't valid JSON or doesn't * carry a known `type` discriminator. Does NOT validate per-envelope * field shape beyond `type` — the wire is trusted within a single user's * 0700-perms socket; structural errors will surface as TypeScript-level * field access failures at the call site. */ export function parseEnvelope(line: string): Envelope { let parsed: unknown; try { parsed = JSON.parse(line); } catch (err) { throw new Error( `Failed to parse JSONL envelope: ${(err as Error).message}. Line: ${truncate(line, 200)}`, ); } if (typeof parsed !== "object" || parsed === null || !("type" in parsed)) { throw new Error( `Envelope missing "type" discriminator. Got: ${truncate(JSON.stringify(parsed), 200)}`, ); } const type = (parsed as { type: unknown }).type; if (typeof type !== "string" || !KNOWN_TYPES.has(type as EnvelopeType)) { throw new Error( `Unknown envelope type "${String(type)}". Expected one of: ${[...KNOWN_TYPES].join(", ")}.`, ); } return parsed as Envelope; } const KNOWN_TYPES: ReadonlySet = new Set([ "hello", "hello_ack", "prompt", "prompt_ack", "report", "event", "ping", "pong", "bye", ]); function truncate(s: string, max: number): string { return s.length <= max ? s : `${s.slice(0, max)}…(+${s.length - max} chars)`; }