diff --git a/README.md b/README.md index 6c4c827783..0c1bf47ef1 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ The Web UI and CLI work out of the box. Optionally connect a chat platform for r ▼ ▼ ▼ ▼ ┌───────────┐ ┌────────────┐ ┌──────────────────────────┐ │ Command │ │ Workflow │ │ AI Assistant Clients │ -│ Handler │ │ Executor │ │ (Claude / Codex) │ +│ Handler │ │ Executor │ │ (Claude / Codex / Pi) │ │ (Slash) │ │ (YAML) │ │ │ └───────────┘ └────────────┘ └──────────────────────────┘ │ │ │ @@ -282,6 +282,94 @@ The Web UI and CLI work out of the box. Optionally connect a chat platform for r └─────────────────────────────────────────────────────────┘ ``` +## Using Pi.dev as Your AI Backend + +[Pi](https://pi.dev) is a multi-provider coding agent that works with Anthropic, OpenAI, Google, and many other LLM providers. Archon integrates Pi as a first-class AI assistant alongside Claude and Codex. + +### Why Pi? + +- **Multi-provider** — One integration, many models: Anthropic, OpenAI, Google Gemini, OpenRouter, Mistral, and more +- **Same tools** — Pi uses the same file tools (read, bash, edit, write) as Claude Code +- **No vendor lock-in** — Swap models without changing your workflows + +### Setup + +**1. Install Pi** + +```bash +npm install -g @mariozechner/pi-coding-agent +``` + +**2. Authenticate with your LLM provider** + +Pi reads API keys from environment variables. Set the key for your chosen provider: + +```bash +# Anthropic (Claude models) +export ANTHROPIC_API_KEY=sk-ant-... + +# OpenAI (GPT models, Codex) +export OPENAI_API_KEY=sk-... + +# Google (Gemini models) +export GOOGLE_API_KEY=AIza... + +# Or log in interactively (OAuth for supported providers) +pi /login +``` + +**3. Set Pi as the default assistant in `.archon/config.yaml`** + +```yaml +# ~/.archon/config.yaml (global) or .archon/config.yaml (per-repo) +defaultAssistant: pi + +assistants: + pi: + model: anthropic/claude-opus-4-5 # provider/model-id format +``` + +Model format is `provider/model-id`. Omit `model` to let Pi auto-select based on available API keys. + +**Supported providers and example model strings:** + +| Provider | Example `model` value | +|----------|----------------------| +| Anthropic | `anthropic/claude-opus-4-5` | +| OpenAI | `openai/gpt-4o` | +| Google | `google/gemini-2.5-pro` | +| OpenRouter | `openrouter/openai/gpt-5.1-codex` | +| Mistral | `mistral/devstral-medium-latest` | +| Azure OpenAI | `azure-openai-responses/gpt-5.2` | + +### Using Pi in Workflows + +Set `provider: pi` on a workflow or individual node: + +```yaml +# .archon/workflows/my-workflow.yaml +provider: pi +model: openai/gpt-4o # optional — falls back to config default + +nodes: + - id: plan + prompt: "Explore the codebase and create an implementation plan" + + - id: implement + depends_on: [plan] + provider: pi # per-node override (uses Pi backend) + model: anthropic/claude-opus-4-5 + prompt: "Implement the plan" +``` + +> **Note:** Claude-specific features (hooks, MCP servers, skills, structured output) are silently skipped when using `provider: pi`. Pi workflows use the standard read/bash/edit/write tool set. + +### Environment Variable Override + +```bash +DEFAULT_AI_ASSISTANT=pi bun run dev +``` + ## Documentation Full documentation is available at **[archon.diy](https://archon.diy)**. @@ -294,7 +382,7 @@ Full documentation is available at **[archon.diy](https://archon.diy)**. | [Authoring Workflows](https://archon.diy/guides/authoring-workflows/) | Create custom YAML workflows | | [Authoring Commands](https://archon.diy/guides/authoring-commands/) | Create reusable AI commands | | [Configuration](https://archon.diy/reference/configuration/) | All config options, env vars, YAML settings | -| [AI Assistants](https://archon.diy/getting-started/ai-assistants/) | Claude and Codex setup details | +| [AI Assistants](https://archon.diy/getting-started/ai-assistants/) | Claude, Codex, and Pi setup details | | [Deployment](https://archon.diy/deployment/) | Docker, VPS, production setup | | [Architecture](https://archon.diy/reference/architecture/) | System design and internals | | [Troubleshooting](https://archon.diy/reference/troubleshooting/) | Common issues and fixes | diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index b94529cd4c..13c29feac2 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -45,7 +45,7 @@ interface SetupConfig { claudeOauthToken?: string; codex: boolean; codexTokens?: CodexTokens; - defaultAssistant: 'claude' | 'codex'; + defaultAssistant: 'claude' | 'codex' | 'pi'; }; platforms: { github: boolean; @@ -677,7 +677,7 @@ After upgrading, run 'archon setup' again.`, } // Determine default assistant - let defaultAssistant: 'claude' | 'codex' = 'claude'; + let defaultAssistant: 'claude' | 'codex' | 'pi' = 'claude'; if (hasClaude && hasCodex) { const defaultChoice = await select({ diff --git a/packages/core/package.json b/packages/core/package.json index d0d93635b6..e669bd7cbc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -33,6 +33,7 @@ "@archon/isolation": "workspace:*", "@archon/paths": "workspace:*", "@archon/workflows": "workspace:*", + "@mariozechner/pi-coding-agent": "^0.66.1", "@openai/codex-sdk": "^0.116.0", "pg": "^8.11.0", "zod": "^3" diff --git a/packages/core/src/clients/factory.ts b/packages/core/src/clients/factory.ts index 027f9843fa..8bc91f6c84 100644 --- a/packages/core/src/clients/factory.ts +++ b/packages/core/src/clients/factory.ts @@ -2,11 +2,12 @@ * AI Assistant Client Factory * * Dynamically instantiates the appropriate AI assistant client based on type string. - * Supports Claude and Codex assistants. + * Supports Claude, Codex, and Pi assistants. */ import type { IAssistantClient } from '../types'; import { ClaudeClient } from './claude'; import { CodexClient } from './codex'; +import { PiClient } from './pi'; import { createLogger } from '@archon/paths'; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ @@ -19,7 +20,7 @@ function getLog(): ReturnType { /** * Get the appropriate AI assistant client based on type * - * @param type - Assistant type identifier ('claude' or 'codex') + * @param type - Assistant type identifier ('claude', 'codex', or 'pi') * @returns Instantiated assistant client * @throws Error if assistant type is unknown */ @@ -31,7 +32,10 @@ export function getAssistantClient(type: string): IAssistantClient { case 'codex': getLog().debug({ provider: 'codex' }, 'client_selected'); return new CodexClient(); + case 'pi': + getLog().debug({ provider: 'pi' }, 'client_selected'); + return new PiClient(); default: - throw new Error(`Unknown assistant type: ${type}. Supported types: 'claude', 'codex'`); + throw new Error(`Unknown assistant type: ${type}. Supported types: 'claude', 'codex', 'pi'`); } } diff --git a/packages/core/src/clients/index.ts b/packages/core/src/clients/index.ts index 98b1d10f20..e39451bdbb 100644 --- a/packages/core/src/clients/index.ts +++ b/packages/core/src/clients/index.ts @@ -10,6 +10,7 @@ export { ClaudeClient } from './claude'; export { CodexClient } from './codex'; +export { PiClient } from './pi'; export { getAssistantClient } from './factory'; // Re-export types for consumers importing from this submodule directly diff --git a/packages/core/src/clients/pi.ts b/packages/core/src/clients/pi.ts new file mode 100644 index 0000000000..886a20d146 --- /dev/null +++ b/packages/core/src/clients/pi.ts @@ -0,0 +1,227 @@ +/** + * Pi.dev Agent SDK wrapper (https://pi.dev) + * Provides async generator interface for streaming Pi coding agent responses. + * + * Authentication: + * - OAuth login (stored in ~/.pi/agent/auth.json after `pi /login`) + * - API key env vars: ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, etc. + * Pi supports many providers; see https://pi.dev docs for the full list. + * + * Model format: + * - Provide `provider/model-id` in AssistantRequestOptions.model + * e.g. "anthropic/claude-opus-4-5", "openai/gpt-4o", "google/gemini-2.5-pro" + * - Omit to use Pi's auto-selected default (based on available API keys) + * + * Pi SDK bridging: + * - Pi SDK uses event subscription (`session.subscribe(callback)`) + * - This client bridges that to Archon's `AsyncGenerator` interface + * via a queue-based adapter pattern. + */ +import { + AuthStorage, + createAgentSession, + createCodingTools, + ModelRegistry, + SessionManager, + type AgentSessionEvent, +} from '@mariozechner/pi-coding-agent'; +import type { AssistantRequestOptions, IAssistantClient, MessageChunk } from '../types'; +import { createLogger } from '@archon/paths'; + +/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ +let cachedLog: ReturnType | undefined; +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('client.pi'); + return cachedLog; +} + +/** + * Serialize a pi tool result to a display string. + * Results can be strings, objects, or null/undefined. + */ +function serializeToolResult(result: unknown): string { + if (result === null || result === undefined) return ''; + if (typeof result === 'string') return result; + if (typeof result === 'object') { + // Pi tool results often have an `output` field (bash tool) + const obj = result as Record; + if (typeof obj['output'] === 'string') return obj['output']; + try { + return JSON.stringify(result); + } catch { + return String(result); + } + } + return String(result); +} + +/** + * Pi.dev coding agent client. + * Implements generic IAssistantClient interface using the @mariozechner/pi-coding-agent SDK. + * + * Pi supports many LLM providers (Anthropic, OpenAI, Google, and more). + * Set the relevant API key env var and specify the model in provider/model-id format. + */ +export class PiClient implements IAssistantClient { + getType(): string { + return 'pi'; + } + + /** + * Send a query to the Pi coding agent and stream responses. + * + * @param prompt - User message or prompt + * @param cwd - Working directory for the agent's file tools + * @param resumeSessionId - Not supported by Pi; a warning is logged and execution proceeds fresh + * @param options - Optional request options. `model` should be in "provider/model-id" format. + */ + async *sendQuery( + prompt: string, + cwd: string, + resumeSessionId?: string, + options?: AssistantRequestOptions + ): AsyncGenerator { + if (resumeSessionId) { + getLog().warn( + { resumeSessionId }, + 'pi.session_resume_unsupported' + ); + } + + // Check if already aborted + if (options?.abortSignal?.aborted) { + throw new Error('Query aborted'); + } + + // Resolve model from options: parse "provider/model-id" format + const authStorage = AuthStorage.create(); + const modelRegistry = ModelRegistry.create(authStorage); + + let model: ReturnType | undefined; + if (options?.model) { + const slashIdx = options.model.indexOf('/'); + if (slashIdx > 0) { + const provider = options.model.slice(0, slashIdx); + const modelId = options.model.slice(slashIdx + 1); + model = modelRegistry.find(provider, modelId); + if (!model) { + getLog().warn( + { model: options.model }, + 'pi.model_not_found' + ); + } + } else { + getLog().warn( + { model: options.model }, + 'pi.model_format_invalid' + ); + } + } + + // Create a Pi coding agent session scoped to the cwd. + // SessionManager.inMemory() prevents Pi from persisting sessions to disk + // (Archon manages its own session state in its database). + const { session } = await createAgentSession({ + cwd, + sessionManager: SessionManager.inMemory(), + tools: createCodingTools(cwd), + ...(model !== undefined ? { model } : {}), + }); + + // Set up abort signal handling + let abortRegistered = false; + const abortHandler = (): void => { + session.abort().catch((err: unknown) => { + getLog().warn({ err }, 'pi.session_abort_failed'); + }); + }; + if (options?.abortSignal) { + options.abortSignal.addEventListener('abort', abortHandler, { once: true }); + abortRegistered = true; + } + + // Queue for bridging event callbacks to async generator + const chunks: MessageChunk[] = []; + let notifyNext: (() => void) | null = null; + let done = false; + let promptError: Error | undefined; + + const notify = (): void => { + const fn = notifyNext; + notifyNext = null; + fn?.(); + }; + + const enqueue = (chunk: MessageChunk): void => { + chunks.push(chunk); + notify(); + }; + + // Subscribe to Pi SDK events and map them to Archon MessageChunk types + const unsubscribe = session.subscribe((event: AgentSessionEvent) => { + if (event.type === 'message_update') { + const ae = event.assistantMessageEvent; + if (ae.type === 'text_delta') { + enqueue({ type: 'assistant', content: ae.delta }); + } else if (ae.type === 'thinking_delta') { + enqueue({ type: 'thinking', content: ae.delta }); + } + } else if (event.type === 'tool_execution_start') { + // event.args is typed `any` in pi-agent-core — normalize to Record + const toolInput: Record = + event.args !== null && typeof event.args === 'object' + ? (event.args as Record) + : {}; + enqueue({ + type: 'tool', + toolName: event.toolName, + toolInput, + toolCallId: event.toolCallId, + }); + } else if (event.type === 'tool_execution_end') { + // event.result is typed `any` in pi-agent-core — serialize to string + const toolOutput = event.isError + ? `Error: ${serializeToolResult(event.result)}` + : serializeToolResult(event.result); + enqueue({ + type: 'tool_result', + toolName: event.toolName, + toolOutput, + toolCallId: event.toolCallId, + }); + } else if (event.type === 'agent_end') { + done = true; + notify(); + } + }); + + // Start the prompt; errors are captured and re-thrown after the generator finishes + session.prompt(prompt).catch((err: unknown) => { + promptError = err instanceof Error ? err : new Error(String(err)); + done = true; + notify(); + }); + + try { + while (!done || chunks.length > 0) { + if (chunks.length > 0) { + yield chunks.shift()!; + } else { + await new Promise(resolve => { + notifyNext = resolve; + }); + } + } + + if (promptError) throw promptError; + + yield { type: 'result' }; + } finally { + unsubscribe(); + if (abortRegistered && options?.abortSignal) { + options.abortSignal.removeEventListener('abort', abortHandler); + } + session.dispose(); + } + } +} diff --git a/packages/core/src/config/config-loader.ts b/packages/core/src/config/config-loader.ts index 8ee702c613..cab678a441 100644 --- a/packages/core/src/config/config-loader.ts +++ b/packages/core/src/config/config-loader.ts @@ -75,7 +75,7 @@ const DEFAULT_CONFIG_CONTENT = `# Archon Global Configuration # Bot display name (shown in messages) # botName: Archon -# Default AI assistant (claude or codex) +# Default AI assistant (claude, codex, or pi) # defaultAssistant: claude # Assistant defaults @@ -88,6 +88,9 @@ const DEFAULT_CONFIG_CONTENT = `# Archon Global Configuration # webSearchMode: disabled # additionalDirectories: # - /absolute/path/to/other/repo +# pi: +# model: anthropic/claude-opus-4-5 # provider/model-id format +# # Pi supports: anthropic, openai, google, and many more providers # Streaming mode per platform (stream or batch) # streaming: @@ -194,6 +197,7 @@ function getDefaults(): MergedConfig { assistants: { claude: {}, codex: {}, + pi: {}, }, streaming: { telegram: 'stream', @@ -232,7 +236,7 @@ function applyEnvOverrides(config: MergedConfig): MergedConfig { // Assistant override const envAssistant = process.env.DEFAULT_AI_ASSISTANT; - if (envAssistant === 'claude' || envAssistant === 'codex') { + if (envAssistant === 'claude' || envAssistant === 'codex' || envAssistant === 'pi') { config.assistant = envAssistant; } @@ -277,6 +281,7 @@ function mergeGlobalConfig(defaults: MergedConfig, global: GlobalConfig): Merged assistants: { claude: { ...defaults.assistants.claude }, codex: { ...defaults.assistants.codex }, + pi: { ...defaults.assistants.pi }, }, }; @@ -302,6 +307,12 @@ function mergeGlobalConfig(defaults: MergedConfig, global: GlobalConfig): Merged ...global.assistants.codex, }; } + if (global.assistants?.pi) { + result.assistants.pi = { + ...result.assistants.pi, + ...global.assistants.pi, + }; + } // Streaming preferences if (global.streaming) { @@ -339,6 +350,7 @@ function mergeRepoConfig(merged: MergedConfig, repo: RepoConfig): MergedConfig { assistants: { claude: { ...merged.assistants.claude }, codex: { ...merged.assistants.codex }, + pi: { ...merged.assistants.pi }, }, }; @@ -359,6 +371,12 @@ function mergeRepoConfig(merged: MergedConfig, repo: RepoConfig): MergedConfig { ...repo.assistants.codex, }; } + if (repo.assistants?.pi) { + result.assistants.pi = { + ...result.assistants.pi, + ...repo.assistants.pi, + }; + } // Commands config if (repo.commands) { @@ -479,6 +497,7 @@ export async function updateGlobalConfig(updates: Partial): Promis merged.assistants = { claude: { ...current.assistants?.claude, ...updates.assistants.claude }, codex: { ...current.assistants?.codex, ...updates.assistants.codex }, + pi: { ...current.assistants?.pi, ...updates.assistants.pi }, }; } @@ -529,6 +548,9 @@ export function toSafeConfig(config: MergedConfig): SafeConfig { modelReasoningEffort: config.assistants.codex.modelReasoningEffort, webSearchMode: config.assistants.codex.webSearchMode, }, + pi: { + model: config.assistants.pi.model, + }, }, streaming: { telegram: config.streaming.telegram, diff --git a/packages/core/src/config/config-types.ts b/packages/core/src/config/config-types.ts index 3baa3dfdca..b933ac4c71 100644 --- a/packages/core/src/config/config-types.ts +++ b/packages/core/src/config/config-types.ts @@ -18,7 +18,7 @@ export interface AssistantDefaults { webSearchMode?: WebSearchMode; additionalDirectories?: string[]; /** Path to the Codex CLI binary. Overrides auto-detection in compiled Archon builds. - * Only relevant for the Codex provider; ignored for Claude. */ + * Only relevant for the Codex provider; ignored for Claude and Pi. */ codexBinaryPath?: string; } @@ -30,6 +30,15 @@ export interface ClaudeAssistantDefaults { settingSources?: ('project' | 'user')[]; } +/** + * Pi.dev agent defaults. + * Model must be specified in "provider/model-id" format, e.g. "anthropic/claude-opus-4-5". + * Pi supports many providers — see https://pi.dev for the full list. + */ +export interface PiAssistantDefaults { + model?: string; +} + export interface GlobalConfig { /** * Bot display name (shown in messages) @@ -41,7 +50,7 @@ export interface GlobalConfig { * Default AI assistant when no codebase-specific preference * @default 'claude' */ - defaultAssistant?: 'claude' | 'codex'; + defaultAssistant?: 'claude' | 'codex' | 'pi'; /** * Assistant-specific defaults (model, reasoning effort, etc.) @@ -49,6 +58,7 @@ export interface GlobalConfig { assistants?: { claude?: ClaudeAssistantDefaults; codex?: AssistantDefaults; + pi?: PiAssistantDefaults; }; /** @@ -112,7 +122,7 @@ export interface RepoConfig { * AI assistant preference for this repository * Overrides global default */ - assistant?: 'claude' | 'codex'; + assistant?: 'claude' | 'codex' | 'pi'; /** * Assistant-specific defaults for this repository @@ -120,6 +130,7 @@ export interface RepoConfig { assistants?: { claude?: ClaudeAssistantDefaults; codex?: AssistantDefaults; + pi?: PiAssistantDefaults; }; /** @@ -215,10 +226,11 @@ export interface RepoConfig { */ export interface MergedConfig { botName: string; - assistant: 'claude' | 'codex'; + assistant: 'claude' | 'codex' | 'pi'; assistants: { claude: ClaudeAssistantDefaults; codex: AssistantDefaults; + pi: PiAssistantDefaults; }; streaming: { telegram: 'stream' | 'batch'; @@ -279,10 +291,11 @@ export interface MergedConfig { */ export interface SafeConfig { botName: string; - assistant: 'claude' | 'codex'; + assistant: 'claude' | 'codex' | 'pi'; assistants: { claude: Pick; codex: Pick; + pi: Pick; }; streaming: { telegram: 'stream' | 'batch'; diff --git a/packages/core/src/services/title-generator.ts b/packages/core/src/services/title-generator.ts index 7bfb8f9179..b2ae2f82f9 100644 --- a/packages/core/src/services/title-generator.ts +++ b/packages/core/src/services/title-generator.ts @@ -26,7 +26,7 @@ const MAX_TITLE_LENGTH = 100; * * @param conversationDbId - Database UUID of the conversation * @param userMessage - The user's message to generate a title from - * @param assistantType - 'claude' or 'codex' + * @param assistantType - 'claude', 'codex', or 'pi' * @param cwd - Working directory for the AI client * @param workflowName - Optional workflow name for additional context */ diff --git a/packages/server/src/routes/api.ts b/packages/server/src/routes/api.ts index ed267c1d41..a6b9bd23f1 100644 --- a/packages/server/src/routes/api.ts +++ b/packages/server/src/routes/api.ts @@ -2545,10 +2545,11 @@ export function registerApiRoutes( if (body.assistant !== undefined) { updates.defaultAssistant = body.assistant; } - if (body.claude !== undefined || body.codex !== undefined) { + if (body.claude !== undefined || body.codex !== undefined || body.pi !== undefined) { updates.assistants = { ...(body.claude ? { claude: body.claude } : {}), ...(body.codex ? { codex: body.codex } : {}), + ...(body.pi ? { pi: body.pi } : {}), }; } diff --git a/packages/server/src/routes/schemas/config.schemas.ts b/packages/server/src/routes/schemas/config.schemas.ts index d3ba003366..ce82c26cbd 100644 --- a/packages/server/src/routes/schemas/config.schemas.ts +++ b/packages/server/src/routes/schemas/config.schemas.ts @@ -7,7 +7,7 @@ import { z } from '@hono/zod-openapi'; export const safeConfigSchema = z .object({ botName: z.string(), - assistant: z.enum(['claude', 'codex']), + assistant: z.enum(['claude', 'codex', 'pi']), assistants: z.object({ claude: z.object({ model: z.string().optional() }), codex: z.object({ @@ -15,6 +15,7 @@ export const safeConfigSchema = z modelReasoningEffort: z.enum(['minimal', 'low', 'medium', 'high', 'xhigh']).optional(), webSearchMode: z.enum(['disabled', 'cached', 'live']).optional(), }), + pi: z.object({ model: z.string().optional() }), }), streaming: z.object({ telegram: z.enum(['stream', 'batch']), @@ -34,7 +35,7 @@ export const safeConfigSchema = z /** Body for PATCH /api/config/assistants — all fields optional (partial update). */ export const updateAssistantConfigBodySchema = z .object({ - assistant: z.enum(['claude', 'codex']).optional(), + assistant: z.enum(['claude', 'codex', 'pi']).optional(), claude: z .object({ model: z.string(), @@ -47,6 +48,11 @@ export const updateAssistantConfigBodySchema = z webSearchMode: z.enum(['disabled', 'cached', 'live']).optional(), }) .optional(), + pi: z + .object({ + model: z.string(), + }) + .optional(), }) .openapi('UpdateAssistantConfigBody'); diff --git a/packages/workflows/src/dag-executor.ts b/packages/workflows/src/dag-executor.ts index facfbd1068..3b1f7a2291 100644 --- a/packages/workflows/src/dag-executor.ts +++ b/packages/workflows/src/dag-executor.ts @@ -362,7 +362,7 @@ function expandEnvVars(config: Record): { */ async function resolveNodeProviderAndModel( node: DagNode, - workflowProvider: 'claude' | 'codex', + workflowProvider: 'claude' | 'codex' | 'pi', workflowModel: string | undefined, config: WorkflowConfig, platform: IWorkflowPlatform, @@ -371,11 +371,11 @@ async function resolveNodeProviderAndModel( cwd: string, workflowLevelOptions: WorkflowLevelOptions ): Promise<{ - provider: 'claude' | 'codex'; + provider: 'claude' | 'codex' | 'pi'; model: string | undefined; options: WorkflowAssistantOptions | undefined; }> { - let provider: 'claude' | 'codex'; + let provider: 'claude' | 'codex' | 'pi'; if (node.provider) { provider = node.provider; @@ -496,6 +496,11 @@ async function resolveNodeProviderAndModel( if (node.output_format) { options.outputFormat = { type: 'json_schema', schema: node.output_format }; } + } else if (provider === 'pi') { + // Pi supports model (in "provider/model-id" format) and nothing else from the Archon + // workflow options — features like hooks, MCP, skills, output_format, and env injection + // are Claude/Codex-specific and are silently skipped for Pi. + options = model ? { model } : undefined; } else { const claudeOptions: WorkflowAssistantOptions = {}; if (model) claudeOptions.model = model; @@ -716,7 +721,7 @@ async function executeNodeInternal( cwd: string, workflowRun: WorkflowRun, node: CommandNode | PromptNode, - provider: 'claude' | 'codex', + provider: 'claude' | 'codex' | 'pi', nodeOptions: WorkflowAssistantOptions | undefined, artifactsDir: string, logDir: string, @@ -1667,7 +1672,7 @@ async function executeScriptNode( * Caller is responsible for resolving per-node overrides before passing model. */ function buildLoopNodeOptions( - provider: 'claude' | 'codex', + provider: 'claude' | 'codex' | 'pi', model: string | undefined, config: WorkflowConfig ): WorkflowAssistantOptions | undefined { @@ -1704,7 +1709,7 @@ async function executeLoopNode( cwd: string, workflowRun: WorkflowRun, node: LoopNode, - workflowProvider: 'claude' | 'codex', + workflowProvider: 'claude' | 'codex' | 'pi', workflowModel: string | undefined, artifactsDir: string, logDir: string, @@ -2194,7 +2199,7 @@ async function executeApprovalNode( deps: WorkflowDeps, platform: IWorkflowPlatform, conversationId: string, - workflowProvider: 'claude' | 'codex', + workflowProvider: 'claude' | 'codex' | 'pi', workflowModel: string | undefined, cwd: string, artifactsDir: string, @@ -2364,7 +2369,7 @@ export async function executeDagWorkflow( cwd: string, workflow: { name: string; nodes: readonly DagNode[] } & WorkflowLevelOptions, workflowRun: WorkflowRun, - workflowProvider: 'claude' | 'codex', + workflowProvider: 'claude' | 'codex' | 'pi', workflowModel: string | undefined, artifactsDir: string, logDir: string, @@ -2601,7 +2606,7 @@ export async function executeDagWorkflow( // 3b. Loop node dispatch — manages its own AI sessions and iteration if (isLoopNode(node)) { // Resolve per-node provider/model overrides (same logic as other node types) - let loopProvider: 'claude' | 'codex'; + let loopProvider: 'claude' | 'codex' | 'pi'; if (node.provider) { loopProvider = node.provider; } else if (node.model && isClaudeModel(node.model)) { diff --git a/packages/workflows/src/deps.ts b/packages/workflows/src/deps.ts index ce586a177b..af30b2af3f 100644 --- a/packages/workflows/src/deps.ts +++ b/packages/workflows/src/deps.ts @@ -226,7 +226,7 @@ export interface IWorkflowAssistantClient { getType(): string; } -export type AssistantClientFactory = (provider: 'claude' | 'codex') => IWorkflowAssistantClient; +export type AssistantClientFactory = (provider: 'claude' | 'codex' | 'pi') => IWorkflowAssistantClient; // --------------------------------------------------------------------------- // Narrow config interface (subset of MergedConfig) @@ -237,8 +237,8 @@ export type AssistantClientFactory = (provider: 'claude' | 'codex') => IWorkflow // --------------------------------------------------------------------------- export interface WorkflowConfig { - /** Default assistant provider ('claude' | 'codex') */ - assistant: 'claude' | 'codex'; + /** Default assistant provider ('claude' | 'codex' | 'pi') */ + assistant: 'claude' | 'codex' | 'pi'; baseBranch?: string; docsPath?: string; /** @@ -263,6 +263,9 @@ export interface WorkflowConfig { webSearchMode?: WebSearchMode; additionalDirectories?: string[]; }; + pi: { + model?: string; + }; }; } diff --git a/packages/workflows/src/executor.ts b/packages/workflows/src/executor.ts index e87ea9065b..5527e6c066 100644 --- a/packages/workflows/src/executor.ts +++ b/packages/workflows/src/executor.ts @@ -278,7 +278,8 @@ export async function executeWorkflow( // Resolve provider and model once (used by all nodes) // When workflow sets a model but not a provider, infer provider from the model. // e.g. model: sonnet → provider: claude, even if config.assistant is codex. - let resolvedProvider: 'claude' | 'codex'; + // Pi workflows should always set provider: pi explicitly (pi models cannot be auto-inferred). + let resolvedProvider: 'claude' | 'codex' | 'pi'; let providerSource: string; if (workflow.provider) { resolvedProvider = workflow.provider; diff --git a/packages/workflows/src/loader.ts b/packages/workflows/src/loader.ts index 0fd93cce1f..8723624c03 100644 --- a/packages/workflows/src/loader.ts +++ b/packages/workflows/src/loader.ts @@ -271,7 +271,9 @@ export function parseWorkflow(content: string, filename: string): ParseResult { // Note: modelReasoningEffort and webSearchMode use warn-and-ignore for invalid values // (consistent with original behavior) rather than schema-level rejection. const provider = - raw.provider === 'claude' || raw.provider === 'codex' ? raw.provider : undefined; + raw.provider === 'claude' || raw.provider === 'codex' || raw.provider === 'pi' + ? raw.provider + : undefined; const model = typeof raw.model === 'string' ? raw.model : undefined; // Validate model/provider compatibility at workflow level diff --git a/packages/workflows/src/model-validation.ts b/packages/workflows/src/model-validation.ts index b035582717..2a149b3ca1 100644 --- a/packages/workflows/src/model-validation.ts +++ b/packages/workflows/src/model-validation.ts @@ -8,8 +8,10 @@ export function isClaudeModel(model: string): boolean { ); } -export function isModelCompatible(provider: 'claude' | 'codex', model?: string): boolean { +export function isModelCompatible(provider: 'claude' | 'codex' | 'pi', model?: string): boolean { if (!model) return true; + // Pi supports any model via its multi-provider architecture — no restrictions apply. + if (provider === 'pi') return true; if (provider === 'claude') return isClaudeModel(model); // Codex: accept most models, but reject obvious Claude aliases/prefixes return !isClaudeModel(model); diff --git a/packages/workflows/src/schemas/dag-node.ts b/packages/workflows/src/schemas/dag-node.ts index 82bd90ac86..767c326387 100644 --- a/packages/workflows/src/schemas/dag-node.ts +++ b/packages/workflows/src/schemas/dag-node.ts @@ -116,7 +116,7 @@ export const dagNodeBaseSchema = z.object({ when: z.string().optional(), trigger_rule: triggerRuleSchema.optional(), model: z.string().optional(), - provider: z.enum(['claude', 'codex']).optional(), + provider: z.enum(['claude', 'codex', 'pi']).optional(), context: z.enum(['fresh', 'shared']).optional(), output_format: z.record(z.unknown()).optional(), allowed_tools: z.array(z.string()).optional(), diff --git a/packages/workflows/src/schemas/workflow.ts b/packages/workflows/src/schemas/workflow.ts index 008ef19a8f..a15d0029ce 100644 --- a/packages/workflows/src/schemas/workflow.ts +++ b/packages/workflows/src/schemas/workflow.ts @@ -29,7 +29,7 @@ export type WebSearchMode = z.infer; export const workflowBaseSchema = z.object({ name: z.string().min(1), description: z.string().min(1), - provider: z.enum(['claude', 'codex']).optional(), + provider: z.enum(['claude', 'codex', 'pi']).optional(), model: z.string().optional(), modelReasoningEffort: modelReasoningEffortSchema.optional(), webSearchMode: webSearchModeSchema.optional(), diff --git a/packages/workflows/src/validator.ts b/packages/workflows/src/validator.ts index be0011763c..8cfbd14572 100644 --- a/packages/workflows/src/validator.ts +++ b/packages/workflows/src/validator.ts @@ -335,14 +335,16 @@ export async function validateWorkflowResources( } } - // Warn if using MCP with Codex - if (provider === 'codex') { + // Warn if using MCP with Codex or Pi + if (provider === 'codex' || provider === 'pi') { issues.push({ level: 'warning', nodeId: node.id, field: 'mcp', - message: 'MCP servers are Claude-only per-node — this will be ignored on Codex', - hint: 'For Codex, configure MCP servers globally in ~/.codex/config.toml instead', + message: `MCP servers are Claude-only per-node — this will be ignored on ${provider}`, + hint: provider === 'codex' + ? 'For Codex, configure MCP servers globally in ~/.codex/config.toml instead' + : 'For Pi, MCP is not supported per-node. Use Pi extensions for equivalent functionality', }); } } @@ -367,31 +369,33 @@ export async function validateWorkflowResources( } } - // Warn if using skills with Codex - if (provider === 'codex') { + // Warn if using skills with Codex or Pi + if (provider === 'codex' || provider === 'pi') { issues.push({ level: 'warning', nodeId: node.id, field: 'skills', - message: 'Skills are Claude-only per-node — this will be ignored on Codex', - hint: 'For Codex, place skills in ~/.agents/skills/ for global discovery instead', + message: `Skills are Claude-only per-node — this will be ignored on ${provider}`, + hint: provider === 'codex' + ? 'For Codex, place skills in ~/.agents/skills/ for global discovery instead' + : 'For Pi, skills are not supported. Use Pi extensions instead', }); } } - // --- Hooks with Codex warning --- - if ('hooks' in node && node.hooks && provider === 'codex') { + // --- Hooks with Codex or Pi warning --- + if ('hooks' in node && node.hooks && (provider === 'codex' || provider === 'pi')) { issues.push({ level: 'warning', nodeId: node.id, field: 'hooks', - message: 'Hooks are Claude-only — this will be ignored on Codex', - hint: 'Hooks have no Codex equivalent. Remove them or switch to provider: claude', + message: `Hooks are Claude-only — this will be ignored on ${provider}`, + hint: `Hooks have no ${provider} equivalent. Remove them or switch to provider: claude`, }); } - // --- Tool restrictions with Codex warning --- - if (provider === 'codex') { + // --- Tool restrictions with Codex or Pi warning --- + if (provider === 'codex' || provider === 'pi') { if ( ('allowed_tools' in node && node.allowed_tools !== undefined) || ('denied_tools' in node && node.denied_tools !== undefined) @@ -400,8 +404,10 @@ export async function validateWorkflowResources( level: 'warning', nodeId: node.id, field: 'allowed_tools/denied_tools', - message: 'Tool restrictions are Claude-only — this will be ignored on Codex', - hint: 'For Codex, configure tool restrictions per MCP server in ~/.codex/config.toml', + message: `Tool restrictions are Claude-only — this will be ignored on ${provider}`, + hint: provider === 'codex' + ? 'For Codex, configure tool restrictions per MCP server in ~/.codex/config.toml' + : 'For Pi, tool restrictions are not supported per-node', }); } }