170 lines
6.1 KiB
Go
170 lines
6.1 KiB
Go
// Package pi implements the Pi agent adapter: launching new headless Pi
|
|
// sessions and resuming sessions when a native Pi session id is known.
|
|
//
|
|
// Pi (badlogic / "@earendil-works/pi-coding-agent", binary "pi") is a minimal
|
|
// terminal coding harness. AO drives it non-interactively with `-p` / `--print`
|
|
// ("process prompt and exit"). The initial prompt is delivered in-command as a
|
|
// trailing positional message; Pi's argument parser does not honor a `--`
|
|
// options terminator, so AO relies on prompts not beginning with a literal "-".
|
|
//
|
|
// System prompts are appended to Pi's default coding-assistant prompt via
|
|
// `--append-system-prompt <text>`. Pi's flag takes inline text only (no file
|
|
// variant), so a system-prompt file is read from disk and its contents are
|
|
// inlined into the flag; a read failure aborts the launch.
|
|
//
|
|
// Permissions: Pi has no permission/approval CLI flags ("No permission popups" --
|
|
// confirmation flows are built via TypeScript extensions), so AO emits no
|
|
// permission flag and defers to Pi's own behavior.
|
|
//
|
|
// Restore: Pi persists sessions to ~/.pi/agent/sessions/ and resumes by id with
|
|
// `--session <id>` (partial UUIDs accepted). The native session id is emitted on
|
|
// the first line of `--mode json` output as {"type":"session","id":"<uuid>",...}
|
|
// and is captured into session metadata out-of-band; GetRestoreCommand reads it
|
|
// back from metadata. ok=false when no native id is known (manager falls back to
|
|
// a fresh launch).
|
|
//
|
|
// Hooks/activity: Pi exposes lifecycle hooks only through in-process TypeScript
|
|
// extensions (pi.on("session_start", ...), etc.), not a config file AO can
|
|
// install, and it has no Claude Code hook compatibility. There is therefore no
|
|
// Tier A native hook installer nor a Tier B Claude-compat delegation; hook
|
|
// installation and SessionInfo are intentionally no-ops until a Pi-specific
|
|
// extension exists.
|
|
package pi
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase"
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
|
)
|
|
|
|
const adapterID = "pi"
|
|
|
|
// Plugin is the Pi agent adapter. It is safe for concurrent use; the binary
|
|
// path is resolved once and cached under binaryMu.
|
|
type Plugin struct {
|
|
agentbase.Base
|
|
binaryMu sync.Mutex
|
|
resolvedBinary string
|
|
}
|
|
|
|
// New returns a ready-to-register Pi adapter.
|
|
func New() *Plugin {
|
|
return &Plugin{}
|
|
}
|
|
|
|
var _ adapters.Adapter = (*Plugin)(nil)
|
|
var _ ports.Agent = (*Plugin)(nil)
|
|
|
|
// Manifest returns the adapter's static self-description.
|
|
func (p *Plugin) Manifest() adapters.Manifest {
|
|
return adapters.Manifest{
|
|
ID: adapterID,
|
|
Name: "Pi",
|
|
Description: "Run Pi worker sessions.",
|
|
Version: "0.0.1",
|
|
Capabilities: []adapters.Capability{
|
|
adapters.CapabilityAgent,
|
|
},
|
|
}
|
|
}
|
|
|
|
// GetLaunchCommand builds the argv to start a new headless Pi session:
|
|
//
|
|
// pi --print [--append-system-prompt <system prompt>] [<prompt>]
|
|
//
|
|
// The prompt is delivered in-command as a trailing positional message. Pi does
|
|
// not honor a `--` options terminator, so the prompt must not begin with "-".
|
|
// Pi has no permission flags, so none are emitted.
|
|
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
|
binary, err := p.piBinary(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cmd = []string{binary, "--print"}
|
|
if cfg.SystemPrompt != "" {
|
|
cmd = append(cmd, "--append-system-prompt", cfg.SystemPrompt)
|
|
} else if cfg.SystemPromptFile != "" {
|
|
data, err := os.ReadFile(cfg.SystemPromptFile) //nolint:gosec // path is AO-owned launch config
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cmd = append(cmd, "--append-system-prompt", string(data))
|
|
}
|
|
if cfg.Prompt != "" {
|
|
cmd = append(cmd, cfg.Prompt)
|
|
}
|
|
return cmd, nil
|
|
}
|
|
|
|
// GetRestoreCommand rebuilds the argv that continues an existing Pi session when
|
|
// a native session id is available in metadata. Pi resumes by id with
|
|
// `--session <id>` (partial UUIDs accepted). Until that id exists, ok is false
|
|
// and callers fall back to fresh launch behavior.
|
|
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, false, err
|
|
}
|
|
agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID])
|
|
if agentSessionID == "" {
|
|
return nil, false, nil
|
|
}
|
|
|
|
binary, err := p.piBinary(ctx)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
cmd = []string{binary, "--print", "--session", agentSessionID}
|
|
if cfg.SystemPrompt != "" {
|
|
cmd = append(cmd, "--append-system-prompt", cfg.SystemPrompt)
|
|
} else if cfg.SystemPromptFile != "" {
|
|
data, err := os.ReadFile(cfg.SystemPromptFile) //nolint:gosec // path is AO-owned launch config
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
cmd = append(cmd, "--append-system-prompt", string(data))
|
|
}
|
|
return cmd, true, nil
|
|
}
|
|
|
|
var piBinarySpec = binaryutil.BinarySpec{
|
|
Label: "pi",
|
|
Names: []string{"pi"},
|
|
WinNames: []string{"pi.cmd", "pi.exe", "pi"},
|
|
UnixPaths: []string{"/usr/local/bin/pi", "/opt/homebrew/bin/pi"},
|
|
UnixHomePaths: [][]string{{".npm-global", "bin", "pi"}, {".local", "bin", "pi"}, {".pi", "bin", "pi"}},
|
|
WinPaths: []binaryutil.WinPath{
|
|
{Base: binaryutil.WinAppData, Parts: []string{"npm", "pi.cmd"}},
|
|
{Base: binaryutil.WinAppData, Parts: []string{"npm", "pi.exe"}},
|
|
},
|
|
}
|
|
|
|
// ResolvePiBinary finds the `pi` binary, searching PATH then common install
|
|
// locations. It returns "pi" as a last resort so callers get the shell's normal
|
|
// command-not-found behavior if Pi is absent.
|
|
func ResolvePiBinary(ctx context.Context) (string, error) {
|
|
return binaryutil.ResolveBinary(ctx, piBinarySpec)
|
|
}
|
|
|
|
func (p *Plugin) piBinary(ctx context.Context) (string, error) {
|
|
p.binaryMu.Lock()
|
|
defer p.binaryMu.Unlock()
|
|
|
|
if p.resolvedBinary != "" {
|
|
return p.resolvedBinary, nil
|
|
}
|
|
|
|
binary, err := ResolvePiBinary(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
p.resolvedBinary = binary
|
|
return binary, nil
|
|
}
|