Files
ai-agent/runner/README.md
Gabriel Vidal 924883eb35 feat(ai-agent): pi harness parity — CC-schema tool remap, real pi cost, harness badge
- runner: map pi's lowercase built-in tool calls (read/write/edit/bash/grep/
  find/ls, path/edits[]) to Claude-Code names+args (Read/Write/Edit|MultiEdit/
  Bash/Grep/Glob/LS, file_path/old_string/new_string; bash timeout s->ms), so
  cost-by-tool buckets, byToolSub, auto project/service tags, bash cards and
  Read image previews all light up for pi sessions; tool-result image blocks
  become an [image] placeholder; costUSD only written when pi reports one
- backend: cost accumulates per assistant record and a record's runner-mirrored
  costUSD (pi's real provider cost) beats the price-table estimate; bare
  (local EVOX2) qwen ids price at 0; PARSER_VERSION 19 backfills the archive
- frontend: HarnessBadge (teal pi pill) on conversation cards + detail header;
  the claude.ai deep link is hidden for pi sessions

Verified end-to-end: live pi run through the new runner produced remapped
transcripts; parser smoke-tested on old pi + large claude archives.
2026-07-13 22:12:36 +02:00

4.5 KiB

pi runner — the ai-agent's second harness

Runs viewer-spawned sessions on pi (@earendil-works/ pi-coding-agent) instead of the claude CLI, with open models: Qwen 3.6 via OpenRouter (hosted) or the EVOX2 box's LM Studio (local, free).

How harness switching works

The model chip in the composer is the harness switch. /api/models returns every pickable model tagged with its harness (claude | pi); picking a qwen chip routes the session through this runner, picking a Claude family runs claude exactly as before. The chain:

composer chip → POST /api/spawn {model, harness}
             → sidecar /spawn  → harness == "pi" ? RUNNER_BIN : CLAUDE_BIN
             → runner: pi --mode json --session-id <uuid> …
             → transcript mirrored to ~/.pi-runner/transcripts/<cwd>/<uuid>.jsonl
             → container watches it (PI_TRANSCRIPTS_DIR mount) → SSE → viewer
  • The spawn stamps harness into the conversation metadata sidecar, so a resume with no model picked continues on the same harness (backend fallback); picking a chip from the other harness switches mid-conversation.
  • The runner accepts the same flag shape as claude (-p --session-id/--resume --model), so the sidecar treats both harnesses identically — pidfiles, launch probe, SIGINT interrupt, reused idempotency all work unchanged.
  • Transcript compatibility is the design: the runner translates pi's message_end events (user / assistant / toolResult) into Claude-Code-schema JSONL, so the viewer's parser, cost-by-tool, SSE streaming, DONE detection and lifecycle checks need no pi-specific code. Adding a third harness = another runner that writes the same schema.
  • Built-in tool calls are remapped to Claude's shape (mapToolCall in cli.mjs): pi's lowercase read/write/edit/bash/grep/find/ls with path/edits[] become Read/Write/Edit|MultiEdit/Bash/Grep/Glob/LS with file_path/old_string/new_string (bash timeout s→ms). That's what makes the viewer's cost-by-tool buckets, per-extension sub-breakdowns, auto project/service tags, bash cards and Read image previews light up for pi sessions. Unknown (extension) tools pass through and render as generic cards.
  • Real cost, not estimates: each assistant record carries pi's own per-message provider cost as costUSD (omitted when pi reports none). The parser (backend/conversations.py) prefers it over its price table — so OpenRouter runs show the billed amount and free local EVOX2 runs cost $0 (bare qwen ids also price at 0 in the fallback table).

Model → provider routing

model id provider
qwen/qwen3.6-35b-a3b (vendor prefix) OpenRouter (OPENROUTER_API_KEY, auto-loaded from ~/homelab/.env.claude)
qwen3.6-35b-a3b (bare) EVOX2 LM Studio (~/.pi/agent/models.json evox2 provider; box is WoL-woken automatically)

The pickable list lives in backend/models.py (PI_MODELS_DEFAULT, override with PI_MODELS_JSON).

Permissions

extensions/folder-permissions.ts blocks tool calls outside the session's scope (PI_PERM_SCOPE, default = the run's cwd): write/edit only inside the scope, reads inside PI_PERM_READ roots (default $HOME) + system prefixes, bash commands may not reference paths outside the readable roots, and a deny-always list protects .env/credentials/.ssh from writes. This is stricter than the claude harness's bypassPermissions — pi itself has no permission system, so the extension is the enforcement point.

pi session state

pi sessions persist under ~/.pi-runner/sessions/ keyed by the same UUID the viewer uses (--session-dir + --session-id), so resume is stateless: the same id simply reopens the session. Skills load from ~/.claude/skills via ~/.pi/agent/settings.json; pi reads the repo CLAUDE.md natively.

Files

  • cli.mjs — the runner (arg parsing, EVOX2 wake, pi spawn, transcript writer).
  • extensions/folder-permissions.ts — the permission gate.
  • Env knobs: PI_BIN, PI_RUNNER_ROOT, RUNNER_DEFAULT_MODEL, RUNNER_PERM_EXT, RUNNER_ENV_FILE, EVOX2_URL/EVOX2_MAC/EVOX2_BCAST, and on the sidecar side RUNNER_BIN, SIDECAR_PI_MODEL.

Setup (already done on this host)

cd services/ai-agent/runner && npm install --ignore-scripts
# ~/.pi/agent/models.json     — evox2 provider (LM Studio token inside)
# ~/.pi/agent/settings.json   — {"skills": ["~/.claude/skills"]}
# OPENROUTER_API_KEY          — in ~/homelab/.env.claude