From de44fddf29a969d3b8587f78c0abede0562b1416 Mon Sep 17 00:00:00 2001 From: Guoyu Su Date: Wed, 29 Jul 2026 02:10:06 +0800 Subject: [PATCH] feat(ai): add Pi Coding Agent provider --- apps/desktop/public/icons/ai/pi.svg | 26 + .../editor/EditorSettingsDialog.vue | 44 +- .../src/composables/useAiModelCatalog.ts | 2 + apps/desktop/src/i18n/backend-errors.ts | 10 + apps/desktop/src/i18n/locales/en.ts | 10 + apps/desktop/src/i18n/locales/es.ts | 10 + apps/desktop/src/i18n/locales/it.ts | 10 + apps/desktop/src/i18n/locales/ja.ts | 10 + apps/desktop/src/i18n/locales/pt-BR.ts | 10 + apps/desktop/src/i18n/locales/zh-CN.ts | 10 + apps/desktop/src/i18n/locales/zh-TW.ts | 10 + .../__tests__/ai/aiConfigCandidates.spec.ts | 2 +- .../lib/ai/__tests__/aiConfigOrdering.spec.ts | 3 +- apps/desktop/src/lib/ai/aiConfigCandidates.ts | 2 +- apps/desktop/src/stores/settingsStore.ts | 14 +- apps/desktop/src/types/ai.ts | 4 +- crates/dbx-core/assets/pi-mcp-bridge.mjs | 201 ++++ crates/dbx-core/src/agent_loop.rs | 10 +- crates/dbx-core/src/ai.rs | 85 +- crates/dbx-core/src/ai_claude_code_cli.rs | 2 + crates/dbx-core/src/ai_codex_cli.rs | 2 + crates/dbx-core/src/ai_effort.rs | 12 +- crates/dbx-core/src/ai_model_filter.rs | 1 + crates/dbx-core/src/ai_pi_agent_cli.rs | 988 ++++++++++++++++++ crates/dbx-core/src/cloud_sync.rs | 2 + crates/dbx-core/src/lib.rs | 1 + crates/dbx-core/src/storage.rs | 2 + crates/dbx-web/src/routes/ai.rs | 12 +- packages/app-tests/aiCliErrorI18n.test.ts | 25 +- packages/app-tests/piAgentMcpBridge.test.ts | 86 ++ packages/app-tests/settingsStore.test.ts | 14 + src-tauri/src/commands/ai.rs | 3 +- 32 files changed, 1592 insertions(+), 31 deletions(-) create mode 100644 apps/desktop/public/icons/ai/pi.svg create mode 100644 crates/dbx-core/assets/pi-mcp-bridge.mjs create mode 100644 crates/dbx-core/src/ai_pi_agent_cli.rs create mode 100644 packages/app-tests/piAgentMcpBridge.test.ts diff --git a/apps/desktop/public/icons/ai/pi.svg b/apps/desktop/public/icons/ai/pi.svg new file mode 100644 index 0000000000..9aed6edec1 --- /dev/null +++ b/apps/desktop/public/icons/ai/pi.svg @@ -0,0 +1,26 @@ + + + + + + diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index e776078345..93861e2973 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -2195,7 +2195,7 @@ async function saveMaxAgentTurnsSetting() { const aiDeleteConfirmOpen = ref(false); const aiDeleteConfigId = ref(null); -const CLI_AI_PROVIDERS = new Set(["claude-code-cli", "codex-cli"]); +const CLI_AI_PROVIDERS = new Set(["claude-code-cli", "pi-agent-cli", "codex-cli"]); const aiProviderOptions = computed(() => Object.values(AI_PROVIDER_PRESETS).filter((provider) => !isWeb || !CLI_AI_PROVIDERS.has(provider.provider))); const selectedAiProviderPreset = computed(() => AI_PROVIDER_PRESETS[aiEditProvider.value]); @@ -2215,6 +2215,8 @@ const aiEditCodexCliPath = ref(""); const aiEditCodexCliEnvRows = ref([]); const aiEditClaudeCodeCliPath = ref(""); const aiEditClaudeCodeCliEnvRows = ref([]); +const aiEditPiAgentCliPath = ref(""); +const aiEditPiAgentCliEnvRows = ref([]); const aiAnthropicMessagesMode = computed(() => aiEditApiStyle.value === "anthropic-messages"); @@ -2243,21 +2245,40 @@ const aiTestErrorPresentation = computed(() => { const aiTestErrorDisplay = computed(() => [aiTestErrorPresentation.value.summary, aiTestErrorPresentation.value.detail].filter(Boolean).join(" ")); const aiIsCodexCli = computed(() => aiEditProvider.value === "codex-cli"); const aiIsClaudeCodeCli = computed(() => aiEditProvider.value === "claude-code-cli"); +const aiIsPiAgentCli = computed(() => aiEditProvider.value === "pi-agent-cli"); const aiIsCliProvider = computed(() => CLI_AI_PROVIDERS.has(aiEditProvider.value)); const aiCliProviderLabel = computed(() => selectedAiProviderPreset.value.label); -const aiCliCommandName = computed(() => (aiIsClaudeCodeCli.value ? "claude" : "codex")); -const aiCliLoginCommand = computed(() => (aiIsClaudeCodeCli.value ? "claude auth login" : "codex login")); +const aiCliCommandName = computed(() => { + if (aiIsClaudeCodeCli.value) return "claude"; + if (aiIsPiAgentCli.value) return "pi"; + return "codex"; +}); +const aiCliLoginCommand = computed(() => { + if (aiIsClaudeCodeCli.value) return "claude auth login"; + if (aiIsPiAgentCli.value) return "pi"; + return "codex login"; +}); const aiEditCliPath = computed({ - get: () => (aiIsClaudeCodeCli.value ? aiEditClaudeCodeCliPath.value : aiEditCodexCliPath.value), + get: () => { + if (aiIsClaudeCodeCli.value) return aiEditClaudeCodeCliPath.value; + if (aiIsPiAgentCli.value) return aiEditPiAgentCliPath.value; + return aiEditCodexCliPath.value; + }, set: (value: string) => { if (aiIsClaudeCodeCli.value) { aiEditClaudeCodeCliPath.value = value; + } else if (aiIsPiAgentCli.value) { + aiEditPiAgentCliPath.value = value; } else { aiEditCodexCliPath.value = value; } }, }); -const aiEditCliEnvRows = computed(() => (aiIsClaudeCodeCli.value ? aiEditClaudeCodeCliEnvRows.value : aiEditCodexCliEnvRows.value)); +const aiEditCliEnvRows = computed(() => { + if (aiIsClaudeCodeCli.value) return aiEditClaudeCodeCliEnvRows.value; + if (aiIsPiAgentCli.value) return aiEditPiAgentCliEnvRows.value; + return aiEditCodexCliEnvRows.value; +}); watch(aiIsCliProvider, (isCliProvider) => { if (isCliProvider) void ensureCliMcpStatus(); }); @@ -2325,7 +2346,10 @@ function cliEnvValidationError(): string { for (const row of aiEditCliEnvRows.value) { const key = row.key.trim(); if (key && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return t("ai.cliEnvInvalidName", { name: key }); - if (key.toUpperCase().startsWith("DBX_MCP_")) return t("ai.cliEnvReservedName", { name: key }); + const upper = key.toUpperCase(); + if (upper.startsWith("DBX_MCP_") || (aiIsPiAgentCli.value && upper.startsWith("DBX_PI_"))) { + return t("ai.cliEnvReservedName", { name: key }); + } } return ""; } @@ -2337,6 +2361,8 @@ function addCliEnvRow() { function removeCliEnvRow(id: string) { if (aiIsClaudeCodeCli.value) { aiEditClaudeCodeCliEnvRows.value = aiEditClaudeCodeCliEnvRows.value.filter((row) => row.id !== id); + } else if (aiIsPiAgentCli.value) { + aiEditPiAgentCliEnvRows.value = aiEditPiAgentCliEnvRows.value.filter((row) => row.id !== id); } else { aiEditCodexCliEnvRows.value = aiEditCodexCliEnvRows.value.filter((row) => row.id !== id); } @@ -2363,6 +2389,8 @@ function currentAiEditConfig() { codexCliEnv: aiIsCodexCli.value ? cliEnvFromRows(aiEditCodexCliEnvRows.value) : {}, claudeCodeCliPath: aiEditClaudeCodeCliPath.value.trim() || undefined, claudeCodeCliEnv: aiIsClaudeCodeCli.value ? cliEnvFromRows(aiEditClaudeCodeCliEnvRows.value) : {}, + piAgentCliPath: aiEditPiAgentCliPath.value.trim() || undefined, + piAgentCliEnv: aiIsPiAgentCli.value ? cliEnvFromRows(aiEditPiAgentCliEnvRows.value) : {}, }; } @@ -2430,6 +2458,8 @@ function aiEnterEditMode(configId?: string) { aiEditCodexCliEnvRows.value = aiEnvRowsFromConfig(config.codexCliEnv); aiEditClaudeCodeCliPath.value = config.claudeCodeCliPath ?? ""; aiEditClaudeCodeCliEnvRows.value = aiEnvRowsFromConfig(config.claudeCodeCliEnv); + aiEditPiAgentCliPath.value = config.piAgentCliPath ?? ""; + aiEditPiAgentCliEnvRows.value = aiEnvRowsFromConfig(config.piAgentCliEnv); } } else { aiEditConfigName.value = ""; @@ -2449,6 +2479,8 @@ function aiEnterEditMode(configId?: string) { aiEditCodexCliEnvRows.value = []; aiEditClaudeCodeCliPath.value = ""; aiEditClaudeCodeCliEnvRows.value = []; + aiEditPiAgentCliPath.value = ""; + aiEditPiAgentCliEnvRows.value = []; } } diff --git a/apps/desktop/src/composables/useAiModelCatalog.ts b/apps/desktop/src/composables/useAiModelCatalog.ts index f3ff32e1d4..ca51970a2a 100644 --- a/apps/desktop/src/composables/useAiModelCatalog.ts +++ b/apps/desktop/src/composables/useAiModelCatalog.ts @@ -56,6 +56,7 @@ function configSignature(config: AiConfigItem): string { contextWindow: config.contextWindow ?? null, codexCliPath: config.codexCliPath ?? null, claudeCodeCliPath: config.claudeCodeCliPath ?? null, + piAgentCliPath: config.piAgentCliPath ?? null, connectionFingerprint: fingerprint( JSON.stringify({ apiKey: config.apiKey, @@ -63,6 +64,7 @@ function configSignature(config: AiConfigItem): string { proxyUrl: config.proxyUrl ?? "", codexCliEnv: sortedRecord(config.codexCliEnv), claudeCodeCliEnv: sortedRecord(config.claudeCodeCliEnv), + piAgentCliEnv: sortedRecord(config.piAgentCliEnv), }), ), }); diff --git a/apps/desktop/src/i18n/backend-errors.ts b/apps/desktop/src/i18n/backend-errors.ts index 5dbf3b36a5..9837c91119 100644 --- a/apps/desktop/src/i18n/backend-errors.ts +++ b/apps/desktop/src/i18n/backend-errors.ts @@ -11,6 +11,16 @@ const taggedAiCliErrorKeys: Record = { claudeCodeMcpStartupFailed: "ai.cliErrors.claudeCodeMcpStartupFailed", claudeCodeCommandLineTooLong: "ai.cliErrors.claudeCodeCommandLineTooLong", claudeCodeRunFailed: "ai.cliErrors.claudeCodeRunFailed", + piAgentNotInstalled: "ai.cliErrors.piAgentNotInstalled", + piAgentCliPathInvalid: "ai.cliErrors.piAgentCliPathInvalid", + piAgentEnvInvalid: "ai.cliErrors.piAgentEnvInvalid", + piAgentEnvReserved: "ai.cliErrors.piAgentEnvReserved", + piAgentNotAuthenticated: "ai.cliErrors.piAgentNotAuthenticated", + piAgentMcpStartupFailed: "ai.cliErrors.piAgentMcpStartupFailed", + piAgentTimeout: "ai.cliErrors.piAgentTimeout", + piAgentProtocolError: "ai.cliErrors.piAgentProtocolError", + piAgentModelInvalid: "ai.cliErrors.piAgentModelInvalid", + piAgentRunFailed: "ai.cliErrors.piAgentRunFailed", }; const patterns: [RegExp, string][] = [ diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 8cce9a67b7..05f23c56d0 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1786,6 +1786,16 @@ export default { claudeCodeMcpStartupFailed: "Claude Code loaded the MCP configuration but could not start DBX MCP Server. Check Settings > MCP and the diagnostics below.", claudeCodeCommandLineTooLong: "Windows rejected the Claude Code command because it was too long. Update DBX and retry using the generated MCP configuration file.", claudeCodeRunFailed: "Claude Code CLI exited unexpectedly. Use the error code and diagnostics below to identify the failing executable or CLI output.", + piAgentNotInstalled: "Pi Coding Agent was not found. Install Pi or set its executable path in Settings > AI.", + piAgentCliPathInvalid: "The Pi Coding Agent path is invalid. Select only the Pi executable and configure environment variables separately.", + piAgentEnvInvalid: "A Pi Coding Agent environment variable name is invalid. Use names such as HTTPS_PROXY.", + piAgentEnvReserved: "A DBX-managed Pi or MCP environment variable was overridden. Remove DBX_PI_* and DBX_MCP_* variables from the provider configuration.", + piAgentNotAuthenticated: "Pi Coding Agent has no authenticated model provider. Configure or sign in to a provider with Pi and try again.", + piAgentMcpStartupFailed: "Pi could not start the scoped DBX MCP bridge. Check Settings > MCP and the diagnostics below.", + piAgentTimeout: "Pi Coding Agent did not respond before the operation timed out.", + piAgentProtocolError: "Pi Coding Agent returned an invalid RPC response. Check the Pi version and diagnostics below.", + piAgentModelInvalid: "The selected Pi model identifier is invalid. Refresh the model list and select a provider/model entry.", + piAgentRunFailed: "Pi Coding Agent exited unexpectedly. Use the error code and diagnostics below to identify the failing executable or CLI output.", }, actions: { general: "General", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index fcb0bd8bad..eed317cc51 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1634,6 +1634,16 @@ export default withEnglishFallback({ claudeCodeMcpStartupFailed: "Claude Code cargó la configuración MCP, pero no pudo iniciar DBX MCP Server. Comprueba Configuración > MCP y los detalles siguientes.", claudeCodeCommandLineTooLong: "Windows rechazó el comando de Claude Code porque era demasiado largo. Actualiza DBX y vuelve a intentarlo con el archivo MCP generado.", claudeCodeRunFailed: "Claude Code CLI terminó de forma inesperada. Usa el código de error y los detalles siguientes para identificar el ejecutable o la salida que falló.", + piAgentNotInstalled: "No se encontró Pi Coding Agent. Instala Pi o configura la ruta del ejecutable en Configuración > AI.", + piAgentCliPathInvalid: "La ruta de Pi Coding Agent no es válida. Selecciona solo el ejecutable de Pi y configura las variables de entorno por separado.", + piAgentEnvInvalid: "El nombre de una variable de entorno de Pi Coding Agent no es válido. Usa nombres como HTTPS_PROXY.", + piAgentEnvReserved: "Se sobrescribió una variable de Pi o MCP administrada por DBX. Elimina DBX_PI_* y DBX_MCP_* de la configuración.", + piAgentNotAuthenticated: "Pi Coding Agent no tiene un proveedor de modelos autenticado. Configura o inicia sesión en un proveedor con Pi.", + piAgentMcpStartupFailed: "Pi no pudo iniciar el puente MCP de DBX. Comprueba Configuración > MCP y los detalles siguientes.", + piAgentTimeout: "Pi Coding Agent no respondió antes de que la operación agotara el tiempo.", + piAgentProtocolError: "Pi Coding Agent devolvió una respuesta RPC no válida. Comprueba la versión de Pi y los detalles siguientes.", + piAgentModelInvalid: "El identificador del modelo Pi no es válido. Actualiza la lista y selecciona una entrada provider/model.", + piAgentRunFailed: "Pi Coding Agent terminó de forma inesperada. Usa el código de error y los detalles siguientes para identificar el fallo.", }, run: "Ejecutar", readingSchema: "Leyendo esquema", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 3d17d59554..50419ba0e4 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1765,6 +1765,16 @@ export default withEnglishFallback({ claudeCodeMcpStartupFailed: "Claude Code ha caricato la configurazione MCP ma non ha potuto avviare DBX MCP Server. Controlla Impostazioni > MCP e i dettagli seguenti.", claudeCodeCommandLineTooLong: "Windows ha rifiutato il comando Claude Code perché troppo lungo. Aggiorna DBX e riprova usando il file MCP generato.", claudeCodeRunFailed: "Claude Code CLI è terminato in modo imprevisto. Usa il codice errore e i dettagli seguenti per identificare l'eseguibile o l'output non riuscito.", + piAgentNotInstalled: "Pi Coding Agent non è stato trovato. Installa Pi o imposta il percorso dell'eseguibile in Impostazioni > AI.", + piAgentCliPathInvalid: "Il percorso di Pi Coding Agent non è valido. Seleziona solo l'eseguibile Pi e configura separatamente le variabili d'ambiente.", + piAgentEnvInvalid: "Il nome di una variabile d'ambiente di Pi Coding Agent non è valido. Usa nomi come HTTPS_PROXY.", + piAgentEnvReserved: "È stata sovrascritta una variabile Pi o MCP gestita da DBX. Rimuovi DBX_PI_* e DBX_MCP_* dalla configurazione.", + piAgentNotAuthenticated: "Pi Coding Agent non dispone di un provider di modelli autenticato. Configura o accedi a un provider con Pi.", + piAgentMcpStartupFailed: "Pi non ha potuto avviare il bridge MCP DBX. Controlla Impostazioni > MCP e i dettagli seguenti.", + piAgentTimeout: "Pi Coding Agent non ha risposto prima del timeout dell'operazione.", + piAgentProtocolError: "Pi Coding Agent ha restituito una risposta RPC non valida. Controlla la versione di Pi e i dettagli seguenti.", + piAgentModelInvalid: "L'identificatore del modello Pi non è valido. Aggiorna l'elenco e seleziona una voce provider/model.", + piAgentRunFailed: "Pi Coding Agent è terminato in modo imprevisto. Usa il codice errore e i dettagli seguenti per identificare il problema.", }, actions: { general: "Generale", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 37765e4c3e..b0fdee6856 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1638,6 +1638,16 @@ export default withEnglishFallback({ claudeCodeMcpStartupFailed: "Claude CodeはMCP設定を読み込みましたが、DBX MCP Serverを起動できません。設定 > MCPと以下の診断詳細を確認してください。", claudeCodeCommandLineTooLong: "Windowsが長すぎるClaude Codeコマンドを拒否しました。DBXを更新し、生成されたMCP設定ファイルで再試行してください。", claudeCodeRunFailed: "Claude Code CLIが予期せず終了しました。エラーコードと以下の診断詳細から、失敗した実行ファイルまたはCLI出力を確認してください。", + piAgentNotInstalled: "Pi Coding Agentが見つかりません。Piをインストールするか、設定 > AIで実行ファイルのパスを指定してください。", + piAgentCliPathInvalid: "Pi Coding Agentのパスが無効です。Piの実行ファイルのみを選択し、環境変数は別に設定してください。", + piAgentEnvInvalid: "Pi Coding Agentの環境変数名が無効です。HTTPS_PROXYなどの有効な名前を使用してください。", + piAgentEnvReserved: "DBXが管理するPiまたはMCP環境変数が上書きされています。DBX_PI_*とDBX_MCP_*を削除してください。", + piAgentNotAuthenticated: "Pi Coding Agentに認証済みのモデルプロバイダーがありません。Piでプロバイダーを設定またはログインしてください。", + piAgentMcpStartupFailed: "PiはDBX MCPブリッジを起動できませんでした。設定 > MCPと以下の診断詳細を確認してください。", + piAgentTimeout: "Pi Coding Agentは操作のタイムアウトまでに応答しませんでした。", + piAgentProtocolError: "Pi Coding Agentが無効なRPC応答を返しました。Piのバージョンと以下の診断詳細を確認してください。", + piAgentModelInvalid: "選択したPiモデルIDが無効です。モデル一覧を更新してprovider/model項目を選択してください。", + piAgentRunFailed: "Pi Coding Agentが予期せず終了しました。エラーコードと以下の診断詳細から問題を確認してください。", }, run: "実行", readingSchema: "スキーマを読み取り中", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index f462696b58..800f3c0bc5 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1636,6 +1636,16 @@ export default withEnglishFallback({ claudeCodeMcpStartupFailed: "O Claude Code carregou a configuração MCP, mas não conseguiu iniciar o DBX MCP Server. Verifique Configurações > MCP e os detalhes abaixo.", claudeCodeCommandLineTooLong: "O Windows rejeitou o comando do Claude Code porque era muito longo. Atualize o DBX e tente novamente com o arquivo MCP gerado.", claudeCodeRunFailed: "Claude Code CLI foi encerrado inesperadamente. Use o código do erro e os detalhes abaixo para identificar o executável ou a saída com falha.", + piAgentNotInstalled: "Pi Coding Agent não foi encontrado. Instale o Pi ou defina o caminho do executável em Configurações > AI.", + piAgentCliPathInvalid: "O caminho do Pi Coding Agent é inválido. Selecione apenas o executável do Pi e configure as variáveis de ambiente separadamente.", + piAgentEnvInvalid: "O nome de uma variável de ambiente do Pi Coding Agent é inválido. Use nomes como HTTPS_PROXY.", + piAgentEnvReserved: "Uma variável Pi ou MCP gerenciada pelo DBX foi sobrescrita. Remova DBX_PI_* e DBX_MCP_* da configuração.", + piAgentNotAuthenticated: "O Pi Coding Agent não possui um provedor de modelos autenticado. Configure ou entre em um provedor com o Pi.", + piAgentMcpStartupFailed: "O Pi não conseguiu iniciar a ponte MCP do DBX. Verifique Configurações > MCP e os detalhes abaixo.", + piAgentTimeout: "O Pi Coding Agent não respondeu antes do tempo limite da operação.", + piAgentProtocolError: "O Pi Coding Agent retornou uma resposta RPC inválida. Verifique a versão do Pi e os detalhes abaixo.", + piAgentModelInvalid: "O identificador do modelo Pi é inválido. Atualize a lista e selecione uma entrada provider/model.", + piAgentRunFailed: "O Pi Coding Agent foi encerrado inesperadamente. Use o código do erro e os detalhes abaixo para identificar a falha.", }, run: "Executar", readingSchema: "Lendo schema", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 4a1e91b396..074adab950 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1787,6 +1787,16 @@ export default withEnglishFallback({ claudeCodeMcpStartupFailed: "Claude Code 已加载 MCP 配置,但无法启动 DBX MCP Server。请检查 设置 > MCP 和下方诊断详情。", claudeCodeCommandLineTooLong: "Windows 拒绝了过长的 Claude Code 命令。请更新 DBX,并使用生成的 MCP 配置文件重试。", claudeCodeRunFailed: "Claude Code CLI 异常退出。请根据错误代码和下方诊断详情确认失败的可执行文件或 CLI 输出。", + piAgentNotInstalled: "未找到 Pi Coding Agent。请安装 Pi,或在 设置 > AI 中填写其可执行文件路径。", + piAgentCliPathInvalid: "Pi Coding Agent 路径无效。请只选择 Pi 可执行文件,环境变量需单独配置。", + piAgentEnvInvalid: "Pi Coding Agent 环境变量名称无效。请使用 HTTPS_PROXY 这类合法名称。", + piAgentEnvReserved: "配置覆盖了由 DBX 管理的 Pi 或 MCP 环境变量。请移除 DBX_PI_* 和 DBX_MCP_* 变量。", + piAgentNotAuthenticated: "Pi Coding Agent 没有可用的已认证模型供应商。请先在 Pi 中配置或登录供应商。", + piAgentMcpStartupFailed: "Pi 无法启动 DBX 的作用域 MCP 桥接。请检查 设置 > MCP 和下方诊断详情。", + piAgentTimeout: "Pi Coding Agent 未在操作超时前响应。", + piAgentProtocolError: "Pi Coding Agent 返回了无效的 RPC 响应。请检查 Pi 版本和下方诊断详情。", + piAgentModelInvalid: "所选 Pi 模型标识无效。请刷新模型列表并选择 provider/model 条目。", + piAgentRunFailed: "Pi Coding Agent 异常退出。请根据错误代码和下方诊断详情确认失败的可执行文件或 CLI 输出。", }, actions: { general: "通用问答", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 92eb13646b..996c8331ff 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1766,6 +1766,16 @@ export default withEnglishFallback({ claudeCodeMcpStartupFailed: "Claude Code 已載入 MCP 設定,但無法啟動 DBX MCP Server。請檢查 設定 > MCP 和下方診斷詳情。", claudeCodeCommandLineTooLong: "Windows 拒絕了過長的 Claude Code 命令。請更新 DBX,並使用產生的 MCP 設定檔重試。", claudeCodeRunFailed: "Claude Code CLI 異常結束。請依據錯誤代碼和下方診斷詳情確認失敗的可執行檔或 CLI 輸出。", + piAgentNotInstalled: "找不到 Pi Coding Agent。請安裝 Pi,或在 設定 > AI 中填寫其可執行檔路徑。", + piAgentCliPathInvalid: "Pi Coding Agent 路徑無效。請只選擇 Pi 可執行檔,環境變數需另外設定。", + piAgentEnvInvalid: "Pi Coding Agent 環境變數名稱無效。請使用 HTTPS_PROXY 這類合法名稱。", + piAgentEnvReserved: "設定覆寫了由 DBX 管理的 Pi 或 MCP 環境變數。請移除 DBX_PI_* 與 DBX_MCP_* 變數。", + piAgentNotAuthenticated: "Pi Coding Agent 沒有可用的已驗證模型供應商。請先在 Pi 中設定或登入供應商。", + piAgentMcpStartupFailed: "Pi 無法啟動 DBX 的作用域 MCP 橋接。請檢查 設定 > MCP 和下方診斷詳情。", + piAgentTimeout: "Pi Coding Agent 未在操作逾時前回應。", + piAgentProtocolError: "Pi Coding Agent 傳回無效的 RPC 回應。請檢查 Pi 版本和下方診斷詳情。", + piAgentModelInvalid: "所選 Pi 模型識別碼無效。請重新整理模型清單並選擇 provider/model 項目。", + piAgentRunFailed: "Pi Coding Agent 異常結束。請依據錯誤代碼和下方診斷詳情確認失敗的可執行檔或 CLI 輸出。", }, actions: { general: "通用問答", diff --git a/apps/desktop/src/lib/__tests__/ai/aiConfigCandidates.spec.ts b/apps/desktop/src/lib/__tests__/ai/aiConfigCandidates.spec.ts index 9e0abdb48a..f9a45f83d5 100644 --- a/apps/desktop/src/lib/__tests__/ai/aiConfigCandidates.spec.ts +++ b/apps/desktop/src/lib/__tests__/ai/aiConfigCandidates.spec.ts @@ -24,7 +24,7 @@ describe("isAiConfigModelCandidate", () => { expect(isAiConfigModelCandidate(config({ apiKey: "" }), true)).toBe(false); }); - it.each(["codex-cli", "claude-code-cli"] as const)("keeps %s configs eligible without endpoint, API key, or model metadata", (provider) => { + it.each(["codex-cli", "claude-code-cli", "pi-agent-cli"] as const)("keeps %s configs eligible without endpoint, API key, or model metadata", (provider) => { expect( isAiConfigModelCandidate( config({ diff --git a/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts b/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts index 4fc71f6207..f0975d4358 100644 --- a/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts +++ b/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts @@ -14,10 +14,11 @@ describe("orderAiConfigsForDisplay", () => { { id: "claude", provider: "claude" }, { id: "openai", provider: "openai" }, { id: "codex", provider: "codex-cli" }, + { id: "pi", provider: "pi-agent-cli" }, { id: "custom", provider: "custom" }, ]; - expect(orderAiConfigsForDisplay(configs).map((config) => config.id)).toEqual(["claude", "openai", "claude-code-1", "codex", "custom"]); + expect(orderAiConfigsForDisplay(configs).map((config) => config.id)).toEqual(["claude", "openai", "claude-code-1", "codex", "pi", "custom"]); }); it("preserves creation order for configs from the same provider", () => { diff --git a/apps/desktop/src/lib/ai/aiConfigCandidates.ts b/apps/desktop/src/lib/ai/aiConfigCandidates.ts index 907ca1c92c..b10b947347 100644 --- a/apps/desktop/src/lib/ai/aiConfigCandidates.ts +++ b/apps/desktop/src/lib/ai/aiConfigCandidates.ts @@ -1,6 +1,6 @@ import type { AiConfig } from "@/types/ai"; -const CLI_PROVIDERS = new Set(["codex-cli", "claude-code-cli"]); +const CLI_PROVIDERS = new Set(["codex-cli", "claude-code-cli", "pi-agent-cli"]); export function isAiConfigModelCandidate(config: AiConfig, requiresApiKey: boolean): boolean { // CLI providers resolve their model and credentials externally, so keep the existing eligibility bypass. diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index f5d9b818c0..062158a202 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -210,6 +210,16 @@ export const AI_PROVIDER_PRESETS: Record = { authMethod: "bearer", requiresApiKey: false, }, + "pi-agent-cli": { + label: "Pi Coding Agent", + iconSlug: "pi", + provider: "pi-agent-cli", + endpoint: "", + model: "default", + apiStyle: "completions", + authMethod: "bearer", + requiresApiKey: false, + }, custom: { label: "Custom", provider: "custom", @@ -264,6 +274,8 @@ export function normalizeAiConfig(config: Partial | null | undefined): codexCliEnv: normalizeAiEnv(config?.codexCliEnv), claudeCodeCliPath: config?.claudeCodeCliPath?.trim() || undefined, claudeCodeCliEnv: normalizeAiEnv(config?.claudeCodeCliEnv), + piAgentCliPath: config?.piAgentCliPath?.trim() || undefined, + piAgentCliEnv: normalizeAiEnv(config?.piAgentCliEnv), }; } @@ -1257,7 +1269,7 @@ export const useSettingsStore = defineStore("settings", () => { const config = aiConfigs.value.find((c) => c.id === activeModel.value!.configId); if (!config) return false; const preset = AI_PROVIDER_PRESETS[config.provider]; - if (config.provider === "codex-cli" || config.provider === "claude-code-cli") return true; + if (config.provider === "codex-cli" || config.provider === "claude-code-cli" || config.provider === "pi-agent-cli") return true; return !!config.endpoint && !!activeModel.value!.modelId && (!preset.requiresApiKey || !!config.apiKey); }); diff --git a/apps/desktop/src/types/ai.ts b/apps/desktop/src/types/ai.ts index 365952a59f..cdf3b5ea61 100644 --- a/apps/desktop/src/types/ai.ts +++ b/apps/desktop/src/types/ai.ts @@ -1,4 +1,4 @@ -export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "ollama" | "openai-compatible" | "claude-code-cli" | "codex-cli" | "custom"; +export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "ollama" | "openai-compatible" | "claude-code-cli" | "pi-agent-cli" | "codex-cli" | "custom"; export type AiApiStyle = "completions" | "responses" | "anthropic-messages"; export type AiAuthMethod = "api-key" | "bearer"; export type AiEffortLevel = "low" | "medium" | "high" | "xhigh" | "max"; @@ -44,6 +44,8 @@ export interface AiConfig { codexCliEnv?: Record; claudeCodeCliPath?: string | null; claudeCodeCliEnv?: Record; + piAgentCliPath?: string | null; + piAgentCliEnv?: Record; runtimeEffort?: AiEffortSelection | null; } diff --git a/crates/dbx-core/assets/pi-mcp-bridge.mjs b/crates/dbx-core/assets/pi-mcp-bridge.mjs new file mode 100644 index 0000000000..49c87ddeec --- /dev/null +++ b/crates/dbx-core/assets/pi-mcp-bridge.mjs @@ -0,0 +1,201 @@ +import { spawn } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { createInterface } from "node:readline"; + +const MCP_PROGRAM_ENV = "DBX_PI_MCP_PROGRAM"; +const MCP_ARGS_ENV = "DBX_PI_MCP_ARGS"; +const ENABLED_TOOLS_ENV = "DBX_PI_ENABLED_TOOLS"; +const READY_FILE_ENV = "DBX_PI_BRIDGE_READY_FILE"; +const REQUEST_TIMEOUT_MS = 30_000; + +function parseJsonEnv(name, fallback) { + const value = process.env[name]; + if (!value) return fallback; + try { + return JSON.parse(value); + } catch (error) { + throw new Error(`Invalid ${name}: ${error.message}`); + } +} + +function textFromContent(content) { + return (content ?? []) + .filter((item) => item?.type === "text" && typeof item.text === "string") + .map((item) => item.text) + .join("\n"); +} + +function piContent(content) { + const result = []; + for (const item of content ?? []) { + if (item?.type === "text" && typeof item.text === "string") { + result.push({ type: "text", text: item.text }); + } else if ( + item?.type === "image" && + typeof item.data === "string" && + typeof item.mimeType === "string" + ) { + result.push({ type: "image", data: item.data, mimeType: item.mimeType }); + } + } + if (result.length === 0) { + result.push({ type: "text", text: "" }); + } + return result; +} + +export default async function registerDbxMcpBridge(pi) { + const program = process.env[MCP_PROGRAM_ENV]; + const readyFile = process.env[READY_FILE_ENV]; + const args = parseJsonEnv(MCP_ARGS_ENV, []); + const enabledTools = new Set(parseJsonEnv(ENABLED_TOOLS_ENV, [])); + + if (!program || !readyFile || !Array.isArray(args) || enabledTools.size === 0) { + throw new Error("DBX Pi MCP bridge configuration is incomplete"); + } + + const child = spawn(program, args, { + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + env: process.env, + }); + const pending = new Map(); + let nextId = 1; + let stderr = ""; + let closed = false; + + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { + stderr = `${stderr}${chunk}`.slice(-16_384); + }); + + const rejectPending = (message) => { + for (const { reject, timer } of pending.values()) { + clearTimeout(timer); + reject(new Error(message)); + } + pending.clear(); + }; + + child.on("error", (error) => rejectPending(`DBX MCP process error: ${error.message}`)); + child.on("exit", (code, signal) => { + closed = true; + const detail = stderr.trim(); + rejectPending( + `DBX MCP process exited (${signal ?? code ?? "unknown"})${detail ? `: ${detail}` : ""}`, + ); + }); + + const lines = createInterface({ input: child.stdout, crlfDelay: Infinity }); + lines.on("line", (line) => { + let message; + try { + message = JSON.parse(line); + } catch { + return; + } + if (message.id == null) return; + const request = pending.get(String(message.id)); + if (!request) return; + pending.delete(String(message.id)); + clearTimeout(request.timer); + if (message.error) { + request.reject(new Error(message.error.message ?? JSON.stringify(message.error))); + } else { + request.resolve(message.result); + } + }); + + const send = (message) => { + if (closed || !child.stdin.writable) { + throw new Error("DBX MCP process is not available"); + } + child.stdin.write(`${JSON.stringify(message)}\n`); + }; + + const request = (method, params = {}, signal) => + new Promise((resolve, reject) => { + const id = String(nextId++); + let onAbort; + const cleanup = () => { + clearTimeout(timer); + if (signal && onAbort) signal.removeEventListener("abort", onAbort); + }; + const resolveRequest = (value) => { + cleanup(); + resolve(value); + }; + const rejectRequest = (error) => { + cleanup(); + reject(error); + }; + const timer = setTimeout(() => { + pending.delete(id); + rejectRequest(new Error(`DBX MCP request timed out: ${method}`)); + }, REQUEST_TIMEOUT_MS); + pending.set(id, { resolve: resolveRequest, reject: rejectRequest, timer }); + try { + send({ jsonrpc: "2.0", id, method, params }); + } catch (error) { + pending.delete(id); + rejectRequest(error); + return; + } + if (signal) { + onAbort = () => { + const active = pending.get(id); + if (!active) return; + pending.delete(id); + rejectRequest(new Error(`DBX MCP request aborted: ${method}`)); + }; + if (signal.aborted) { + onAbort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + } + }); + + await request("initialize", { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "dbx-pi-bridge", version: "1" }, + }); + send({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }); + + const toolList = await request("tools/list"); + const tools = (toolList?.tools ?? []).filter((tool) => enabledTools.has(tool.name)); + const missing = [...enabledTools].filter((name) => !tools.some((tool) => tool.name === name)); + if (missing.length > 0) { + throw new Error(`DBX MCP did not expose required tools: ${missing.join(", ")}`); + } + + for (const tool of tools) { + pi.registerTool({ + name: tool.name, + label: tool.title ?? tool.name, + description: tool.description ?? "", + parameters: tool.inputSchema ?? { type: "object", properties: {} }, + async execute(_toolCallId, params, signal) { + const result = await request("tools/call", { name: tool.name, arguments: params ?? {} }, signal); + if (result?.isError) { + throw new Error(textFromContent(result.content) || `DBX MCP tool failed: ${tool.name}`); + } + return { + content: piContent(result?.content), + details: result ?? null, + }; + }, + }); + } + + await writeFile(readyFile, "ready", "utf8"); + + pi.on("session_shutdown", async () => { + if (closed) return; + closed = true; + lines.close(); + child.stdin.end(); + child.kill(); + }); +} diff --git a/crates/dbx-core/src/agent_loop.rs b/crates/dbx-core/src/agent_loop.rs index d50646b6d9..7460346bfb 100644 --- a/crates/dbx-core/src/agent_loop.rs +++ b/crates/dbx-core/src/agent_loop.rs @@ -137,7 +137,7 @@ pub async fn run_agent_loop( let contract_system_prompt = augment_system_prompt_with_task_contract(system_prompt, task_contract, is_agent_mode); let system_prompt = contract_system_prompt.as_str(); - if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) { + if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) { let connection_name = { let configs = agent_ctx.state.configs.read().await; configs @@ -164,6 +164,14 @@ pub async fn run_agent_loop( return crate::ai_claude_code_cli::run_claude_code_agent(config, &prompt, options, cancelled, on_event) .await; } + if matches!(config.provider, AiProvider::PiAgentCli) { + let prompt = crate::ai_pi_agent_cli::build_pi_agent_prompt( + system_prompt, + messages, + agent_ctx.sql_permissions.allow_writes, + ); + return crate::ai_pi_agent_cli::run_pi_agent(config, &prompt, options, cancelled, on_event).await; + } let prompt = crate::ai_codex_cli::build_codex_prompt(system_prompt, messages, agent_ctx.sql_permissions.allow_writes); return crate::ai_codex_cli::run_codex_agent(config, &prompt, options, cancelled, on_event).await; diff --git a/crates/dbx-core/src/ai.rs b/crates/dbx-core/src/ai.rs index 862b97f1c1..6e21d23d10 100644 --- a/crates/dbx-core/src/ai.rs +++ b/crates/dbx-core/src/ai.rs @@ -63,6 +63,8 @@ pub enum AiProvider { CodexCli, #[serde(rename = "claude-code-cli")] ClaudeCodeCli, + #[serde(rename = "pi-agent-cli")] + PiAgentCli, Custom, } @@ -77,6 +79,7 @@ impl AiProvider { AiProvider::Ollama => "ollama", AiProvider::OpenaiCompatible => "openai-compatible", AiProvider::ClaudeCodeCli => "claude-code-cli", + AiProvider::PiAgentCli => "pi-agent-cli", AiProvider::CodexCli => "codex-cli", AiProvider::Custom => "custom", } @@ -345,6 +348,10 @@ pub struct AiConfig { pub claude_code_cli_path: Option, #[serde(default)] pub claude_code_cli_env: HashMap, + #[serde(default)] + pub pi_agent_cli_path: Option, + #[serde(default)] + pub pi_agent_cli_env: HashMap, } fn default_enable_thinking() -> bool { @@ -554,7 +561,11 @@ pub fn resolve_endpoint(config: &AiConfig) -> String { format!("{base}/chat/completions") } } - AiProvider::Claude | AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::Gemini => unreachable!(), + AiProvider::Claude + | AiProvider::CodexCli + | AiProvider::ClaudeCodeCli + | AiProvider::PiAgentCli + | AiProvider::Gemini => unreachable!(), } } @@ -976,7 +987,7 @@ fn normalized_api_key(config: &AiConfig) -> &str { fn validate_config(config: &AiConfig) -> Result<(), String> { crate::ai_effort::validate_runtime_effort(config)?; - if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) { + if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) { return Ok(()); } if provider_requires_api_key(&config.provider) && config.api_key.trim().is_empty() { @@ -992,7 +1003,7 @@ fn validate_config(config: &AiConfig) -> Result<(), String> { } fn validate_model_list_config(config: &AiConfig) -> Result<(), String> { - if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) { + if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) { return Ok(()); } if provider_requires_api_key(&config.provider) && config.api_key.trim().is_empty() { @@ -1336,6 +1347,7 @@ pub async fn list_models_core(config: &AiConfig) -> Result, Str let mut models = match config.provider { AiProvider::CodexCli => crate::ai_codex_cli::list_codex_models(config).await?, AiProvider::ClaudeCodeCli => crate::ai_claude_code_cli::list_claude_code_models(config).await?, + AiProvider::PiAgentCli => crate::ai_pi_agent_cli::list_pi_agent_models(config).await?, _ => { validate_model_list_config(config)?; let client = build_ai_http_client(config, 30)?; @@ -1356,7 +1368,7 @@ pub async fn list_models_core(config: &AiConfig) -> Result, Str list_openai_compatible_models(&client, config).await? } } - AiProvider::CodexCli | AiProvider::ClaudeCodeCli => unreachable!(), + AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => unreachable!(), } } }; @@ -1371,6 +1383,10 @@ pub async fn resolve_model_effort_core(config: &AiConfig, model_id: &str) -> Res return Err("Model is required".to_string()); } + if matches!(config.provider, AiProvider::PiAgentCli) { + return crate::ai_pi_agent_cli::resolve_pi_agent_model_effort(config, model_id).await; + } + if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) { let models = list_models_core(config).await?; return Ok(models @@ -1850,6 +1866,9 @@ pub async fn test_connection_core(config: &AiConfig) -> Result &'static str { pub async fn complete(request: &AiCompletionRequest) -> Result { validate_config(&request.config)?; - if matches!(request.config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) { + if matches!(request.config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) { return Err("CLI providers are only supported in DBX AI agent mode".to_string()); } @@ -2037,7 +2056,7 @@ pub async fn complete(request: &AiCompletionRequest) -> Result { match request.config.provider { AiProvider::Claude => call_claude(&client, request.clone()).await, AiProvider::Gemini => call_gemini(&client, request.clone()).await, - AiProvider::CodexCli | AiProvider::ClaudeCodeCli => unreachable!(), + AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => unreachable!(), AiProvider::Openai | AiProvider::Deepseek | AiProvider::Qwen @@ -2073,7 +2092,7 @@ pub async fn stream( ) -> Result<(), String> { validate_config(&request.config)?; - if matches!(request.config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) { + if matches!(request.config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) { return Err("CLI providers are only supported in DBX AI agent mode".to_string()); } @@ -2083,7 +2102,7 @@ pub async fn stream( match request.config.provider { AiProvider::Claude => stream_claude(&client, session_id, request, cancelled, &on_chunk).await, AiProvider::Gemini => stream_gemini(&client, session_id, request, cancelled, &on_chunk).await, - AiProvider::CodexCli | AiProvider::ClaudeCodeCli => unreachable!(), + AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => unreachable!(), AiProvider::Openai | AiProvider::Deepseek | AiProvider::Qwen @@ -3194,7 +3213,7 @@ pub async fn stream_with_tools( on_chunk: impl Fn(AiStreamChunk), ) -> Result<(Vec, Option), String> { validate_config(config)?; - if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) { + if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) { return Err("CLI providers are only supported through the DBX AI agent loop".to_string()); } @@ -3390,6 +3409,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }, system_prompt: "Be concise.".to_string(), messages: vec![AiMessage { @@ -3682,6 +3703,8 @@ mod tests { assert!(config.claude_code_cli_path.is_none()); assert!(config.claude_code_cli_env.is_empty()); assert!(config.codex_cli_env.is_empty()); + assert!(config.pi_agent_cli_path.is_none()); + assert!(config.pi_agent_cli_env.is_empty()); } #[test] @@ -3704,6 +3727,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }; let err = build_ai_http_client(&config, 1).unwrap_err(); @@ -3731,6 +3756,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }; build_ai_http_client(&config, 1).unwrap(); @@ -3756,6 +3783,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }; build_ai_http_client(&config, 1).unwrap(); @@ -3781,6 +3810,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }; assert_eq!( @@ -3810,6 +3841,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }; assert_eq!(resolve_endpoint(&ollama), "http://localhost:11434/v1/chat/completions"); @@ -3836,6 +3869,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }; for provider in [AiProvider::Ollama, AiProvider::OpenaiCompatible, AiProvider::Custom] { @@ -3874,6 +3909,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }; assert_eq!(resolve_model_list_endpoint(&openai).unwrap(), "https://api.openai.com/v1/models"); @@ -3895,6 +3932,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }; assert_eq!(resolve_model_list_endpoint(&claude).unwrap(), "https://api.anthropic.com/v1/models"); } @@ -3919,6 +3958,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }; assert!(uses_anthropic_messages_api(&config)); @@ -3967,6 +4008,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }; assert_eq!(resolve_endpoint(&config), "https://api.example.com/v1/chat/completions"); assert_eq!(resolve_model_list_endpoint(&config).unwrap(), "https://api.example.com/v1/models"); @@ -4027,6 +4070,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }; assert_eq!(resolve_endpoint(&config), "https://api.openai.com/v1/responses"); @@ -4059,6 +4104,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }; let api_key_headers = claude_headers(&config).unwrap(); @@ -4171,6 +4218,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }; assert_eq!(resolve_ollama_show_endpoint(&config).unwrap(), "http://localhost:11434/api/show"); @@ -4200,6 +4249,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }; assert_eq!(ollama_selected_model_tool_support(&config).await.unwrap(), Some(true)); @@ -4278,6 +4329,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }; let models = vec![ AiModelInfo::new("qwen3:0.6b", None), @@ -4536,6 +4589,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }; let mut body = serde_json::json!({ @@ -4666,6 +4721,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model, @@ -4700,6 +4757,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model }); @@ -4740,6 +4799,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model }); @@ -4774,6 +4835,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model }); @@ -4835,6 +4898,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }; let request = AiCompletionRequest { config: config.clone(), @@ -4890,6 +4955,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model, diff --git a/crates/dbx-core/src/ai_claude_code_cli.rs b/crates/dbx-core/src/ai_claude_code_cli.rs index caa00e5671..385bc38e84 100644 --- a/crates/dbx-core/src/ai_claude_code_cli.rs +++ b/crates/dbx-core/src/ai_claude_code_cli.rs @@ -577,6 +577,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), } } diff --git a/crates/dbx-core/src/ai_codex_cli.rs b/crates/dbx-core/src/ai_codex_cli.rs index b14e0b35b1..6a9c2cac4e 100644 --- a/crates/dbx-core/src/ai_codex_cli.rs +++ b/crates/dbx-core/src/ai_codex_cli.rs @@ -636,6 +636,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), } } diff --git a/crates/dbx-core/src/ai_effort.rs b/crates/dbx-core/src/ai_effort.rs index 2bde1880d5..5bd29f6d27 100644 --- a/crates/dbx-core/src/ai_effort.rs +++ b/crates/dbx-core/src/ai_effort.rs @@ -79,7 +79,7 @@ pub fn static_effort_capability(config: &AiConfig, model_id: &str) -> Option { Some(AiEffortCapability::FreeText { placeholder: None, source: AiCapabilitySource::Custom }) } - AiProvider::Claude | AiProvider::CodexCli | AiProvider::ClaudeCodeCli => None, + AiProvider::Claude | AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => None, } } @@ -181,6 +181,7 @@ pub fn registry_source_url(provider: &AiProvider) -> Option<&'static str> { | AiProvider::OpenaiCompatible | AiProvider::CodexCli | AiProvider::ClaudeCodeCli + | AiProvider::PiAgentCli | AiProvider::Custom => None, } } @@ -193,7 +194,10 @@ pub fn validate_runtime_effort(config: &AiConfig) -> Result<(), String> { return Ok(()); } - if matches!(config.provider, AiProvider::Claude | AiProvider::CodexCli | AiProvider::ClaudeCodeCli) { + if matches!( + config.provider, + AiProvider::Claude | AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli + ) { return match selection { AiEffortSelection::Enum(value) if !value.trim().is_empty() => Ok(()), _ => Err("Invalid effort selection for dynamic provider".to_string()), @@ -244,7 +248,7 @@ pub fn apply_runtime_effort(body: &mut Value, config: &AiConfig) { apply_openai_effort(object, &config.api_style, selection); } } - AiProvider::CodexCli | AiProvider::ClaudeCodeCli => {} + AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => {} } } @@ -383,6 +387,8 @@ mod tests { codex_cli_env: HashMap::new(), claude_code_cli_path: None, claude_code_cli_env: HashMap::new(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), } } diff --git a/crates/dbx-core/src/ai_model_filter.rs b/crates/dbx-core/src/ai_model_filter.rs index 0b3d06a487..bae2b8db1e 100644 --- a/crates/dbx-core/src/ai_model_filter.rs +++ b/crates/dbx-core/src/ai_model_filter.rs @@ -94,6 +94,7 @@ pub(crate) fn model_is_assistant_compatible(provider: &AiProvider, model_id: &st | AiProvider::OpenaiCompatible | AiProvider::CodexCli | AiProvider::ClaudeCodeCli + | AiProvider::PiAgentCli | AiProvider::Custom => true, } } diff --git a/crates/dbx-core/src/ai_pi_agent_cli.rs b/crates/dbx-core/src/ai_pi_agent_cli.rs new file mode 100644 index 0000000000..9c9ab0922a --- /dev/null +++ b/crates/dbx-core/src/ai_pi_agent_cli.rs @@ -0,0 +1,988 @@ +use crate::agent_events::AgentEvent; +use crate::ai::{ + AiCapabilitySource, AiConfig, AiEffortCapability, AiEffortSelection, AiModelInfo, AiTestConnectionResult, + AGENT_CANCELLED_ERROR, +}; +use crate::ai_cli_agent::{ + build_cli_agent_prompt, cli_command, dbx_mcp_enabled_tools, dbx_mcp_scope_env, CliAgentCommandSpec, + CliAgentRunOptions, +}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::env; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; +use tokio::sync::Notify; + +const PI_RPC_TIMEOUT: Duration = Duration::from_secs(15); +const PI_BRIDGE_STARTUP_TIMEOUT: Duration = Duration::from_secs(15); +const PI_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3); +const PI_MCP_BRIDGE: &str = include_str!("../assets/pi-mcp-bridge.mjs"); +const PI_PRIVATE_ENV_PREFIX: &str = "DBX_PI_"; + +pub type PiAgentRunOptions = CliAgentRunOptions; + +struct PiIsolatedRuntime { + path: PathBuf, + extension_path: PathBuf, + ready_path: PathBuf, +} + +impl PiIsolatedRuntime { + fn create() -> Result { + let path = env::temp_dir().join(format!("dbx-pi-agent-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&path) + .map_err(|error| format!("[piAgentRunFailed] Failed to create isolated Pi directory: {error}"))?; + let extension_path = path.join("dbx-mcp-bridge.mjs"); + let ready_path = path.join("dbx-mcp-ready"); + std::fs::write(&extension_path, PI_MCP_BRIDGE) + .map_err(|error| format!("[piAgentRunFailed] Failed to write Pi MCP bridge: {error}"))?; + Ok(Self { path, extension_path, ready_path }) + } +} + +impl Drop for PiIsolatedRuntime { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +struct PiRpcProcess { + child: Child, + stdin: Option, + stdout: Lines>, + stderr: Arc>, + next_id: u64, +} + +impl PiRpcProcess { + async fn spawn( + config: &AiConfig, + runtime: Option<&PiIsolatedRuntime>, + apply_selection: bool, + ) -> Result { + let command = resolve_pi_command(config)?; + let mut process = cli_command(&command.program); + process + .args(command.args.iter().map(String::as_str)) + .args(pi_rpc_args(runtime)) + .args(if apply_selection { pi_selection_args(config)? } else { Vec::new() }) + .envs(pi_agent_process_env(config, &command)?) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + if let Some(runtime) = runtime { + process.current_dir(&runtime.path); + } + + Self::spawn_command(process).await + } + + async fn spawn_command(mut process: Command) -> Result { + let mut child = process.spawn().map_err(|error| classify_pi_spawn_error(&error.to_string()))?; + let stdin = child.stdin.take().ok_or_else(|| "[piAgentRunFailed] Failed to open Pi RPC stdin".to_string())?; + let stdout = + child.stdout.take().ok_or_else(|| "[piAgentRunFailed] Failed to open Pi RPC stdout".to_string())?; + let stderr_pipe = + child.stderr.take().ok_or_else(|| "[piAgentRunFailed] Failed to open Pi RPC stderr".to_string())?; + let stderr = Arc::new(Mutex::new(String::new())); + let stderr_capture = stderr.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr_pipe).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let mut output = stderr_capture.lock().unwrap_or_else(|error| error.into_inner()); + output.push_str(&line); + output.push('\n'); + if output.len() > 16_384 { + let drain_to = output.len() - 16_384; + output.drain(..drain_to); + } + } + }); + + Ok(Self { child, stdin: Some(stdin), stdout: BufReader::new(stdout).lines(), stderr, next_id: 1 }) + } + + fn stderr_text(&self) -> String { + self.stderr.lock().unwrap_or_else(|error| error.into_inner()).trim().to_string() + } + + async fn write(&mut self, value: &Value) -> Result<(), String> { + let stdin = self.stdin.as_mut().ok_or_else(|| "[piAgentRunFailed] Pi RPC stdin is closed".to_string())?; + let mut data = serde_json::to_vec(value).map_err(|error| format!("[piAgentProtocolError] {error}"))?; + data.push(b'\n'); + stdin.write_all(&data).await.map_err(|error| classify_pi_run_error(&error.to_string()))?; + stdin.flush().await.map_err(|error| classify_pi_run_error(&error.to_string())) + } + + async fn request(&mut self, command_type: &str, data: Value) -> Result<(Value, Vec), String> { + let id = format!("dbx-{}", self.next_id); + self.next_id += 1; + let mut request = match data { + Value::Object(map) => map, + _ => serde_json::Map::new(), + }; + request.insert("id".to_string(), Value::String(id.clone())); + request.insert("type".to_string(), Value::String(command_type.to_string())); + self.write(&Value::Object(request)).await?; + + let mut events = Vec::new(); + let response = tokio::time::timeout(PI_RPC_TIMEOUT, async { + loop { + let line = self + .stdout + .next_line() + .await + .map_err(|error| classify_pi_run_error(&error.to_string()))? + .ok_or_else(|| { + let stderr = self.stderr_text(); + classify_pi_run_error(if stderr.is_empty() { "Pi RPC stdout closed" } else { &stderr }) + })?; + let value: Value = + serde_json::from_str(&line).map_err(|error| format!("[piAgentProtocolError] {error}: {line}"))?; + if value.get("type").and_then(Value::as_str) == Some("response") + && value.get("id").and_then(Value::as_str) == Some(id.as_str()) + { + return Ok::(value); + } + events.push(value); + } + }) + .await + .map_err(|_| format!("[piAgentTimeout] Pi RPC command `{command_type}` timed out"))??; + + if response.get("success").and_then(Value::as_bool) == Some(false) { + let message = response + .get("error") + .and_then(Value::as_str) + .or_else(|| response.pointer("/data/error").and_then(Value::as_str)) + .unwrap_or("Pi RPC command failed"); + return Err(classify_pi_run_error(message)); + } + Ok((response.get("data").cloned().unwrap_or(Value::Null), events)) + } + + async fn shutdown(mut self) { + self.stdin.take(); + if tokio::time::timeout(PI_SHUTDOWN_TIMEOUT, self.child.wait()).await.is_err() { + let _ = self.child.kill().await; + let _ = self.child.wait().await; + } + } + + async fn abort_and_shutdown(&mut self) { + let id = format!("dbx-{}", self.next_id); + self.next_id += 1; + let _ = self.write(&json!({ "id": id, "type": "abort" })).await; + self.stdin.take(); + if tokio::time::timeout(PI_SHUTDOWN_TIMEOUT, self.child.wait()).await.is_err() { + let _ = self.child.kill().await; + let _ = self.child.wait().await; + } + } +} + +fn pi_rpc_args(runtime: Option<&PiIsolatedRuntime>) -> Vec { + let mut args = vec![ + "--mode".to_string(), + "rpc".to_string(), + "--no-session".to_string(), + "--no-extensions".to_string(), + "--no-skills".to_string(), + "--no-prompt-templates".to_string(), + "--no-context-files".to_string(), + "--no-builtin-tools".to_string(), + "--no-approve".to_string(), + ]; + if let Some(runtime) = runtime { + args.extend(["-e".to_string(), runtime.extension_path.to_string_lossy().to_string()]); + } + args +} + +fn pi_selection_args(config: &AiConfig) -> Result, String> { + let mut args = Vec::new(); + let model = config.model.trim(); + if !model.is_empty() && !model.eq_ignore_ascii_case("default") { + let (provider, model_id) = split_pi_model_key(model)?; + args.extend(["--provider".to_string(), provider.to_string(), "--model".to_string(), model_id.to_string()]); + } + if let Some(level) = pi_thinking_level(config.runtime_effort.as_ref()) { + args.extend(["--thinking".to_string(), level]); + } + Ok(args) +} + +fn pi_program(config: &AiConfig) -> String { + config.pi_agent_cli_path.as_deref().map(str::trim).filter(|value| !value.is_empty()).unwrap_or("pi").to_string() +} + +fn resolve_pi_command(config: &AiConfig) -> Result { + let program = pi_program(config); + if starts_with_env_assignment(&program) { + return Err("[piAgentCliPathInvalid] Pi Coding Agent path should contain only the executable path. Add environment variables in the Pi Coding Agent environment variables section.".to_string()); + } + let program = if is_path_like_program(&program) { crate::path_utils::expand_tilde(&program) } else { program }; + let path = Path::new(&program); + if path.is_dir() { + return Err("[piAgentCliPathInvalid] Pi Coding Agent path should point to the pi executable.".to_string()); + } + + #[cfg(windows)] + if let Some(command) = windows_npm_pi_shim_command(&program) { + return Ok(command); + } + + Ok(CliAgentCommandSpec { program, args: Vec::new() }) +} + +#[cfg(windows)] +fn windows_npm_pi_shim_command(program: &str) -> Option { + let path = Path::new(program); + let extension = path.extension()?.to_str()?.to_ascii_lowercase(); + if extension != "cmd" && extension != "bat" { + return None; + } + let parent = path.parent()?; + let cli = parent.join("node_modules").join("@earendil-works").join("pi-coding-agent").join("dist").join("cli.js"); + if !cli.is_file() { + return None; + } + let bundled_node = parent.join("node.exe"); + let node = if bundled_node.is_file() { bundled_node.to_string_lossy().to_string() } else { "node".to_string() }; + Some(CliAgentCommandSpec { program: node, args: vec![cli.to_string_lossy().to_string()] }) +} + +fn pi_agent_process_env(config: &AiConfig, command: &CliAgentCommandSpec) -> Result, String> { + let mut values = BTreeMap::new(); + for (key, value) in &config.pi_agent_cli_env { + let key = key.trim(); + if key.is_empty() { + continue; + } + if !is_env_var_name(key) { + return Err(format!( + "[piAgentEnvInvalid] Invalid Pi Coding Agent environment variable name `{key}`. Use names like HTTPS_PROXY." + )); + } + let upper = key.to_ascii_uppercase(); + if upper.starts_with("DBX_MCP_") || upper.starts_with(PI_PRIVATE_ENV_PREFIX) { + return Err(format!( + "[piAgentEnvReserved] `{key}` is managed by DBX for the scoped MCP bridge and cannot be set here." + )); + } + values.insert(key.to_string(), value.clone()); + } + if let Some(parent) = Path::new(&command.program).parent().filter(|parent| !parent.as_os_str().is_empty()) { + let user_path = values.get("PATH").map(String::as_str); + values.insert("PATH".to_string(), merged_path_with_dir(parent, user_path)); + } + Ok(values.into_iter().collect()) +} + +fn merged_path_with_dir(dir: &Path, user_path: Option<&str>) -> String { + let mut seen = BTreeSet::new(); + let mut paths = vec![dir.to_path_buf()]; + if let Some(path) = user_path { + paths.extend(env::split_paths(path)); + } + if let Ok(path) = env::var("PATH") { + paths.extend(env::split_paths(&path)); + } + env::join_paths(paths.into_iter().filter(|path| seen.insert(path.clone()))) + .unwrap_or_default() + .to_string_lossy() + .to_string() +} + +fn is_path_like_program(program: &str) -> bool { + program.contains('/') || program.contains('\\') || program.starts_with('~') +} + +fn starts_with_env_assignment(program: &str) -> bool { + program + .split_whitespace() + .next() + .and_then(|token| token.split_once('=')) + .is_some_and(|(key, _)| is_env_var_name(key)) +} + +fn is_env_var_name(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first.is_ascii_alphabetic()) && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) +} + +fn classify_pi_spawn_error(message: &str) -> String { + let lower = message.to_ascii_lowercase(); + if lower.contains("no such file") || lower.contains("not found") || lower.contains("cannot find") { + format!("[piAgentNotInstalled] {message}") + } else { + format!("[piAgentRunFailed] {message}") + } +} + +fn classify_pi_run_error(message: &str) -> String { + let lower = message.to_ascii_lowercase(); + if lower.contains("not authenticated") + || lower.contains("authentication required") + || lower.contains("no api key") + || lower.contains("please login") + { + format!("[piAgentNotAuthenticated] {message}") + } else if lower.contains("dbx mcp") || lower.contains("dbx-mcp") { + format!("[piAgentMcpStartupFailed] {message}") + } else if message.starts_with('[') { + message.to_string() + } else { + format!("[piAgentRunFailed] {message}") + } +} + +fn pi_model_key(provider: &str, model_id: &str) -> String { + format!("{provider}/{model_id}") +} + +fn split_pi_model_key(model: &str) -> Result<(&str, &str), String> { + model + .split_once('/') + .filter(|(provider, id)| !provider.is_empty() && !id.is_empty()) + .ok_or_else(|| format!("[piAgentModelInvalid] Pi model `{model}` must use the provider/model-id format.")) +} + +fn pi_thinking_level(selection: Option<&AiEffortSelection>) -> Option { + match selection { + None | Some(AiEffortSelection::ProviderDefault) => None, + Some(AiEffortSelection::Disabled | AiEffortSelection::Boolean(false)) => Some("off".to_string()), + Some(AiEffortSelection::Boolean(true)) => Some("high".to_string()), + Some(AiEffortSelection::Enum(value) | AiEffortSelection::Text(value)) => { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) + } + Some(AiEffortSelection::Integer(value)) => Some(value.to_string()), + } +} + +fn parse_thinking_levels(data: &Value) -> Vec { + let mut seen = BTreeSet::new(); + data.get("levels") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|level| !level.is_empty()) + .filter(|level| seen.insert((*level).to_string())) + .map(ToString::to_string) + .collect() +} + +fn pi_effort_capability(data: &Value) -> Option { + crate::ai_effort::dynamic_enum_capability(parse_thinking_levels(data), AiCapabilitySource::LocalCli) +} + +pub async fn list_pi_agent_models(config: &AiConfig) -> Result, String> { + let mut process = PiRpcProcess::spawn(config, None, false).await?; + let result = list_pi_agent_models_with_process(&mut process).await; + process.shutdown().await; + result +} + +async fn list_pi_agent_models_with_process(process: &mut PiRpcProcess) -> Result, String> { + let (state, _) = process.request("get_state", json!({})).await?; + let (data, _) = process.request("get_available_models", json!({})).await?; + let models = data + .get("models") + .and_then(Value::as_array) + .ok_or_else(|| "[piAgentProtocolError] Pi did not return an available model list".to_string())?; + if models.is_empty() { + return Err("[piAgentNotAuthenticated] Pi Coding Agent did not report any available models".to_string()); + } + + let default_label = state + .get("model") + .and_then(|model| model.get("name").and_then(Value::as_str).or_else(|| model.get("id").and_then(Value::as_str))) + .map(|name| format!("Default ({name})")) + .unwrap_or_else(|| "Default".to_string()); + let (levels, _) = process.request("get_available_thinking_levels", json!({})).await?; + let mut result = vec![AiModelInfo { + id: "default".to_string(), + display_name: Some(default_label), + supported_effort_levels: Vec::new(), + effort_capability: pi_effort_capability(&levels), + }]; + let mut seen = BTreeSet::new(); + + for model in models { + let Some(provider) = model.get("provider").and_then(Value::as_str).filter(|value| !value.is_empty()) else { + continue; + }; + let Some(model_id) = model.get("id").and_then(Value::as_str).filter(|value| !value.is_empty()) else { + continue; + }; + let key = pi_model_key(provider, model_id); + if !seen.insert(key.clone()) { + continue; + } + let name = model.get("name").and_then(Value::as_str).unwrap_or(model_id); + result.push(AiModelInfo { + id: key, + display_name: Some(format!("{name} ({provider})")), + supported_effort_levels: Vec::new(), + effort_capability: None, + }); + } + Ok(result) +} + +pub async fn resolve_pi_agent_model_effort(config: &AiConfig, model_id: &str) -> Result { + let mut config = config.clone(); + config.model = model_id.to_string(); + config.runtime_effort = None; + let mut process = PiRpcProcess::spawn(&config, None, true).await?; + let result = async { + let (levels, _) = process.request("get_available_thinking_levels", json!({})).await?; + Ok(pi_effort_capability(&levels).unwrap_or(AiEffortCapability::Unsupported)) + } + .await; + process.shutdown().await; + result +} + +pub async fn test_pi_agent_connection(config: &AiConfig) -> Result { + let start = Instant::now(); + list_pi_agent_models(config).await?; + Ok(AiTestConnectionResult { + success: true, + message: format!("OK - {}ms", start.elapsed().as_millis()), + latency_ms: Some(start.elapsed().as_millis() as u64), + model_used: config.model.trim().to_string(), + error_category: None, + }) +} + +pub fn build_pi_agent_prompt(system_prompt: &str, messages: &[crate::ai::AiMessage], allow_write_sql: bool) -> String { + build_cli_agent_prompt("Pi Coding Agent", system_prompt, messages, allow_write_sql) +} + +fn configure_pi_bridge( + process: &mut Command, + runtime: &PiIsolatedRuntime, + options: &PiAgentRunOptions, +) -> Result<(), String> { + let mcp = options + .mcp_server_command + .as_ref() + .ok_or_else(|| "[dbxMcpMissing] DBX MCP server was not resolved for Pi Coding Agent".to_string())?; + process.env("DBX_PI_MCP_PROGRAM", &mcp.program); + process.env( + "DBX_PI_MCP_ARGS", + serde_json::to_string(&mcp.args).map_err(|error| format!("[piAgentRunFailed] {error}"))?, + ); + process.env( + "DBX_PI_ENABLED_TOOLS", + serde_json::to_string(&dbx_mcp_enabled_tools(options.agent_mode)) + .map_err(|error| format!("[piAgentRunFailed] {error}"))?, + ); + process.env("DBX_PI_BRIDGE_READY_FILE", &runtime.ready_path); + for (name, value) in dbx_mcp_scope_env(options) { + process.env(name, value); + } + Ok(()) +} + +async fn wait_for_bridge(process: &mut PiRpcProcess, runtime: &PiIsolatedRuntime) -> Result<(), String> { + tokio::time::timeout(PI_BRIDGE_STARTUP_TIMEOUT, async { + loop { + if runtime.ready_path.is_file() { + return Ok(()); + } + if let Some(status) = process.child.try_wait().map_err(|error| classify_pi_run_error(&error.to_string()))? { + let stderr = process.stderr_text(); + let message = if stderr.is_empty() { + format!("Pi exited before the DBX MCP bridge started: {status}") + } else { + stderr + }; + return Err(classify_pi_run_error(&message)); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await + .map_err(|_| { + let stderr = process.stderr_text(); + classify_pi_run_error(if stderr.is_empty() { "DBX MCP bridge startup timed out" } else { &stderr }) + })? +} + +fn event_text(value: &Value, pointer: &str) -> Option { + value.pointer(pointer).and_then(Value::as_str).filter(|text| !text.is_empty()).map(ToString::to_string) +} + +fn u32_token(value: Option) -> Option { + value.map(|token| token.min(u32::MAX as u64) as u32) +} + +fn add_tokens(total: &mut Option, value: Option) { + if let Some(value) = u32_token(value) { + *total = Some(total.unwrap_or_default().saturating_add(value)); + } +} + +fn emit_pi_event( + value: &Value, + on_event: &impl Fn(AgentEvent), + final_text: &mut String, + input_tokens: &mut Option, + output_tokens: &mut Option, +) -> Result { + match value.get("type").and_then(Value::as_str).unwrap_or_default() { + "turn_start" => on_event(AgentEvent::TurnStart { turn: 0 }), + "turn_end" => on_event(AgentEvent::TurnEnd { turn: 0 }), + "message_update" => { + if value.pointer("/message/role").and_then(Value::as_str) != Some("assistant") { + return Ok(false); + } + let update = value.get("assistantMessageEvent").unwrap_or(&Value::Null); + match update.get("type").and_then(Value::as_str).unwrap_or_default() { + "text_delta" => { + if let Some(delta) = update.get("delta").and_then(Value::as_str).filter(|text| !text.is_empty()) { + final_text.push_str(delta); + on_event(AgentEvent::TextDelta { delta: delta.to_string() }); + } + } + "thinking_delta" | "reasoning_delta" => { + if let Some(delta) = update.get("delta").and_then(Value::as_str).filter(|text| !text.is_empty()) { + on_event(AgentEvent::ReasoningDelta { delta: delta.to_string() }); + } + } + "error" => { + let message = + update.get("error").and_then(Value::as_str).unwrap_or("Pi Coding Agent response failed"); + on_event(AgentEvent::Error { message: message.to_string() }); + return Err(classify_pi_run_error(message)); + } + _ => {} + } + } + "message_end" => { + let message = value.get("message").unwrap_or(&Value::Null); + if message.get("role").and_then(Value::as_str) != Some("assistant") { + return Ok(false); + } + let usage = message.get("usage").unwrap_or(&Value::Null); + add_tokens( + input_tokens, + usage + .get("input") + .or_else(|| usage.get("inputTokens")) + .or_else(|| usage.get("input_tokens")) + .and_then(Value::as_u64), + ); + add_tokens( + output_tokens, + usage + .get("output") + .or_else(|| usage.get("outputTokens")) + .or_else(|| usage.get("output_tokens")) + .and_then(Value::as_u64), + ); + if final_text.is_empty() { + if let Some(content) = message.get("content").and_then(Value::as_array) { + for item in content { + if item.get("type").and_then(Value::as_str) == Some("text") { + if let Some(text) = item.get("text").and_then(Value::as_str).filter(|text| !text.is_empty()) + { + final_text.push_str(text); + on_event(AgentEvent::TextDelta { delta: text.to_string() }); + } + } + } + } + } + if message.get("stopReason").and_then(Value::as_str) == Some("error") { + let error = + message.get("errorMessage").and_then(Value::as_str).unwrap_or("Pi Coding Agent response failed"); + on_event(AgentEvent::Error { message: error.to_string() }); + return Err(classify_pi_run_error(error)); + } + } + "tool_execution_start" => on_event(AgentEvent::ToolCallStart { + tool_call_id: value.get("toolCallId").and_then(Value::as_str).unwrap_or("pi-tool-call").to_string(), + tool_name: value.get("toolName").and_then(Value::as_str).unwrap_or("unknown").to_string(), + args: value.get("args").cloned().unwrap_or_else(|| json!({})), + }), + "tool_execution_end" => on_event(AgentEvent::ToolCallEnd { + tool_call_id: value.get("toolCallId").and_then(Value::as_str).unwrap_or("pi-tool-call").to_string(), + tool_name: value.get("toolName").and_then(Value::as_str).unwrap_or("unknown").to_string(), + result: value.get("result").cloned().unwrap_or(Value::Null), + is_error: value.get("isError").and_then(Value::as_bool).unwrap_or(false), + }), + "agent_settled" => return Ok(true), + "error" => { + let message = + event_text(value, "/message").unwrap_or_else(|| "Pi Coding Agent response failed".to_string()); + on_event(AgentEvent::Error { message: message.clone() }); + return Err(classify_pi_run_error(&message)); + } + _ => {} + } + Ok(false) +} + +pub async fn run_pi_agent( + config: &AiConfig, + prompt: &str, + options: PiAgentRunOptions, + cancelled: &Notify, + on_event: impl Fn(AgentEvent) + Send + Sync + 'static, +) -> Result { + let runtime = PiIsolatedRuntime::create()?; + let mut process = spawn_pi_with_bridge(config, &runtime, &options).await?; + let result = run_pi_agent_session(&mut process, prompt, &runtime, cancelled, &on_event).await; + if result.is_ok() { + process.shutdown().await; + } else { + process.abort_and_shutdown().await; + } + result +} + +async fn run_pi_agent_session( + process: &mut PiRpcProcess, + prompt: &str, + runtime: &PiIsolatedRuntime, + cancelled: &Notify, + on_event: &impl Fn(AgentEvent), +) -> Result { + wait_for_bridge(process, runtime).await?; + let (_, buffered) = process.request("prompt", json!({ "message": prompt })).await?; + + let mut final_text = String::new(); + let mut input_tokens = None; + let mut output_tokens = None; + for event in buffered { + if emit_pi_event(&event, &on_event, &mut final_text, &mut input_tokens, &mut output_tokens)? { + on_event(AgentEvent::AgentEnd { input_tokens, output_tokens }); + return Ok(final_text); + } + } + + loop { + tokio::select! { + _ = cancelled.notified() => { + return Err(AGENT_CANCELLED_ERROR.to_string()); + } + line = process.stdout.next_line() => { + let line = line + .map_err(|error| classify_pi_run_error(&error.to_string()))? + .ok_or_else(|| { + let stderr = process.stderr_text(); + classify_pi_run_error(if stderr.is_empty() { "Pi RPC stdout closed before agent_end" } else { &stderr }) + })?; + let value: Value = serde_json::from_str(&line) + .map_err(|error| format!("[piAgentProtocolError] {error}: {line}"))?; + if emit_pi_event(&value, &on_event, &mut final_text, &mut input_tokens, &mut output_tokens)? { + on_event(AgentEvent::AgentEnd { input_tokens, output_tokens }); + return Ok(final_text); + } + } + } + } +} + +async fn spawn_pi_with_bridge( + config: &AiConfig, + runtime: &PiIsolatedRuntime, + options: &PiAgentRunOptions, +) -> Result { + let command = resolve_pi_command(config)?; + let mut process = cli_command(&command.program); + process + .args(command.args.iter().map(String::as_str)) + .args(pi_rpc_args(Some(runtime))) + .args(pi_selection_args(config)?) + .envs(pi_agent_process_env(config, &command)?) + .current_dir(&runtime.path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + configure_pi_bridge(&mut process, runtime, options)?; + PiRpcProcess::spawn_command(process).await +} + +#[cfg(test)] +mod tests { + use super::{ + build_pi_agent_prompt, classify_pi_run_error, configure_pi_bridge, emit_pi_event, parse_thinking_levels, + pi_agent_process_env, pi_rpc_args, pi_selection_args, split_pi_model_key, PiIsolatedRuntime, + }; + use crate::agent_events::AgentEvent; + use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiEffortSelection, AiProvider, AiReasoningLevel}; + use crate::ai_cli_agent::{CliAgentCommandSpec, CliAgentRunOptions}; + use serde_json::json; + use std::collections::HashMap; + use std::sync::Mutex; + + fn config() -> AiConfig { + AiConfig { + provider: AiProvider::PiAgentCli, + api_key: String::new(), + auth_method: AiAuthMethod::Bearer, + endpoint: String::new(), + model: "openai-codex/gpt-5.4".to_string(), + models: Vec::new(), + api_style: AiApiStyle::Completions, + proxy_enabled: false, + proxy_url: String::new(), + enable_thinking: true, + reasoning_level: AiReasoningLevel::High, + runtime_effort: Some(AiEffortSelection::Enum("high".to_string())), + context_window: None, + codex_cli_path: None, + codex_cli_env: HashMap::new(), + claude_code_cli_path: None, + claude_code_cli_env: HashMap::new(), + pi_agent_cli_path: None, + pi_agent_cli_env: HashMap::new(), + } + } + + #[test] + fn rpc_arguments_disable_ambient_pi_features() { + assert_eq!( + pi_rpc_args(None), + [ + "--mode", + "rpc", + "--no-session", + "--no-extensions", + "--no-skills", + "--no-prompt-templates", + "--no-context-files", + "--no-builtin-tools", + "--no-approve", + ] + ); + } + + #[test] + fn selection_uses_startup_arguments_instead_of_mutating_rpc_commands() { + let config = config(); + assert_eq!( + pi_selection_args(&config).unwrap(), + ["--provider", "openai-codex", "--model", "gpt-5.4", "--thinking", "high"] + ); + + let mut default_config = config; + default_config.model = "default".to_string(); + default_config.runtime_effort = Some(AiEffortSelection::ProviderDefault); + assert!(pi_selection_args(&default_config).unwrap().is_empty()); + } + + #[test] + fn pi_model_keys_preserve_provider_and_nested_model_ids() { + assert_eq!(split_pi_model_key("openai-codex/gpt-5.4").unwrap(), ("openai-codex", "gpt-5.4")); + assert_eq!(split_pi_model_key("custom/provider/model").unwrap(), ("custom", "provider/model")); + assert!(split_pi_model_key("missing-provider-separator").is_err()); + } + + #[test] + fn parses_all_supported_pi_thinking_levels_in_cli_order() { + let levels = parse_thinking_levels(&json!({ + "levels": ["off", "minimal", "low", "medium", "high", "xhigh", "max", "future", "high"] + })); + assert_eq!( + serde_json::to_value(levels).unwrap(), + json!(["off", "minimal", "low", "medium", "high", "xhigh", "max", "future"]) + ); + } + + #[test] + fn rejects_dbx_managed_pi_environment_variables() { + let mut config = config(); + config.pi_agent_cli_env.insert("DBX_PI_ENABLED_TOOLS".to_string(), "[]".to_string()); + let command = CliAgentCommandSpec { program: "pi".to_string(), args: Vec::new() }; + + let error = pi_agent_process_env(&config, &command).unwrap_err(); + assert!(error.contains("[piAgentEnvReserved]")); + } + + #[test] + fn bridge_reuses_dbx_mcp_scope_and_write_policy() { + let runtime = PiIsolatedRuntime::create().unwrap(); + let options = CliAgentRunOptions { + connection_id: "connection-1".to_string(), + connection_name: "Test connection".to_string(), + database: "dbx_test".to_string(), + agent_mode: true, + allow_writes: true, + allow_dangerous: false, + confirmed_write_sql: None, + mcp_server_command: Some(CliAgentCommandSpec { + program: "/usr/local/bin/dbx-mcp-server".to_string(), + args: vec!["--stdio".to_string()], + }), + }; + let mut process = tokio::process::Command::new("pi"); + + configure_pi_bridge(&mut process, &runtime, &options).unwrap(); + + let env = process + .as_std() + .get_envs() + .filter_map(|(name, value)| { + value.map(|value| (name.to_string_lossy().into_owned(), value.to_string_lossy().into_owned())) + }) + .collect::>(); + assert_eq!(env.get("DBX_PI_MCP_PROGRAM").map(String::as_str), Some("/usr/local/bin/dbx-mcp-server")); + assert_eq!(env.get("DBX_PI_MCP_ARGS").map(String::as_str), Some("[\"--stdio\"]")); + assert_eq!(env.get("DBX_MCP_ALLOW_WRITES").map(String::as_str), Some("1")); + assert_eq!(env.get("DBX_MCP_ALLOW_DANGEROUS_SQL").map(String::as_str), Some("0")); + assert_eq!(env.get("DBX_MCP_SCOPE_CONNECTION_ID").map(String::as_str), Some("connection-1")); + assert_eq!(env.get("DBX_MCP_SCOPE_CONNECTION_NAME").map(String::as_str), Some("Test connection")); + assert_eq!(env.get("DBX_MCP_SCOPE_DATABASE").map(String::as_str), Some("dbx_test")); + + let enabled_tools = serde_json::from_str::>(env.get("DBX_PI_ENABLED_TOOLS").unwrap()).unwrap(); + assert!(enabled_tools.iter().any(|tool| tool == "dbx_execute_query")); + assert!(enabled_tools.iter().any(|tool| tool == "dbx_execute_redis_command")); + } + + #[test] + fn classifies_authentication_failures_separately() { + assert!(classify_pi_run_error("Authentication required. Please login").starts_with("[piAgentNotAuthenticated]")); + assert!(classify_pi_run_error("unexpected exit").starts_with("[piAgentRunFailed]")); + } + + #[test] + fn maps_pi_stream_events_to_dbx_agent_events_and_usage() { + let events = Mutex::new(Vec::new()); + let on_event = |event| events.lock().unwrap().push(event); + let mut text = String::new(); + let mut input_tokens = None; + let mut output_tokens = None; + + assert!(!emit_pi_event( + &json!({ + "type": "message_update", + "message": { "role": "assistant" }, + "assistantMessageEvent": { "type": "thinking_delta", "delta": "reason" } + }), + &on_event, + &mut text, + &mut input_tokens, + &mut output_tokens, + ) + .unwrap()); + assert!(!emit_pi_event( + &json!({ + "type": "message_update", + "message": { "role": "assistant" }, + "assistantMessageEvent": { "type": "text_delta", "delta": "answer" } + }), + &on_event, + &mut text, + &mut input_tokens, + &mut output_tokens, + ) + .unwrap()); + emit_pi_event( + &json!({ + "type": "message_end", + "message": { "role": "assistant", "usage": { "input": 12, "output": 4 } } + }), + &on_event, + &mut text, + &mut input_tokens, + &mut output_tokens, + ) + .unwrap(); + emit_pi_event( + &json!({ + "type": "message_end", + "message": { "role": "assistant", "usage": { "inputTokens": 3, "output_tokens": 2 } } + }), + &on_event, + &mut text, + &mut input_tokens, + &mut output_tokens, + ) + .unwrap(); + assert!(!emit_pi_event( + &json!({ "type": "agent_end", "willRetry": false }), + &on_event, + &mut text, + &mut input_tokens, + &mut output_tokens, + ) + .unwrap()); + assert!(emit_pi_event( + &json!({ "type": "agent_settled" }), + &on_event, + &mut text, + &mut input_tokens, + &mut output_tokens, + ) + .unwrap()); + + assert_eq!(text, "answer"); + assert_eq!(input_tokens, Some(15)); + assert_eq!(output_tokens, Some(6)); + let events = events.into_inner().unwrap(); + assert!(matches!(&events[0], AgentEvent::ReasoningDelta { delta } if delta == "reason")); + assert!(matches!(&events[1], AgentEvent::TextDelta { delta } if delta == "answer")); + } + + #[test] + fn ignores_non_assistant_message_content() { + let events = Mutex::new(Vec::new()); + let on_event = |event| events.lock().unwrap().push(event); + let mut text = String::new(); + let mut input_tokens = None; + let mut output_tokens = None; + + emit_pi_event( + &json!({ + "type": "message_end", + "message": { + "role": "user", + "content": [{ "type": "text", "text": "Do not echo this prompt" }] + } + }), + &on_event, + &mut text, + &mut input_tokens, + &mut output_tokens, + ) + .unwrap(); + + assert!(text.is_empty()); + assert!(events.into_inner().unwrap().is_empty()); + } + + #[test] + fn prompt_includes_existing_cli_agent_safety_contract() { + let prompt = build_pi_agent_prompt( + "System context", + &[crate::ai::AiMessage { + role: "user".to_string(), + content: "Count the keys".to_string(), + tool_call_id: None, + tool_calls: Vec::new(), + }], + false, + ); + assert!(prompt.contains("Pi Coding Agent")); + assert!(prompt.contains("System context")); + assert!(prompt.contains("Count the keys")); + } +} diff --git a/crates/dbx-core/src/cloud_sync.rs b/crates/dbx-core/src/cloud_sync.rs index d5f9297f3d..f3ea31bb46 100644 --- a/crates/dbx-core/src/cloud_sync.rs +++ b/crates/dbx-core/src/cloud_sync.rs @@ -1097,6 +1097,8 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), }, } } diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs index 235a3c8de7..9eeb866c82 100644 --- a/crates/dbx-core/src/lib.rs +++ b/crates/dbx-core/src/lib.rs @@ -14,6 +14,7 @@ pub mod ai_cli_agent; pub mod ai_codex_cli; pub mod ai_effort; mod ai_model_filter; +pub mod ai_pi_agent_cli; pub mod changelog; pub mod cloud_sync; pub mod connection; diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index a5275d7306..df61a7cadd 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -4917,6 +4917,8 @@ mod tests { codex_cli_env: std::collections::HashMap::new(), claude_code_cli_path: None, claude_code_cli_env: std::collections::HashMap::new(), + pi_agent_cli_path: None, + pi_agent_cli_env: std::collections::HashMap::new(), }, } } diff --git a/crates/dbx-web/src/routes/ai.rs b/crates/dbx-web/src/routes/ai.rs index 0ca55a061c..8f8b82b751 100644 --- a/crates/dbx-web/src/routes/ai.rs +++ b/crates/dbx-web/src/routes/ai.rs @@ -114,7 +114,7 @@ fn default_agent_mode() -> String { } fn reject_web_unsupported_ai_provider(config: &AiConfig) -> Result<(), AppError> { - if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) { + if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) { return Err(AppError::bad_request("CLI providers are only supported in DBX Desktop.")); } Ok(()) @@ -479,13 +479,17 @@ mod tests { codex_cli_env: Default::default(), claude_code_cli_path: None, claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), } } #[test] - fn rejects_codex_cli_single() { - let config = make_config(AiProvider::CodexCli); - assert!(reject_web_unsupported_ai_provider(&config).is_err()); + fn rejects_local_cli_providers_single() { + for provider in [AiProvider::CodexCli, AiProvider::ClaudeCodeCli, AiProvider::PiAgentCli] { + let config = make_config(provider); + assert!(reject_web_unsupported_ai_provider(&config).is_err()); + } } #[test] diff --git a/packages/app-tests/aiCliErrorI18n.test.ts b/packages/app-tests/aiCliErrorI18n.test.ts index 1ed3ced728..e8401e7e99 100644 --- a/packages/app-tests/aiCliErrorI18n.test.ts +++ b/packages/app-tests/aiCliErrorI18n.test.ts @@ -10,7 +10,28 @@ import ptBR from "../../apps/desktop/src/i18n/locales/pt-BR"; import zhCN from "../../apps/desktop/src/i18n/locales/zh-CN"; import zhTW from "../../apps/desktop/src/i18n/locales/zh-TW"; -const errorCodes = ["claudeCodeNotInstalled", "claudeCodeCliPathInvalid", "claudeCodeEnvInvalid", "claudeCodeEnvReserved", "claudeCodeNotAuthenticated", "claudeCodeMcpConfigInvalid", "dbxMcpMissing", "claudeCodeMcpStartupFailed", "claudeCodeCommandLineTooLong", "claudeCodeRunFailed"] as const; +const errorCodes = [ + "claudeCodeNotInstalled", + "claudeCodeCliPathInvalid", + "claudeCodeEnvInvalid", + "claudeCodeEnvReserved", + "claudeCodeNotAuthenticated", + "claudeCodeMcpConfigInvalid", + "dbxMcpMissing", + "claudeCodeMcpStartupFailed", + "claudeCodeCommandLineTooLong", + "claudeCodeRunFailed", + "piAgentNotInstalled", + "piAgentCliPathInvalid", + "piAgentEnvInvalid", + "piAgentEnvReserved", + "piAgentNotAuthenticated", + "piAgentMcpStartupFailed", + "piAgentTimeout", + "piAgentProtocolError", + "piAgentModelInvalid", + "piAgentRunFailed", +] as const; test("Claude Code CLI errors are localized while retaining their stable code and raw diagnostics", () => { const messages: Record = { @@ -35,7 +56,7 @@ test("Claude Code CLI errors are localized while retaining their stable code and assert.match(translated, /C:\\Temp\\mcp\.json/); }); -test("every current locale defines all Claude Code CLI diagnostic messages", () => { +test("every current locale defines all AI CLI diagnostic messages", () => { const locales = { en, es, it, ja, ptBR, zhCN, zhTW } as const; for (const [localeName, locale] of Object.entries(locales)) { diff --git a/packages/app-tests/piAgentMcpBridge.test.ts b/packages/app-tests/piAgentMcpBridge.test.ts new file mode 100644 index 0000000000..bcfedde644 --- /dev/null +++ b/packages/app-tests/piAgentMcpBridge.test.ts @@ -0,0 +1,86 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +const bridgePath = resolve("crates/dbx-core/assets/pi-mcp-bridge.mjs"); +const envNames = ["DBX_PI_MCP_PROGRAM", "DBX_PI_MCP_ARGS", "DBX_PI_ENABLED_TOOLS", "DBX_PI_BRIDGE_READY_FILE"] as const; +const originalEnv = Object.fromEntries(envNames.map((name) => [name, process.env[name]])); + +afterEach(() => { + for (const name of envNames) { + const value = originalEnv[name]; + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } +}); + +describe("Pi Coding Agent MCP bridge", () => { + it("registers an allowed DBX MCP tool and forwards its result", async () => { + const directory = await mkdtemp(join(tmpdir(), "dbx-pi-bridge-test-")); + const readyPath = join(directory, "ready"); + const fakeMcp = String.raw` + const readline = require("node:readline"); + const lines = readline.createInterface({ input: process.stdin }); + lines.on("line", (line) => { + const request = JSON.parse(line); + if (request.id == null) return; + let result = {}; + if (request.method === "tools/list") { + result = { + tools: [{ + name: "dbx_ping", + title: "DBX Ping", + description: "Return a deterministic value", + inputSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, + }], + }; + } else if (request.method === "tools/call") { + result = { + content: [{ type: "text", text: "pong:" + request.params.arguments.value }], + isError: false, + }; + } + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\n"); + }); + `; + process.env.DBX_PI_MCP_PROGRAM = process.execPath; + process.env.DBX_PI_MCP_ARGS = JSON.stringify(["-e", fakeMcp]); + process.env.DBX_PI_ENABLED_TOOLS = JSON.stringify(["dbx_ping"]); + process.env.DBX_PI_BRIDGE_READY_FILE = readyPath; + + const registeredTools: Array<{ + name: string; + execute: (toolCallId: string, params: unknown, signal: AbortSignal) => Promise<{ content: unknown[]; details: unknown }>; + }> = []; + let shutdown: (() => Promise) | undefined; + const bridge = await import(`${pathToFileURL(bridgePath).href}?test=${Date.now()}`); + await bridge.default({ + registerTool(tool: (typeof registeredTools)[number]) { + registeredTools.push(tool); + }, + on(event: string, handler: () => Promise) { + if (event === "session_shutdown") shutdown = handler; + }, + }); + + try { + expect(await readFile(readyPath, "utf8")).toBe("ready"); + expect(registeredTools.map((tool) => tool.name)).toEqual(["dbx_ping"]); + const result = await registeredTools[0].execute("call-1", { value: "ok" }, new AbortController().signal); + expect(result.content).toEqual([{ type: "text", text: "pong:ok" }]); + expect(result.details).toMatchObject({ isError: false }); + } finally { + await shutdown?.(); + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/app-tests/settingsStore.test.ts b/packages/app-tests/settingsStore.test.ts index 055ce0a793..dc3bcc4047 100644 --- a/packages/app-tests/settingsStore.test.ts +++ b/packages/app-tests/settingsStore.test.ts @@ -508,7 +508,12 @@ test("AI provider presets include common hosted and local providers", () => { assert.equal(AI_PROVIDER_PRESETS["claude-code-cli"].model, "default"); assert.equal(AI_PROVIDER_PRESETS["claude-code-cli"].iconSlug, "claudecode"); assert.equal(AI_PROVIDER_PRESETS["claude-code-cli"].requiresApiKey, false); + assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].model, "default"); + assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].iconSlug, "pi"); + assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].requiresApiKey, false); assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("claude-code-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli")); + assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("claude-code-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli")); + assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli")); }); test("normalizes legacy AI config and fills provider defaults", () => { @@ -558,6 +563,15 @@ test("normalizes legacy AI config and fills provider defaults", () => { ]); assert.equal(normalizeAiConfig({ provider: "claude-code-cli", reasoningLevel: "max" } as any).reasoningLevel, "max"); assert.equal(normalizeAiConfig({ provider: "claude-code-cli", reasoningLevel: "future" } as any).reasoningLevel, "default"); + + const piAgent = normalizeAiConfig({ + provider: "pi-agent-cli", + piAgentCliPath: " /opt/homebrew/bin/pi ", + piAgentCliEnv: { HTTPS_PROXY: "http://proxy:9800" }, + } as any); + assert.equal(piAgent.piAgentCliPath, "/opt/homebrew/bin/pi"); + assert.deepEqual(piAgent.piAgentCliEnv, { HTTPS_PROXY: "http://proxy:9800" }); + assert.equal(piAgent.model, "default"); }); test("infers legacy AI provider from saved endpoint and model", () => { diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index 861fd5b032..31761a7d7f 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -195,6 +195,7 @@ fn resolve_cli_provider_config(mut config: AiConfig) -> AiConfig { let (path_slot, default_command) = match config.provider { AiProvider::CodexCli => (&mut config.codex_cli_path, "codex"), AiProvider::ClaudeCodeCli => (&mut config.claude_code_cli_path, "claude"), + AiProvider::PiAgentCli => (&mut config.pi_agent_cli_path, "pi"), _ => return config, }; let command = path_slot.as_deref().map(str::trim).filter(|path| !path.is_empty()).unwrap_or(default_command); @@ -209,7 +210,7 @@ fn resolve_cli_provider_config(mut config: AiConfig) -> AiConfig { } fn is_cli_provider(provider: &AiProvider) -> bool { - matches!(provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) + matches!(provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) } fn is_explicit_cli_path(command: &str) -> bool {