Introduce AGENT_PROVIDER constant to switch between pi and opencode backends.
pi streams JSON output immediately, avoiding the opencode idle timeout bug
where git check-ignore indexing produces zero stdout.
Also removes redundant copyToWorktree since onSandboxReady pnpm install
handles node_modules via the mounted pnpm store.
- name: Validate secrets
run: |
+ if [ -z "$PI_AUTH_CONTENT" ]; then
+ echo "::error::PI_AUTH_CONTENT secret is not configured"
+ exit 1
+ fi
if [ -z "$OPENCODE_AUTH_CONTENT" ]; then
echo "::error::OPENCODE_AUTH_CONTENT secret is not configured"
exit 1
fi
env:
+ PI_AUTH_CONTENT: ${{ secrets.PI_AUTH_CONTENT }}
OPENCODE_AUTH_CONTENT: ${{ secrets.OPENCODE_AUTH_CONTENT }}
- run: docker build -t sandcastle-sandbox .sandcastle/
run: |
printf 'GH_TOKEN=%s\n' "$GH_TOKEN" > .sandcastle/.env
printf 'GITHUB_TOKEN=%s\n' "$GITHUB_TOKEN" >> .sandcastle/.env
+ printf 'PI_AUTH_CONTENT=%s\n' "$PI_AUTH_CONTENT" >> .sandcastle/.env
printf 'OPENCODE_AUTH_CONTENT=%s\n' "$OPENCODE_AUTH_CONTENT" >> .sandcastle/.env
env:
GH_TOKEN: ${{ github.token }}
+ PI_AUTH_CONTENT: ${{ secrets.PI_AUTH_CONTENT }}
OPENCODE_AUTH_CONTENT: ${{ secrets.OPENCODE_AUTH_CONTENT }}
- run: pnpm run sandcastle
// ── Agent ────────────────────────────────────────────────────────────────────
+export type AgentProviderType = 'opencode' | 'pi'
+
+export const AGENT_PROVIDER = 'pi' as AgentProviderType
+
export const AGENT_ACTOR_EFFORT = 'high'
export const AGENT_ACTOR_MODEL = 'github-copilot/claude-sonnet-4.6'
export const DOCKER_MOUNTS = resolveDockerMounts()
+export const SANDBOX_AUTH_HOOKS = {
+ sandbox: {
+ onSandboxReady: [
+ ...(AGENT_PROVIDER === 'pi'
+ ? [
+ {
+ command:
+ 'mkdir -p ~/.pi/agent && printf \'%s\' "$PI_AUTH_CONTENT" > ~/.pi/agent/auth.json',
+ },
+ ]
+ : []),
+ ],
+ },
+}
+
+export const SANDBOX_BUILD_HOOKS = {
+ sandbox: {
+ onSandboxReady: [
+ ...SANDBOX_AUTH_HOOKS.sandbox.onSandboxReady,
+ { command: 'pnpm install && pnpm run build' },
+ ],
+ },
+}
+
/**
* @returns Mount entries for pnpm store, or empty if store path is unavailable.
*/
GIT_BRANCH_PREFIX,
GITHUB_ISSUE_LABEL,
MAX_PARALLEL,
+ SANDBOX_BUILD_HOOKS,
} from './constants.js'
import { runRefinementLoop } from './refinement-loop.js'
import { implementStrategy } from './strategies/implement/strategy.js'
try {
await using sandbox = await sandcastle.createSandbox({
branch: spec.branch,
- hooks: {
- sandbox: { onSandboxReady: [{ command: 'pnpm install && pnpm run build' }] },
- },
+ hooks: SANDBOX_BUILD_HOOKS,
sandbox: docker({ imageName: DOCKER_IMAGE, mounts: [...DOCKER_MOUNTS] }),
})
-import * as sandcastle from '@ai-hero/sandcastle'
import crypto from 'node:crypto'
import { readFile, realpath } from 'node:fs/promises'
import { join, sep } from 'node:path'
HASH_PREFIX_LENGTH,
} from './constants.js'
import { parseFindingsSafe } from './types.js'
-import { execFileAsync } from './utils.js'
+import { agentProvider, execFileAsync } from './utils.js'
import { runValidation } from './validation.js'
/** Options for configuring the refinement loop. */
let actorResult: Awaited<ReturnType<typeof sandbox.run>>
try {
actorResult = await sandbox.run({
- agent: sandcastle.opencode(strategy.actorModel ?? AGENT_ACTOR_MODEL),
+ agent: agentProvider(strategy.actorModel ?? AGENT_ACTOR_MODEL),
completionSignal: COMPLETION_SIGNAL,
idleTimeoutSeconds: AGENT_IDLE_TIMEOUT_S,
maxIterations: budget,
const { baseBranch, sandbox, signal, spec, strategy } = ctx
let critic = await sandbox.run({
- agent: sandcastle.opencode(strategy.criticModel ?? AGENT_CRITIC_MODEL),
+ agent: agentProvider(strategy.criticModel ?? AGENT_CRITIC_MODEL),
completionSignal: COMPLETION_SIGNAL,
idleTimeoutSeconds: AGENT_IDLE_TIMEOUT_S,
maxIterations: 1,
if (findings === null) {
console.warn(` #${spec.id}: Critic parse failed. Retrying.`)
critic = await sandbox.run({
- agent: sandcastle.opencode(strategy.criticModel ?? AGENT_CRITIC_MODEL),
+ agent: agentProvider(strategy.criticModel ?? AGENT_CRITIC_MODEL),
completionSignal: COMPLETION_SIGNAL,
idleTimeoutSeconds: AGENT_IDLE_TIMEOUT_S,
maxIterations: 1,
GITHUB_MAX_ISSUES_FETCH,
GITHUB_MAX_PRS_FETCH,
MAX_TITLE_CHARS,
+ SANDBOX_AUTH_HOOKS,
} from './constants.js'
-import { execFileAsync, toErrorMessage } from './utils.js'
+import { agentProvider, execFileAsync, toErrorMessage } from './utils.js'
const RawIssueSchema = z.object({
body: z
let plan: Awaited<ReturnType<typeof sandcastle.run>>
try {
plan = await sandcastle.run({
- agent: sandcastle.opencode(AGENT_PLANNER_MODEL),
+ agent: agentProvider(AGENT_PLANNER_MODEL),
completionSignal: COMPLETION_SIGNAL,
+ hooks: SANDBOX_AUTH_HOOKS,
idleTimeoutSeconds: AGENT_IDLE_TIMEOUT_S,
maxIterations: 1,
name: 'Planner',
+import type { AgentProvider } from '@ai-hero/sandcastle'
+
+import * as sandcastle from '@ai-hero/sandcastle'
import { execFile } from 'node:child_process'
import util from 'node:util'
+import { AGENT_PROVIDER } from './constants.js'
+
/** Async execFile — does not block the event loop. Same error shape as execFileSync. */
export const execFileAsync = util.promisify(execFile)
+/**
+ * Returns a sandcastle agent provider for the given model, selected by AGENT_PROVIDER constant.
+ * @param model - The model identifier (e.g., 'github-copilot/claude-sonnet-4.6').
+ * @returns The configured agent provider.
+ */
+export function agentProvider (model: string): AgentProvider {
+ switch (AGENT_PROVIDER) {
+ case 'opencode':
+ return sandcastle.opencode(model)
+ case 'pi':
+ return sandcastle.pi(model)
+ }
+}
+
/**
* Converts an unknown thrown value to a human-readable error message.
* @param err - The caught value (may be an `Error` or any other type).
'imsi',
'ocpp',
'onconnection',
+ 'opencode',
'evse',
'evses',
'kvar',