Skip to content

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 (with agent_settled as the consume-once backstop for a pending blocked Stop, never a duplicate) — with tool_name capitalized to Claude's names and pi's path normalized onto file_path. Because every payload arrives claude-shaped, hooks/harness.sh needs no pi parse arm — pi joins the claude family through the default case, exactly like plugin. 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_call blocks via pi's typed return ({ block: true, reason }). For Stop there is no blocking return, so Stop rides the runtime's dispatchStop from agent_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), with agent_settled as 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 one stop_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 (awaited pi.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) exports CLAUDE_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 unchangedownsRendezvous: 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.