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
This commit is contained in:
Claude
2026-08-12 18:21:35 +00:00
parent 356fa276a2
commit a3a5c272a2
24 changed files with 1417 additions and 84 deletions
+1
View File
@@ -34,6 +34,7 @@ PROJECTS_ROOT=/var/lib/handler/projects
# Binary overrides (defaults shown). Point at fakes in tests/CI. # Binary overrides (defaults shown). Point at fakes in tests/CI.
# CLAUDE_BIN=claude # CLAUDE_BIN=claude
# PI_BIN=pi
# MISE_BIN=mise # MISE_BIN=mise
# TMUX_BIN=tmux # TMUX_BIN=tmux
# FORGE_BIN=forge # FORGE_BIN=forge
+8 -4
View File
@@ -39,11 +39,14 @@ FROM python:3.11-slim
# tmux — one detached session per agent (and per login attempt) # tmux — one detached session per agent (and per login attempt)
# node + claude — the Claude Code CLI the agents *are*, and the /login flow the # node + claude — the Claude Code CLI the agents *are*, and the /login flow the
# dashboard drives (see control/login.py) # dashboard drives (see control/login.py)
# pi — the lightweight pi coding agent (harness='pi' model backends,
# see control/pi_harness.py); speaks OpenAI-compatible endpoints
# natively, so local vLLM/llama.cpp backends need no proxy
# mise — the per-project task runner the test/build gates invoke # mise — the per-project task runner the test/build gates invoke
# forge — the cross-forge CLI the CI poller reads run status from # forge — the cross-forge CLI the CI poller reads run status from
# Node comes from NodeSource (>=18 is required by Claude Code); mise from its official apt # Node comes from NodeSource (>=18 for Claude Code, >=22.19 for pi); mise from its official
# repo; forge from the build stage above. Installed under /usr/{bin,local/bin} — outside the # apt repo; forge from the build stage above. Installed under /usr/{bin,local/bin} — outside
# /var/lib/handler VOLUME — so the volume mount never masks them at runtime. The image is # the /var/lib/handler VOLUME — so the volume mount never masks them at runtime. The image is
# built for amd64 and arm64: NodeSource + forge detect the arch, and the mise apt source is # built for amd64 and arm64: NodeSource + forge detect the arch, and the mise apt source is
# pinned to `dpkg --print-architecture` (the image's own arch) so the arm64 build pulls the # pinned to `dpkg --print-architecture` (the image's own arch) so the arm64 build pulls the
# arm64 package, not an amd64 one. # arm64 package, not an amd64 one.
@@ -51,9 +54,10 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
git tmux openssh-client curl ca-certificates gnupg \ git tmux openssh-client curl ca-certificates gnupg \
&& install -dm 755 /etc/apt/keyrings \ && install -dm 755 /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \ && apt-get install -y --no-install-recommends nodejs \
&& npm install -g @anthropic-ai/claude-code \ && npm install -g @anthropic-ai/claude-code \
&& npm install -g --ignore-scripts @earendil-works/pi-coding-agent \
&& npm cache clean --force \ && npm cache clean --force \
&& curl -fsSL https://mise.jdx.dev/gpg-key.pub \ && curl -fsSL https://mise.jdx.dev/gpg-key.pub \
| gpg --dearmor -o /etc/apt/keyrings/mise-archive-keyring.gpg \ | gpg --dearmor -o /etc/apt/keyrings/mise-archive-keyring.gpg \
+13 -9
View File
@@ -132,7 +132,7 @@ Configuration is entirely environment-driven (see [`.env.example`](.env.example)
| `WEBHOOK_URL` | Generic target for the `Notification` hook (ntfy, Slack, …) | unset → no-op | | `WEBHOOK_URL` | Generic target for the `Notification` hook (ntfy, Slack, …) | unset → no-op |
| `HANDLER_SECRET_KEY` | Fernet key encrypting git-server tokens + SSH keys at rest (set the same value on API and control) | unset → secret store disabled | | `HANDLER_SECRET_KEY` | Fernet key encrypting git-server tokens + SSH keys at rest (set the same value on API and control) | unset → secret store disabled |
| `PROJECTS_ROOT` | Base dir for per-project roots / worktrees / auto-clones | `./projects` | | `PROJECTS_ROOT` | Base dir for per-project roots / worktrees / auto-clones | `./projects` |
| `CLAUDE_BIN` / `MISE_BIN` / `TMUX_BIN` / `FORGE_BIN` / `GIT_BIN` | Binary overrides | `claude` / `mise` / `tmux` / `forge` / `git` | | `CLAUDE_BIN` / `PI_BIN` / `MISE_BIN` / `TMUX_BIN` / `FORGE_BIN` / `GIT_BIN` | Binary overrides | `claude` / `pi` / `mise` / `tmux` / `forge` / `git` |
| `FORGE_VERSION` | Pinned forge version verified at spawn (Phase 2) | unset → skip check | | `FORGE_VERSION` | Pinned forge version verified at spawn (Phase 2) | unset → skip check |
| `PROTECTED_BRANCHES` | Branches a direct push needs an approval to reach (Phase 2) | `main,master` | | `PROTECTED_BRANCHES` | Branches a direct push needs an approval to reach (Phase 2) | `main,master` |
@@ -257,14 +257,18 @@ What the dashboard can now do (all state-changing actions require `ADMIN_TOKEN`)
- **Claude** — the management page for the Claude Code install agents run on. The account - **Claude** — the management page for the Claude Code install agents run on. The account
login lives here (see below), plus web-managed **model backends**, **skills**, login lives here (see below), plus web-managed **model backends**, **skills**,
**MCP connectors**, **plugins**, and **permission overrides**. Model backends are **MCP connectors**, **plugins**, and **permission overrides**. Model backends are
Anthropic-API-compatible endpoints (a local Qwen/Llama behind LiteLLM or alternative endpoints offered in the spawn form's **Model** dropdown next to the
claude-code-router, an LLM gateway) offered in the spawn form's **Model** dropdown next Claude subscription, and each picks a **harness**: `claude` (the same `claude` binary
to the Claude subscription: the same `claude` binary is pointed at the endpoint via pointed at an Anthropic-API-compatible endpoint — a local model behind LiteLLM or
`ANTHROPIC_BASE_URL`/`ANTHROPIC_MODEL` env at launch, so hooks, skills, connectors, and claude-code-router, an LLM gateway — via `ANTHROPIC_BASE_URL`/`ANTHROPIC_MODEL` env at
gates apply unchanged, and the agent stays pinned to its backend across resumes. API launch) or `pi` (the lightweight [pi coding agent](https://github.com/badlogic/pi-mono),
keys are stored encrypted (`HANDLER_SECRET_KEY`) and never returned. See which speaks bare OpenAI-compatible endpoints — vLLM, llama.cpp, Ollama — natively, no
[`docs/local-models.md`](docs/local-models.md) for working local stacks (and why bare translation proxy, with handler's hooks/gates/memory/skills bridged in via a bundled pi
OpenAI-compatible servers break tool calling). These are plain DB rows the control container extension). Either way hooks, skills, and gates apply, and the agent stays pinned to
its backend across resumes. API keys are stored encrypted (`HANDLER_SECRET_KEY`) and
never returned. See [`docs/local-models.md`](docs/local-models.md) for working local
stacks (and why bare OpenAI-compatible servers break tool calling *on the claude
harness*). These are plain DB rows the control container
applies at every launch: skills sync to each worker's user-level `~/.claude/skills` applies at every launch: skills sync to each worker's user-level `~/.claude/skills`
(marker-file managed, so hand-installed skills survive), enabled connectors become the (marker-file managed, so hand-installed skills survive), enabled connectors become the
run's `--mcp-config` file (nothing lands in the repo tree), and plugins/permissions fold run's `--mcp-config` file (nothing lands in the repo tree), and plugins/permissions fold
+81 -4
View File
@@ -1,9 +1,25 @@
# Local model backends (Qwen-Coder & friends) # Local model backends (Qwen-Coder & friends)
Handler can run agents on locally-hosted models without changing anything about how an Handler runs agents on locally-hosted models through a **model backend** row, and every
agent works: it is still the same `claude` binary with the same generated backend picks one of two **harnesses**:
`settings.json`, hooks, skills, MCP connectors, plugins, and permission gates. The only
thing a **model backend** changes is the environment of that one agent's process: | Harness | Binary | Endpoint it needs | When to pick it |
|---|---|---|---|
| `claude` (default) | Claude Code | **Anthropic Messages API** incl. tool use — put LiteLLM / claude-code-router in front of a local server | You want the exact Claude Code toolchain (MCP connectors, plugins, permission modes) |
| `pi` | [pi coding agent](https://github.com/badlogic/pi-mono) | **bare OpenAI-compatible** (`/v1/chat/completions`) — vLLM, llama.cpp, Ollama directly, no proxy | You want the lightest loop for slow local token throughput |
Both harnesses keep handler's contract intact: the same hooks (test/completion gate,
push gate, approval gate), the same checkmark/log streaming, the same memory layer, the
same skills, kill/resume, and schedules. The Claude subscription (no backend selected)
always launches `claude` — pi is only ever used when you point an agent at a backend row
that says so.
## The claude harness
Nothing about how an agent works changes: it is still the same `claude` binary with the
same generated `settings.json`, hooks, skills, MCP connectors, plugins, and permission
gates. The only thing the backend changes is the environment of that one agent's
process:
| Variable | From | | Variable | From |
|---|---| |---|---|
@@ -88,6 +104,67 @@ Then register the backend in Handler: base URL `http://<host>:4000`, model
[claude-code-router](https://github.com/musistudio/claude-code-router) in front as [claude-code-router](https://github.com/musistudio/claude-code-router) in front as
the Anthropic translator. the Anthropic translator.
## The pi harness
Set **Harness: pi** on the backend row (or `"harness": "pi"` via `POST /claude/models`)
and point `base_url` straight at the OpenAI-compatible endpoint — no LiteLLM, no
claude-code-router:
```bash
# vLLM with the Qwen tool parser is all you need:
vllm serve Qwen/Qwen3-Coder-30B-A3B-Instruct \
--enable-auto-tool-choice --tool-call-parser qwen3_coder --port 8000
```
Backend row: base URL `http://<host>:8000/v1`, model
`Qwen/Qwen3-Coder-30B-A3B-Instruct`, harness `pi`, API key optional (pi requires *some*
credential, so handler injects a placeholder when none is stored). The same
tool-parser/template caveats apply as ever — the model's tool calls must come back as
structured `tool_calls`, so use vLLM's parser flags, `--jinja` on `llama-server`, or an
Ollama model whose template declares `.Tools`.
### What the control layer generates
At every launch (spawn *and* resume) the backend row is materialized into a per-agent
`PI_CODING_AGENT_DIR` under `~/.handler-pi/` — outside the repo tree, so the clean-tree
completion gate never sees generated files:
- **`models.json` + `settings.json`** — the row as a pi provider (`openai-completions`
by default) pinned as the default model. Row `env` keys `PI_PROVIDER_API`,
`PI_CONTEXT_WINDOW`, and `PI_MAX_TOKENS` tune it; everything else in the env map
passes through to the process.
- **`extensions/handler-bridge.ts`** — the bundled bridge extension that adapts pi's
events to the same `python -m handler.hooks` contract claude uses. The gates are the
*same tested Python code*: the Stop/completion gate re-prompts pi with the blockers,
`git push` runs the test + image-build + protected-branch approval gates and denies on
failure, and questions go through an `ask_operator` tool that pauses the agent for the
normal answer/resume flow. Memory recall is injected at session start, and the memory
tools (`memory_search/get/save/link`) are registered directly — pi has no MCP by
design, so the bridge shells to `python -m handler.mcpserver --call <tool>` instead.
- **`APPEND_SYSTEM.md`** — the handler conventions (completion contract, ask_operator,
memory usage) appended to pi's system prompt.
Skills work unchanged: pi implements the same SKILL.md standard as Claude Code, and the
generated `settings.json` points pi's discovery at the web-managed `~/.claude/skills`
sync plus the repo's committed `.claude/skills` (the forge role skills). pi also reads
`AGENTS.md` / `CLAUDE.md` context files natively.
Sessions are single JSONL files pre-assigned by handler (`--session <path>`), so
cross-worker resume works exactly like claude's: archived to the DB, materialized by
whichever worker claims the resume, continued by launching pi again on the same file.
### What differs from the claude harness
- **MCP connectors and plugins don't apply** — pi has no MCP client or plugin system.
The bundled memory server is bridged as native tools; other connectors are
claude-harness-only for now.
- **Permission modes don't apply** — pi has no permission system. The hard gates
(PreToolUse-equivalent blocking, Stop gate) are enforced by the bridge, which is the
layer handler actually relies on for claude too.
- **`--max-budget-usd` doesn't apply** — local tokens are free; pi has no budget flag.
- The `pi` binary must be on the worker's PATH (the control image bakes it in;
`PI_BIN` overrides, same as `CLAUDE_BIN`).
## Expectations and tips for small models ## Expectations and tips for small models
- **Keep the harness light.** Handler's agents run tool-heavy (hooks, MCP connectors, - **Keep the harness light.** Handler's agents run tool-heavy (hooks, MCP connectors,
+24 -7
View File
@@ -506,11 +506,17 @@ const emptyModel = {
base_url: "", base_url: "",
model: "", model: "",
small_fast_model: "", small_fast_model: "",
harness: "claude" as "claude" | "pi",
api_key: "", api_key: "",
env: "", env: "",
enabled: true, enabled: true,
}; };
const HARNESS_OPTS = [
{ value: "claude", label: "claude — Anthropic-compatible endpoint (LiteLLM/gateway)" },
{ value: "pi", label: "pi — bare OpenAI-compatible endpoint (vLLM/llama.cpp/Ollama)" },
];
function ModelsPanel() { function ModelsPanel() {
const s = useDashboard(); const s = useDashboard();
const [form, setForm] = useState(emptyModel); const [form, setForm] = useState(emptyModel);
@@ -527,6 +533,7 @@ function ModelsPanel() {
base_url: form.base_url.trim(), base_url: form.base_url.trim(),
model: form.model.trim(), model: form.model.trim(),
small_fast_model: form.small_fast_model.trim() || null, small_fast_model: form.small_fast_model.trim() || null,
harness: form.harness,
api_key: form.api_key.trim() || null, api_key: form.api_key.trim() || null,
env: parseKeyValues(form.env), env: parseKeyValues(form.env),
enabled: form.enabled, enabled: form.enabled,
@@ -544,6 +551,7 @@ function ModelsPanel() {
base_url: m.base_url, base_url: m.base_url,
model: m.model, model: m.model,
small_fast_model: m.small_fast_model ?? "", small_fast_model: m.small_fast_model ?? "",
harness: m.harness ?? "claude",
api_key: "", // write-only; blank = keep the stored key api_key: "", // write-only; blank = keep the stored key
env: formatKeyValues(m.env), env: formatKeyValues(m.env),
enabled: m.enabled, enabled: m.enabled,
@@ -555,13 +563,15 @@ function ModelsPanel() {
<> <>
<div className="faint" style={{ fontSize: "var(--text-sm)", marginBottom: 14 }}> <div className="faint" style={{ fontSize: "var(--text-sm)", marginBottom: 14 }}>
Alternative model backends the spawn dropdown offers next to the Claude Alternative model backends the spawn dropdown offers next to the Claude
subscription the same <span className="mono">claude</span> binary pointed at a subscription. The <b>claude</b> harness is the same{" "}
different endpoint via <span className="mono">ANTHROPIC_BASE_URL</span>, so <span className="mono">claude</span> binary pointed at a different endpoint via{" "}
skills, connectors, hooks, and gates apply unchanged. The endpoint must speak the{" "} <span className="mono">ANTHROPIC_BASE_URL</span> the endpoint must speak the{" "}
<b>Anthropic Messages API including tool use</b> a bare OpenAI-compatible <b>Anthropic Messages API including tool use</b>, so front a bare
server (Ollama, llama.cpp, LM Studio) breaks tool calling; front it with LiteLLM OpenAI-compatible server with LiteLLM or claude-code-router. The <b>pi</b>{" "}
or claude-code-router and enable the backend&apos;s native tool parser. See{" "} harness runs the lightweight pi coding agent instead, which speaks{" "}
<span className="mono">docs/local-models.md</span> for working Qwen-Coder stacks. <b>OpenAI-compatible endpoints natively</b> (vLLM, llama.cpp, Ollama no
translation proxy) hooks, gates, memory, and skills still apply via
handler&apos;s bridge. See <span className="mono">docs/local-models.md</span>.
</div> </div>
<Card> <Card>
<div className="card-head" style={{ marginBottom: 14 }}> <div className="card-head" style={{ marginBottom: 14 }}>
@@ -594,6 +604,12 @@ function ModelsPanel() {
onChange={(v) => setForm({ ...form, small_fast_model: v })} onChange={(v) => setForm({ ...form, small_fast_model: v })}
placeholder="qwen3-1.7b" placeholder="qwen3-1.7b"
/> />
<Select
label="Harness (which agent binary runs against this endpoint)"
value={form.harness}
onChange={(v) => setForm({ ...form, harness: v as "claude" | "pi" })}
options={HARNESS_OPTS}
/>
<Input <Input
label={editingId != null ? "API key (blank = keep stored key)" : "API key (optional)"} label={editingId != null ? "API key (blank = keep stored key)" : "API key (optional)"}
value={form.api_key} value={form.api_key}
@@ -635,6 +651,7 @@ function ModelsPanel() {
{m.name} {m.name}
</span> </span>
<div className="hstack"> <div className="hstack">
{m.harness === "pi" && <Badge tone="info">pi harness</Badge>}
{m.has_api_key && <Badge tone="info">key stored</Badge>} {m.has_api_key && <Badge tone="info">key stored</Badge>}
<Badge tone={m.enabled ? "success" : "neutral"}> <Badge tone={m.enabled ? "success" : "neutral"}>
{m.enabled ? "enabled" : "disabled"} {m.enabled ? "enabled" : "disabled"}
+1
View File
@@ -201,6 +201,7 @@ export interface ModelBody {
base_url: string; base_url: string;
model: string; model: string;
small_fast_model: string | null; small_fast_model: string | null;
harness: "claude" | "pi";
/* Write-only: encrypted at rest server-side, never echoed back. Null = no change. */ /* Write-only: encrypted at rest server-side, never echoed back. Null = no change. */
api_key: string | null; api_key: string | null;
clear_api_key?: boolean; clear_api_key?: boolean;
+3
View File
@@ -193,6 +193,9 @@ export interface ClaudeModel {
base_url: string; base_url: string;
model: string; model: string;
small_fast_model?: string | null; small_fast_model?: string | null;
/* Which agent binary runs against this backend: "claude" (Anthropic-compatible
endpoint required) or "pi" (bare OpenAI-compatible endpoint, lightweight). */
harness?: "claude" | "pi";
env?: Record<string, string> | null; env?: Record<string, string> | null;
enabled: boolean; enabled: boolean;
has_api_key: boolean; has_api_key: boolean;
+1
View File
@@ -304,6 +304,7 @@ def create_model(body: ClaudeModelIn, conn: Connection = Depends(db_conn)) -> di
body.model, body.model,
api_key_enc=api_key_enc, api_key_enc=api_key_enc,
small_fast_model=body.small_fast_model, small_fast_model=body.small_fast_model,
harness=body.harness,
env=body.env, env=body.env,
enabled=body.enabled, enabled=body.enabled,
) )
+9
View File
@@ -14,6 +14,9 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
Role = Literal["junior", "senior", "deploy"] Role = Literal["junior", "senior", "deploy"]
ForgeType = Literal["github", "gitlab", "gitea", "forgejo", "bitbucket"] ForgeType = Literal["github", "gitlab", "gitea", "forgejo", "bitbucket"]
# Agent harness a model backend launches (claude_models.harness).
Harness = Literal["claude", "pi"]
# credential_ref schemes an operator may set over the web. ``cmd:`` is intentionally # credential_ref schemes an operator may set over the web. ``cmd:`` is intentionally
# excluded — it would run an arbitrary command in the control container at spawn — so the # excluded — it would run an arbitrary command in the control container at spawn — so the
# API rejects it even though the CLI/DB path still allows it. # API rejects it even though the CLI/DB path still allows it.
@@ -532,6 +535,10 @@ class ClaudeModelIn(BaseModel):
base_url: str = Field(min_length=1) base_url: str = Field(min_length=1)
model: str = Field(min_length=1) model: str = Field(min_length=1)
small_fast_model: str | None = None small_fast_model: str | None = None
# Which agent binary runs against this backend. "claude" needs an
# Anthropic-API-compatible endpoint; "pi" speaks OpenAI-compatible endpoints
# (vLLM, llama.cpp, Ollama) natively — no translation proxy.
harness: Harness = "claude"
api_key: str | None = None api_key: str | None = None
env: dict[str, str] = Field(default_factory=dict) env: dict[str, str] = Field(default_factory=dict)
enabled: bool = True enabled: bool = True
@@ -550,6 +557,7 @@ class ClaudeModelUpdateIn(BaseModel):
base_url: str | None = None base_url: str | None = None
model: str | None = Field(default=None, min_length=1) model: str | None = Field(default=None, min_length=1)
small_fast_model: str | None = None small_fast_model: str | None = None
harness: Harness | None = None
api_key: str | None = None api_key: str | None = None
clear_api_key: bool = False clear_api_key: bool = False
env: dict[str, str] | None = None env: dict[str, str] | None = None
@@ -574,6 +582,7 @@ class ClaudeModelOut(BaseModel):
base_url: str base_url: str
model: str model: str
small_fast_model: str | None = None small_fast_model: str | None = None
harness: Harness = "claude"
env: dict[str, str] | None = None env: dict[str, str] | None = None
enabled: bool enabled: bool
# The key never leaves the server; this says whether one is stored. # The key never leaves the server; this says whether one is stored.
+1
View File
@@ -46,6 +46,7 @@ class Settings(BaseSettings):
# Binary overrides so tests/CI can point at fakes. # Binary overrides so tests/CI can point at fakes.
claude_bin: str = "claude" claude_bin: str = "claude"
pi_bin: str = "pi"
mise_bin: str = "mise" mise_bin: str = "mise"
tmux_bin: str = "tmux" tmux_bin: str = "tmux"
forge_bin: str = "forge" forge_bin: str = "forge"
+118 -27
View File
@@ -1,4 +1,6 @@
"""The headless runner: worker-owned ``claude -p`` subprocesses streaming to the DB. """The headless runner: worker-owned agent subprocesses streaming to the DB
``claude -p --output-format stream-json`` by default, ``pi -p --mode json`` for agents
pinned to a pi-harness model backend (see ``control.pi_harness``).
This is the tmux replacement seam for agent *runs* (tmux stays only for the interactive This is the tmux replacement seam for agent *runs* (tmux stays only for the interactive
``/login`` flow). Each invocation is ``claude -p --output-format stream-json`` with a ``/login`` flow). Each invocation is ``claude -p --output-format stream-json`` with a
@@ -34,7 +36,7 @@ from pathlib import Path
from ..config import get_settings from ..config import get_settings
from ..db import repository as repo from ..db import repository as repo
from ..db.engine import connection from ..db.engine import connection
from . import claude_gen from . import claude_gen, pi_harness
# Stream types we recognize from ``--output-format stream-json``; anything else (or an # Stream types we recognize from ``--output-format stream-json``; anything else (or an
# unparseable line) is stored as-is so no output is ever dropped. ``worker`` is our own: # unparseable line) is stored as-is so no output is ever dropped. ``worker`` is our own:
@@ -144,20 +146,59 @@ def assistant_text(payload: dict) -> str | None:
return text or None return text or None
def archive_session(working_dir: str, session_id: str, max_bytes: int | None = None) -> bytes | None: def pi_assistant_text(payload: dict) -> str | None:
"""Tar.gz the session transcript + sidecar dir, or None when nothing exists yet or """The assistant text of a pi ``message_end`` event, or None for non-assistant
the result would exceed ``max_bytes`` (the caller records a worker event; the run messages (pi emits ``message_end`` for user/custom messages too claude's
itself is unaffected only cross-worker resume degrades).""" ``assistant`` events never carry another role, so this guard is pi-only)."""
base = session_dir(working_dir) message = payload.get("message")
jsonl = base / f"{session_id}.jsonl" if not isinstance(message, dict) or message.get("role") != "assistant":
sidecar = base / session_id return None
if not jsonl.exists() and not sidecar.exists(): return assistant_text(payload)
def pi_result_payload(agent_end: dict) -> dict:
"""A pi ``agent_end`` event normalized into the claude-``result``-shaped payload the
run row stores: enough for the ``is_error`` verdict and the UI. pi emits one
``agent_end`` per low-level run (auto-retries and stop-gate continuations each get
their own); the supervisor overwrites on each, so the final one wins exactly the
verdict of the run's last words."""
messages = agent_end.get("messages")
stop_reason = None
if isinstance(messages, list):
for message in reversed(messages):
if isinstance(message, dict) and message.get("role") == "assistant":
stop_reason = message.get("stopReason")
break
return {
"harness": "pi",
"is_error": stop_reason in ("error", "aborted"),
"stop_reason": stop_reason,
"will_retry": bool(agent_end.get("willRetry")),
}
def archive_session(
working_dir: str, session_id: str, max_bytes: int | None = None, harness: str = "claude"
) -> bytes | None:
"""Tar.gz the session transcript (+ claude's sidecar dir), or None when nothing
exists yet or the result would exceed ``max_bytes`` (the caller records a worker
event; the run itself is unaffected only cross-worker resume degrades). A pi
session is a single JSONL file at the path handler pre-assigned via ``--session``."""
if harness == "pi":
base = pi_harness.sessions_dir(working_dir)
jsonl = pi_harness.session_file(working_dir, session_id)
sidecar = None
else:
base = session_dir(working_dir)
jsonl = base / f"{session_id}.jsonl"
sidecar = base / session_id
if not jsonl.exists() and (sidecar is None or not sidecar.exists()):
return None return None
buf = io.BytesIO() buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar: with tarfile.open(fileobj=buf, mode="w:gz") as tar:
if jsonl.exists(): if jsonl.exists():
tar.add(jsonl, arcname=jsonl.name) tar.add(jsonl, arcname=jsonl.name)
if sidecar.is_dir(): if sidecar is not None and sidecar.is_dir():
tar.add(sidecar, arcname=sidecar.name) tar.add(sidecar, arcname=sidecar.name)
data = buf.getvalue() data = buf.getvalue()
limit = max_bytes if max_bytes is not None else get_settings().session_archive_max_bytes limit = max_bytes if max_bytes is not None else get_settings().session_archive_max_bytes
@@ -166,13 +207,16 @@ def archive_session(working_dir: str, session_id: str, max_bytes: int | None = N
return data return data
def materialize_session(working_dir: str, archive: bytes) -> None: def materialize_session(working_dir: str, archive: bytes, harness: str = "claude") -> None:
"""Unpack a session archive where claude will look for it on ``--resume``. """Unpack a session archive where the harness will look for it on resume.
``filter="data"`` rejects path traversal and special members the archive came from ``filter="data"`` rejects path traversal and special members the archive came from
our own DB, but a defense-in-depth default costs nothing. our own DB, but a defense-in-depth default costs nothing.
""" """
base = session_dir(working_dir) if harness == "pi":
base = pi_harness.sessions_dir(working_dir)
else:
base = session_dir(working_dir)
base.mkdir(parents=True, exist_ok=True) base.mkdir(parents=True, exist_ok=True)
with tarfile.open(fileobj=io.BytesIO(archive), mode="r:gz") as tar: with tarfile.open(fileobj=io.BytesIO(archive), mode="r:gz") as tar:
tar.extractall(base, filter="data") tar.extractall(base, filter="data")
@@ -198,6 +242,8 @@ class RunSupervisor:
cancel_poll: float = 5.0, cancel_poll: float = 5.0,
archive_interval: float = 60.0, archive_interval: float = 60.0,
on_exit=None, on_exit=None,
harness: str = "claude",
prompt_stdin: str | None = None,
) -> None: ) -> None:
self.agent = agent self.agent = agent
self.run = run self.run = run
@@ -207,6 +253,10 @@ class RunSupervisor:
self.cancel_poll = cancel_poll self.cancel_poll = cancel_poll
self.archive_interval = archive_interval self.archive_interval = archive_interval
self.on_exit = on_exit # worker's slot-release callback self.on_exit = on_exit # worker's slot-release callback
self.harness = harness
# pi takes the prompt on stdin (it has no ``--`` argv separator); claude runs
# keep stdin closed as before.
self.prompt_stdin = prompt_stdin
self._seq = 0 self._seq = 0
self._seq_lock = threading.Lock() # reader thread + supervisor both emit events self._seq_lock = threading.Lock() # reader thread + supervisor both emit events
self._result_payload: dict | None = None self._result_payload: dict | None = None
@@ -241,7 +291,18 @@ class RunSupervisor:
etype, payload = parse_stream_line(line) etype, payload = parse_stream_line(line)
try: try:
self._insert_event(etype, payload) self._insert_event(etype, payload)
if etype == "result": if self.harness == "pi":
# pi's stream is normalized on the fly: the final ``agent_end``
# plays claude's ``result`` role, assistant ``message_end`` events
# feed last_output. Raw events are stored as-is either way.
if etype == "agent_end":
self._result_payload = pi_result_payload(payload)
elif etype == "message_end":
text = pi_assistant_text(payload)
if text:
with connection() as conn:
repo.update_agent_output(conn, self.agent["id"], text)
elif etype == "result":
self._result_payload = payload self._result_payload = payload
elif etype == "assistant": elif etype == "assistant":
text = assistant_text(payload) text = assistant_text(payload)
@@ -253,7 +314,7 @@ class RunSupervisor:
def _upload_archive(self) -> None: def _upload_archive(self) -> None:
try: try:
data = archive_session(self.cwd, self.run["session_id"]) data = archive_session(self.cwd, self.run["session_id"], harness=self.harness)
if data is None: if data is None:
return return
with connection() as conn: with connection() as conn:
@@ -278,20 +339,27 @@ class RunSupervisor:
def _supervise(self) -> None: def _supervise(self) -> None:
stderr_file = tempfile.TemporaryFile() stderr_file = tempfile.TemporaryFile()
pipe_prompt = self.prompt_stdin is not None
try: try:
proc = subprocess.Popen( proc = subprocess.Popen(
self.argv, self.argv,
cwd=self.cwd, cwd=self.cwd,
env={**os.environ, **self.env}, env={**os.environ, **self.env},
stdin=subprocess.DEVNULL, stdin=subprocess.PIPE if pipe_prompt else subprocess.DEVNULL,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=stderr_file, stderr=stderr_file,
text=True, text=True,
) )
except OSError as exc: except OSError as exc:
stderr_file.close() stderr_file.close()
self._settle(exit_code=None, stderr_tail=f"failed to launch claude: {exc}") self._settle(exit_code=None, stderr_tail=f"failed to launch {self.harness}: {exc}")
return return
if pipe_prompt:
try:
proc.stdin.write(self.prompt_stdin)
proc.stdin.close()
except (OSError, ValueError):
pass # a dead-on-arrival process is settled by the wait loop below
reader = threading.Thread( reader = threading.Thread(
target=self._pump_stdout, args=(proc.stdout,), daemon=True target=self._pump_stdout, args=(proc.stdout,), daemon=True
) )
@@ -381,33 +449,56 @@ def launch(
env: dict[str, str], env: dict[str, str],
worker_id: str, worker_id: str,
on_exit=None, on_exit=None,
harness: str = "claude",
) -> dict: ) -> dict:
"""Start a headless run for ``agent`` and return its ``agent_runs`` row. """Start a headless run for ``agent`` and return its ``agent_runs`` row.
``kind`` is ``spawn`` (fresh session, new UUID) or ``resume`` (materialize the stored ``kind`` is ``spawn`` (fresh session, new UUID) or ``resume`` (materialize the stored
archive, continue the agent's existing session). Fire-and-forget from the caller's archive, continue the agent's existing session). Fire-and-forget from the caller's
perspective the returned run row is already ``running`` and a daemon supervisor perspective the returned run row is already ``running`` and a daemon supervisor
owns the process from here. owns the process from here. ``harness`` selects the binary: ``claude`` (default) or
``pi`` (see ``control.pi_harness``) the supervision, event log, archive, and
status reconciliation are identical either way.
""" """
working_dir = agent["working_dir"] working_dir = agent["working_dir"]
# The generated connectors file (control.claude_gen) rides along when present; its prompt_stdin: str | None = None
# presence on disk is the contract, so the launch seam's signature stays stable.
mcp_config = claude_gen.mcp_config_path(working_dir)
if not os.path.exists(mcp_config):
mcp_config = None
if kind == "spawn": if kind == "spawn":
session_id = str(uuid.uuid4()) session_id = str(uuid.uuid4())
argv = build_spawn_argv(prompt, settings_path, session_id, mcp_config)
else: else:
session_id = agent.get("session_id") session_id = agent.get("session_id")
if not session_id: if not session_id:
raise ValueError(f"agent '{agent['name']}' has no session to resume") raise ValueError(f"agent '{agent['name']}' has no session to resume")
argv = build_resume_argv(session_id, prompt, settings_path, mcp_config) if harness == "pi":
# Spawn and resume are the same invocation: the pre-assigned --session file is
# created on first use and appended to afterwards. The prompt travels on stdin.
argv = pi_harness.build_argv(session_id, working_dir)
prompt_stdin = prompt
# The bridge extension reports hook events under the run's session UUID (the
# transcript basename), same identity contract as claude's hook stdin.
env = {**env, "HANDLER_SESSION_ID": session_id}
else:
# The generated connectors file (control.claude_gen) rides along when present;
# its presence on disk is the contract, so the launch seam's signature stays
# stable. (pi has no MCP — its bridge registers the memory tools directly.)
mcp_config = claude_gen.mcp_config_path(working_dir)
if not os.path.exists(mcp_config):
mcp_config = None
if kind == "spawn":
argv = build_spawn_argv(prompt, settings_path, session_id, mcp_config)
else:
argv = build_resume_argv(session_id, prompt, settings_path, mcp_config)
with connection() as conn: with connection() as conn:
run = repo.create_run(conn, agent["id"], session_id, worker_id, kind) run = repo.create_run(conn, agent["id"], session_id, worker_id, kind)
repo.set_agent_session(conn, agent["id"], session_id, worker_id) repo.set_agent_session(conn, agent["id"], session_id, worker_id)
supervisor = RunSupervisor( supervisor = RunSupervisor(
agent, run, argv, cwd=working_dir, env=env, on_exit=on_exit agent,
run,
argv,
cwd=working_dir,
env=env,
on_exit=on_exit,
harness=harness,
prompt_stdin=prompt_stdin,
) )
supervisor.start() supervisor.start()
return run return run
+43 -9
View File
@@ -9,11 +9,16 @@ binary at the endpoint, ``ANTHROPIC_MODEL`` / ``ANTHROPIC_SMALL_FAST_MODEL`` nam
it serves, and ``ANTHROPIC_AUTH_TOKEN`` carries the endpoint's key (decrypted here, in it serves, and ``ANTHROPIC_AUTH_TOKEN`` carries the endpoint's key (decrypted here, in
the control container, from the encrypted column the API never returns it). the control container, from the encrypted column the API never returns it).
The endpoint must speak the Anthropic Messages API *including tool use*. A bare For the default ``harness='claude'`` the endpoint must speak the Anthropic Messages API
OpenAI-compatible server (Ollama, llama.cpp, LM Studio, vLLM) is not enough on its own *including tool use*. A bare OpenAI-compatible server (Ollama, llama.cpp, LM Studio,
that mismatch is exactly the "tool calling not working" failure with Qwen-Coder so put vLLM) is not enough on its own that mismatch is exactly the "tool calling not working"
a translating proxy in front and enable the backend's native tool parser; see failure with Qwen-Coder so put a translating proxy in front and enable the backend's
``docs/local-models.md`` for working stacks. native tool parser; see ``docs/local-models.md`` for working stacks.
``harness='pi'`` rows skip all of that: the lightweight pi coding agent speaks the
OpenAI Completions API natively, so the row's ``base_url`` is the bare local endpoint
and there is no ``ANTHROPIC_*`` env at all the row renders into a pi provider config
instead (see ``control.pi_harness``).
""" """
from __future__ import annotations from __future__ import annotations
@@ -35,10 +40,11 @@ class ModelError(Exception):
"""Raised when a selected model backend cannot be resolved into an environment.""" """Raised when a selected model backend cannot be resolved into an environment."""
def resolve_model_env( def resolve_model(
conn, model_id: int | None, *, require_enabled: bool = False conn, model_id: int | None, *, require_enabled: bool = False
) -> dict[str, str]: ) -> tuple[dict, str] | None:
"""The env overrides for ``model_id``, or ``{}`` for None (the Claude subscription). """The backend row + its decrypted API key (a placeholder when none is stored), or
``None`` for ``model_id=None`` (the Claude subscription).
``require_enabled`` is the spawn path (a disabled backend must not take new agents); ``require_enabled`` is the spawn path (a disabled backend must not take new agents);
resumes pass False so an agent already pinned to a since-disabled backend can still resumes pass False so an agent already pinned to a since-disabled backend can still
@@ -47,7 +53,7 @@ def resolve_model_env(
this agent not to use. this agent not to use.
""" """
if model_id is None: if model_id is None:
return {} return None
row = repo.get_claude_model(conn, model_id) row = repo.get_claude_model(conn, model_id)
if row is None: if row is None:
raise ModelError( raise ModelError(
@@ -65,6 +71,22 @@ def resolve_model_env(
) from exc ) from exc
else: else:
key = _PLACEHOLDER_KEY key = _PLACEHOLDER_KEY
return row, key
def harness_of(resolved: tuple[dict, str] | None) -> str:
"""Which agent binary a resolved backend launches; the subscription is claude."""
if resolved is None:
return "claude"
return resolved[0].get("harness") or "claude"
def claude_env(resolved: tuple[dict, str] | None) -> dict[str, str]:
"""The ``ANTHROPIC_*`` env for a claude-harness backend, ``{}`` for None (the
subscription needs no overrides)."""
if resolved is None:
return {}
row, key = resolved
env = { env = {
**_LOCAL_DEFAULTS, **_LOCAL_DEFAULTS,
"ANTHROPIC_BASE_URL": row["base_url"], "ANTHROPIC_BASE_URL": row["base_url"],
@@ -77,3 +99,15 @@ def resolve_model_env(
for k, v in (row.get("env") or {}).items(): for k, v in (row.get("env") or {}).items():
env[str(k)] = str(v) env[str(k)] = str(v)
return env return env
def resolve_model_env(
conn, model_id: int | None, *, require_enabled: bool = False
) -> dict[str, str]:
"""The claude-harness env overrides for ``model_id`` (``{}`` for the subscription).
Kept as the validation seam ``spawn`` fail-fasts through; pi-harness rows resolve
fine here too (spawn only checks resolvability), their env just isn't this one."""
resolved = resolve_model(conn, model_id, require_enabled=require_enabled)
if harness_of(resolved) != "claude":
return {}
return claude_env(resolved)
+249
View File
@@ -0,0 +1,249 @@
/**
* handler-bridge the pi extension that wires a pi agent into handler's hooks.
*
* Installed by `handler.control.pi_harness` into the per-agent PI_CODING_AGENT_DIR and
* loaded with `-e`, so it runs on every pi-harness launch. It adapts pi's extension
* events to the exact stdin/stdout contract of `python -m handler.hooks <event>` all
* gate logic (test gate, push gate, approval gate, question deferral, memory recall)
* stays in the tested Python modules; this file only translates:
*
* - before_agent_start hooks session_start (memory recall, injected once as context)
* - tool_call "bash" hooks pre_tool_use (git-push / merge-deploy gates; can block)
* - ask_operator tool hooks pre_tool_use as AskUserQuestion (defer + pause), then
* terminates the run so the async answer/resume flow takes over
* - agent_settled hooks stop (the completion gate; a block is fed back as a
* follow-up user message, continuing the run like claude's
* Stop-hook re-invoke; stop_hook_active guards the loop)
* - session_shutdown hooks session_end
*
* It also registers the memory tools (memory_search/get/save/link) that claude agents
* reach over MCP, by shelling to `python -m handler.mcpserver --call <tool>` pi has
* no MCP by design, and a subprocess inheriting the agent env is the same trust model
* the MCP server used anyway.
*
* Identity and configuration arrive via the spawn environment, exactly like hooks:
* HANDLER_AGENT_ID / HANDLER_PROJECT_ID / HANDLER_AGENT_NAME / DATABASE_URL, plus
* HANDLER_PYTHON (the control layer's interpreter) and HANDLER_SESSION_ID (the run's
* session UUID, which is also the session file's basename).
*/
import { spawnSync } from "node:child_process";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
const PYTHON = process.env.HANDLER_PYTHON || "python3";
const SESSION_ID = process.env.HANDLER_SESSION_ID || "";
// The stop hook runs the project's own test suite; give it real time.
const STOP_HOOK_TIMEOUT_MS = 30 * 60 * 1000;
const HOOK_TIMEOUT_MS = 10 * 60 * 1000;
function runHook(event: string, payload: Record<string, unknown>, timeoutMs = HOOK_TIMEOUT_MS): any {
const res = spawnSync(PYTHON, ["-m", "handler.hooks", event], {
input: JSON.stringify({ session_id: SESSION_ID, cwd: process.cwd(), ...payload }),
encoding: "utf8",
timeout: timeoutMs,
});
if (res.error || res.status !== 0) {
const detail = res.error ? String(res.error) : (res.stderr || "").slice(-2000);
process.stderr.write(`handler-bridge: hook ${event} failed: ${detail}\n`);
return null;
}
const line = (res.stdout || "").trim().split("\n").filter(Boolean).pop();
if (!line) return {};
try {
return JSON.parse(line);
} catch {
return {};
}
}
function callMemory(tool: string, args: Record<string, unknown>): string {
const res = spawnSync(PYTHON, ["-m", "handler.mcpserver", "--call", tool], {
input: JSON.stringify(args ?? {}),
encoding: "utf8",
timeout: 60_000,
});
if (res.error || res.status !== 0) {
const detail = res.error ? String(res.error) : (res.stderr || "").slice(-2000);
throw new Error(`${tool} failed: ${detail}`);
}
return (res.stdout || "").trim() || "{}";
}
function permissionDeny(out: any): string | null {
const spec = out?.hookSpecificOutput;
if (spec?.permissionDecision === "deny") {
return String(spec.permissionDecisionReason || "denied by handler");
}
return null;
}
export default function (pi: ExtensionAPI) {
let contextInjected = false;
let askDeferred = false;
let stopRounds = 0;
let lastAssistantText = "";
// ---- memory recall at session start (claude's SessionStart hook) ----------------
pi.on("before_agent_start", async () => {
if (contextInjected) return;
contextInjected = true;
const out = runHook("session_start", {});
const text = out?.hookSpecificOutput?.additionalContext;
if (typeof text === "string" && text.trim()) {
return {
message: { customType: "handler-context", content: text, display: true },
};
}
});
// ---- PreToolUse gates ------------------------------------------------------------
pi.on("tool_call", async (event) => {
if (event.toolName === "bash") {
const command = String((event.input as any)?.command ?? "");
// Cheap pre-filter mirroring the hook's own matchers: only push/merge/deploy
// commands pay for a hook subprocess; everything else runs untouched.
if (!/\bgit\s+push\b|\bforge\s+(?:pr\s+)?merge\b|\bmise\s+run\s+deploy\b/.test(command)) return;
const out = runHook(
"pre_tool_use",
{ tool_name: "Bash", tool_input: { command } },
STOP_HOOK_TIMEOUT_MS, // the push gate runs tests + an image build
);
const reason = permissionDeny(out);
if (reason) return { block: true, reason };
return;
}
if (event.toolName === "ask_operator") {
const question = String((event.input as any)?.question ?? "").trim();
const out = runHook("pre_tool_use", {
tool_name: "AskUserQuestion",
tool_input: { questions: [{ question }] },
});
askDeferred = true;
runHook("notification", { message: `question for the operator: ${question}`.slice(0, 500) });
const reason =
permissionDeny(out) ||
"Question deferred to the operator; the run pauses here and resumes with the answer.";
// terminate: the operator answers asynchronously (answer + resume); there is
// nothing useful this process can do while it waits.
return { block: true, reason, terminate: true };
}
});
// ---- the completion gate (claude's Stop hook) -------------------------------------
pi.on("message_end", async (event) => {
const m: any = (event as any).message;
if (m?.role === "assistant" && Array.isArray(m.content)) {
const text = m.content
.filter((b: any) => b?.type === "text" && b.text)
.map((b: any) => b.text)
.join("\n")
.trim();
if (text) lastAssistantText = text;
}
});
pi.on("agent_settled", async () => {
if (askDeferred) return; // paused_for_input is already the checkpoint of record
const out = runHook(
"stop",
{
stop_hook_active: stopRounds > 0,
final_assistant_text: lastAssistantText || null,
},
STOP_HOOK_TIMEOUT_MS,
);
if (out?.decision === "block" && out.reason) {
stopRounds += 1;
pi.sendUserMessage(String(out.reason), { deliverAs: "followUp" });
}
});
pi.on("session_shutdown", async () => {
if (askDeferred) return;
runHook("session_end", { reason: "session ended" });
});
// ---- the operator question tool ----------------------------------------------------
pi.registerTool({
name: "ask_operator",
label: "Ask operator",
description:
"Ask the human operator a question you cannot answer yourself (a decision, a " +
"credential, an ambiguity in the task). The run pauses; the operator's answer " +
"arrives when the session is resumed. Ask only when genuinely blocked.",
parameters: Type.Object({
question: Type.String({ description: "The question for the operator" }),
}),
async execute() {
// Normally unreachable: the tool_call handler defers the question and
// terminates the run before execution.
return {
content: [{ type: "text", text: "Question recorded; the run will pause for the operator." }],
details: {},
};
},
});
// ---- memory tools (the handler-memory MCP server's surface, sans MCP) --------------
const memoryTools: Array<{ name: string; label: string; description: string; parameters: any }> = [
{
name: "memory_search",
label: "Memory search",
description:
"Search the team memory store (notes left by earlier agent runs and the operator) " +
"for your project plus global notes. Every whitespace-separated term must match " +
"the title, body, or kind (case-insensitive). An empty query returns the most " +
"recent notes. Use this BEFORE re-deriving how something works.",
parameters: Type.Object({
query: Type.Optional(Type.String({ description: "Search terms; empty = recent notes" })),
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 50 })),
}),
},
{
name: "memory_get",
label: "Memory get",
description: "Fetch one memory note in full, including its links to other notes.",
parameters: Type.Object({
note_id: Type.Integer({ description: "The note id" }),
}),
},
{
name: "memory_save",
label: "Memory save",
description:
"Save durable knowledge for future agent runs: a fact, a decision and its " +
"rationale, a gotcha, or a runbook. Write for a reader with no context from this " +
"session. Pass note_id to update; pass global=true only for cross-project knowledge.",
parameters: Type.Object({
title: Type.String({ description: "Short, searchable headline" }),
body: Type.String({ description: "The knowledge itself, markdown ok" }),
kind: Type.Optional(Type.String({ description: "fact | decision | gotcha | runbook" })),
tags: Type.Optional(Type.Array(Type.String())),
note_id: Type.Optional(Type.Integer({ description: "Update this note instead" })),
global: Type.Optional(Type.Boolean({ description: "Store unscoped (all projects)" })),
}),
},
{
name: "memory_link",
label: "Memory link",
description:
"Connect two memory notes so the knowledge graph shows how they relate. " +
"Idempotent: repeating an existing link is fine.",
parameters: Type.Object({
src_note_id: Type.Integer(),
dst_note_id: Type.Integer(),
relation: Type.Optional(Type.String({ description: "default: relates_to" })),
}),
},
];
for (const tool of memoryTools) {
pi.registerTool({
...tool,
async execute(_toolCallId: string, params: Record<string, unknown>) {
const text = callMemory(tool.name, params ?? {});
return { content: [{ type: "text", text }], details: {} };
},
});
}
}
+214
View File
@@ -0,0 +1,214 @@
"""The pi harness: run an agent through the lightweight `pi` coding agent instead of
``claude``, keeping every handler behavior (hooks, gates, memory, skills, resume).
Why it exists: ``claude`` is a heavy loop for a local 30B and it only speaks the
Anthropic Messages API, so a local vLLM/llama.cpp/Ollama endpoint needs a translating
proxy (LiteLLM, claude-code-router) in front of it just to make tool calling work. pi
speaks the OpenAI Completions API natively and carries a fraction of the harness
overhead, so a ``claude_models`` row with ``harness='pi'`` points pi straight at the
bare endpoint. Agents without a model backend (the Claude subscription) are untouched.
The launch is the same shape as claude's: ``pi -p --mode json`` streaming JSON events
on stdout, supervised by :class:`handler.control.headless.RunSupervisor`. Parity with
the claude harness comes from three generated artifacts, all under a per-working-dir
``PI_CODING_AGENT_DIR`` (outside the repo tree, so no gate ever sees them as dirt):
- ``models.json`` + ``settings.json`` the backend row rendered as a pi provider
(``openai-completions`` by default) and pinned as the default model; ``settings.json``
also points pi's skills discovery at the same ``~/.claude/skills`` dir the web-managed
skill sync maintains (pi implements the same SKILL.md standard), plus the repo's
committed ``.claude/skills`` (the forge role skills).
- ``extensions/handler-bridge.ts`` the bundled extension (``pi_bridge.ts``) adapting
pi's events to ``python -m handler.hooks``: the Stop/test gate, the git-push and
merge/deploy gates, AskUserQuestion deferral (as an ``ask_operator`` tool), memory
recall at session start, and the memory tools that claude reaches over MCP.
- ``APPEND_SYSTEM.md`` the handler conventions appended to pi's system prompt.
Sessions: the launch passes ``--session <dir>/sessions/<uuid>.jsonl`` explicitly, so the
transcript is a single file at a path handler chose pre-assignable like claude's
``--session-id``, trivially archivable for cross-worker resume, and resuming is just
launching again with the same path (verified: pi creates the file on first use and
appends on subsequent runs).
The task prompt travels via **stdin**, not argv: pi merges piped stdin into the prompt
and has no ``--`` separator, so argv delivery would misparse a task starting with ``-``.
"""
from __future__ import annotations
import json
import os
import sys
from importlib import resources
from pathlib import Path
from ..config import get_settings
PROVIDER_NAME = "handler"
BRIDGE_FILENAME = "handler-bridge.ts"
# Sensible local-endpoint defaults for pi model entries; overridable per row via the
# env map keys below (the same escape hatch claude rows use for endpoint quirks).
_DEFAULT_CONTEXT_WINDOW = 128_000
_DEFAULT_MAX_TOKENS = 16_384
# Row ``env`` keys the pi harness interprets itself (everything else passes through to
# the process environment unchanged):
# PI_PROVIDER_API — pi api dialect (default "openai-completions"; also accepts
# "anthropic-messages", "openai-responses", …)
# PI_CONTEXT_WINDOW — advertised context window for the model entries
# PI_MAX_TOKENS — max output tokens for the model entries
_CONFIG_KEYS = {"PI_PROVIDER_API", "PI_CONTEXT_WINDOW", "PI_MAX_TOKENS"}
_SYSTEM_APPEND = """\
## Handler agent contract
You are an unattended background agent supervised by handler. No human watches this
terminal, and plain-text questions go nowhere.
- **Questions**: when genuinely blocked on a decision only the operator can make, call
the `ask_operator` tool. The run pauses; the operator's answer arrives when the
session resumes. Never invent credentials or guess at destructive choices.
- **Completion gate**: you are only done when the project's test task (`mise run test`)
passes AND your work is committed AND pushed. Ending the session runs this gate; a
failure sends the blockers back to you fix them rather than re-explaining.
- **Pushing**: `git push` is gated the same way (tests, then a throwaway image build
when the project defines one). A denied push tells you exactly why.
- **Team memory**: search it with `memory_search` before re-deriving how something
works earlier runs may have written it down. Save durable findings (facts,
decisions, gotchas, runbooks) with `memory_save`; connect related notes with
`memory_link`.
"""
def _munged(working_dir: str) -> str:
"""Same path-munging claude uses for its per-cwd session dirs (see
``headless.munged_project_dir``): stable, filesystem-safe, layout-invariant across
workers that share ``PROJECTS_ROOT``."""
return working_dir.replace("/", "-").replace(".", "-")
def pi_dir(working_dir: str) -> Path:
"""The per-working-dir ``PI_CODING_AGENT_DIR`` — pi's whole config universe for this
agent (models, settings, extensions, sessions). Under ``$HOME`` and not the repo
tree, so the clean-tree completion gate never trips on generated files."""
return Path(os.path.expanduser("~")) / ".handler-pi" / _munged(working_dir)
def sessions_dir(working_dir: str) -> Path:
return pi_dir(working_dir) / "sessions"
def session_file(working_dir: str, session_id: str) -> Path:
"""The transcript path handler pre-assigns via ``--session`` (pi creates it)."""
return sessions_dir(working_dir) / f"{session_id}.jsonl"
def bridge_path(working_dir: str) -> Path:
return pi_dir(working_dir) / "extensions" / BRIDGE_FILENAME
def _model_entries(row: dict) -> list[dict]:
env = row.get("env") or {}
try:
context_window = int(env.get("PI_CONTEXT_WINDOW", _DEFAULT_CONTEXT_WINDOW))
except (TypeError, ValueError):
context_window = _DEFAULT_CONTEXT_WINDOW
try:
max_tokens = int(env.get("PI_MAX_TOKENS", _DEFAULT_MAX_TOKENS))
except (TypeError, ValueError):
max_tokens = _DEFAULT_MAX_TOKENS
ids: list[str] = [row["model"]]
small = row.get("small_fast_model")
if small and small not in ids:
ids.append(small)
return [
{
"id": model_id,
"name": model_id,
"reasoning": False,
"input": ["text"],
"cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
"contextWindow": context_window,
"maxTokens": max_tokens,
}
for model_id in ids
]
def write_config(working_dir: str, row: dict, api_key: str) -> Path:
"""Materialize the pi config dir for one launch (spawn and resume both, like
``claude_gen.apply``): provider + default model from the backend row, the bridge
extension, the skills pointers, and the system-prompt append. Regenerated every
launch so a row edit reaches the next run."""
base = pi_dir(working_dir)
(base / "extensions").mkdir(parents=True, exist_ok=True)
sessions_dir(working_dir).mkdir(parents=True, exist_ok=True)
env = row.get("env") or {}
provider = {
"name": PROVIDER_NAME,
"baseUrl": row["base_url"],
"api": env.get("PI_PROVIDER_API") or "openai-completions",
# pi hides models until the provider has *some* credential; local endpoints
# accept anything, and the placeholder never leaks a real secret.
"apiKey": api_key,
"models": _model_entries(row),
}
with open(base / "models.json", "w") as fh:
json.dump({"providers": {PROVIDER_NAME: provider}}, fh, indent=2)
settings = {
"defaultProvider": PROVIDER_NAME,
"defaultModel": row["model"],
# pi implements the same SKILL.md standard as claude: reuse the web-managed
# sync's user-level dir, plus the repo's committed skills (forge roles).
"skills": [
os.path.join(os.path.expanduser("~"), ".claude", "skills"),
os.path.join(working_dir, ".claude", "skills"),
],
}
with open(base / "settings.json", "w") as fh:
json.dump(settings, fh, indent=2)
bridge_src = resources.files("handler.control").joinpath("pi_bridge.ts").read_text()
with open(bridge_path(working_dir), "w") as fh:
fh.write(bridge_src)
with open(base / "APPEND_SYSTEM.md", "w") as fh:
fh.write(_SYSTEM_APPEND)
return base
def agent_env(working_dir: str, row: dict) -> dict[str, str]:
"""The env overrides a pi-harness agent launches with (the pi analog of the
``ANTHROPIC_*`` set): pi's config dir, offline startup (a local endpoint serves no
update checks), and the interpreter the bridge shells hooks out to. Row env extras
win over everything, minus the keys the config writer already consumed."""
env = {
"PI_CODING_AGENT_DIR": str(pi_dir(working_dir)),
"PI_OFFLINE": "1",
"HANDLER_PYTHON": sys.executable,
}
for k, v in (row.get("env") or {}).items():
if str(k) not in _CONFIG_KEYS:
env[str(k)] = str(v)
return env
def build_argv(session_id: str, working_dir: str) -> list[str]:
"""The headless pi invocation — identical for spawn and resume, because the
pre-assigned ``--session`` file either doesn't exist yet (spawn: pi creates it) or
carries the history (resume: pi appends). The prompt is NOT here: it is piped to
stdin by the supervisor (pi has no ``--`` separator, so argv can't safely carry an
arbitrary task). ``--no-extensions`` disables discovery the bridge is the one
extension, loaded explicitly so a managed repo can't inject code into the run."""
s = get_settings()
return [
s.pi_bin,
"-p",
"--mode", "json",
"--no-extensions",
"-e", str(bridge_path(working_dir)),
"--session", str(session_file(working_dir, session_id)),
]
+39 -16
View File
@@ -22,6 +22,7 @@ from . import (
headless, headless,
mise, mise,
models, models,
pi_harness,
reposync, reposync,
settings_gen, settings_gen,
worktree, worktree,
@@ -139,7 +140,7 @@ def spawn(
# Same fail-fast contract as credentials: a selected model backend that is # Same fail-fast contract as credentials: a selected model backend that is
# missing, disabled, or undecryptable refuses the spawn before any row exists. # missing, disabled, or undecryptable refuses the spawn before any row exists.
try: try:
models.resolve_model_env(conn, model_id, require_enabled=True) models.resolve_model(conn, model_id, require_enabled=True)
except models.ModelError as exc: except models.ModelError as exc:
raise SpawnError(str(exc)) from exc raise SpawnError(str(exc)) from exc
@@ -155,9 +156,10 @@ def spawn(
settings_path = settings_gen.write_settings(working_dir) settings_path = settings_gen.write_settings(working_dir)
# Materialize the web-managed Claude config (MCP connectors + user-level skills) # Materialize the web-managed Claude config (MCP connectors + user-level skills)
# so this launch picks up what the operator configured in the dashboard. # so this launch picks up what the operator configured in the dashboard. The skills
# half also feeds pi-harness agents (their settings.json points at the same dir).
claude_gen.apply(working_dir) claude_gen.apply(working_dir)
env = _agent_env(project, agent, token, role=role, mise_init=mise_init) env, harness = _agent_env(project, agent, token, role=role, mise_init=mise_init)
# Verify the pinned forge version, if one is configured. Non-fatal: a version drift # Verify the pinned forge version, if one is configured. Non-fatal: a version drift
# is recorded as a warning rather than blocking the spawn, since not every agent # is recorded as a warning rather than blocking the spawn, since not every agent
@@ -171,6 +173,7 @@ def spawn(
settings_path=settings_path, settings_path=settings_path,
env=env, env=env,
worker_id=worker_id or f"cli-{os.getpid()}", worker_id=worker_id or f"cli-{os.getpid()}",
harness=harness,
) )
agent = {**agent, "forge_note": forge_note, "sync_note": sync_note} agent = {**agent, "forge_note": forge_note, "sync_note": sync_note}
return agent return agent
@@ -183,10 +186,11 @@ def _agent_env(
*, *,
role: str | None = None, role: str | None = None,
mise_init: bool = False, mise_init: bool = False,
) -> dict[str, str]: ) -> tuple[dict[str, str], str]:
"""The environment an agent process (and therefore its hooks) runs with: identity, """The environment an agent process (and therefore its hooks) runs with identity,
``DATABASE_URL``, and resolved forge/git credentials. Shared by spawn and the ``DATABASE_URL``, and resolved forge/git credentials plus which harness launches
headless resume path (a resume is a brand-new process needing the same env).""" it. Shared by spawn and the headless resume path (a resume is a brand-new process
needing the same env)."""
env = { env = {
"HANDLER_PROJECT_ID": project["id"], "HANDLER_PROJECT_ID": project["id"],
"HANDLER_AGENT_NAME": agent["name"], "HANDLER_AGENT_NAME": agent["name"],
@@ -202,13 +206,22 @@ def _agent_env(
# A short read connection lets credential/host resolution consult the forge_hosts # A short read connection lets credential/host resolution consult the forge_hosts
# registry (falling back to the built-in host map when a host has no row). # registry (falling back to the built-in host map when a host has no row).
with connection() as conn: with connection() as conn:
# Agent pinned to a model backend: point the claude binary at it. Resumes are a # Agent pinned to a model backend: point its harness at it. Resumes are a
# brand-new process, so this is what keeps an agent on the backend it started on # brand-new process, so this is what keeps an agent on the backend it started on
# (a since-disabled backend may still finish; a deleted one fails loudly). # (a since-disabled backend may still finish; a deleted one fails loudly).
try: try:
env.update(models.resolve_model_env(conn, agent.get("model_id"))) resolved = models.resolve_model(conn, agent.get("model_id"))
except models.ModelError as exc: except models.ModelError as exc:
raise SpawnError(str(exc)) from exc raise SpawnError(str(exc)) from exc
harness = models.harness_of(resolved)
if harness == "pi":
row, key = resolved
# The pi config dir is regenerated per launch, same contract as the claude
# settings/skills materialization — a row edit reaches the next run.
pi_harness.write_config(agent["working_dir"], row, key)
env.update(pi_harness.agent_env(agent["working_dir"], row))
else:
env.update(models.claude_env(resolved))
env.update(credentials.credential_env(token, project.get("git_remote"), conn)) env.update(credentials.credential_env(token, project.get("git_remote"), conn))
if token: if token:
_install_git_credentials(agent["working_dir"], project.get("git_remote"), conn) _install_git_credentials(agent["working_dir"], project.get("git_remote"), conn)
@@ -217,7 +230,7 @@ def _agent_env(
env.update(reposync.ssh_env(project.get("git_remote"), conn)) env.update(reposync.ssh_env(project.get("git_remote"), conn))
except reposync.SyncError as exc: except reposync.SyncError as exc:
raise SpawnError(str(exc)) from exc raise SpawnError(str(exc)) from exc
return env return env, harness
def _check_forge_version(working_dir: str) -> str | None: def _check_forge_version(working_dir: str) -> str | None:
@@ -278,20 +291,23 @@ def resume(agent: dict, answer: str, worker_id: str | None = None) -> tuple[bool
token = credentials.resolve_for_project(project, conn) token = credentials.resolve_for_project(project, conn)
except credentials.CredentialError as exc: except credentials.CredentialError as exc:
return False, str(exc) return False, str(exc)
env = _agent_env(project, agent, token) env, harness = _agent_env(project, agent, token)
if not agent.get("session_id"): if not agent.get("session_id"):
# Pre-headless agent row (or a spawn that never launched): nothing to --resume. # Pre-headless agent row (or a spawn that never launched): nothing to --resume.
return _resume_reinjected(agent, answer, settings_path, env, worker_id) return _resume_reinjected(agent, answer, settings_path, env, worker_id, harness)
transcript = headless.session_dir(working_dir) / f"{agent['session_id']}.jsonl" if harness == "pi":
transcript = pi_harness.session_file(working_dir, agent["session_id"])
else:
transcript = headless.session_dir(working_dir) / f"{agent['session_id']}.jsonl"
if archive is not None: if archive is not None:
try: try:
headless.materialize_session(working_dir, bytes(archive["archive"])) headless.materialize_session(working_dir, bytes(archive["archive"]), harness=harness)
except (OSError, ValueError) as exc: except (OSError, ValueError) as exc:
return False, f"could not materialize session archive: {exc}" return False, f"could not materialize session archive: {exc}"
elif not transcript.exists(): elif not transcript.exists():
return _resume_reinjected(agent, answer, settings_path, env, worker_id) return _resume_reinjected(agent, answer, settings_path, env, worker_id, harness)
try: try:
run = headless.launch( run = headless.launch(
@@ -301,6 +317,7 @@ def resume(agent: dict, answer: str, worker_id: str | None = None) -> tuple[bool
settings_path=settings_path, settings_path=settings_path,
env=env, env=env,
worker_id=worker_id, worker_id=worker_id,
harness=harness,
) )
except repo.RunConflictError: except repo.RunConflictError:
# Another worker won the race for this resume (two queued resume commands, or a # Another worker won the race for this resume (two queued resume commands, or a
@@ -311,7 +328,12 @@ def resume(agent: dict, answer: str, worker_id: str | None = None) -> tuple[bool
def _resume_reinjected( def _resume_reinjected(
agent: dict, answer: str, settings_path: str, env: dict, worker_id: str agent: dict,
answer: str,
settings_path: str,
env: dict,
worker_id: str,
harness: str = "claude",
) -> tuple[bool, str]: ) -> tuple[bool, str]:
"""Degraded resume: no transcript anywhere, so start a fresh session with the """Degraded resume: no transcript anywhere, so start a fresh session with the
context rebuilt from the DB. Continuity is approximate say so in the event log.""" context rebuilt from the DB. Continuity is approximate say so in the event log."""
@@ -341,6 +363,7 @@ def _resume_reinjected(
settings_path=settings_path, settings_path=settings_path,
env=env, env=env,
worker_id=worker_id, worker_id=worker_id,
harness=harness,
) )
except repo.RunConflictError: except repo.RunConflictError:
return False, "another worker is already running this agent's session" return False, "another worker is already running this agent's session"
+12 -1
View File
@@ -1198,6 +1198,7 @@ def create_claude_model(
model: str, model: str,
api_key_enc: str | None = None, api_key_enc: str | None = None,
small_fast_model: str | None = None, small_fast_model: str | None = None,
harness: str = "claude",
env: dict | None = None, env: dict | None = None,
enabled: bool = True, enabled: bool = True,
) -> dict: ) -> dict:
@@ -1208,6 +1209,7 @@ def create_claude_model(
api_key_enc=api_key_enc, api_key_enc=api_key_enc,
model=model, model=model,
small_fast_model=small_fast_model, small_fast_model=small_fast_model,
harness=harness,
env=env, env=env,
enabled=enabled, enabled=enabled,
created_at=_now(), created_at=_now(),
@@ -1217,7 +1219,16 @@ def create_claude_model(
def update_claude_model(conn: Connection, model_id: int, **fields: Any) -> dict | None: def update_claude_model(conn: Connection, model_id: int, **fields: Any) -> dict | None:
allowed = {"name", "base_url", "api_key_enc", "model", "small_fast_model", "env", "enabled"} allowed = {
"name",
"base_url",
"api_key_enc",
"model",
"small_fast_model",
"harness",
"env",
"enabled",
}
values = {k: v for k, v in fields.items() if k in allowed} values = {k: v for k, v in fields.items() if k in allowed}
if values: if values:
conn.execute( conn.execute(
+4
View File
@@ -424,6 +424,10 @@ claude_models = Table(
Column("api_key_enc", String), # encrypted (HANDLER_SECRET_KEY); never returned by the API Column("api_key_enc", String), # encrypted (HANDLER_SECRET_KEY); never returned by the API
Column("model", String, nullable=False), # ANTHROPIC_MODEL — the id the endpoint serves Column("model", String, nullable=False), # ANTHROPIC_MODEL — the id the endpoint serves
Column("small_fast_model", String), # ANTHROPIC_SMALL_FAST_MODEL; falls back to ``model`` Column("small_fast_model", String), # ANTHROPIC_SMALL_FAST_MODEL; falls back to ``model``
# Which agent binary this backend launches: "claude" (default — the claude binary
# pointed at an Anthropic-API-compatible endpoint) or "pi" (the lightweight pi coding
# agent, which speaks OpenAI-compatible endpoints natively — no translation proxy).
Column("harness", String, nullable=False, server_default="claude"),
Column("env", PortableJSON), # extra env overrides (timeouts, max tokens, …), merged last Column("env", PortableJSON), # extra env overrides (timeouts, max tokens, …), merged last
Column("enabled", Boolean, nullable=False, server_default="1"), Column("enabled", Boolean, nullable=False, server_default="1"),
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()), Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
+5 -2
View File
@@ -169,8 +169,11 @@ def handle_stop(conn: Connection, ident: Identity, hook_input: HookInput) -> dic
) )
# A done agent's checkmark carries its own closing message — the substance the # A done agent's checkmark carries its own closing message — the substance the
# dashboard shows; a blocked one carries the blockers (the next turn will re-capture # dashboard shows; a blocked one carries the blockers (the next turn will re-capture
# the narrative once the gate clears). # the narrative once the gate clears). A harness that passes the text directly (pi)
final_text = _final_assistant_text(hook_input.transcript_path) # wins over the claude-transcript parse.
final_text = hook_input.final_assistant_text or _final_assistant_text(
hook_input.transcript_path
)
where_it_stopped = final_text[:4000] if not blockers and final_text else summary where_it_stopped = final_text[:4000] if not blockers and final_text else summary
log_id = repo.insert_log_entry( log_id = repo.insert_log_entry(
+10
View File
@@ -52,6 +52,16 @@ class HookInput:
def stop_hook_active(self) -> bool: def stop_hook_active(self) -> bool:
return bool(self.raw.get("stop_hook_active")) return bool(self.raw.get("stop_hook_active"))
@property
def final_assistant_text(self) -> str | None:
"""The agent's closing message, when the harness passes it directly (the pi
bridge does pi session files aren't claude-transcript-shaped, so the
transcript fallback can't parse them)."""
value = self.raw.get("final_assistant_text")
if isinstance(value, str) and value.strip():
return value
return None
@property @property
def reason(self) -> str | None: def reason(self) -> str | None:
return self.raw.get("reason") return self.raw.get("reason")
+43 -3
View File
@@ -1,10 +1,50 @@
"""``python -m handler.mcpserver`` — run the bundled handler-memory MCP server.""" """``python -m handler.mcpserver`` — run the bundled handler-memory MCP server.
``--call <tool>`` runs one memory tool directly instead: JSON arguments on stdin, JSON
result on stdout, exit 1 with the error on stderr for a failed call. This is the seam
the pi harness's bridge extension uses (pi has no MCP by design), sharing the exact
tool implementations and the same identity-from-environment contract the MCP
server dispatches to.
"""
from __future__ import annotations from __future__ import annotations
import json
import os
import sys import sys
from . import serve from . import MemoryServer, serve
def call_tool(name: str, stdin=None, stdout=None) -> int:
stdin = stdin or sys.stdin
stdout = stdout or sys.stdout
raw = stdin.read()
try:
args = json.loads(raw) if raw.strip() else {}
except ValueError:
print("invalid JSON arguments on stdin", file=sys.stderr)
return 2
agent_id_raw = os.environ.get("HANDLER_AGENT_ID")
server = MemoryServer(
agent_id=int(agent_id_raw) if agent_id_raw else None,
project_id=os.environ.get("HANDLER_PROJECT_ID") or None,
)
try:
payload = server.call_tool(name, args if isinstance(args, dict) else {})
except Exception as exc: # noqa: BLE001 - the caller renders this as a tool error
print(f"error: {exc}", file=sys.stderr)
return 1
print(json.dumps(payload, ensure_ascii=False), file=stdout, flush=True)
return 0
def main(argv: list[str] | None = None) -> int:
argv = sys.argv[1:] if argv is None else argv
if len(argv) >= 2 and argv[0] == "--call":
return call_tool(argv[1])
return serve()
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(serve()) sys.exit(main())
@@ -0,0 +1,37 @@
"""model backends: harness selection (claude | pi)
Revision ID: 0015_model_harness
Revises: 0014_agent_memory
Create Date: 2026-08-12
Adds ``claude_models.harness`` which agent binary a backend row launches. ``claude``
(the default, and what every existing row becomes) keeps the current behavior: the
``claude`` binary pointed at an Anthropic-API-compatible endpoint. ``pi`` launches the
same agent run through the lightweight `pi` coding agent instead, which speaks the
OpenAI Completions API natively so a local vLLM/llama.cpp/Ollama endpoint needs no
Anthropic-translation proxy in front of it.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0015_model_harness"
down_revision: str | None = "0014_agent_memory"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"claude_models",
sa.Column("harness", sa.String(), nullable=False, server_default="claude"),
)
def downgrade() -> None:
with op.batch_alter_table("claude_models", schema=None) as batch_op:
batch_op.drop_column("harness")
+3 -2
View File
@@ -135,14 +135,15 @@ def fake_launch(monkeypatch):
calls: list[dict] = [] calls: list[dict] = []
def launch(agent, *, kind, prompt, settings_path, env, worker_id, on_exit=None): def launch(agent, *, kind, prompt, settings_path, env, worker_id, on_exit=None,
harness="claude"):
session_id = agent.get("session_id") if kind == "resume" else f"fake-sid-{len(calls) + 1}" session_id = agent.get("session_id") if kind == "resume" else f"fake-sid-{len(calls) + 1}"
with connection() as conn: with connection() as conn:
run = repo.create_run(conn, agent["id"], session_id, worker_id, kind) run = repo.create_run(conn, agent["id"], session_id, worker_id, kind)
repo.set_agent_session(conn, agent["id"], session_id, worker_id) repo.set_agent_session(conn, agent["id"], session_id, worker_id)
calls.append( calls.append(
{"agent": agent, "kind": kind, "prompt": prompt, "settings_path": settings_path, {"agent": agent, "kind": kind, "prompt": prompt, "settings_path": settings_path,
"env": env, "worker_id": worker_id, "run": run} "env": env, "worker_id": worker_id, "run": run, "harness": harness}
) )
return run return run
Vendored Executable
+126
View File
@@ -0,0 +1,126 @@
#!/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())
+372
View File
@@ -0,0 +1,372 @@
"""The pi harness: config generation, the launch/resume paths against the fake ``pi``
binary, event normalization, and the API/spawn plumbing that selects it.
Same testing philosophy as the claude runner (``test_headless_run``): real
subprocesses, real threads, real SQLite ``fake_pi.py`` stands in for the binary via
the ``pi_bin`` setting and emits genuine ``--mode json`` events, so what lands in the
DB is exactly what the API/UI will read. The bridge extension itself is TypeScript and
runs inside real pi, so here it is asserted as an artifact (installed, wired into
argv); its hook contract is the same ``python -m handler.hooks`` surface the hook tests
already cover.
"""
from __future__ import annotations
import json
import time
from pathlib import Path
import pytest
from handler.control import headless, models, pi_harness, spawn
from handler.db import repository as repo
from handler.db.engine import get_engine
REPO_ROOT = Path(__file__).resolve().parents[1]
FAKE_PI = str(REPO_ROOT / "tests" / "fixtures" / "fake_pi.py")
@pytest.fixture
def pi_env(env, monkeypatch):
from handler import config
monkeypatch.setenv("PI_BIN", FAKE_PI)
config.get_settings.cache_clear()
yield env
config.get_settings.cache_clear()
def _pi_row(**overrides):
row = {
"name": "qwen-local",
"base_url": "http://127.0.0.1:8000/v1",
"model": "qwen3-coder-30b",
"small_fast_model": "qwen3-1.7b",
"harness": "pi",
"env": {},
}
row.update(overrides)
return row
def _make_agent(tmp_path, name="p1", model_id=None):
working_dir = tmp_path / "projects" / "p" / name
working_dir.mkdir(parents=True)
with get_engine().begin() as conn:
if repo.get_project(conn, "p") is None:
repo.create_project(conn, "p", str(tmp_path / "projects" / "p"))
agent = repo.create_agent(conn, "p", name, str(working_dir), model_id=model_id)
return agent
def _wait_for(predicate, timeout=20.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
result = predicate()
if result:
return result
time.sleep(0.1)
return None
def _finished_run(run_id):
def check():
with get_engine().begin() as conn:
run = repo.get_run(conn, run_id)
return run if run["status"] != "running" else None
return check
# --- config generation -------------------------------------------------------------------
def test_write_config_renders_provider_and_bridge(pi_env, tmp_path):
wd = str(tmp_path / "wd")
base = pi_harness.write_config(wd, _pi_row(), "sk-local-123")
provider = json.loads((base / "models.json").read_text())["providers"]["handler"]
assert provider["baseUrl"] == "http://127.0.0.1:8000/v1"
assert provider["api"] == "openai-completions"
assert provider["apiKey"] == "sk-local-123"
assert [m["id"] for m in provider["models"]] == ["qwen3-coder-30b", "qwen3-1.7b"]
settings = json.loads((base / "settings.json").read_text())
assert settings["defaultProvider"] == "handler"
assert settings["defaultModel"] == "qwen3-coder-30b"
# Skills parity: the web-managed sync's user dir plus the repo's committed skills.
assert any(s.endswith(".claude/skills") for s in settings["skills"])
assert any(s.startswith(wd) for s in settings["skills"])
bridge = pi_harness.bridge_path(wd)
assert bridge.exists()
text = bridge.read_text()
# The bridge is the hooks adapter — it must shell to the hook dispatcher and the
# memory tool CLI, and register the question-deferral tool.
assert "handler.hooks" in text
assert "handler.mcpserver" in text
assert "ask_operator" in text
assert (base / "APPEND_SYSTEM.md").read_text().strip()
def test_write_config_row_env_tunes_provider(pi_env, tmp_path):
wd = str(tmp_path / "wd")
row = _pi_row(
env={
"PI_PROVIDER_API": "anthropic-messages",
"PI_CONTEXT_WINDOW": "32000",
"PI_MAX_TOKENS": "4096",
"SOME_VAR": "yes",
}
)
base = pi_harness.write_config(wd, row, "k")
provider = json.loads((base / "models.json").read_text())["providers"]["handler"]
assert provider["api"] == "anthropic-messages"
assert provider["models"][0]["contextWindow"] == 32000
assert provider["models"][0]["maxTokens"] == 4096
env = pi_harness.agent_env(wd, row)
# Config-only keys are consumed by the writer, not leaked into the process env;
# everything else passes through, and the defaults are present.
assert "PI_PROVIDER_API" not in env
assert env["SOME_VAR"] == "yes"
assert env["PI_OFFLINE"] == "1"
assert env["PI_CODING_AGENT_DIR"] == str(pi_harness.pi_dir(wd))
assert env["HANDLER_PYTHON"]
def test_build_argv_wires_bridge_and_session(pi_env, tmp_path):
wd = str(tmp_path / "wd")
argv = pi_harness.build_argv("sid-1", wd)
assert argv[1:5] == ["-p", "--mode", "json", "--no-extensions"]
assert argv[argv.index("-e") + 1] == str(pi_harness.bridge_path(wd))
assert argv[argv.index("--session") + 1] == str(pi_harness.session_file(wd, "sid-1"))
# No prompt in argv: pi has no ``--`` separator, so the task travels on stdin.
assert argv[-1] == str(pi_harness.session_file(wd, "sid-1"))
# --- model resolution --------------------------------------------------------------------
def test_resolve_model_and_harness(conn):
row = repo.create_claude_model(
conn, "qwen-pi", "http://127.0.0.1:8000/v1", "qwen3", harness="pi"
)
resolved = models.resolve_model(conn, row["id"])
assert models.harness_of(resolved) == "pi"
assert models.harness_of(None) == "claude"
# A pi row produces no ANTHROPIC_* env — its config is files, not env.
assert models.resolve_model_env(conn, row["id"]) == {}
# claude rows are unchanged.
claude_row = repo.create_claude_model(conn, "qwen-claude", "http://llm:4000", "qwen3")
env = models.resolve_model_env(conn, claude_row["id"])
assert env["ANTHROPIC_BASE_URL"] == "http://llm:4000"
# --- headless runs against the fake pi binary ---------------------------------------------
def test_pi_spawn_streams_events_and_completes(pi_env, tmp_path):
agent = _make_agent(tmp_path)
pi_harness.write_config(agent["working_dir"], _pi_row(), "k")
run = headless.launch(
agent, kind="spawn", prompt="build the thing",
settings_path=str(tmp_path / "s.json"), env={}, worker_id="w1", harness="pi",
)
finished = _wait_for(_finished_run(run["id"]))
assert finished is not None, "run never finished"
assert finished["status"] == "completed"
assert finished["exit_code"] == 0
# agent_end normalized into the result the run row stores.
assert finished["result"]["is_error"] is False
assert finished["result"]["harness"] == "pi"
with get_engine().begin() as conn:
events = repo.list_agent_events(conn, agent["id"])
updated = repo.get_agent_by_id(conn, agent["id"])
archive = repo.get_session_archive(conn, agent["id"])
types = [e["type"] for e in events]
assert "session" in types and "agent_end" in types and "message_end" in types
# last_output comes from assistant message_end events (user ones don't count).
assert updated["last_output"] == "working on: build the thing"
assert updated["status"] == "blocked" # no hooks ran in the fake — not done
assert updated["session_id"] == run["session_id"]
# The single-file pi session was archived for cross-worker resume.
assert archive is not None
assert pi_harness.session_file(agent["working_dir"], run["session_id"]).exists()
def test_pi_failed_run_records_error_result(pi_env, tmp_path, monkeypatch):
monkeypatch.setenv("FAKE_PI_MODE", "error")
agent = _make_agent(tmp_path, "p-err")
run = headless.launch(
agent, kind="spawn", prompt="boom",
settings_path=str(tmp_path / "s.json"), env={}, worker_id="w1", harness="pi",
)
finished = _wait_for(_finished_run(run["id"]))
assert finished["status"] == "failed"
assert finished["exit_code"] == 1
assert finished["result"]["is_error"] is True
with get_engine().begin() as conn:
events = repo.list_agent_events(conn, agent["id"])
assert repo.get_agent_by_id(conn, agent["id"])["status"] == "blocked"
raw = next(e for e in events if e["type"] == "raw")
assert "this is not json" in raw["payload"]["line"]
def test_pi_cancel_terminates_hanging_run(pi_env, tmp_path, monkeypatch):
monkeypatch.setenv("FAKE_PI_MODE", "hang")
agent = _make_agent(tmp_path, "p-hang")
run = headless.launch(
agent, kind="spawn", prompt="hang",
settings_path=str(tmp_path / "s.json"), env={}, worker_id="w1", harness="pi",
)
_wait_for(lambda: _events_count(agent["id"]) >= 1)
with get_engine().begin() as conn:
assert repo.request_run_cancel(conn, run["id"]) is True
finished = _wait_for(_finished_run(run["id"]), timeout=30.0)
assert finished is not None and finished["status"] == "canceled"
def _events_count(agent_id):
with get_engine().begin() as conn:
return len(repo.list_agent_events(conn, agent_id))
def test_pi_cross_worker_resume_materializes_single_file(pi_env, tmp_path, monkeypatch):
"""Worker B resumes a pi session it never ran, from the DB archive alone — the pi
analog of the claude linchpin test, on the single-file session layout."""
with get_engine().begin() as conn:
model = repo.create_claude_model(
conn, "qwen-pi", "http://127.0.0.1:8000/v1", "qwen3", harness="pi"
)
agent = _make_agent(tmp_path, "p-resume", model_id=model["id"])
pi_harness.write_config(agent["working_dir"], _pi_row(), "k")
run = headless.launch(
agent, kind="spawn", prompt="first pass",
settings_path=str(tmp_path / "s.json"), env={}, worker_id="worker-a", harness="pi",
)
assert _wait_for(_finished_run(run["id"]))["status"] == "completed"
# "Worker B": a clean HOME — no pi config, no session file.
other_home = tmp_path / "worker-b-home"
other_home.mkdir()
monkeypatch.setenv("HOME", str(other_home))
# The fake proves materialization: it exits 3 when the session file is absent.
monkeypatch.setenv("FAKE_PI_EXPECT_HISTORY", "1")
with get_engine().begin() as conn:
agent = repo.get_agent_by_id(conn, agent["id"])
ok, detail = spawn.resume(agent, "the operator's answer", worker_id="worker-b")
assert ok, detail
with get_engine().begin() as conn:
resumed = repo.get_latest_run(conn, agent["id"])
assert resumed["kind"] == "resume"
finished = _wait_for(_finished_run(resumed["id"]))
assert finished["status"] == "completed", f"exit={finished['exit_code']}"
assert finished["session_id"] == run["session_id"] # same session, continued
# Resume regenerated the pi config under worker B's HOME (row edits reach resumes).
assert pi_harness.pi_dir(agent["working_dir"]).exists()
# --- spawn plumbing ------------------------------------------------------------------------
def test_spawn_with_pi_model_launches_pi_harness(env, fake_launch, tmp_path):
root = tmp_path / "projects" / "proj"
root.mkdir(parents=True)
(root / ".mise.toml").write_text("[tasks.test]\nrun = 'pytest'\n")
with get_engine().begin() as conn:
repo.create_project(conn, "proj", str(root))
model = repo.create_claude_model(
conn, "qwen-pi", "http://127.0.0.1:8000/v1", "qwen3", harness="pi"
)
agent = spawn.spawn("proj", "worker", task="do it", model_id=model["id"])
call = fake_launch[0]
assert call["harness"] == "pi"
assert call["env"]["PI_CODING_AGENT_DIR"] == str(pi_harness.pi_dir(agent["working_dir"]))
assert "ANTHROPIC_BASE_URL" not in call["env"]
# The config artifacts were materialized before launch.
assert pi_harness.bridge_path(agent["working_dir"]).exists()
provider = json.loads(
(pi_harness.pi_dir(agent["working_dir"]) / "models.json").read_text()
)["providers"]["handler"]
assert provider["baseUrl"] == "http://127.0.0.1:8000/v1"
def test_spawn_with_claude_model_still_launches_claude(env, fake_launch, tmp_path):
root = tmp_path / "projects" / "proj2"
root.mkdir(parents=True)
(root / ".mise.toml").write_text("[tasks.test]\nrun = 'pytest'\n")
with get_engine().begin() as conn:
repo.create_project(conn, "proj2", str(root))
model = repo.create_claude_model(conn, "qwen-claude", "http://llm:4000", "qwen3")
spawn.spawn("proj2", "worker", task="do it", model_id=model["id"])
call = fake_launch[0]
assert call["harness"] == "claude"
assert call["env"]["ANTHROPIC_BASE_URL"] == "http://llm:4000"
assert "PI_CODING_AGENT_DIR" not in call["env"]
# --- API -------------------------------------------------------------------------------------
def test_model_api_harness_round_trip(client, auth):
r = client.post(
"/claude/models",
json={
"name": "qwen-pi",
"base_url": "http://127.0.0.1:8000/v1",
"model": "qwen3",
"harness": "pi",
},
headers=auth,
)
assert r.status_code == 201
assert r.json()["harness"] == "pi"
# Default stays claude, and junk is rejected with a clean 422.
r = client.post(
"/claude/models",
json={"name": "plain", "base_url": "http://llm:4000", "model": "m"},
headers=auth,
)
assert r.json()["harness"] == "claude"
r = client.post(
"/claude/models",
json={"name": "bad", "base_url": "http://x", "model": "m", "harness": "aider"},
headers=auth,
)
assert r.status_code == 422
model_id = client.get("/claude/models", headers=auth).json()[0]["id"]
r = client.patch(f"/claude/models/{model_id}", json={"harness": "pi"}, headers=auth)
assert r.status_code == 200 and r.json()["harness"] == "pi"
# --- hook input: harness-provided final text --------------------------------------------------
def test_stop_checkpoint_prefers_harness_final_text(env, monkeypatch, tmp_path):
"""The pi bridge passes the closing message directly (pi session files aren't
claude-transcript-shaped); the checkpoint must prefer it over the transcript parse."""
from handler.hooks import checkpoint, verify
from handler.hooks.context import HookInput, Identity
monkeypatch.setattr(verify, "run_test", lambda cwd: (True, "ok"))
agent = _make_agent(tmp_path, "hooked")
ident = Identity(agent["id"], "p", "hooked", working_dir=agent["working_dir"])
hook_input = HookInput(
raw={"session_id": "s1", "final_assistant_text": "shipped the feature"},
event="stop",
)
with get_engine().begin() as conn:
result = checkpoint.handle_stop(conn, ident, hook_input)
cm = repo.get_checkmark(conn, agent["id"])
assert result == {}
assert cm["status"] == "done"
assert cm["where_it_stopped"] == "shipped the feature"