Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .scion/templates/pi-generic/harness-configs/generic/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

harness: generic
image: scion-pi:latest
user: scion
command_args:
- pi
- --print
17 changes: 17 additions & 0 deletions .scion/templates/pi-generic/scion-agent.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

schema_version: "1"
description: "Pi agent via generic harness (pass-through validation)"
default_harness_config: generic
23 changes: 23 additions & 0 deletions image-build/cloudbuild-harnesses.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,29 @@ steps:
env:
- 'DOCKER_CLI_EXPERIMENTAL=enabled'

# Build Pi Harness Image
- name: 'gcr.io/cloud-builders/docker'
id: 'build-scion-pi'
dir: 'image-build/pi'
args:
- 'buildx'
- 'build'
- '--platform'
- 'linux/amd64,linux/arm64'
- '--build-arg'
- 'BASE_IMAGE=$_REGISTRY/scion-base:latest'
- '-t'
- '$_REGISTRY/scion-pi:$_SHORT_SHA'
- '-t'
- '$_REGISTRY/scion-pi:latest'
- '-f'
- 'Dockerfile'
- '--pull'
- '--push'
- '.'
env:
- 'DOCKER_CLI_EXPERIMENTAL=enabled'

substitutions:
_REGISTRY: 'us-central1-docker.pkg.dev/${PROJECT_ID}/public-docker'
options:
Expand Down
36 changes: 36 additions & 0 deletions image-build/pi/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# syntax=docker/dockerfile:1
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

ARG BASE_IMAGE
FROM ${BASE_IMAGE}

RUN mkdir -p /home/scion/.pi/agent && \
chown -R scion:scion /home/scion/.pi

# Install pi coding agent
RUN npm install -g @mariozechner/pi-coding-agent \
&& npm cache clean --force

# Install gccli, gdcli, gmcli globally (BUN_INSTALL=/usr/local puts binaries in /usr/local/bin)
RUN BUN_INSTALL=/usr/local bun add -g @mariozechner/gccli @mariozechner/gdcli @mariozechner/gmcli

# Clone pi-skills and pre-install JS dependencies for skills that require it
RUN git clone --depth=1 https://github.com/badlogic/pi-skills /home/scion/.pi/agent/skills/pi-skills && \
cd /home/scion/.pi/agent/skills/pi-skills/brave-search && bun install && \
cd /home/scion/.pi/agent/skills/pi-skills/browser-tools && bun install && \
cd /home/scion/.pi/agent/skills/pi-skills/youtube-transcript && bun install && \
chown -R scion:scion /home/scion/.pi

CMD ["pi"]
2 changes: 1 addition & 1 deletion image-build/scripts/build-images.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ PUSH=""
PLATFORM=""
TAG="latest"

HARNESSES=(claude gemini opencode codex)
HARNESSES=(claude gemini opencode codex pi)

usage() {
cat <<EOF
Expand Down
1 change: 1 addition & 0 deletions pkg/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ type AuthConfig struct {
CodexAPIKey string
CodexAuthFile string
OpenCodeAuthFile string
PiAuthFile string

// GCP metadata server mode ("block", "passthrough", "assign").
// When "assign", a GCP service account is available via the metadata
Expand Down
9 changes: 8 additions & 1 deletion pkg/config/harness_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,14 @@ func loadHarnessConfigsFromDir(parentDir string, into map[string]*HarnessConfigD
return
}
for _, entry := range entries {
if !entry.IsDir() {
isDir := entry.IsDir()
// If the entry is a symlink, follow it to check if the target is a directory.
if !isDir && entry.Type()&os.ModeSymlink != 0 {
if info, err := os.Stat(filepath.Join(parentDir, entry.Name())); err == nil {
isDir = info.IsDir()
}
}
if !isDir {
continue
}
hc, err := LoadHarnessConfigDir(filepath.Join(parentDir, entry.Name()))
Expand Down
3 changes: 3 additions & 0 deletions pkg/harness/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ func New(harnessName string) api.Harness {
return &OpenCode{}
case "codex":
return &Codex{}
case "pi":
return &Pi{}
default:
// Check plugin registry before falling back to Generic
if pluginManager != nil && pluginManager.HasPlugin(scionplugin.PluginTypeHarness, harnessName) {
Expand All @@ -62,5 +64,6 @@ func All() []api.Harness {
&ClaudeCode{},
&OpenCode{},
&Codex{},
&Pi{},
}
}
3 changes: 2 additions & 1 deletion pkg/harness/harness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ func TestNew_PluginHarness(t *testing.T) {

func TestAll_ReturnsBuiltins(t *testing.T) {
all := All()
assert.Len(t, all, 4)
assert.Len(t, all, 5)
names := make([]string, len(all))
for i, h := range all {
names[i] = h.Name()
Expand All @@ -123,4 +123,5 @@ func TestAll_ReturnsBuiltins(t *testing.T) {
assert.Contains(t, names, "claude")
assert.Contains(t, names, "opencode")
assert.Contains(t, names, "codex")
assert.Contains(t, names, "pi")
}
10 changes: 4 additions & 6 deletions pkg/harness/opencode.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,12 @@ func (o *OpenCode) GetEnv(agentName string, agentHome string, unixUsername strin
}

func (o *OpenCode) GetCommand(task string, resume bool, baseArgs []string) []string {
args := []string{"opencode"}
args := []string{"opencode", "run"}
if resume {
args = append(args, "--continue")
} else {
args = append(args, "--prompt")
if task != "" {
args = append(args, task)
}
}
if task != "" {
args = append(args, task)
}

args = append(args, baseArgs...)
Expand Down
186 changes: 186 additions & 0 deletions pkg/harness/pi.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package harness

import (
"context"
"embed"
"fmt"
"os"
"path/filepath"

"github.com/GoogleCloudPlatform/scion/pkg/api"
piEmbeds "github.com/GoogleCloudPlatform/scion/pkg/harness/pi"
)

type Pi struct{}

func (p *Pi) Name() string {
return "pi"
}

func (p *Pi) AdvancedCapabilities() api.HarnessAdvancedCapabilities {
return api.HarnessAdvancedCapabilities{
Harness: "pi",
Limits: api.HarnessLimitCapabilities{
MaxTurns: api.CapabilityField{Support: api.SupportNo, Reason: "This harness has no hook dialect for turn events"},
MaxModelCalls: api.CapabilityField{Support: api.SupportNo, Reason: "This harness has no hook dialect for model events"},
MaxDuration: api.CapabilityField{Support: api.SupportYes},
},
Telemetry: api.HarnessTelemetryCapabilities{
EnabledConfig: api.CapabilityField{Support: api.SupportNo, Reason: "Native telemetry config is not supported for this harness"},
NativeEmitter: api.CapabilityField{Support: api.SupportNo, Reason: "Native telemetry forwarding is not wired for this harness"},
},
Prompts: api.HarnessPromptCapabilities{
SystemPrompt: api.CapabilityField{Support: api.SupportYes},
AgentInstructions: api.CapabilityField{Support: api.SupportYes},
},
Auth: api.HarnessAuthCapabilities{
APIKey: api.CapabilityField{Support: api.SupportYes},
AuthFile: api.CapabilityField{Support: api.SupportYes},
VertexAI: api.CapabilityField{Support: api.SupportNo, Reason: "Vertex AI auth is not supported for this harness"},
},
}
}

func (p *Pi) GetEnv(agentName string, agentHome string, unixUsername string) map[string]string {
return map[string]string{}
}

func (p *Pi) GetCommand(task string, resume bool, baseArgs []string) []string {
args := []string{"pi", "--print"}
if resume {
args = append(args, "--continue")
}
if task != "" {
args = append(args, task)
}
args = append(args, baseArgs...)
return args
}

func (p *Pi) DefaultConfigDir() string {
return ".pi/agent"
}

func (p *Pi) SkillsDir() string {
return ".pi/agent/skills"
}

func (p *Pi) HasSystemPrompt(agentHome string) bool {
_, err := os.Stat(filepath.Join(agentHome, ".pi", "agent", "SYSTEM.md"))
return err == nil
}

func (p *Pi) Provision(ctx context.Context, agentName, agentDir, agentHome, agentWorkspace string) error {
return nil
}

func (p *Pi) GetEmbedDir() string {
return "pi"
}

func (p *Pi) GetInterruptKey() string {
return "C-c"
}

func (p *Pi) GetHarnessEmbedsFS() (embed.FS, string) {
return piEmbeds.EmbedsFS, "embeds"
}

func (p *Pi) GetTelemetryEnv() map[string]string {
return nil
}

func (p *Pi) InjectAgentInstructions(agentHome string, content []byte) error {
target := filepath.Join(agentHome, ".pi", "agent", "AGENTS.md")
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return fmt.Errorf("failed to create pi agent dir: %w", err)
}
return os.WriteFile(target, content, 0644)
}

func (p *Pi) InjectSystemPrompt(agentHome string, content []byte) error {
target := filepath.Join(agentHome, ".pi", "agent", "SYSTEM.md")
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return fmt.Errorf("failed to create pi agent dir: %w", err)
}
return os.WriteFile(target, content, 0644)
}

func (p *Pi) ResolveAuth(auth api.AuthConfig) (*api.ResolvedAuth, error) {
// Explicit selection support
if auth.SelectedType != "" {
switch auth.SelectedType {
case "api-key":
key := auth.AnthropicAPIKey
if key == "" {
key = auth.OpenAIAPIKey
}
if key == "" {
return nil, fmt.Errorf("pi: auth type %q selected but no API key found; set ANTHROPIC_API_KEY or OPENAI_API_KEY", auth.SelectedType)
}
envKey := "ANTHROPIC_API_KEY"
if auth.AnthropicAPIKey == "" {
envKey = "OPENAI_API_KEY"
}
return &api.ResolvedAuth{
Method: "api-key",
EnvVars: map[string]string{envKey: key},
}, nil
case "auth-file":
if auth.PiAuthFile == "" {
return nil, fmt.Errorf("pi: auth type %q selected but no auth file found; expected ~/.pi/agent/auth.json", auth.SelectedType)
}
return &api.ResolvedAuth{
Method: "auth-file",
Files: []api.FileMapping{
{SourcePath: auth.PiAuthFile, ContainerPath: "~/.pi/agent/auth.json"},
},
}, nil
default:
return nil, fmt.Errorf("pi: unknown auth type %q; valid types are: api-key, auth-file", auth.SelectedType)
}
}

// Auto-detect preference order: AnthropicAPIKey → OpenAIAPIKey → PiAuthFile → error

if auth.AnthropicAPIKey != "" {
return &api.ResolvedAuth{
Method: "api-key",
EnvVars: map[string]string{"ANTHROPIC_API_KEY": auth.AnthropicAPIKey},
}, nil
}

if auth.OpenAIAPIKey != "" {
return &api.ResolvedAuth{
Method: "api-key",
EnvVars: map[string]string{"OPENAI_API_KEY": auth.OpenAIAPIKey},
}, nil
}

if auth.PiAuthFile != "" {
return &api.ResolvedAuth{
Method: "auth-file",
Files: []api.FileMapping{
{SourcePath: auth.PiAuthFile, ContainerPath: "~/.pi/agent/auth.json"},
},
}, nil
}

// No credentials found — pi can still run with locally-configured models
// (e.g. via ~/.pi/agent/models.json pointing at a local oMLX/OpenAI-compat server).
return &api.ResolvedAuth{Method: "none"}, nil
}
20 changes: 20 additions & 0 deletions pkg/harness/pi/embeds.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package pi

import "embed"

//go:embed all:embeds/*
var EmbedsFS embed.FS
Loading