Skip to content

Commit 608d562

Browse files
authored
feat: add pi and omp agent vendors (#639)
* feat: add pi and omp agent vendors Support Pi (pi) and Oh My Pi (omp) as fresh-run agent vendors, following the grok-build pattern: config/CLI allowlists, spawn argv defaults, model catalog probe, dashboard selectors, and docs. Native resume and interactive takeover remain unsupported. * fix: preserve opaque Pi model identifiers Parse rows by the aligned pi model table structure instead of enforcing a lowercase identifier alphabet, and cover uppercase/colon values. Generated-By: looper 0.11.8 (runner=fixer, agent=codex)
1 parent f7a7a25 commit 608d562

26 files changed

Lines changed: 695 additions & 27 deletions

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ The loops compose: planner hands off to reviewer↔fixer, reviewer↔fixer hands
3535
- 🐙 **The forge is the source of truth.** Issues, PRs, labels, reviews, and assignees *are* the workflow — no external task tracker, no YAML pipeline. GitHub is fully supported; Forgejo supports planner, worker, native reviewer requests/reviews, and summary-comment compatibility flows.
3636
- 🛰️ **Many repos, one daemon.** Register your projects once — Looper watches them together and runs loops across repos in parallel.
3737
- 🌳 **Parallel-safe by design.** Every loop runs in its own git worktree, so agents work across issues and repos without stepping on each other.
38-
- 🤖 **Bring your own agent.** Pluggable vendor layer (`opencode`, `claude-code`, `codex`, `cursor-cli`, `grok-build`) so you're not locked into one model or CLI.
38+
- 🤖 **Bring your own agent.** Pluggable vendor layer (`opencode`, `claude-code`, `codex`, `cursor-cli`, `grok-build`, `pi`, `omp`) so you're not locked into one model or CLI.
3939
- 🧰 **Local, inspectable, stoppable.** Daemon on your machine, thin CLI to drive it. `looper ps`, `looper logs`, `looper stop` — no hosted control plane.
4040

4141
## Quick start
@@ -120,7 +120,7 @@ looper takeover acme/repo#42
120120
- starts a continuous **reviewer** loop and **fixer** loop on the PR, which ping-pong until the review comes back clean;
121121
- with `--merge`, lets the reviewer enable GitHub auto-merge so the PR merges itself once it's approved and green.
122122

123-
It picks your coding agent automatically when exactly one of `claude` / `codex` / `grok` / `opencode` is on `PATH`, prompts when it's ambiguous, and accepts `--agent-vendor` / `--yes` for non-interactive (agent-driven) runs:
123+
It picks your coding agent automatically when exactly one of `claude` / `codex` / `grok` / `opencode` / `pi` / `omp` is on `PATH`, prompts when it's ambiguous, and accepts `--agent-vendor` / `--yes` for non-interactive (agent-driven) runs:
124124

125125
```bash
126126
looper takeover acme/repo#42 --merge --agent-vendor claude-code
@@ -135,7 +135,7 @@ looper takeover stop acme/repo#42 # stop this takeover's reviewer + fixer loo
135135
looper takeover stop --all
136136
```
137137

138-
Requirements: `git`, an authenticated `gh`, and one supported agent CLI installed locally (the agent runs on your machine with your own credentials). Grok Build from xAI uses `agent.vendor = "grok-build"` and the `grok` executable; see [Grok Build configuration](docs/configuration.md#grok-build-xai) for daemon authentication and automation limits.
138+
Requirements: `git`, an authenticated `gh`, and one supported agent CLI installed locally (the agent runs on your machine with your own credentials). Grok Build from xAI uses `agent.vendor = "grok-build"` and the `grok` executable; see [Grok Build configuration](docs/configuration.md#grok-build-xai). Pi uses `agent.vendor = "pi"` (`pi` binary); Oh My Pi uses `agent.vendor = "omp"` (`omp` binary)—see [Pi](docs/configuration.md#pi) and [Oh My Pi](docs/configuration.md#oh-my-pi-omp).
139139

140140
### One-liner for not-yet-installed users
141141

docs/configuration.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,36 @@ For fresh unattended runs, Looper supplies `--always-approve` and `--sandbox off
369369

370370
Grok Build support is fresh-run only. Daemon native resume and interactive takeover through `looper resume` are unsupported. A retry uses a fresh checkpoint prompt, and Looper never uses Grok Build's ambient `--continue`.
371371

372+
## Pi
373+
374+
Use `pi` as the `agent.vendor` identifier. Looper invokes the [Pi](https://pi.dev) coding agent executable as `pi`:
375+
376+
```toml
377+
[agent]
378+
vendor = "pi"
379+
```
380+
381+
Authenticate via Pi's own login/config (project-local `.pi` and vendor credentials). Prefer vendor authentication over storing secrets in Looper configuration.
382+
383+
For fresh unattended runs, Looper supplies `-p` with the generated task prompt and `--approve` (trusts project-local `.pi` for the run). Configured `agent.params.args` override these defaults: if `-p`/`--print` is already present, Looper does not append its prompt (operator owns print/prompt); if any of `-a`/`--approve`/`-na`/`--no-approve` is present, Looper does not add `--approve`. There is no `--cwd` flag—Looper sets the process working directory to the worktree.
384+
385+
Pi support is fresh-run only. Daemon native resume and interactive takeover through `looper resume` are unsupported. A retry uses a fresh checkpoint prompt.
386+
387+
## Oh My Pi (omp)
388+
389+
Use `omp` as the `agent.vendor` identifier (not `oh-my-pi`). Looper invokes the [Oh My Pi](https://omp.sh) executable as `omp`:
390+
391+
```toml
392+
[agent]
393+
vendor = "omp"
394+
```
395+
396+
Authenticate via omp's own login/config. Prefer vendor authentication over storing secrets in Looper configuration.
397+
398+
For fresh unattended runs, Looper supplies `-p` with the generated task prompt, `--cwd <worktree>` when the workdir is non-empty, and `--auto-approve`. Configured arguments override defaults: if `-p`/`--print` is already present, Looper does not append its prompt; if `--cwd` is present, Looper does not add workdir; if `--auto-approve` or `--approval-mode` (including `--approval-mode=...`) is present, Looper does not add `--auto-approve`.
399+
400+
Oh My Pi support is fresh-run only. Daemon native resume and interactive takeover through `looper resume` are unsupported. A retry uses a fresh checkpoint prompt.
401+
372402
## Provider support
373403

374404
Looper supports three provider kinds:

docs/users-guide.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ For xAI Grok Build, configure `agent.vendor = "grok-build"`; Looper runs the `gr
4545

4646
Configured Grok arguments take precedence: `--permission-mode` can prompt or fail unattended work, a non-`plain` `--output-format` can break direct completion-marker parsing, and `-p`/`--single` replaces Looper's generated task prompt. Grok Build has no daemon native resume or interactive `looper resume` takeover. Retries start with a fresh checkpoint prompt; Looper never uses ambient `--continue`.
4747

48+
### Pi
49+
50+
For Pi ([pi.dev](https://pi.dev)), configure `agent.vendor = "pi"`; Looper runs the `pi` executable. Authenticate with Pi's own login/config (project-local `.pi`). Looper defaults to `-p <prompt> --approve` for unattended fresh runs. Configured `-p`/`--print` means you own the prompt; any of `-a`/`--approve`/`-na`/`--no-approve` skips the default approve flag. Pi has no daemon native resume or interactive `looper resume` takeover.
51+
52+
### Oh My Pi (omp)
53+
54+
For Oh My Pi ([omp.sh](https://omp.sh)), configure `agent.vendor = "omp"` (not `oh-my-pi`); Looper runs the `omp` executable. Authenticate with omp's own login/config. Looper defaults to `-p <prompt> --cwd <worktree> --auto-approve`. Configured print, cwd, or approval flags (`--auto-approve` / `--approval-mode`) override those defaults. Oh My Pi has no daemon native resume or interactive `looper resume` takeover.
55+
4856
## 1a. Local-only vs Routed projects
4957

5058
Looper supports two project modes:
@@ -562,7 +570,7 @@ looper takeover owner/repo#42 --merge # also auto-merge once approved + green
562570
3. starts a continuous reviewer loop and fixer loop on the target PR (skip the fixer with `--no-fix`);
563571
4. with `--merge`, sets `roles.reviewer.autoMerge.enabled` for the project so the reviewer enables GitHub auto-merge once the PR is approved and checks are green.
564572

565-
Agent selection: `takeover` reuses the vendor already in your config; otherwise it auto-detects an installed `claude` / `codex` / `grok` / `opencode` CLI, prompts when the choice is ambiguous, and accepts `--agent-vendor` plus `--yes` for non-interactive runs. Auto-merge still depends on the repository allowing it (and, by default, on branch protection with required checks); when GitHub refuses, the reviewer keeps reviewing and reports why instead.
573+
Agent selection: `takeover` reuses the vendor already in your config; otherwise it auto-detects an installed `claude` / `codex` / `grok` / `opencode` / `pi` / `omp` CLI, prompts when the choice is ambiguous, and accepts `--agent-vendor` plus `--yes` for non-interactive runs. Auto-merge still depends on the repository allowing it (and, by default, on branch protection with required checks); when GitHub refuses, the reviewer keeps reviewing and reports why instead.
566574

567575
Manage and stop takeovers:
568576

internal/agent/executor.go

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1967,7 +1967,8 @@ func shellSingleQuote(s string) string {
19671967

19681968
// ResolveCommand returns the agent binary used for spawn for vendor + params.
19691969
// When params["command"] is a non-empty string it wins; otherwise vendor defaults
1970-
// apply (claude, codex, opencode, agent for cursor-cli, grok for grok-build).
1970+
// apply (claude, codex, opencode, agent for cursor-cli, grok for grok-build,
1971+
// pi for pi, omp for omp).
19711972
// Same resolution as process spawn — callers may LookPath for an absolute path.
19721973
func ResolveCommand(vendor config.AgentVendor, params map[string]any) string {
19731974
return resolveCommand(ExecutorConfig{Vendor: vendor, Params: params})
@@ -1985,6 +1986,7 @@ func resolveCommand(cfg ExecutorConfig) string {
19851986
case config.AgentVendorGrokBuild:
19861987
return "grok"
19871988
default:
1989+
// pi, omp, codex, opencode, and any future vendor whose id matches the binary.
19881990
return string(cfg.Vendor)
19891991
}
19901992
}
@@ -2002,6 +2004,10 @@ func resolveArgs(cfg ExecutorConfig, workingDirectory string, prompt string) []s
20022004
return resolveCursorArgs(cfg, resolvedArgs, prompt)
20032005
case config.AgentVendorGrokBuild:
20042006
return resolveGrokArgs(cfg, resolvedArgs, workingDirectory, prompt)
2007+
case config.AgentVendorPi:
2008+
return resolvePiArgs(cfg, resolvedArgs, prompt)
2009+
case config.AgentVendorOmp:
2010+
return resolveOmpArgs(cfg, resolvedArgs, workingDirectory, prompt)
20052011
default:
20062012
return append([]string{}, resolvedArgs...)
20072013
}
@@ -2079,6 +2085,38 @@ func resolveGrokArgs(cfg ExecutorConfig, args []string, workingDirectory string,
20792085
return resolved
20802086
}
20812087

2088+
// resolvePiArgs builds argv for the Pi coding agent (https://pi.dev).
2089+
// -p/--print is boolean; the prompt is a positional message. No --cwd flag —
2090+
// Looper already sets process cmd.Dir to the worktree. Fresh-run only.
2091+
func resolvePiArgs(cfg ExecutorConfig, args []string, prompt string) []string {
2092+
resolved := prependModelFlag(args, cfg.Model, "--model", []string{"--model"})
2093+
if !hasAnyFlag(resolved, []string{"-p", "--print"}) {
2094+
// Operator-owned print/prompt: when -p/--print is already configured,
2095+
// do not append Looper's generated prompt (matches claude-code).
2096+
resolved = append(resolved, "-p", prompt)
2097+
}
2098+
if !hasAnyFlag(resolved, []string{"-a", "--approve", "-na", "--no-approve"}) {
2099+
resolved = append(resolved, "--approve")
2100+
}
2101+
return resolved
2102+
}
2103+
2104+
// resolveOmpArgs builds argv for Oh My Pi / omp (https://omp.sh).
2105+
// Print mode matches pi (-p/--print boolean + positional prompt). Fresh-run only.
2106+
func resolveOmpArgs(cfg ExecutorConfig, args []string, workingDirectory string, prompt string) []string {
2107+
resolved := prependModelFlag(args, cfg.Model, "--model", []string{"--model"})
2108+
if !hasAnyFlag(resolved, []string{"-p", "--print"}) {
2109+
resolved = append(resolved, "-p", prompt)
2110+
}
2111+
if strings.TrimSpace(workingDirectory) != "" && !hasAnyFlag(resolved, []string{"--cwd"}) {
2112+
resolved = append(resolved, "--cwd", workingDirectory)
2113+
}
2114+
if !hasAnyFlag(resolved, []string{"--auto-approve", "--approval-mode"}) {
2115+
resolved = append(resolved, "--auto-approve")
2116+
}
2117+
return resolved
2118+
}
2119+
20822120
func resolveNativeResumeArgs(cfg ExecutorConfig, workingDirectory string, args []string, sessionID string, prompt string) []string {
20832121
switch cfg.Vendor {
20842122
case config.AgentVendorClaudeCode:

internal/agent/modelcatalog/catalog.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ func cloneResult(in Result) Result {
338338
func isKnownVendor(vendor config.AgentVendor) bool {
339339
switch vendor {
340340
case config.AgentVendorClaudeCode, config.AgentVendorCodex, config.AgentVendorOpenCode,
341-
config.AgentVendorCursorCLI, config.AgentVendorGrokBuild:
341+
config.AgentVendorCursorCLI, config.AgentVendorGrokBuild, config.AgentVendorPi, config.AgentVendorOmp:
342342
return true
343343
default:
344344
return false
@@ -347,7 +347,8 @@ func isKnownVendor(vendor config.AgentVendor) bool {
347347

348348
func supportsProbe(vendor config.AgentVendor) bool {
349349
switch vendor {
350-
case config.AgentVendorCodex, config.AgentVendorOpenCode, config.AgentVendorCursorCLI, config.AgentVendorGrokBuild:
350+
case config.AgentVendorCodex, config.AgentVendorOpenCode, config.AgentVendorCursorCLI, config.AgentVendorGrokBuild,
351+
config.AgentVendorPi, config.AgentVendorOmp:
351352
return true
352353
default:
353354
return false

internal/agent/modelcatalog/catalog_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ func TestLoadStaticCatalog(t *testing.T) {
4141
config.AgentVendorOpenCode,
4242
config.AgentVendorCursorCLI,
4343
config.AgentVendorGrokBuild,
44+
config.AgentVendorPi,
45+
config.AgentVendorOmp,
4446
} {
4547
if len(catalog[vendor]) == 0 {
4648
t.Fatalf("static catalog empty for %s", vendor)
@@ -157,6 +159,55 @@ func TestParseCursorAndGrokFixtures(t *testing.T) {
157159
}
158160
}
159161

162+
func TestParsePiModelsFixture(t *testing.T) {
163+
got := parsePiModels(readTestdata(t, "pi_models.txt"))
164+
want := []string{"openai/gpt-4o", "anthropic/claude-sonnet-4-5", "google/gemini-2.5-pro"}
165+
if !reflect.DeepEqual(modelIDs(got), want) {
166+
t.Fatalf("pi ids = %#v, want %#v", modelIDs(got), want)
167+
}
168+
for _, m := range got {
169+
if m.Source != SourceProbe {
170+
t.Fatalf("source = %q, want probe", m.Source)
171+
}
172+
}
173+
}
174+
175+
func TestParsePiModelsSkipsHeaderAndNoise(t *testing.T) {
176+
raw := []byte("provider model context\nNot logged in\nopenai gpt-4o 128K\n")
177+
got := parsePiModels(raw)
178+
if !reflect.DeepEqual(modelIDs(got), []string{"openai/gpt-4o"}) {
179+
t.Fatalf("ids = %#v", modelIDs(got))
180+
}
181+
}
182+
183+
func TestParsePiModelsPreservesOpaqueIdentifiers(t *testing.T) {
184+
raw := []byte("provider model context\nAcme:Cloud Model:Preview 128K\n")
185+
got := parsePiModels(raw)
186+
if !reflect.DeepEqual(modelIDs(got), []string{"Acme:Cloud/Model:Preview"}) {
187+
t.Fatalf("ids = %#v", modelIDs(got))
188+
}
189+
}
190+
191+
func TestParseOmpModelsFixture(t *testing.T) {
192+
got, err := parseOmpModels(readTestdata(t, "omp_models.json"))
193+
if err != nil {
194+
t.Fatalf("parseOmpModels() error = %v", err)
195+
}
196+
want := []string{"anthropic/claude-sonnet-4-5", "openai/gpt-5.4", "google/gemini-2.5-pro", "sonnet"}
197+
if !reflect.DeepEqual(modelIDs(got), want) {
198+
t.Fatalf("omp ids = %#v, want %#v", modelIDs(got), want)
199+
}
200+
if got[0].Label != "Claude Sonnet 4.5" {
201+
t.Fatalf("label = %q, want Claude Sonnet 4.5", got[0].Label)
202+
}
203+
if got[2].ID != "google/gemini-2.5-pro" || got[2].Label != "Gemini 2.5 Pro" {
204+
t.Fatalf("provider/id fallback entry = %#v", got[2])
205+
}
206+
if got[3].ID != "sonnet" || got[3].Label != "Sonnet alias" {
207+
t.Fatalf("bare id entry = %#v", got[3])
208+
}
209+
}
210+
160211
func TestParseLineModelsRejectsLoggedOutWarning(t *testing.T) {
161212
raw := []byte("Not logged in\nPlease authenticate first\nauto - Auto\n")
162213
got := parseLineModels(raw)

internal/agent/modelcatalog/parse.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,124 @@ func parseGrokModels(stdout []byte) []Model {
1919
return parseTableOrIDLines(stdout)
2020
}
2121

22+
// parsePiModels parses `pi --list-models` multi-column table output:
23+
//
24+
// provider model context ...
25+
// openai gpt-4o 128K ...
26+
//
27+
// Model IDs are provider/model when both columns are present (pi accepts that form).
28+
func parsePiModels(stdout []byte) []Model {
29+
lines := strings.Split(string(stdout), "\n")
30+
out := make([]Model, 0, len(lines))
31+
seen := make(map[string]struct{})
32+
inTable := false
33+
columnCount := 0
34+
for _, line := range lines {
35+
line = strings.TrimSpace(line)
36+
if line == "" || shouldSkipDecorLine(line) {
37+
continue
38+
}
39+
columns := splitPiTableColumns(line)
40+
if len(columns) >= 2 && strings.EqualFold(columns[0], "provider") && strings.EqualFold(columns[1], "model") {
41+
inTable = true
42+
columnCount = len(columns)
43+
continue
44+
}
45+
if !inTable || len(columns) != columnCount {
46+
continue
47+
}
48+
provider := columns[0]
49+
model := columns[1]
50+
id := provider + "/" + model
51+
if _, ok := seen[id]; ok {
52+
continue
53+
}
54+
seen[id] = struct{}{}
55+
out = append(out, Model{ID: id, Source: SourceProbe})
56+
}
57+
return out
58+
}
59+
60+
// splitPiTableColumns splits the aligned columns emitted by pi --list-models.
61+
// Two or more spaces (or a tab) delimit columns; single spaces stay in values.
62+
func splitPiTableColumns(line string) []string {
63+
var columns []string
64+
start := 0
65+
for i := 0; i < len(line); {
66+
if line[i] != '\t' && line[i] != ' ' {
67+
i++
68+
continue
69+
}
70+
end := i + 1
71+
for end < len(line) && (line[end] == ' ' || line[end] == '\t') {
72+
end++
73+
}
74+
if line[i] == '\t' || end-i >= 2 {
75+
if column := strings.TrimSpace(line[start:i]); column != "" {
76+
columns = append(columns, column)
77+
}
78+
start = end
79+
}
80+
i = end
81+
}
82+
if column := strings.TrimSpace(line[start:]); column != "" {
83+
columns = append(columns, column)
84+
}
85+
return columns
86+
}
87+
88+
type ompModelsPayload struct {
89+
Models []ompModelEntry `json:"models"`
90+
}
91+
92+
type ompModelEntry struct {
93+
Provider string `json:"provider"`
94+
ID string `json:"id"`
95+
Selector string `json:"selector"`
96+
Name string `json:"name"`
97+
}
98+
99+
// parseOmpModels parses `omp models --json` output. Prefer selector as Model.ID;
100+
// fallback provider/id or bare id. Use name as Label when present.
101+
func parseOmpModels(stdout []byte) ([]Model, error) {
102+
trimmed := bytesTrimSpace(stdout)
103+
if len(trimmed) == 0 {
104+
return nil, nil
105+
}
106+
var payload ompModelsPayload
107+
if err := json.Unmarshal(trimmed, &payload); err != nil {
108+
var arr []ompModelEntry
109+
if err2 := json.Unmarshal(trimmed, &arr); err2 != nil {
110+
return nil, err
111+
}
112+
payload.Models = arr
113+
}
114+
out := make([]Model, 0, len(payload.Models))
115+
seen := make(map[string]struct{})
116+
for _, e := range payload.Models {
117+
id := strings.TrimSpace(e.Selector)
118+
if id == "" {
119+
provider := strings.TrimSpace(e.Provider)
120+
rawID := strings.TrimSpace(e.ID)
121+
switch {
122+
case provider != "" && rawID != "":
123+
id = provider + "/" + rawID
124+
case rawID != "":
125+
id = rawID
126+
default:
127+
continue
128+
}
129+
}
130+
if _, ok := seen[id]; ok {
131+
continue
132+
}
133+
seen[id] = struct{}{}
134+
label := strings.TrimSpace(e.Name)
135+
out = append(out, Model{ID: id, Label: label, Source: SourceProbe})
136+
}
137+
return out, nil
138+
}
139+
22140
// parseLineModels is the shared text parser used by cursor/grok-style output.
23141
// Kept as an alias for tests and call sites that want the table-tolerant path.
24142
func parseLineModels(stdout []byte) []Model {

internal/agent/modelcatalog/probe.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,18 @@ func (s *Service) probe(ctx context.Context, vendor config.AgentVendor, binary s
303303
return nil, err
304304
}
305305
return parseGrokModels(out), nil
306+
case config.AgentVendorPi:
307+
out, err := s.runner.Run(runCtx, env, binary, "--list-models")
308+
if err != nil {
309+
return nil, err
310+
}
311+
return parsePiModels(out), nil
312+
case config.AgentVendorOmp:
313+
out, err := s.runner.Run(runCtx, env, binary, "models", "--json")
314+
if err != nil {
315+
return nil, err
316+
}
317+
return parseOmpModels(out)
306318
default:
307319
return nil, errors.New("probe unsupported")
308320
}

0 commit comments

Comments
 (0)