pi-harness¶
The pi adapter (@earendil-works/pi-coding-agent) — pi's harness-specific machinery behind the one Adapter seam. The shim is a generated TypeScript extension (pi has no external hook binding); prompt delivery and liveness reuse claude's rendezvous channel wholesale.
pi is the third native harness. Its adapter (piHarness in harness-adapter's harness.ts) encodes one
governing observation: pi is claude-shaped at both ends — the caller pins the session id at launch and the
shim lives in the worktree — so nearly every divergence point collapses onto the claude pattern, and the one
genuinely new fact lives here in pi-harness.ts: pi has no external hook binding at all. Its lifecycle
surface is an in-process TypeScript extension API, so pi's shim is a generated extension
(.pi/extensions/spexcode.ts, run natively by pi) that this node's file produces.
the generated extension — a thin host over the shared runtime¶
The extension is a THIN HOST over the shared shim runtime (shim-runtime, embedded verbatim by the
generator): the payload synthesis into dispatch.sh pi <Event>, the block-verdict parse, and the rendezvous
socket server are the runtime's, one source shared with every generative shim. What this generator declares
is pi's OWN half, chosen so the rest of the product needs NO pi branch:
- The event mapping. pi's five lifecycle events onto the claude vocabulary —
session_start→SessionStart,input→UserPromptSubmit,tool_call→PreToolUse,tool_result→PostToolUse,agent_end→Stop (withagent_settledas the consume-once backstop for a pending blocked Stop, never a duplicate) — withtool_namecapitalized to Claude's names and pi'spathnormalized ontofile_path. Because every payload arrives claude-shaped,hooks/harness.shneeds no pi parse arm —pijoins the claude family through the default case, exactly likeplugin. pi has no idle/attention or failed-stop event, so Notification/StopFailure are genuinely absent (the codex gap, not a TODO). - The verdict consumers. The runtime decides blocked (exit 2) and extracts the reason (stdout
decision:block JSON, stderr for a bare exit-2 handler — shim-runtime's one contract); pi consumes that
verdict through its own two channels.
tool_callblocks via pi's typed return ({ block: true, reason }). For Stop there is no blocking return, so Stop rides the runtime'sdispatchStopfromagent_end(the normal dispatch: pi awaits agent_end listeners inside the run loop and a message they queue drains as the SAME awaited prompt's continuation, never as a late settle-time injection against stale host context), withagent_settledas the CONSUME-ONCE backstop: a naturally allowed agent_end leaves no pending state and settle dispatches nothing (exactly one gate entry per stop); only a blocked agent_end arms the pending bit (stopPending), and settle — which fires exactly once per prompt, after every drain — consumes it with onestop_hook_active-flagged dispatch whose escape paths always allow (a subprocess write, no inject), so the record is declared even when the drained continuation's own agent_end never re-reaches the extension (the measured dispatched-run gap). On block the gate's reason is sent back in as a user message (awaitedpi.sendUserMessage, deliverAs steer) — pi's equivalent of claude's Stop-hook continuation — and a genuinely uninjectable host is reported loud by the runtime. - The rendezvous inject. The pi adapter's
launchEnv(id)exportsCLAUDE_BG_RENDEZVOUS_SOCK=<rvSock(id)>; the runtime's server binds it and pi supplies only the inject —sendUserMessage({deliverAs: steer}), always able, so no reject gate. Claude's best-effort rendezvous poke and socket-LISTENER liveness work for pi unchanged —ownsRendezvous: true, zero new transport code; the runtime's server is multi-connection, so a probe cannot displace another poke.
The extension also exports PI_SESSION_ID (the adapter's sessionEnvVar) at session_start, so tool
subprocesses — and the agent's own spex calls — inherit their session identity; the pinned --session-id
makes that id equal the governed record id, claude-style, so no alias step is needed anywhere.
trust — one saved decision plus a one-run flag¶
pi gates every project-local resource (.pi/extensions, .pi/skills, .pi/prompts, .pi/settings.json)
behind per-directory project trust: decisions live in ~/.pi/agent/trust.json as a flat
{ "<canonical dir>": true|false } map, and the closest decision on the cwd's parent chain wins. An
untrusted project never loads our extension — zero hooks, silently — so writeTrust stamps
<mainCheckout>: true there (nearest-parent lookup covers every .worktrees/* beneath it), and the launch
additionally carries --approve (pi's one-run trust override) as defence for worktrees outside the checkout.
The writer is idempotent and surgical: other projects' decisions untouched, a corrupt trust.json fails loud
rather than being clobbered, and removeTrust deletes only a true we could have written — never a user's
saved "do not trust". pi hardcodes its config dir, so SPEXCODE_PI_AGENT_DIR is purely our test seam.
what stayed generic¶
Registering the adapter forced ONE generalization in the seam itself: the shim payload field is now
content, not json — a shim is whatever file THAT harness discovers (hooks JSON for claude/codex, a .ts
extension for pi), and materialize writes it without knowing which. Launch (pi --approve --session-id <id>
"<prompt>" — the TUI submits the trailing message itself), resume (--session <id>, failing loud when the
session file is gone rather than silently minting an empty one), the / menu (built-ins extracted from the
installed pi's own command table, in slash-commands.ts), skills (.pi/skills), and the AGENTS.md contract
file all ride existing seams with one-line adapter answers.