Skip to content
25 changes: 25 additions & 0 deletions cmd/entire/cli/agent/pi/generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package pi

import (
"context"
"fmt"

"github.com/entireio/cli/cmd/entire/cli/agent"
)

// GenerateText sends a prompt to Pi in non-interactive text mode and returns
// the raw response. The prompt is passed as a positional message because Pi's
// CLI consumes prompts from argv in --print mode.
func (a *PiAgent) GenerateText(ctx context.Context, prompt string, model string) (string, error) {
args := []string{"--print", "--no-tools", "--no-session"}
if model != "" {
args = append(args, "--model", model)
}
args = append(args, prompt)

result, err := agent.RunIsolatedTextGeneratorCLI(ctx, nil, "pi", "pi", args, "")
if err != nil {
return "", fmt.Errorf("pi text generation failed: %w", err)
}
return result, nil
}
17 changes: 17 additions & 0 deletions cmd/entire/cli/agent/pi/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ func (a *PiAgent) ParseHookEvent(ctx context.Context, hookName string, stdin io.
Type: agent.TurnEnd,
SessionID: sessionID,
SessionRef: sessionRef,
Model: extractModelFromPiSessionFile(sessionRef),
Timestamp: now,
}, nil

Expand Down Expand Up @@ -262,6 +263,22 @@ func cacheSessionID(ctx context.Context, id string) {
}
}

func extractModelFromPiSessionFile(path string) string {
if path == "" {
return ""
}
//nolint:gosec // path comes from Pi's hook payload or our captured transcript path
data, err := os.ReadFile(path)
if err != nil {
return ""
}
model, err := (&PiAgent{}).ExtractModel(data)
if err != nil {
return ""
}
return model
}

func readCachedSessionID(ctx context.Context) string {
dir := resolveSessionDir(ctx)
//nolint:gosec // path constructed from validated repo root
Expand Down
49 changes: 49 additions & 0 deletions cmd/entire/cli/agent/pi/models.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package pi

import (
"bufio"
"context"
"fmt"
"strings"

"github.com/entireio/cli/cmd/entire/cli/agent"
)

var _ agent.ModelLister = (*PiAgent)(nil)

// ListModels returns Pi's live model catalog by shelling out to
// `pi --list-models`. Unlike the curated lists for claude-code/codex/gemini,
// Pi has a real enumeration command spanning every configured provider, so the
// result reflects what this machine/account can actually use.
func (a *PiAgent) ListModels(ctx context.Context) ([]agent.ModelInfo, error) {
out, err := agent.RunIsolatedTextGeneratorCLI(ctx, nil, "pi", "pi", []string{"--list-models"}, "")
if err != nil {
return nil, fmt.Errorf("pi --list-models: %w", err)
}
return parsePiModelList(out), nil
}

// parsePiModelList parses the tabular `pi --list-models` output. Each non-header
// row is "<provider> <model> <context> <max-out> <thinking> <images>"; the model
// ID is rendered as "provider/model" (the unambiguous form Pi's --model accepts)
// with the context window kept as a note.
func parsePiModelList(raw string) []agent.ModelInfo {
var models []agent.ModelInfo
scanner := bufio.NewScanner(strings.NewReader(raw))
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 2 {
continue
}
provider, model := fields[0], fields[1]
if provider == "provider" && model == "model" {
continue // header row
}
note := ""
if len(fields) >= 3 {
note = fields[2] + " ctx"
}
models = append(models, agent.ModelInfo{ID: provider + "/" + model, Note: note})
}
return models
}
35 changes: 35 additions & 0 deletions cmd/entire/cli/agent/pi/models_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package pi

import "testing"

func TestParsePiModelList(t *testing.T) {
raw := "provider model context max-out thinking images\n" +
"anthropic claude-opus-4-0 200K 32K yes yes \n" +
"openai gpt-5 400K 128K yes no \n" +
"\n" +
"google gemini-2.5-pro 1M 64K yes yes \n"

got := parsePiModelList(raw)
if len(got) != 3 {
t.Fatalf("parsed %d models, want 3: %#v", len(got), got)
}
want := []struct{ id, note string }{
{"anthropic/claude-opus-4-0", "200K ctx"},
{"openai/gpt-5", "400K ctx"},
{"google/gemini-2.5-pro", "1M ctx"},
}
for i, w := range want {
if got[i].ID != w.id {
t.Errorf("model[%d].ID = %q, want %q", i, got[i].ID, w.id)
}
if got[i].Note != w.note {
t.Errorf("model[%d].Note = %q, want %q", i, got[i].Note, w.note)
}
}
}

func TestParsePiModelList_HeaderAndBlanksSkipped(t *testing.T) {
if got := parsePiModelList("provider model\n\n \n"); len(got) != 0 {
t.Fatalf("expected no models, got %#v", got)
}
}
216 changes: 216 additions & 0 deletions cmd/entire/cli/agent/pi/reviewer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
package pi

import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"

"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/review"
reviewtypes "github.com/entireio/cli/cmd/entire/cli/review/types"
)

// NewReviewer returns the AgentReviewer for Pi.
//
// Argv shape: pi --mode json --print [--model <model>] <prompt>.
// The prompt is passed as a positional message because Pi's CLI accepts prompts
// as message arguments in non-interactive mode. Stdout is newline-delimited JSON
// session events; the parser maps Pi's AgentSessionEvent stream into Entire's
// review Event stream.
func NewReviewer() *reviewtypes.ReviewerTemplate {
return &reviewtypes.ReviewerTemplate{
AgentName: string(agent.AgentNamePi),
BuildCmd: buildPiReviewCmd,
Parser: parsePiReviewOutput,
}
}

func buildPiReviewCmd(ctx context.Context, cfg reviewtypes.RunConfig) *exec.Cmd {
prompt := review.ComposeReviewPrompt(cfg)
args := []string{"--mode", "json", "--print"}
if cfg.Model != "" {
args = append(args, "--model", cfg.Model)
}
args = append(args, prompt)
cmd := exec.CommandContext(ctx, "pi", args...)
cmd.Env = review.AppendReviewEnv(os.Environ(), string(agent.AgentNamePi), cfg, prompt)
return cmd
}

func parsePiReviewOutput(r io.Reader) <-chan reviewtypes.Event {
out := make(chan reviewtypes.Event, 32)
go func() {
defer close(out)
out <- reviewtypes.Started{}

scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, min(1024*1024, piReviewMaxScannerBuf)), piReviewMaxScannerBuf)
messageIDsWithTextDelta := map[string]struct{}{}
finished := false
success := true

for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var env piReviewEnvelope
if err := json.Unmarshal(line, &env); err != nil {
out <- reviewtypes.RunError{Err: fmt.Errorf("pi --mode json: %w", err)}
continue
}

switch env.Type {
case "session", "agent_start", "turn_start", "queue_update", "compaction_start", "compaction_end", "auto_retry_start", "auto_retry_end":
// Session/control events do not map to user-visible review output.
case "message_update":
if text := env.AssistantMessageEvent.TextDelta(); text != "" {
messageIDsWithTextDelta[env.MessageID()] = struct{}{}
out <- reviewtypes.AssistantText{Text: text}
}
case "message_end":
if env.Message.Role == "assistant" {
if env.Message.StopReason == "error" || env.Message.StopReason == "aborted" {
success = false
}
if env.Message.Usage != nil {
out <- piReviewTokens(env.Message.Usage)
}
if _, sawDelta := messageIDsWithTextDelta[env.MessageID()]; !sawDelta {
if text := piReviewMessageText(env.Message.Content); text != "" {
out <- reviewtypes.AssistantText{Text: text}
}
}
}
case "tool_execution_start":
out <- reviewtypes.ToolCall{Name: env.ToolName, Args: piReviewJSONArg(env.Args)}
case "tool_execution_end":
// Tool errors are part of normal agent execution (for example grep
// finding no matches). The agent's stopReason determines review
// success/failure.
case "turn_end":
if env.Message.StopReason == "error" || env.Message.StopReason == "aborted" {
success = false
}
if env.Message.Usage != nil {
out <- piReviewTokens(env.Message.Usage)
}
case "agent_end":
finished = true
out <- reviewtypes.Finished{Success: success}
default:
// Unknown future events are ignored; Pi's event stream is additive.
}
}

if err := scanner.Err(); err != nil {
out <- reviewtypes.RunError{Err: fmt.Errorf("read stdout: %w", err)}
out <- reviewtypes.Finished{Success: false}
return
}
if !finished {
out <- reviewtypes.Finished{Success: false}
}
}()
return out
}

const piReviewMaxScannerBuf = 64 * 1024 * 1024

type piReviewEnvelope struct {
Type string `json:"type"`
ID string `json:"id"`
Message piReviewMessage `json:"message"`
AssistantMessageEvent piAssistantMessageEvent `json:"assistantMessageEvent"`
ToolName string `json:"toolName"`
Args json.RawMessage `json:"args"`
}

func (e piReviewEnvelope) MessageID() string {
if e.Message.ID != "" {
return e.Message.ID
}
return e.ID
}

type piReviewMessage struct {
ID string `json:"id"`
Role string `json:"role"`
Content json.RawMessage `json:"content"`
Usage *piReviewUsage `json:"usage"`
StopReason string `json:"stopReason"`
}

type piAssistantMessageEvent struct {
Type string `json:"type"`
Delta string `json:"delta"`
Text string `json:"text"`
}

func (e piAssistantMessageEvent) TextDelta() string {
switch e.Type {
case "text_delta":
if e.Delta != "" {
return e.Delta
}
return e.Text
default:
return ""
}
}

type piReviewUsage struct {
Input int `json:"input"`
Output int `json:"output"`
CacheRead int `json:"cacheRead"`
CacheWrite int `json:"cacheWrite"`
}

func piReviewTokens(usage *piReviewUsage) reviewtypes.Tokens {
if usage == nil {
return reviewtypes.Tokens{}
}
return reviewtypes.Tokens{
In: usage.Input + usage.CacheRead + usage.CacheWrite,
Out: usage.Output,
}
}

func piReviewJSONArg(raw json.RawMessage) string {
if len(raw) == 0 || string(raw) == "null" {
return ""
}
return string(raw)
}

func piReviewMessageText(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
var items []struct {
Type string `json:"type"`
Text string `json:"text"`
}
if err := json.Unmarshal(raw, &items); err != nil {
return ""
}
text := ""
for _, item := range items {
if item.Type != "text" || item.Text == "" {
continue
}
if text != "" {
text += "\n"
}
text += item.Text
}
return text
}
Loading
Loading