diff --git a/apps/desktop/public/icons/ai/opencode.svg b/apps/desktop/public/icons/ai/opencode.svg
new file mode 100644
index 000000000..d3664de25
--- /dev/null
+++ b/apps/desktop/public/icons/ai/opencode.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue
index 29a09b19b..26a882614 100644
--- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue
+++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue
@@ -2553,7 +2553,7 @@ async function saveMaxAgentTurnsSetting() {
}
// Max Retries (global). Default 2, range 0–10. Applied to all API-backed
-// AI providers. CLI providers (claude-code, codex, pi) are unaffected
+// AI providers. CLI providers (Claude Code, Codex, OpenCode, Pi) are unaffected
// because they use their own retry logic.
const editMaxRetries = ref(undefined);
const maxRetriesSaving = ref(false);
@@ -2604,7 +2604,8 @@ function normalizeMaxRetries(value: number | undefined): number {
const aiDeleteConfirmOpen = ref(false);
const aiDeleteConfigId = ref(null);
-const CLI_AI_PROVIDERS = new Set(["claude-code-cli", "pi-agent-cli", "codex-cli"]);
+const CLI_AI_PROVIDERS = new Set(["claude-code-cli", "codex-cli", "opencode-cli", "pi-agent-cli"]);
+const OPENCODE_CONTROL_ENV = new Set(["OPENCODE_CONFIG", "OPENCODE_CONFIG_CONTENT", "OPENCODE_CONFIG_DIR", "OPENCODE_DB", "OPENCODE_PERMISSION", "OPENCODE_DISABLE_PROJECT_CONFIG"]);
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]);
@@ -2626,6 +2627,8 @@ const aiEditClaudeCodeCliPath = ref("");
const aiEditClaudeCodeCliEnvRows = ref([]);
const aiEditPiAgentCliPath = ref("");
const aiEditPiAgentCliEnvRows = ref([]);
+const aiEditOpenCodeCliPath = ref("");
+const aiEditOpenCodeCliEnvRows = ref([]);
const aiAnthropicMessagesMode = computed(() => aiEditApiStyle.value === "anthropic-messages");
@@ -2656,22 +2659,26 @@ const aiTestErrorDisplay = computed(() => [aiTestErrorPresentation.value.summary
const aiIsCodexCli = computed(() => aiEditProvider.value === "codex-cli");
const aiIsClaudeCodeCli = computed(() => aiEditProvider.value === "claude-code-cli");
const aiIsPiAgentCli = computed(() => aiEditProvider.value === "pi-agent-cli");
+const aiIsOpenCodeCli = computed(() => aiEditProvider.value === "opencode-cli");
const aiIsCliProvider = computed(() => CLI_AI_PROVIDERS.has(aiEditProvider.value));
const aiCliProviderLabel = computed(() => selectedAiProviderPreset.value.label);
const aiCliCommandName = computed(() => {
if (aiIsClaudeCodeCli.value) return "claude";
if (aiIsPiAgentCli.value) return "pi";
+ if (aiIsOpenCodeCli.value) return "opencode";
return "codex";
});
const aiCliLoginCommand = computed(() => {
if (aiIsClaudeCodeCli.value) return "claude auth login";
if (aiIsPiAgentCli.value) return "pi";
+ if (aiIsOpenCodeCli.value) return "opencode auth login";
return "codex login";
});
const aiEditCliPath = computed({
get: () => {
if (aiIsClaudeCodeCli.value) return aiEditClaudeCodeCliPath.value;
if (aiIsPiAgentCli.value) return aiEditPiAgentCliPath.value;
+ if (aiIsOpenCodeCli.value) return aiEditOpenCodeCliPath.value;
return aiEditCodexCliPath.value;
},
set: (value: string) => {
@@ -2679,6 +2686,8 @@ const aiEditCliPath = computed({
aiEditClaudeCodeCliPath.value = value;
} else if (aiIsPiAgentCli.value) {
aiEditPiAgentCliPath.value = value;
+ } else if (aiIsOpenCodeCli.value) {
+ aiEditOpenCodeCliPath.value = value;
} else {
aiEditCodexCliPath.value = value;
}
@@ -2687,6 +2696,7 @@ const aiEditCliPath = computed({
const aiEditCliEnvRows = computed(() => {
if (aiIsClaudeCodeCli.value) return aiEditClaudeCodeCliEnvRows.value;
if (aiIsPiAgentCli.value) return aiEditPiAgentCliEnvRows.value;
+ if (aiIsOpenCodeCli.value) return aiEditOpenCodeCliEnvRows.value;
return aiEditCodexCliEnvRows.value;
});
watch(aiIsCliProvider, (isCliProvider) => {
@@ -2763,7 +2773,7 @@ function cliEnvValidationError(): string {
const key = row.key.trim();
if (key && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return t("ai.cliEnvInvalidName", { name: key });
const upper = key.toUpperCase();
- if (upper.startsWith("DBX_MCP_") || (aiIsPiAgentCli.value && upper.startsWith("DBX_PI_"))) {
+ if (upper.startsWith("DBX_MCP_") || (aiIsPiAgentCli.value && upper.startsWith("DBX_PI_")) || (aiIsOpenCodeCli.value && OPENCODE_CONTROL_ENV.has(upper))) {
return t("ai.cliEnvReservedName", { name: key });
}
}
@@ -2779,6 +2789,8 @@ function removeCliEnvRow(id: string) {
aiEditClaudeCodeCliEnvRows.value = aiEditClaudeCodeCliEnvRows.value.filter((row) => row.id !== id);
} else if (aiIsPiAgentCli.value) {
aiEditPiAgentCliEnvRows.value = aiEditPiAgentCliEnvRows.value.filter((row) => row.id !== id);
+ } else if (aiIsOpenCodeCli.value) {
+ aiEditOpenCodeCliEnvRows.value = aiEditOpenCodeCliEnvRows.value.filter((row) => row.id !== id);
} else {
aiEditCodexCliEnvRows.value = aiEditCodexCliEnvRows.value.filter((row) => row.id !== id);
}
@@ -2807,6 +2819,8 @@ function currentAiEditConfig() {
claudeCodeCliEnv: aiIsClaudeCodeCli.value ? cliEnvFromRows(aiEditClaudeCodeCliEnvRows.value) : {},
piAgentCliPath: aiEditPiAgentCliPath.value.trim() || undefined,
piAgentCliEnv: aiIsPiAgentCli.value ? cliEnvFromRows(aiEditPiAgentCliEnvRows.value) : {},
+ opencodeCliPath: aiEditOpenCodeCliPath.value.trim() || undefined,
+ opencodeCliEnv: aiIsOpenCodeCli.value ? cliEnvFromRows(aiEditOpenCodeCliEnvRows.value) : {},
};
}
@@ -2876,6 +2890,8 @@ function aiEnterEditMode(configId?: string) {
aiEditClaudeCodeCliEnvRows.value = aiEnvRowsFromConfig(config.claudeCodeCliEnv);
aiEditPiAgentCliPath.value = config.piAgentCliPath ?? "";
aiEditPiAgentCliEnvRows.value = aiEnvRowsFromConfig(config.piAgentCliEnv);
+ aiEditOpenCodeCliPath.value = config.opencodeCliPath ?? "";
+ aiEditOpenCodeCliEnvRows.value = aiEnvRowsFromConfig(config.opencodeCliEnv);
}
} else {
aiEditConfigName.value = "";
@@ -2897,6 +2913,8 @@ function aiEnterEditMode(configId?: string) {
aiEditClaudeCodeCliEnvRows.value = [];
aiEditPiAgentCliPath.value = "";
aiEditPiAgentCliEnvRows.value = [];
+ aiEditOpenCodeCliPath.value = "";
+ aiEditOpenCodeCliEnvRows.value = [];
}
}
diff --git a/apps/desktop/src/components/icons/AiProviderLogo.vue b/apps/desktop/src/components/icons/AiProviderLogo.vue
index dc2e4d4cd..148768ecc 100644
--- a/apps/desktop/src/components/icons/AiProviderLogo.vue
+++ b/apps/desktop/src/components/icons/AiProviderLogo.vue
@@ -21,7 +21,7 @@ watch(
},
);
-const usesWhiteDarkIcon = computed(() => props.provider === "claude" || props.provider === "anthropic-compatible" || props.provider === "ollama" || props.provider === "openai" || props.provider === "openai-compatible");
+const usesWhiteDarkIcon = computed(() => props.provider === "claude" || props.provider === "anthropic-compatible" || props.provider === "ollama" || props.provider === "openai" || props.provider === "openai-compatible" || props.provider === "opencode-cli");
const localIconUrl = computed(() => {
if (props.provider === "openai-compatible") return webPath("/icons/ai/openai.svg");
return props.iconSlug ? webPath(`/icons/ai/${props.iconSlug}.svg`) : "";
diff --git a/apps/desktop/src/composables/__tests__/useAiModelCatalog.spec.ts b/apps/desktop/src/composables/__tests__/useAiModelCatalog.spec.ts
index 105751f4d..bec4eb42e 100644
--- a/apps/desktop/src/composables/__tests__/useAiModelCatalog.spec.ts
+++ b/apps/desktop/src/composables/__tests__/useAiModelCatalog.spec.ts
@@ -94,6 +94,30 @@ describe("useAiModelCatalog", () => {
expect(apiMock.aiResolveModelEffort).toHaveBeenCalledTimes(2);
});
+ it("tracks OpenCode executable and environment changes without depending on environment key order", async () => {
+ const initial: AiConfigItem = {
+ ...config(),
+ provider: "opencode-cli",
+ endpoint: "",
+ apiKey: "",
+ model: "default",
+ opencodeCliPath: "/opt/homebrew/bin/opencode",
+ opencodeCliEnv: { HTTPS_PROXY: "http://127.0.0.1:7890", NO_PROXY: "localhost" },
+ };
+ apiMock.aiListModels.mockResolvedValueOnce([{ id: "first" }]).mockResolvedValueOnce([{ id: "second" }]);
+
+ await expect(catalog.loadModels(initial)).resolves.toEqual([{ id: "first" }]);
+ await expect(
+ catalog.loadModels({
+ ...initial,
+ opencodeCliEnv: { NO_PROXY: "localhost", HTTPS_PROXY: "http://127.0.0.1:7890" },
+ }),
+ ).resolves.toEqual([{ id: "first" }]);
+ await expect(catalog.loadModels({ ...initial, opencodeCliPath: "/usr/local/bin/opencode" })).resolves.toEqual([{ id: "second" }]);
+
+ expect(apiMock.aiListModels).toHaveBeenCalledTimes(2);
+ });
+
it("does not let a stale request overwrite a newer provider catalog", async () => {
let resolveInitial: ((models: { id: string }[]) => void) | undefined;
apiMock.aiListModels
diff --git a/apps/desktop/src/composables/useAiModelCatalog.ts b/apps/desktop/src/composables/useAiModelCatalog.ts
index ca51970a2..2aa0a7d11 100644
--- a/apps/desktop/src/composables/useAiModelCatalog.ts
+++ b/apps/desktop/src/composables/useAiModelCatalog.ts
@@ -57,6 +57,7 @@ function configSignature(config: AiConfigItem): string {
codexCliPath: config.codexCliPath ?? null,
claudeCodeCliPath: config.claudeCodeCliPath ?? null,
piAgentCliPath: config.piAgentCliPath ?? null,
+ opencodeCliPath: config.opencodeCliPath ?? null,
connectionFingerprint: fingerprint(
JSON.stringify({
apiKey: config.apiKey,
@@ -65,6 +66,7 @@ function configSignature(config: AiConfigItem): string {
codexCliEnv: sortedRecord(config.codexCliEnv),
claudeCodeCliEnv: sortedRecord(config.claudeCodeCliEnv),
piAgentCliEnv: sortedRecord(config.piAgentCliEnv),
+ opencodeCliEnv: sortedRecord(config.opencodeCliEnv),
}),
),
});
diff --git a/apps/desktop/src/i18n/backend-errors.ts b/apps/desktop/src/i18n/backend-errors.ts
index 382a733fd..f3f299d91 100644
--- a/apps/desktop/src/i18n/backend-errors.ts
+++ b/apps/desktop/src/i18n/backend-errors.ts
@@ -33,6 +33,15 @@ const taggedAiCliErrorKeys: Record = {
piAgentProtocolError: "ai.cliErrors.piAgentProtocolError",
piAgentModelInvalid: "ai.cliErrors.piAgentModelInvalid",
piAgentRunFailed: "ai.cliErrors.piAgentRunFailed",
+ openCodeNotInstalled: "ai.cliErrors.openCodeNotInstalled",
+ openCodeCliPathInvalid: "ai.cliErrors.openCodeCliPathInvalid",
+ openCodeEnvInvalid: "ai.cliErrors.openCodeEnvInvalid",
+ openCodeEnvReserved: "ai.cliErrors.openCodeEnvReserved",
+ openCodeNotAuthenticated: "ai.cliErrors.openCodeNotAuthenticated",
+ openCodeMcpStartupFailed: "ai.cliErrors.openCodeMcpStartupFailed",
+ openCodeTimeout: "ai.cliErrors.openCodeTimeout",
+ openCodeProtocolError: "ai.cliErrors.openCodeProtocolError",
+ openCodeRunFailed: "ai.cliErrors.openCodeRunFailed",
};
const exactMessageKeys: Record = {
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 902f461a6..6c12c8f8a 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -2121,6 +2121,15 @@ export default {
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.",
+ openCodeNotInstalled: "OpenCode CLI was not found. Install OpenCode or set its executable path in Settings > AI.",
+ openCodeCliPathInvalid: "The OpenCode CLI path is invalid. Select only the OpenCode executable and configure environment variables separately.",
+ openCodeEnvInvalid: "An OpenCode CLI environment variable name is invalid. Use names such as HTTPS_PROXY.",
+ openCodeEnvReserved: "An OpenCode or DBX-managed environment variable was overridden. Remove OPENCODE control variables and DBX_MCP_* variables from the provider configuration.",
+ openCodeNotAuthenticated: "OpenCode has no authenticated model provider. Run `opencode auth login` or configure a provider in OpenCode, then try again.",
+ openCodeMcpStartupFailed: "OpenCode could not start the scoped DBX MCP server. Check Settings > MCP and the diagnostics below.",
+ openCodeTimeout: "OpenCode CLI did not respond before the operation timed out.",
+ openCodeProtocolError: "OpenCode CLI returned an invalid JSON event stream. Check the OpenCode version and diagnostics below.",
+ openCodeRunFailed: "OpenCode CLI 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 ddad5d63c..7030606aa 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -1960,6 +1960,15 @@ export default withEnglishFallback({
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.",
+ openCodeNotInstalled: "No se encontró OpenCode CLI. Instala OpenCode o configura la ruta del ejecutable en Ajustes > IA.",
+ openCodeCliPathInvalid: "La ruta de OpenCode CLI no es válida. Selecciona solo el ejecutable de OpenCode y configura las variables de entorno por separado.",
+ openCodeEnvInvalid: "El nombre de una variable de entorno de OpenCode CLI no es válido. Usa nombres como HTTPS_PROXY.",
+ openCodeEnvReserved: "Se intentó sobrescribir una variable administrada por OpenCode o DBX. Elimina las variables de control OPENCODE y DBX_MCP_* de la configuración.",
+ openCodeNotAuthenticated: "OpenCode no tiene un proveedor de modelos autenticado. Ejecuta `opencode auth login` o configura un proveedor en OpenCode y vuelve a intentarlo.",
+ openCodeMcpStartupFailed: "OpenCode no pudo iniciar el servidor DBX MCP con ámbito restringido. Revisa Ajustes > MCP y los detalles siguientes.",
+ openCodeTimeout: "OpenCode CLI no respondió antes de que finalizara el tiempo de espera.",
+ openCodeProtocolError: "OpenCode CLI devolvió un flujo de eventos JSON no válido. Revisa la versión de OpenCode y los detalles siguientes.",
+ openCodeRunFailed: "OpenCode CLI 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 5dc36bcbc..2a521630d 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -2098,6 +2098,15 @@ export default withEnglishFallback({
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.",
+ openCodeNotInstalled: "OpenCode CLI non è stato trovato. Installa OpenCode o imposta il percorso dell'eseguibile in Impostazioni > AI.",
+ openCodeCliPathInvalid: "Il percorso di OpenCode CLI non è valido. Seleziona solo l'eseguibile OpenCode e configura separatamente le variabili d'ambiente.",
+ openCodeEnvInvalid: "Il nome di una variabile d'ambiente di OpenCode CLI non è valido. Usa nomi come HTTPS_PROXY.",
+ openCodeEnvReserved: "È stata sovrascritta una variabile gestita da OpenCode o DBX. Rimuovi le variabili di controllo OPENCODE e DBX_MCP_* dalla configurazione.",
+ openCodeNotAuthenticated: "OpenCode non dispone di un provider di modelli autenticato. Esegui `opencode auth login` o configura un provider in OpenCode, quindi riprova.",
+ openCodeMcpStartupFailed: "OpenCode non ha potuto avviare il server DBX MCP con ambito limitato. Controlla Impostazioni > MCP e i dettagli seguenti.",
+ openCodeTimeout: "OpenCode CLI non ha risposto prima della scadenza dell'operazione.",
+ openCodeProtocolError: "OpenCode CLI ha restituito un flusso di eventi JSON non valido. Controlla la versione di OpenCode e i dettagli seguenti.",
+ openCodeRunFailed: "OpenCode CLI è 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 18749dcb2..1fd3e8e67 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -1994,6 +1994,15 @@ export default withEnglishFallback({
piAgentProtocolError: "Pi Coding Agentが無効なRPC応答を返しました。Piのバージョンと以下の診断詳細を確認してください。",
piAgentModelInvalid: "選択したPiモデルIDが無効です。モデル一覧を更新してprovider/model項目を選択してください。",
piAgentRunFailed: "Pi Coding Agentが予期せず終了しました。エラーコードと以下の診断詳細から問題を確認してください。",
+ openCodeNotInstalled: "OpenCode CLIが見つかりません。OpenCodeをインストールするか、設定 > AIで実行ファイルのパスを指定してください。",
+ openCodeCliPathInvalid: "OpenCode CLIのパスが無効です。OpenCodeの実行ファイルだけを選択し、環境変数は別に設定してください。",
+ openCodeEnvInvalid: "OpenCode CLIの環境変数名が無効です。HTTPS_PROXYのような名前を使用してください。",
+ openCodeEnvReserved: "OpenCodeまたはDBXが管理する環境変数が上書きされています。設定からOPENCODE制御変数とDBX_MCP_*を削除してください。",
+ openCodeNotAuthenticated: "OpenCodeに認証済みのモデルプロバイダーがありません。`opencode auth login`を実行するかOpenCodeでプロバイダーを設定して、再試行してください。",
+ openCodeMcpStartupFailed: "OpenCodeはスコープ付きDBX MCPサーバーを起動できませんでした。設定 > MCPと以下の診断詳細を確認してください。",
+ openCodeTimeout: "OpenCode CLIはタイムアウトまでに応答しませんでした。",
+ openCodeProtocolError: "OpenCode CLIが無効なJSONイベントストリームを返しました。OpenCodeのバージョンと以下の診断詳細を確認してください。",
+ openCodeRunFailed: "OpenCode CLIが予期せず終了しました。エラーコードと以下の診断詳細から問題を確認してください。",
},
run: "実行",
readingSchema: "スキーマを読み取り中",
diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts
index 599a2a533..82af6404e 100644
--- a/apps/desktop/src/i18n/locales/ko.ts
+++ b/apps/desktop/src/i18n/locales/ko.ts
@@ -2001,6 +2001,15 @@ export default withEnglishFallback({
piAgentProtocolError: "Pi Coding Agent가 잘못된 RPC 응답을 반환했습니다. Pi 버전과 아래 진단을 확인하세요.",
piAgentModelInvalid: "선택한 Pi 모델 식별자가 잘못되었습니다. 모델 목록을 새로고침하고 공급자/모델 항목을 선택하세요.",
piAgentRunFailed: "Pi Coding Agent가 예기치 않게 종료되었습니다. 아래의 오류 코드와 진단을 사용하여 실패한 실행 파일이나 CLI 출력을 파악하세요.",
+ openCodeNotInstalled: "OpenCode CLI를 찾을 수 없습니다. OpenCode를 설치하거나 설정 > AI에서 실행 파일 경로를 지정하세요.",
+ openCodeCliPathInvalid: "OpenCode CLI 경로가 올바르지 않습니다. OpenCode 실행 파일만 선택하고 환경 변수는 별도로 설정하세요.",
+ openCodeEnvInvalid: "OpenCode CLI 환경 변수 이름이 올바르지 않습니다. HTTPS_PROXY와 같은 이름을 사용하세요.",
+ openCodeEnvReserved: "OpenCode 또는 DBX가 관리하는 환경 변수를 덮어썼습니다. 공급자 설정에서 OPENCODE 제어 변수와 DBX_MCP_* 변수를 제거하세요.",
+ openCodeNotAuthenticated: "OpenCode에 인증된 모델 공급자가 없습니다. `opencode auth login`을 실행하거나 OpenCode에서 공급자를 구성한 후 다시 시도하세요.",
+ openCodeMcpStartupFailed: "OpenCode가 범위가 제한된 DBX MCP 서버를 시작하지 못했습니다. 설정 > MCP와 아래 진단을 확인하세요.",
+ openCodeTimeout: "OpenCode CLI가 제한 시간 안에 응답하지 않았습니다.",
+ openCodeProtocolError: "OpenCode CLI가 잘못된 JSON 이벤트 스트림을 반환했습니다. OpenCode 버전과 아래 진단을 확인하세요.",
+ openCodeRunFailed: "OpenCode CLI가 예기치 않게 종료되었습니다. 아래의 오류 코드와 진단을 사용하여 실패한 실행 파일이나 CLI 출력을 파악하세요.",
},
actions: {
general: "일반",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index 6d21b5571..bba33ad03 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -1962,6 +1962,15 @@ export default withEnglishFallback({
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.",
+ openCodeNotInstalled: "O OpenCode CLI não foi encontrado. Instale o OpenCode ou defina o caminho do executável em Configurações > IA.",
+ openCodeCliPathInvalid: "O caminho do OpenCode CLI é inválido. Selecione apenas o executável do OpenCode e configure as variáveis de ambiente separadamente.",
+ openCodeEnvInvalid: "O nome de uma variável de ambiente do OpenCode CLI é inválido. Use nomes como HTTPS_PROXY.",
+ openCodeEnvReserved: "Uma variável gerenciada pelo OpenCode ou DBX foi sobrescrita. Remova as variáveis de controle OPENCODE e DBX_MCP_* da configuração.",
+ openCodeNotAuthenticated: "O OpenCode não tem um provedor de modelos autenticado. Execute `opencode auth login` ou configure um provedor no OpenCode e tente novamente.",
+ openCodeMcpStartupFailed: "O OpenCode não conseguiu iniciar o servidor DBX MCP com escopo restrito. Verifique Configurações > MCP e os detalhes abaixo.",
+ openCodeTimeout: "O OpenCode CLI não respondeu antes do tempo limite da operação.",
+ openCodeProtocolError: "O OpenCode CLI retornou um fluxo de eventos JSON inválido. Verifique a versão do OpenCode e os detalhes abaixo.",
+ openCodeRunFailed: "O OpenCode CLI 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 bae2054ae..a7d32e0df 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -2121,6 +2121,15 @@ export default withEnglishFallback({
piAgentProtocolError: "Pi Coding Agent 返回了无效的 RPC 响应。请检查 Pi 版本和下方诊断详情。",
piAgentModelInvalid: "所选 Pi 模型标识无效。请刷新模型列表并选择 provider/model 条目。",
piAgentRunFailed: "Pi Coding Agent 异常退出。请根据错误代码和下方诊断详情确认失败的可执行文件或 CLI 输出。",
+ openCodeNotInstalled: "未找到 OpenCode CLI。请安装 OpenCode,或在 设置 > AI 中填写其可执行文件路径。",
+ openCodeCliPathInvalid: "OpenCode CLI 路径无效。请只选择 OpenCode 可执行文件,环境变量需单独配置。",
+ openCodeEnvInvalid: "OpenCode CLI 环境变量名称无效。请使用 HTTPS_PROXY 这类合法名称。",
+ openCodeEnvReserved: "配置覆盖了由 OpenCode 或 DBX 管理的环境变量。请移除 OPENCODE 控制变量和 DBX_MCP_* 变量。",
+ openCodeNotAuthenticated: "OpenCode 尚未配置已认证的模型提供商。请执行 `opencode auth login` 或在 OpenCode 中配置提供商后重试。",
+ openCodeMcpStartupFailed: "OpenCode 无法启动受限范围的 DBX MCP Server。请检查 设置 > MCP 和下方诊断详情。",
+ openCodeTimeout: "OpenCode CLI 未能在操作超时前响应。",
+ openCodeProtocolError: "OpenCode CLI 返回了无效的 JSON 事件流。请检查 OpenCode 版本和下方诊断详情。",
+ openCodeRunFailed: "OpenCode CLI 异常退出。请根据错误代码和下方诊断详情确认失败的可执行文件或 CLI 输出。",
},
actions: {
general: "通用问答",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index 1b60acda1..4cff12f76 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -2101,6 +2101,15 @@ export default withEnglishFallback({
piAgentProtocolError: "Pi Coding Agent 傳回無效的 RPC 回應。請檢查 Pi 版本和下方診斷詳情。",
piAgentModelInvalid: "所選 Pi 模型識別碼無效。請重新整理模型清單並選擇 provider/model 項目。",
piAgentRunFailed: "Pi Coding Agent 異常結束。請依據錯誤代碼和下方診斷詳情確認失敗的可執行檔或 CLI 輸出。",
+ openCodeNotInstalled: "找不到 OpenCode CLI。請安裝 OpenCode,或在 設定 > AI 中填寫其執行檔路徑。",
+ openCodeCliPathInvalid: "OpenCode CLI 路徑無效。請只選擇 OpenCode 執行檔,環境變數需另外設定。",
+ openCodeEnvInvalid: "OpenCode CLI 環境變數名稱無效。請使用 HTTPS_PROXY 這類合法名稱。",
+ openCodeEnvReserved: "設定覆寫了由 OpenCode 或 DBX 管理的環境變數。請移除 OPENCODE 控制變數和 DBX_MCP_* 變數。",
+ openCodeNotAuthenticated: "OpenCode 尚未設定已驗證的模型供應商。請執行 `opencode auth login` 或在 OpenCode 中設定供應商後重試。",
+ openCodeMcpStartupFailed: "OpenCode 無法啟動受限範圍的 DBX MCP Server。請檢查 設定 > MCP 和下方診斷詳情。",
+ openCodeTimeout: "OpenCode CLI 未能在操作逾時前回應。",
+ openCodeProtocolError: "OpenCode CLI 傳回了無效的 JSON 事件串流。請檢查 OpenCode 版本和下方診斷詳情。",
+ openCodeRunFailed: "OpenCode CLI 異常結束。請依據錯誤代碼和下方診斷詳情確認失敗的執行檔或 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 f9a45f83d..8622163a3 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", "pi-agent-cli"] as const)("keeps %s configs eligible without endpoint, API key, or model metadata", (provider) => {
+ it.each(["codex-cli", "claude-code-cli", "opencode-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 8b69e8380..010b13c76 100644
--- a/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts
+++ b/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts
@@ -21,11 +21,12 @@ describe("orderAiConfigsForDisplay", () => {
{ id: "ollama", provider: "ollama" },
{ id: "openai-compatible", provider: "openai-compatible" },
{ id: "codex", provider: "codex-cli" },
+ { id: "opencode", provider: "opencode-cli" },
{ id: "pi", provider: "pi-agent-cli" },
{ id: "custom", provider: "custom" },
];
- expect(orderAiConfigsForDisplay(configs).map((config) => config.id)).toEqual(["claude", "openai", "gemini", "deepseek", "qwen", "minimax", "ollama", "anthropic-compatible", "openai-compatible", "claude-code-1", "codex", "pi", "custom"]);
+ expect(orderAiConfigsForDisplay(configs).map((config) => config.id)).toEqual(["claude", "openai", "gemini", "deepseek", "qwen", "minimax", "ollama", "anthropic-compatible", "openai-compatible", "claude-code-1", "codex", "opencode", "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 b10b94734..79289a973 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", "pi-agent-cli"]);
+const CLI_PROVIDERS = new Set(["codex-cli", "claude-code-cli", "opencode-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/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts
index 02c4386ec..425ab648f 100644
--- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts
+++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts
@@ -416,6 +416,22 @@ describe("settingsStore AI API key normalization", () => {
it("trims API keys when normalizing loaded configurations", () => {
expect(normalizeAiConfig({ provider: "openai", apiKey: " secret " }).apiKey).toBe("secret");
});
+
+ it("normalizes OpenCode CLI path and environment settings", () => {
+ expect(
+ normalizeAiConfig({
+ provider: "opencode-cli",
+ opencodeCliPath: " /opt/homebrew/bin/opencode ",
+ opencodeCliEnv: { HTTPS_PROXY: "http://127.0.0.1:7890", EMPTY: null as unknown as string },
+ }),
+ ).toMatchObject({
+ provider: "opencode-cli",
+ endpoint: "",
+ model: "default",
+ opencodeCliPath: "/opt/homebrew/bin/opencode",
+ opencodeCliEnv: { HTTPS_PROXY: "http://127.0.0.1:7890", EMPTY: "" },
+ });
+ });
});
describe("settingsStore MCP policy persistence", () => {
diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts
index 42c535a49..6c3c0d863 100644
--- a/apps/desktop/src/stores/settingsStore.ts
+++ b/apps/desktop/src/stores/settingsStore.ts
@@ -232,6 +232,16 @@ export const AI_PROVIDER_PRESETS: Record = {
authMethod: "bearer",
requiresApiKey: false,
},
+ "opencode-cli": {
+ label: "OpenCode CLI",
+ iconSlug: "opencode",
+ provider: "opencode-cli",
+ endpoint: "",
+ model: "default",
+ apiStyle: "completions",
+ authMethod: "bearer",
+ requiresApiKey: false,
+ },
"pi-agent-cli": {
label: "Pi Coding Agent",
iconSlug: "pi",
@@ -298,6 +308,8 @@ export function normalizeAiConfig(config: Partial | null | undefined):
claudeCodeCliEnv: normalizeAiEnv(config?.claudeCodeCliEnv),
piAgentCliPath: config?.piAgentCliPath?.trim() || undefined,
piAgentCliEnv: normalizeAiEnv(config?.piAgentCliEnv),
+ opencodeCliPath: config?.opencodeCliPath?.trim() || undefined,
+ opencodeCliEnv: normalizeAiEnv(config?.opencodeCliEnv),
};
}
@@ -1390,7 +1402,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" || config.provider === "pi-agent-cli") return true;
+ if (config.provider === "codex-cli" || config.provider === "claude-code-cli" || config.provider === "pi-agent-cli" || config.provider === "opencode-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 26593435b..fd7c1effc 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" | "minimax" | "ollama" | "anthropic-compatible" | "openai-compatible" | "claude-code-cli" | "pi-agent-cli" | "codex-cli" | "custom";
+export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "minimax" | "ollama" | "anthropic-compatible" | "openai-compatible" | "claude-code-cli" | "pi-agent-cli" | "codex-cli" | "opencode-cli" | "custom";
export type AiApiStyle = "completions" | "responses" | "anthropic-messages";
export type AiAuthMethod = "api-key" | "bearer";
export type AiEffortLevel = "low" | "medium" | "high" | "xhigh" | "max";
@@ -46,6 +46,8 @@ export interface AiConfig {
claudeCodeCliEnv?: Record;
piAgentCliPath?: string | null;
piAgentCliEnv?: Record;
+ opencodeCliPath?: string | null;
+ opencodeCliEnv?: Record;
runtimeEffort?: AiEffortSelection | null;
}
diff --git a/crates/dbx-core/src/agent_loop.rs b/crates/dbx-core/src/agent_loop.rs
index 1e52dfacf..45de02215 100644
--- a/crates/dbx-core/src/agent_loop.rs
+++ b/crates/dbx-core/src/agent_loop.rs
@@ -139,7 +139,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 | AiProvider::PiAgentCli) {
+ if crate::ai::is_cli_provider(&config.provider) {
let connection_name = {
let configs = agent_ctx.state.configs.read().await;
configs
@@ -175,6 +175,14 @@ pub async fn run_agent_loop(
);
return crate::ai_pi_agent_cli::run_pi_agent(config, &prompt, options, cancelled, on_event).await;
}
+ if matches!(config.provider, AiProvider::OpenCodeCli) {
+ let prompt = crate::ai_opencode_cli::build_opencode_prompt(
+ system_prompt,
+ messages,
+ agent_ctx.sql_permissions.allow_writes,
+ );
+ return crate::ai_opencode_cli::run_opencode_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 329488582..e8d30134b 100644
--- a/crates/dbx-core/src/ai.rs
+++ b/crates/dbx-core/src/ai.rs
@@ -79,6 +79,8 @@ pub enum AiProvider {
ClaudeCodeCli,
#[serde(rename = "pi-agent-cli")]
PiAgentCli,
+ #[serde(rename = "opencode-cli")]
+ OpenCodeCli,
Custom,
}
@@ -96,6 +98,7 @@ impl AiProvider {
AiProvider::OpenaiCompatible => "openai-compatible",
AiProvider::ClaudeCodeCli => "claude-code-cli",
AiProvider::PiAgentCli => "pi-agent-cli",
+ AiProvider::OpenCodeCli => "opencode-cli",
AiProvider::CodexCli => "codex-cli",
AiProvider::Custom => "custom",
}
@@ -373,6 +376,10 @@ pub struct AiConfig {
pub pi_agent_cli_path: Option,
#[serde(default)]
pub pi_agent_cli_env: HashMap,
+ #[serde(default)]
+ pub opencode_cli_path: Option,
+ #[serde(default)]
+ pub opencode_cli_env: HashMap,
}
fn default_enable_thinking() -> bool {
@@ -380,10 +387,13 @@ fn default_enable_thinking() -> bool {
}
/// Whether the provider is a CLI-based provider that goes through its own
-/// executable (claude-code, codex, pi) rather than through `with_retry` /
+/// executable (claude-code, codex, opencode, pi) rather than through `with_retry` /
/// `with_stream_retry`.
pub fn is_cli_provider(provider: &AiProvider) -> bool {
- matches!(provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli)
+ matches!(
+ provider,
+ AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli
+ )
}
/// Merge the global `max_retries` setting into an `AiConfig`.
@@ -612,6 +622,7 @@ pub fn resolve_endpoint(config: &AiConfig) -> String {
| AiProvider::CodexCli
| AiProvider::ClaudeCodeCli
| AiProvider::PiAgentCli
+ | AiProvider::OpenCodeCli
| AiProvider::Gemini => unreachable!(),
}
}
@@ -1064,7 +1075,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 | AiProvider::PiAgentCli) {
+ if is_cli_provider(&config.provider) {
return Ok(());
}
if provider_requires_api_key(&config.provider) && config.api_key.trim().is_empty() {
@@ -1080,7 +1091,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 | AiProvider::PiAgentCli) {
+ if is_cli_provider(&config.provider) {
return Ok(());
}
if provider_requires_api_key(&config.provider) && config.api_key.trim().is_empty() {
@@ -1459,6 +1470,7 @@ pub async fn list_models_core(config: &AiConfig) -> Result, Str
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?,
+ AiProvider::OpenCodeCli => crate::ai_opencode_cli::list_opencode_models(config).await?,
_ => {
validate_model_list_config(config)?;
let client = build_ai_http_client(config, 30)?;
@@ -1481,7 +1493,9 @@ pub async fn list_models_core(config: &AiConfig) -> Result, Str
list_openai_compatible_models(&client, config).await?
}
}
- AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => unreachable!(),
+ AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli => {
+ unreachable!()
+ }
}
}
};
@@ -1500,6 +1514,10 @@ pub async fn resolve_model_effort_core(config: &AiConfig, model_id: &str) -> Res
return crate::ai_pi_agent_cli::resolve_pi_agent_model_effort(config, model_id).await;
}
+ if matches!(config.provider, AiProvider::OpenCodeCli) {
+ return crate::ai_opencode_cli::resolve_opencode_model_effort(config, model_id).await;
+ }
+
if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) {
let models = list_models_core(config).await?;
return Ok(models
@@ -2028,6 +2046,9 @@ pub async fn test_connection_core(config: &AiConfig) -> Result Result {
validate_config(&request.config)?;
- if matches!(request.config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) {
+ if is_cli_provider(&request.config.provider) {
return Err("CLI providers are only supported in DBX AI agent mode".to_string());
}
@@ -2400,7 +2421,9 @@ pub async fn complete(request: &AiCompletionRequest) -> Result {
match request.config.provider {
AiProvider::Gemini => call_gemini(&client, request).await,
- AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => unreachable!(),
+ AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli => {
+ unreachable!()
+ }
AiProvider::Openai
| AiProvider::Deepseek
| AiProvider::Qwen
@@ -2439,7 +2462,7 @@ pub async fn stream(
) -> Result<(), String> {
validate_config(&request.config)?;
- if matches!(request.config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) {
+ if is_cli_provider(&request.config.provider) {
return Err("CLI providers are only supported in DBX AI agent mode".to_string());
}
@@ -2452,7 +2475,9 @@ pub async fn stream(
match request.config.provider {
AiProvider::Gemini => stream_gemini(&client, session_id, request, cancelled, &on_chunk).await,
- AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => unreachable!(),
+ AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli => {
+ unreachable!()
+ }
AiProvider::Openai
| AiProvider::Deepseek
| AiProvider::Qwen
@@ -3736,7 +3761,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 | AiProvider::PiAgentCli) {
+ if is_cli_provider(&config.provider) {
return Err("CLI providers are only supported through the DBX AI agent loop".to_string());
}
@@ -4088,6 +4113,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
},
system_prompt: "Be concise.".to_string(),
messages: vec![AiMessage {
@@ -4669,6 +4696,8 @@ mod tests {
assert!(config.codex_cli_env.is_empty());
assert!(config.pi_agent_cli_path.is_none());
assert!(config.pi_agent_cli_env.is_empty());
+ assert!(config.opencode_cli_path.is_none());
+ assert!(config.opencode_cli_env.is_empty());
}
#[test]
@@ -4694,6 +4723,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
};
let err = build_ai_http_client(&config, 1).unwrap_err();
@@ -4724,6 +4755,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
};
build_ai_http_client(&config, 1).unwrap();
@@ -4752,6 +4785,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
};
build_ai_http_client(&config, 1).unwrap();
@@ -4780,6 +4815,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
};
assert_eq!(
@@ -4812,6 +4849,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
};
assert_eq!(resolve_endpoint(&ollama), "http://localhost:11434/v1/chat/completions");
@@ -4841,6 +4880,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
};
for provider in
@@ -4889,6 +4930,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
};
assert_eq!(resolve_model_list_endpoint(&openai).unwrap(), "https://api.openai.com/v1/models");
@@ -4913,6 +4956,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
};
assert_eq!(resolve_model_list_endpoint(&claude).unwrap(), "https://api.anthropic.com/v1/models");
}
@@ -4940,6 +4985,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
};
assert!(uses_anthropic_messages_api(&config));
@@ -5019,6 +5066,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
};
assert!(!uses_anthropic_messages_api(&config));
@@ -5058,6 +5107,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_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");
@@ -5121,6 +5172,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
};
assert_eq!(resolve_endpoint(&config), "https://api.openai.com/v1/responses");
@@ -5156,6 +5209,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
};
let api_key_headers = claude_headers(&config).unwrap();
@@ -5271,6 +5326,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
};
assert_eq!(resolve_ollama_show_endpoint(&config).unwrap(), "http://localhost:11434/api/show");
@@ -5303,6 +5360,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
};
assert_eq!(ollama_selected_model_tool_support(&config).await.unwrap(), Some(true));
@@ -5384,6 +5443,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
};
let models = vec![
AiModelInfo::new("qwen3:0.6b", None),
@@ -5645,6 +5706,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
};
let mut body = serde_json::json!({
@@ -5856,6 +5919,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
};
let mut body = serde_json::json!({
"model": &config.model,
@@ -5893,6 +5958,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
};
let mut body = serde_json::json!({ "model": &config.model });
@@ -5936,6 +6003,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
};
let mut body = serde_json::json!({ "model": &config.model });
@@ -5973,6 +6042,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
};
let mut body = serde_json::json!({ "model": &config.model });
@@ -6006,6 +6077,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
};
let mut body = serde_json::json!({ "model": &config.model });
@@ -6070,6 +6143,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
};
let request = AiCompletionRequest {
config: config.clone(),
@@ -6128,6 +6203,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
};
let mut body = serde_json::json!({
"model": &config.model,
@@ -6654,7 +6731,9 @@ mod tests {
#[test]
fn merge_global_max_retries_skips_cli_providers() {
- for provider in [AiProvider::CodexCli, AiProvider::ClaudeCodeCli, AiProvider::PiAgentCli] {
+ for provider in
+ [AiProvider::CodexCli, AiProvider::ClaudeCodeCli, AiProvider::PiAgentCli, AiProvider::OpenCodeCli]
+ {
let mut config = test_config(provider.clone());
config.max_retries = None;
merge_global_max_retries(&mut config, 0);
diff --git a/crates/dbx-core/src/ai_claude_code_cli.rs b/crates/dbx-core/src/ai_claude_code_cli.rs
index 6b9019d38..c718466df 100644
--- a/crates/dbx-core/src/ai_claude_code_cli.rs
+++ b/crates/dbx-core/src/ai_claude_code_cli.rs
@@ -525,6 +525,7 @@ pub async fn run_claude_code_agent(
CliAgentProcessSpec {
command,
env,
+ env_remove: Vec::new(),
current_dir: Some(isolated_cwd.path.clone()),
stdin: Some(prompt.to_string()),
dialect: CliAgentJsonlDialect::ClaudeCodePrint,
@@ -580,6 +581,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
}
}
diff --git a/crates/dbx-core/src/ai_cli_agent.rs b/crates/dbx-core/src/ai_cli_agent.rs
index e483ba9c7..6f07ba026 100644
--- a/crates/dbx-core/src/ai_cli_agent.rs
+++ b/crates/dbx-core/src/ai_cli_agent.rs
@@ -34,11 +34,13 @@ pub struct CliAgentCommandSpec {
pub enum CliAgentJsonlDialect {
CodexExec,
ClaudeCodePrint,
+ OpenCodeRun,
}
pub struct CliAgentProcessSpec {
pub command: CliAgentCommandSpec,
pub env: Vec<(String, String)>,
+ pub env_remove: Vec,
pub current_dir: Option,
pub stdin: Option,
pub dialect: CliAgentJsonlDialect,
@@ -217,6 +219,7 @@ struct ParsedCliAgentEvent {
events: Vec,
final_text: Option,
error: Option,
+ usage_delta: Option,
}
pub fn parse_cli_jsonl_event(line: &str, dialect: CliAgentJsonlDialect) -> Option> {
@@ -232,6 +235,7 @@ fn parse_cli_jsonl_line(line: &str, dialect: CliAgentJsonlDialect) -> ParsedCliA
match dialect {
CliAgentJsonlDialect::CodexExec => parse_codex_jsonl_line(line),
CliAgentJsonlDialect::ClaudeCodePrint => parse_claude_code_jsonl_line(line),
+ CliAgentJsonlDialect::OpenCodeRun => parse_open_code_jsonl_line(line),
}
}
@@ -530,13 +534,108 @@ fn claude_code_error_message(value: &Value) -> String {
.to_string()
}
+fn parse_open_code_jsonl_line(line: &str) -> ParsedCliAgentEvent {
+ let Ok(value) = serde_json::from_str::(line) else {
+ return ParsedCliAgentEvent::default();
+ };
+
+ match value.get("type").and_then(Value::as_str).unwrap_or_default() {
+ "text" => {
+ let Some(text) = value.pointer("/part/text").and_then(Value::as_str).filter(|text| !text.is_empty()) else {
+ return ParsedCliAgentEvent::default();
+ };
+ ParsedCliAgentEvent {
+ events: vec![AgentEvent::TextDelta { delta: text.to_string() }],
+ final_text: Some(text.to_string()),
+ ..Default::default()
+ }
+ }
+ "reasoning" => {
+ let Some(text) = value.pointer("/part/text").and_then(Value::as_str).filter(|text| !text.is_empty()) else {
+ return ParsedCliAgentEvent::default();
+ };
+ ParsedCliAgentEvent {
+ events: vec![AgentEvent::ReasoningDelta { delta: text.to_string() }],
+ ..Default::default()
+ }
+ }
+ "tool_use" => parse_open_code_tool(&value),
+ "step_finish" => {
+ let input = value.pointer("/part/tokens/input").and_then(Value::as_u64).unwrap_or(0) as u32;
+ let output = value.pointer("/part/tokens/output").and_then(Value::as_u64).unwrap_or(0) as u32;
+ ParsedCliAgentEvent {
+ usage_delta: (input > 0 || output > 0)
+ .then_some(TokenUsage { input_tokens: input, output_tokens: output }),
+ ..Default::default()
+ }
+ }
+ "error" => {
+ let message = open_code_error_message(&value);
+ ParsedCliAgentEvent {
+ error: Some(message.clone()),
+ events: vec![AgentEvent::Error { message }],
+ ..Default::default()
+ }
+ }
+ _ => ParsedCliAgentEvent::default(),
+ }
+}
+
+fn parse_open_code_tool(value: &Value) -> ParsedCliAgentEvent {
+ let part = &value["part"];
+ let state = &part["state"];
+ let status = state.get("status").and_then(Value::as_str).unwrap_or_default();
+ if status != "completed" && status != "error" {
+ return ParsedCliAgentEvent::default();
+ }
+
+ let tool_call_id = part
+ .get("callID")
+ .and_then(Value::as_str)
+ .or_else(|| part.get("call_id").and_then(Value::as_str))
+ .or_else(|| part.get("id").and_then(Value::as_str))
+ .unwrap_or("opencode-tool-call")
+ .to_string();
+ let tool_name = part.get("tool").and_then(Value::as_str).unwrap_or("opencode_tool").to_string();
+ let args = state.get("input").cloned().unwrap_or_else(|| Value::Object(Default::default()));
+ let result = state
+ .get("output")
+ .filter(|value| !value.is_null())
+ .or_else(|| state.get("error").filter(|value| !value.is_null()))
+ .cloned()
+ .unwrap_or(Value::Null);
+
+ ParsedCliAgentEvent {
+ events: vec![
+ AgentEvent::ToolCallStart { tool_call_id: tool_call_id.clone(), tool_name: tool_name.clone(), args },
+ AgentEvent::ToolCallEnd { tool_call_id, tool_name, result, is_error: status == "error" },
+ ],
+ ..Default::default()
+ }
+}
+
+fn open_code_error_message(value: &Value) -> String {
+ value
+ .pointer("/error/data/message")
+ .and_then(Value::as_str)
+ .or_else(|| value.pointer("/error/message").and_then(Value::as_str))
+ .or_else(|| value.get("message").and_then(Value::as_str))
+ .or_else(|| value.get("error").and_then(Value::as_str))
+ .unwrap_or("OpenCode CLI failed")
+ .to_string()
+}
+
pub async fn run_cli_jsonl_agent(
spec: CliAgentProcessSpec,
cancelled: &Notify,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result {
let mut command = cli_command(&spec.command.program);
- command.args(&spec.command.args).envs(spec.env.iter().map(|(key, value)| (key.as_str(), value.as_str())));
+ command.args(&spec.command.args);
+ for key in &spec.env_remove {
+ command.env_remove(key);
+ }
+ command.envs(spec.env.iter().map(|(key, value)| (key.as_str(), value.as_str())));
if let Some(current_dir) = &spec.current_dir {
command.current_dir(current_dir);
}
@@ -544,6 +643,7 @@ pub async fn run_cli_jsonl_agent(
.stdin(if spec.stdin.is_some() { Stdio::piped() } else { Stdio::null() })
.stdout(Stdio::piped())
.stderr(Stdio::piped())
+ .kill_on_drop(true)
.spawn()
.map_err(|e| (spec.classify_spawn_error)(&e.to_string()))?;
@@ -565,6 +665,7 @@ pub async fn run_cli_jsonl_agent(
let mut final_text = String::new();
let mut saw_agent_end = false;
+ let mut total_usage = TokenUsage::default();
let mut terminal_error: Option = None;
loop {
@@ -583,6 +684,9 @@ pub async fn run_cli_jsonl_agent(
if let Some(text) = parsed.final_text {
final_text.push_str(&text);
}
+ if let Some(usage) = parsed.usage_delta {
+ total_usage.add(&usage);
+ }
for event in parsed.events {
if matches!(event, AgentEvent::AgentEnd { .. }) {
saw_agent_end = true;
@@ -590,7 +694,7 @@ pub async fn run_cli_jsonl_agent(
on_event(event);
}
if let Some(error) = parsed.error {
- terminal_error = Some(error);
+ terminal_error = Some((spec.classify_run_error)(&error));
let _ = child.kill().await;
break;
}
@@ -615,7 +719,10 @@ pub async fn run_cli_jsonl_agent(
}
if !saw_agent_end {
- on_event(AgentEvent::AgentEnd { input_tokens: None, output_tokens: None });
+ on_event(AgentEvent::AgentEnd {
+ input_tokens: (total_usage.input_tokens > 0).then_some(total_usage.input_tokens),
+ output_tokens: (total_usage.output_tokens > 0).then_some(total_usage.output_tokens),
+ });
}
Ok(final_text)
@@ -625,6 +732,7 @@ pub async fn run_cli_jsonl_agent(
mod tests {
use super::*;
use std::process::Command as StdCommand;
+ use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::time::{sleep, timeout, Duration};
@@ -657,6 +765,7 @@ mod tests {
],
},
env: vec![("DBX_TEST_ENV".to_string(), "from-env".to_string())],
+ env_remove: Vec::new(),
current_dir: None,
stdin: None,
dialect: CliAgentJsonlDialect::CodexExec,
@@ -680,6 +789,7 @@ mod tests {
],
},
env: Vec::new(),
+ env_remove: Vec::new(),
current_dir: None,
stdin: Some("prompt from stdin".to_string()),
dialect: CliAgentJsonlDialect::CodexExec,
@@ -692,6 +802,47 @@ mod tests {
assert_eq!(result, "prompt from stdin");
}
+ #[tokio::test]
+ async fn opencode_agent_aggregates_step_usage_and_emits_one_agent_end() {
+ let spec = CliAgentProcessSpec {
+ command: CliAgentCommandSpec {
+ program: "sh".to_string(),
+ args: vec![
+ "-c".to_string(),
+ concat!(
+ "printf '%s\\n' ",
+ "'{\"type\":\"step_finish\",\"part\":{\"tokens\":{\"input\":10,\"output\":2}}}' ",
+ "'{\"type\":\"text\",\"part\":{\"text\":\"hello\"}}' ",
+ "'{\"type\":\"step_finish\",\"part\":{\"tokens\":{\"input\":3,\"output\":4}}}'",
+ )
+ .to_string(),
+ ],
+ },
+ env: Vec::new(),
+ env_remove: Vec::new(),
+ current_dir: None,
+ stdin: None,
+ dialect: CliAgentJsonlDialect::OpenCodeRun,
+ classify_spawn_error,
+ classify_run_error,
+ };
+ let events = Arc::new(Mutex::new(Vec::new()));
+ let captured = Arc::clone(&events);
+
+ let result = run_cli_jsonl_agent(spec, &Notify::new(), move |event| {
+ captured.lock().unwrap().push(event);
+ })
+ .await
+ .unwrap();
+
+ assert_eq!(result, "hello");
+ let events = events.lock().unwrap();
+ assert_eq!(events.iter().filter(|event| matches!(event, AgentEvent::AgentEnd { .. })).count(), 1);
+ assert!(events
+ .iter()
+ .any(|event| { matches!(event, AgentEvent::AgentEnd { input_tokens: Some(13), output_tokens: Some(6) }) }));
+ }
+
#[tokio::test]
async fn jsonl_error_kills_and_waits_for_child() {
let pid_file = std::env::temp_dir().join(format!(
@@ -707,6 +858,7 @@ mod tests {
let spec = CliAgentProcessSpec {
command: CliAgentCommandSpec { program: "sh".to_string(), args: vec!["-c".to_string(), script] },
env: Vec::new(),
+ env_remove: Vec::new(),
current_dir: None,
stdin: None,
dialect: CliAgentJsonlDialect::CodexExec,
diff --git a/crates/dbx-core/src/ai_codex_cli.rs b/crates/dbx-core/src/ai_codex_cli.rs
index f3fbe9505..e09eeaa7a 100644
--- a/crates/dbx-core/src/ai_codex_cli.rs
+++ b/crates/dbx-core/src/ai_codex_cli.rs
@@ -970,6 +970,7 @@ pub async fn run_codex_agent(
CliAgentProcessSpec {
command,
env,
+ env_remove: Vec::new(),
current_dir: None,
stdin: Some(prompt.to_string()),
dialect: CliAgentJsonlDialect::CodexExec,
@@ -1037,6 +1038,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
}
}
diff --git a/crates/dbx-core/src/ai_effort.rs b/crates/dbx-core/src/ai_effort.rs
index faef395d3..205801d95 100644
--- a/crates/dbx-core/src/ai_effort.rs
+++ b/crates/dbx-core/src/ai_effort.rs
@@ -82,7 +82,11 @@ 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 | AiProvider::PiAgentCli => None,
+ AiProvider::Claude
+ | AiProvider::CodexCli
+ | AiProvider::ClaudeCodeCli
+ | AiProvider::PiAgentCli
+ | AiProvider::OpenCodeCli => None,
}
}
@@ -187,6 +191,7 @@ pub fn registry_source_url(provider: &AiProvider) -> Option<&'static str> {
| AiProvider::CodexCli
| AiProvider::ClaudeCodeCli
| AiProvider::PiAgentCli
+ | AiProvider::OpenCodeCli
| AiProvider::Custom => None,
}
}
@@ -201,7 +206,11 @@ pub fn validate_runtime_effort(config: &AiConfig) -> Result<(), String> {
if matches!(
config.provider,
- AiProvider::Claude | AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli
+ AiProvider::Claude
+ | AiProvider::CodexCli
+ | AiProvider::ClaudeCodeCli
+ | AiProvider::PiAgentCli
+ | AiProvider::OpenCodeCli
) {
return match selection {
AiEffortSelection::Enum(value) if !value.trim().is_empty() => Ok(()),
@@ -254,7 +263,7 @@ pub fn apply_runtime_effort(body: &mut Value, config: &AiConfig) {
apply_openai_effort(object, &config.api_style, selection);
}
}
- AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => {}
+ AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli => {}
}
}
@@ -408,6 +417,8 @@ mod tests {
claude_code_cli_env: HashMap::new(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_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 8ed6bd9a2..9dff6d517 100644
--- a/crates/dbx-core/src/ai_model_filter.rs
+++ b/crates/dbx-core/src/ai_model_filter.rs
@@ -96,6 +96,7 @@ pub(crate) fn model_is_assistant_compatible(provider: &AiProvider, model_id: &st
| AiProvider::CodexCli
| AiProvider::ClaudeCodeCli
| AiProvider::PiAgentCli
+ | AiProvider::OpenCodeCli
| AiProvider::MiniMax
| AiProvider::Custom => true,
}
diff --git a/crates/dbx-core/src/ai_opencode_cli.rs b/crates/dbx-core/src/ai_opencode_cli.rs
new file mode 100644
index 000000000..938215e7b
--- /dev/null
+++ b/crates/dbx-core/src/ai_opencode_cli.rs
@@ -0,0 +1,675 @@
+use crate::agent_events::AgentEvent;
+use crate::ai::{AiCapabilitySource, AiConfig, AiEffortCapability, AiModelInfo, AiTestConnectionResult};
+use crate::ai_cli_agent::{
+ build_cli_agent_prompt, cli_command, dbx_mcp_scope_env, parse_cli_jsonl_event, run_cli_jsonl_agent,
+ CliAgentCommandSpec, CliAgentJsonlDialect, CliAgentProcessSpec, CliAgentRunOptions,
+};
+use serde_json::{json, Map, Value};
+use std::collections::{BTreeMap, BTreeSet};
+use std::env;
+use std::path::{Path, PathBuf};
+use std::time::{Duration, Instant};
+use tokio::sync::Notify;
+
+const OPENCODE_MODEL_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(10);
+const OPENCODE_CONTROL_ENV: &[&str] = &[
+ "OPENCODE_CONFIG",
+ "OPENCODE_CONFIG_CONTENT",
+ "OPENCODE_CONFIG_DIR",
+ "OPENCODE_DB",
+ "OPENCODE_PERMISSION",
+ "OPENCODE_DISABLE_PROJECT_CONFIG",
+];
+
+pub type OpenCodeRunOptions = CliAgentRunOptions;
+pub type OpenCodeCommandSpec = CliAgentCommandSpec;
+
+struct OpenCodeIsolatedCwd {
+ path: PathBuf,
+}
+
+impl OpenCodeIsolatedCwd {
+ fn create() -> Result {
+ let path = env::temp_dir().join(format!("dbx-opencode-{}", uuid::Uuid::new_v4()));
+ std::fs::create_dir(&path)
+ .map_err(|error| format!("[openCodeRunFailed] Failed to create isolated OpenCode directory: {error}"))?;
+ Ok(Self { path })
+ }
+}
+
+impl Drop for OpenCodeIsolatedCwd {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_dir_all(&self.path);
+ }
+}
+
+fn opencode_program(config: &AiConfig) -> String {
+ config
+ .opencode_cli_path
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .unwrap_or("opencode")
+ .to_string()
+}
+
+fn resolve_opencode_command(config: &AiConfig) -> Result {
+ let program = opencode_program(config);
+ if starts_with_env_assignment(&program) {
+ return Err("[openCodeCliPathInvalid] OpenCode CLI path should contain only the executable path. Add environment variables in the OpenCode CLI 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 launchable_program_in_dir(path, "opencode")
+ .map(opencode_command_for_program)
+ .ok_or_else(|| {
+ "[openCodeCliPathInvalid] OpenCode CLI path should point to the opencode executable or a directory containing opencode."
+ .to_string()
+ });
+ }
+ if is_path_like_program(&program) && !path.is_file() {
+ return Err("[openCodeCliPathInvalid] OpenCode CLI executable does not exist.".to_string());
+ }
+ Ok(opencode_command_for_program(program))
+}
+
+fn opencode_command_for_program(program: String) -> OpenCodeCommandSpec {
+ #[cfg(windows)]
+ if let Some(command) = windows_npm_opencode_shim_command(&program) {
+ return command;
+ }
+
+ OpenCodeCommandSpec { program, args: Vec::new() }
+}
+
+#[cfg(windows)]
+fn windows_npm_opencode_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 executable = path.parent()?.join("node_modules").join("opencode-ai").join("bin").join("opencode.exe");
+ executable
+ .is_file()
+ .then(|| OpenCodeCommandSpec { program: executable.to_string_lossy().to_string(), args: Vec::new() })
+}
+
+fn launchable_program_in_dir(dir: &Path, program: &str) -> Option {
+ program_path_candidates(dir, program)
+ .into_iter()
+ .find(|candidate| candidate.is_file())
+ .map(|path| path.to_string_lossy().to_string())
+}
+
+#[cfg(not(windows))]
+fn program_path_candidates(dir: &Path, program: &str) -> Vec {
+ vec![dir.join(program)]
+}
+
+#[cfg(windows)]
+fn program_path_candidates(dir: &Path, program: &str) -> Vec {
+ [".exe", ".cmd", ".bat", ""].iter().map(|extension| dir.join(format!("{program}{extension}"))).collect()
+}
+
+fn is_path_like_program(program: &str) -> bool {
+ program.contains('/') || program.contains('\\') || program.starts_with('~')
+}
+
+fn starts_with_env_assignment(program: &str) -> bool {
+ let Some(first_token) = program.split_whitespace().next() else {
+ return false;
+ };
+ let Some((key, _)) = first_token.split_once('=') else {
+ return false;
+ };
+ 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 is_reserved_env_name(name: &str) -> bool {
+ let upper = name.to_ascii_uppercase();
+ upper.starts_with("DBX_MCP_") || OPENCODE_CONTROL_ENV.iter().any(|reserved| upper == *reserved)
+}
+
+pub fn opencode_cli_env(config: &AiConfig) -> Result, String> {
+ let mut env = BTreeMap::new();
+ for (key, value) in &config.opencode_cli_env {
+ let key = key.trim();
+ if key.is_empty() {
+ continue;
+ }
+ if !is_env_var_name(key) {
+ return Err(format!(
+ "[openCodeEnvInvalid] Invalid OpenCode CLI environment variable name `{key}`. Use names like HTTPS_PROXY."
+ ));
+ }
+ if is_reserved_env_name(key) {
+ return Err(format!(
+ "[openCodeEnvReserved] `{key}` is managed by DBX for the isolated OpenCode session and cannot be set here."
+ ));
+ }
+ env.insert(key.to_string(), value.clone());
+ }
+ Ok(env.into_iter().collect())
+}
+
+fn opencode_process_env(config: &AiConfig, runtime_config: Value) -> Result, String> {
+ let mut env = BTreeMap::from_iter(opencode_cli_env(config)?);
+ env.insert("OPENCODE_DB".to_string(), ":memory:".to_string());
+ env.insert("OPENCODE_DISABLE_PROJECT_CONFIG".to_string(), "1".to_string());
+ env.insert("OPENCODE_CONFIG_CONTENT".to_string(), runtime_config.to_string());
+ Ok(env.into_iter().collect())
+}
+
+fn opencode_runtime_config(options: Option<&OpenCodeRunOptions>) -> Value {
+ let mut config = Map::new();
+ config.insert("permission".to_string(), json!({ "*": "deny", "dbx_*": "allow" }));
+
+ if let Some(options) = options {
+ let command = options
+ .mcp_server_command
+ .as_ref()
+ .cloned()
+ .unwrap_or_else(|| CliAgentCommandSpec { program: "dbx-mcp-server".to_string(), args: Vec::new() });
+ let mut command_parts = vec![command.program];
+ command_parts.extend(command.args);
+ let environment = dbx_mcp_scope_env(options)
+ .into_iter()
+ .map(|(name, value)| (name.to_string(), Value::String(value)))
+ .collect::