Files
Claude a3a5c272a2 Add the pi harness: lightweight local-model agents with full gate parity
Model backend rows gain a harness column (claude | pi). A pi-harness row runs
the agent through the pi coding agent instead of the claude binary — pi speaks
the OpenAI Completions API natively, so a bare vLLM/llama.cpp/Ollama endpoint
needs no LiteLLM/claude-code-router translation proxy, and the loop is far
lighter for slow local token throughput. The Claude subscription and existing
claude-harness backends are untouched.

Parity comes from generated per-agent artifacts under ~/.handler-pi (outside
the repo tree, so the clean-tree gate never trips): models.json + settings.json
render the row as a pi provider pinned as the default model; a bundled bridge
extension (pi_bridge.ts) adapts pi's events to the exact stdin/stdout contract
of `python -m handler.hooks` — the Stop/completion gate re-prompts pi with
blockers via a follow-up message, git push runs the test/build/approval gates
and denies on failure, questions defer through an ask_operator tool into the
normal answer/resume flow, and memory recall is injected at session start. The
memory tools are registered natively (pi has no MCP), shelling to a new
`python -m handler.mcpserver --call <tool>` seam that reuses the MCP server's
implementations. Skills reuse the same ~/.claude/skills sync (pi implements the
same SKILL.md standard) plus the repo's committed .claude/skills.

Sessions are single JSONL files pre-assigned via --session, so cross-worker
resume archives/materializes exactly like claude's; the prompt travels on stdin
(pi has no -- separator). The supervisor normalizes pi's event stream on the
fly: assistant message_end feeds last_output, the final agent_end becomes the
run result. The whole chain was validated live against pi 0.84.1 with a stub
OpenAI endpoint: memory injection, push-gate denial (including the protected-
branch approval gate), stop-gate block loop, and ask_operator pause all ran
end to end through the real hooks and DB.

Also: harness selector in the dashboard Models form, pi baked into the control
image (NodeSource 22 for pi's node >= 22.19 floor), PI_BIN override, docs in
docs/local-models.md, fake_pi fixture + 12 tests (361 total green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdGv3u3DfTsP1S188KDhVH
2026-08-12 18:21:35 +00:00

127 lines
4.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""A stand-in ``pi`` binary for pi-harness headless-runner tests.
Wired in via the ``pi_bin`` setting (the same seam ``fake_claude.py`` uses for
``claude_bin``). Parses the real pi-harness argv (``-p --mode json --no-extensions
-e <bridge> --session <path>``), reads the prompt from **stdin** (pi has no ``--``
separator, so that is how the supervisor delivers it), emits a scripted ``--mode json``
event stream on stdout, and appends to the genuine ``--session`` transcript file so
archive/materialize/resume paths exercise the real single-file layout. Behavior is
selected with ``FAKE_PI_MODE``:
- ``success`` (default): session header + user/assistant message_end + agent_end +
agent_settled, exit 0.
- ``error``: header + one garbage line + agent_end whose assistant stopReason is
``error``, then exit 1 (pi's exit code for an errored final message).
- ``hang``: header, then sleep forever (kill/cancel tests SIGTERM it).
``FAKE_PI_EXPECT_HISTORY=1`` makes a run fail loudly (exit 3) when the ``--session``
file does not already exist — the cross-worker resume tests use it to prove the
archive really was materialized where pi would look.
"""
from __future__ import annotations
import json
import os
import signal
import sys
import time
from pathlib import Path
def _parse_argv(argv: list[str]) -> dict:
opts = {
"print": False,
"mode": None,
"no_extensions": False,
"extension": None,
"session": None,
}
i = 0
while i < len(argv):
arg = argv[i]
if arg == "-p":
opts["print"] = True
elif arg == "--mode":
i += 1
opts["mode"] = argv[i]
elif arg == "--no-extensions":
opts["no_extensions"] = True
elif arg == "-e":
i += 1
opts["extension"] = argv[i]
elif arg == "--session":
i += 1
opts["session"] = argv[i]
i += 1
return opts
def _emit(event: dict) -> None:
sys.stdout.write(json.dumps(event) + "\n")
sys.stdout.flush()
def _assistant(text: str) -> dict:
return {
"role": "assistant",
"content": [{"type": "text", "text": text}],
"stopReason": "stop",
}
def main() -> int:
signal.signal(signal.SIGTERM, signal.SIG_DFL)
mode = os.environ.get("FAKE_PI_MODE", "success")
opts = _parse_argv(sys.argv[1:])
if not opts["print"] or opts["mode"] != "json" or not opts["session"]:
sys.stderr.write("fake_pi: expected -p --mode json --session <path>\n")
return 64
if not opts["no_extensions"] or not opts["extension"]:
sys.stderr.write("fake_pi: expected --no-extensions with an explicit -e bridge\n")
return 64
prompt = sys.stdin.read().strip()
session_path = Path(opts["session"])
is_resume = session_path.exists()
if os.environ.get("FAKE_PI_EXPECT_HISTORY") and not is_resume:
sys.stderr.write(f"fake_pi: session file missing at {session_path}\n")
return 3
_emit({"type": "session", "version": 3, "id": "internal-uuid", "cwd": os.getcwd()})
if mode == "hang":
time.sleep(3600)
return 0
user_msg = {"role": "user", "content": [{"type": "text", "text": prompt}]}
_emit({"type": "agent_start"})
_emit({"type": "message_end", "message": user_msg})
if mode == "error":
sys.stdout.write("this is not json\n")
sys.stdout.flush()
errored = {
"role": "assistant",
"content": [],
"stopReason": "error",
"errorMessage": "Connection error.",
}
_emit({"type": "agent_end", "messages": [user_msg, errored], "willRetry": False})
return 1
assistant = _assistant(f"working on: {prompt}")
_emit({"type": "message_end", "message": assistant})
session_path.parent.mkdir(parents=True, exist_ok=True)
with session_path.open("a") as fh:
fh.write(json.dumps({"type": "message", "message": user_msg}) + "\n")
fh.write(json.dumps({"type": "message", "message": assistant}) + "\n")
_emit({"type": "agent_end", "messages": [user_msg, assistant], "willRetry": False})
_emit({"type": "agent_settled"})
return 0
if __name__ == "__main__":
sys.exit(main())