Skip to content

Proposal: Add Pi coding agent as a first-class agent profile #120

Description

@kelvinq

Thank you, Tim, for open-webui and now for computer. Both have been a joy to use and to build for.

Opening as an issue because PR creation is limited to collaborators. The change is complete and ready on my fork: kelvinq/computer:feat/pi-coding-agent-pr (rebased on v0.8.8, 101 tests passing). Happy to open it as a PR if you enable it, or feel free to pull the branch.

Add Pi coding agent as a first-class agent profile

Rebased on origin/main @ v0.8.8. 22 files, +4862 / −1767 (the large delta is uv.lock: a uv format-revision rewrite, not new runtime deps). No runtime dependency: Pi is invoked as a CLI subprocess, so the only new packages are the dev-group test tools. 102 tests, 101 passing.

Adds Pi (@earendil-works/pi-coding-agent) as a 7th first-class coding agent, in the same one-file-per-agent pattern as codex and cline. Admins add it from the agents panel like any other agent. Users pick a Pi model in the model picker and chat with it through the existing task runner. Unlike the single-model agents, Pi exposes a whole roster of models across providers through one profile (agent:pi · 30 models on this box).

Summary

Pi speaks JSON-RPC over stdio behind a --mode rpc flag. This change adds a PiRpcClient stdio transport and a run_pi_agent async generator that adapts Pi's event stream onto cptr's existing AgentEvent types, then wires Pi into registration, dispatch, detection, and the admin UI. It sits alongside codex, claude_code, cursor, grok, opencode, and cline.

The integration lives entirely in one module, cptr/utils/agents/pi.py (626 lines), following the single-file-per-agent convention. Pi is a CLI peer of codex, claude, and cline, not a Python package, so this adds no runtime dependency.

Changes

22 files against origin/main (+4862 / −1767; the lockfile accounts for the bulk, see Dev deps below):

  • cptr/utils/agents/pi.py (new, 626 lines) — PiRpcClient (stdio JSONL transport), event-translation helpers mapping Pi RPC events to cptr AgentEvents, and the public run_pi_agent async generator.
  • Agent wiringmodels.py ("pi" in _VALID_AGENTS, a default profile, a default row, a launch_args-trimming normaliser branch), chat_task.py (runners["pi"] = run_pi_agent), model_targets.py ("pi" in the AgentModelTarget.agent Literal), detection.py (a pi branch: pi --mode rpc --help probe then RPC model listing, with a pi --list-models fallback).
  • Admin UIadmin.ts ("pi" in the AgentType union), AgentProfileModal.svelte (Pi in the type dropdown + a launch_args field), Agents.svelte (label/command maps).
  • Teststests/agents/ (7 files, 99 unit tests, incl. a fake_pi_rpc.py JSONL double that drives tool_execution_update with accumulated partialResult), tests/integration/test_pi_e2e.py (3 real-binary tests).
  • DocsREADME.md (Pi listed with the other coding agents), CHANGELOG.md (Unreleased entry, matching the Cline precedent).
  • Dev depspyproject.toml adds pytest + pytest-asyncio to the dev group with a [tool.pytest.ini_options] block. The uv.lock diff is large (+4862 / −1767) but its substance is small: it adds five packages (iniconfig, packaging, pluggy, pytest, pytest-asyncio) and changes no existing version. The line churn is a uv lockfile format-revision rewrite (the local resolver emits the newer format), not a dependency change. If the project pins a specific uv version for lockfile generation, re-running it under that version collapses the diff to a handful of lines.

How it works

Pi's --mode rpc starts a JSON-RPC server on stdio that splits records on \n only and rejects Unicode line separators. PiRpcClient enforces that contract with manual buffer splitting and a small async read task. (One spec detail worth noting: under --mode rpc, help text goes to stderr because stdout is reserved for JSONL. The detection probe handles this.)

The runner is a thin adapter: build a client, start it, call prompt, forward each event through translate_event. Pi message_update events map to AgentTextDelta and AgentReasoningDelta (reasoning blocks close implicitly, the same path every other agent uses). The tool_execution_start and tool_execution_end events map to AgentToolUpdate. The bash tool name is renamed to run_command so it renders like every other agent's shell tool.

Models come from get_available_models over RPC. The detection layer probes RPC first and falls back to pi --list-models. Resume is supported via session_id stored in chat.meta.agent_sessions and proven by the e2e TestPiE2EResume round-trip.

Streaming tool output

Pi's RPC spec carries the accumulated output in tool_execution_update.partialResult, not the new chunk. cptr's chat_task appends AgentToolOutputDelta.delta to existing output on the consumer side, so a naive forwarder concatenates "a" + "a b" + "a b c" into "aa ba b c". Output grows quadratically with each update and is scrambled. This is the integration's load-bearing correctness problem.

The adapter handles it in run_pi_agent: it tracks the last-forwarded text per call_id and emits only the new suffix as the delta. Because Pi models updates as replace-your-display, if a later partialResult is not a prefix-extension of the previous one the adapter re-emits the full new text and resets the per-call counter (so a truncation can't silently go mute or slice at a stale offset). The consumer still appends, so a genuine truncation renders as old + new concatenated rather than a clean replace. A true display-replace would need a consumer-side change and is out of scope here.

Covered by:

  • tests/agents/test_pi_runner.py::TestStreamingToolOutput (monotonic deltas concatenate exactly once)
  • tests/agents/test_pi_runner.py::TestStreamingToolOutputTruncationSafety (non-monotonic and shorter-prefix resets)
  • tests/integration/test_pi_e2e.py::TestPiE2EStreamingToolOutput (a 12-line bash printf against the real binary: the test whose absence would have let the bug ship)

Tests

$ uv run pytest tests/ -q
.......s.................................................................. [ 70%]
..............................                                           [100%]
101 passed, 1 skipped in 48.03s

102 collected (99 in tests/agents/, 3 in tests/integration/). The single skip is a deliberately slow timeout test (test_request_timeout, off by default, run with -k test_request_timeout).

Real-RPC integration tests against the pi binary on PATH:

$ uv run pytest tests/integration/test_pi_e2e.py -v
TestPiE2EBasic::test_simple_prompt_yields_events PASSED
TestPiE2EResume::test_session_resume_across_turns PASSED
TestPiE2EStreamingToolOutput::test_bash_streaming_no_duplicated_lines PASSED
3 passed

The integration module uses pytestmark = pytest.mark.skipif(not _AUTH_OK, ...), where _AUTH_OK runs a tiny pi -p probe against an authed provider, so CI without secrets stays green. Registration is asserted at three layers by tests/agents/test_pi_registration.py (callable runner, AgentModelTarget.agent Literal membership, and the "pi": run_pi_agent dispatch entry).

Compatibility

The change adds a new agent kind and renames or removes nothing. Existing agents.profiles entries parse unchanged through normalize_agent_profiles. The default profile list grows from six to seven. The RPC client is gated behind profile["agent"] == "pi", so other code paths never import PiRpcClient. The dispatch change is one new key in one map inside _run_agent_target, and no conditionals changed elsewhere.

Trade-offs

  • Permission model. Pi uses its own permission model and does not consume cptr's tool_approval_mode, sandbox_mode, or permission_mode flags (those translate into agent-specific CLI args, and Pi's RPC interface has no equivalent). This is intentional. The admin modal therefore does not surface those fields for Pi: admins configure Pi's approval behaviour through Pi's config and launch_args. The flags are still accepted and ignored by the normaliser, so existing profiles carrying them stay forward-compatible.
  • Framing. Pi's RPC mandates \n-framed JSONL and rejects Unicode separators, and the client splits manually rather than calling readline (covered by test_pi_client.py). Detection has a 5-second RPC timeout with a --list-models fallback when RPC is unavailable. Bad provider keys or a missing binary surface immediately: PiRpcClient ring-buffers the last 20 stderr lines and folds them into the AgentError on prompt failure, timeout, or start failure (covered by TestStderrCapture).

Visuals

Agents panel — Pi registered (agent:pi · 30 models, Ready, green dot) alongside Claude Code, rendering identically to the other agent rows. The footer reads 2 profiles.

Image

Add-profile modal — the agent-type dropdown offers Pi. Selecting it fills Name=Pi, Command placeholder=pi, and surfaces the Launch arguments field, so Pi profiles are addable and editable from the panel like any other agent.

Image

Checklist

  • Rebased onto origin/main at v0.8.8
  • README and CHANGELOG updated
  • Admin panel + add-profile modal screenshots attached
  • Full suite passes (101 passed, 1 skipped)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions