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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions sidecar/src/agent-providers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/** Keep in sync with `KNOWN_PROVIDERS` in `provider_capabilities.rs`. */
export const AGENT_PROVIDERS = [
"claude",
"codex",
"cursor",
"opencode",
] as const;

export type AgentProvider = (typeof AGENT_PROVIDERS)[number];

/** Agent providers disabled as Helmor agents. Bundled CLIs stay shipped. */
export const DISABLED_AGENT_PROVIDERS: readonly AgentProvider[] = [];

export function isAgentProviderEnabled(provider: AgentProvider): boolean {
return !DISABLED_AGENT_PROVIDERS.includes(provider);
}

export function enabledAgentProviders(): AgentProvider[] {
return AGENT_PROVIDERS.filter(isAgentProviderEnabled);
}
38 changes: 25 additions & 13 deletions sidecar/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import { createInterface } from "node:readline";
import type { PermissionUpdate } from "@anthropic-ai/claude-agent-sdk";
import { isAbortError } from "./abort.js";
import { isAgentProviderEnabled } from "./agent-providers.js";
import { applyAgentProxyToProcessEnv } from "./agent-proxy.js";
import { ClaudeSessionManager } from "./claude-session-manager.js";
import { CodexAppServerManager } from "./codex-app-server-manager.js";
Expand Down Expand Up @@ -49,13 +50,20 @@ const claudeManager = new ClaudeSessionManager();
const codexManager = new CodexAppServerManager();
const cursorManager = new CursorSessionManager();
const opencodeManager = new OpencodeSessionManager();
const managers: Record<Provider, SessionManager> = {
const allManagers: Record<Provider, SessionManager> = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore shutdown's manager map reference

When the Rust side requests sidecar shutdown, handleShutdown still calls Object.values(managers), but this change renamed the only manager map to allManagers and no managers binding remains. That makes shutdown throw a ReferenceError before awaiting provider shutdowns or sending the pong ack, so app exit/restart can hang and leave agent child processes alive.

Useful? React with 👍 / 👎.

claude: claudeManager,
codex: codexManager,
cursor: cursorManager,
opencode: opencodeManager,
};

function getManager(provider: Provider): SessionManager {
if (!isAgentProviderEnabled(provider)) {
throw new Error(`${provider} agent is disabled in this build`);
}
return allManagers[provider];
}

// `parentGone` flips to true only when stdin EOFs — that's the
// authoritative "Rust exited" signal. EPIPE on stdout, by contrast, can
// fire transiently from any pipe in the process (Anthropic SDK child
Expand Down Expand Up @@ -190,9 +198,13 @@ function collectErrorChainCodes(err: Error): string[] {
}

function buildTitleProviderOrder(provider: Provider | null): Provider[] {
if (provider === "codex") return ["codex", "claude", "cursor"];
if (provider === "cursor") return ["cursor", "claude", "codex"];
return ["claude", "codex", "cursor"];
const order: Provider[] =
provider === "codex"
? ["codex", "claude", "cursor"]
: provider === "cursor"
? ["cursor", "claude", "codex"]
: ["claude", "codex", "cursor"];
return order.filter(isAgentProviderEnabled);
}

logger.info("Sidecar starting", { pid: process.pid });
Expand Down Expand Up @@ -221,7 +233,7 @@ async function handleSendMessage(
cwd: sendParams.cwd ?? "(none)",
resume: sendParams.resume ?? "(none)",
});
await managers[provider].sendMessage(id, sendParams, emitter);
await getManager(provider).sendMessage(id, sendParams, emitter);
logger.debug(`[${id}] sendMessage completed`);
} catch (err) {
if (isAbortError(err)) {
Expand Down Expand Up @@ -281,7 +293,7 @@ async function handleGenerateTitle(
try {
if (titleProvider === "claude") {
try {
await managers.claude.generateTitle(
await getManager("claude").generateTitle(
id,
userMessage,
branchRenamePrompt,
Expand All @@ -300,7 +312,7 @@ async function handleGenerateTitle(
logger.debug(
`[${id}] generateTitle custom claude failed, trying official claude: ${errorMessage(claudeErr)}`,
);
await managers.claude.generateTitle(
await getManager("claude").generateTitle(
id,
userMessage,
branchRenamePrompt,
Expand All @@ -313,7 +325,7 @@ async function handleGenerateTitle(
return;
}
if (titleProvider === "codex") {
await managers.codex.generateTitle(
await getManager("codex").generateTitle(
id,
userMessage,
branchRenamePrompt,
Expand All @@ -324,7 +336,7 @@ async function handleGenerateTitle(
logger.debug(`[${id}] generateTitle completed (codex)`);
return;
}
await managers.cursor.generateTitle(
await getManager("cursor").generateTitle(
id,
userMessage,
branchRenamePrompt,
Expand Down Expand Up @@ -371,7 +383,7 @@ async function handleListModels(
override: Boolean(apiKey),
forceReload,
});
const models = await managers[provider].listModels(
const models = await getManager(provider).listModels(
apiKey || forceReload
? { ...(apiKey ? { apiKey } : {}), forceReload }
: undefined,
Expand All @@ -396,7 +408,7 @@ async function handleListSlashCommands(
provider,
cwd: listParams.cwd ?? "(none)",
});
const commands = await managers[provider].listSlashCommands(listParams);
const commands = await getManager(provider).listSlashCommands(listParams);
emitter.slashCommandsListed(id, commands);
logger.debug(`[${id}] listSlashCommands → ${commands.length} entries`);
} catch (err) {
Expand All @@ -414,7 +426,7 @@ async function handleStopSession(
const provider = parseProvider(params.provider);
const sessionId = requireString(params, "sessionId");
logger.debug(`[${id}] stopSession`, { sessionId, provider });
await managers[provider].stopSession(sessionId);
await getManager(provider).stopSession(sessionId);
emitter.stopped(id, sessionId);
} catch (err) {
const msg = errorMessage(err);
Expand Down Expand Up @@ -496,7 +508,7 @@ async function handleSteerSession(
fileCount: files.length,
imageCount: images.length,
});
const accepted = await managers[provider].steer(
const accepted = await getManager(provider).steer(
sessionId,
prompt,
files,
Expand Down
7 changes: 6 additions & 1 deletion sidecar/src/request-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* missing or wrong-shaped field.
*/

import { isAgentProviderEnabled } from "./agent-providers.js";
import type { AgentProxySettings } from "./agent-proxy.js";
import type {
GetContextUsageParams,
Expand Down Expand Up @@ -89,8 +90,12 @@ export function parseProvider(value: unknown): Provider {
value === "codex" ||
value === "cursor" ||
value === "opencode"
)
) {
if (!isAgentProviderEnabled(value)) {
throw new Error(`${value} agent is disabled in this build`);
}
return value;
}
throw new Error(`unknown provider: ${String(value)}`);
}

Expand Down
2 changes: 1 addition & 1 deletion sidecar/src/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import type { AgentProxySettings } from "./agent-proxy.js";
import type { SidecarEmitter } from "./emitter.js";

export type Provider = "claude" | "codex" | "cursor" | "opencode";
export type { AgentProvider as Provider } from "./agent-providers.js";

export interface SendMessageParams {
readonly sessionId: string;
Expand Down
9 changes: 6 additions & 3 deletions src-tauri/src/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,8 @@ pub async fn list_agent_model_sections() -> CmdResult<Vec<AgentModelSection>> {
#[tauri::command]
pub async fn list_provider_capabilities(
) -> CmdResult<Vec<provider_capabilities::ProviderCapabilities>> {
Ok(provider_capabilities::KNOWN_PROVIDERS
.iter()
.map(|p| provider_capabilities::capabilities_for_provider(p))
Ok(provider_capabilities::enabled_providers()
.map(provider_capabilities::capabilities_for_provider)
.collect())
}

Expand All @@ -228,6 +227,7 @@ pub async fn list_cursor_models(
sidecar: tauri::State<'_, crate::sidecar::ManagedSidecar>,
api_key: Option<String>,
) -> CmdResult<Vec<queries::CursorModelEntry>> {
provider_capabilities::ensure_agent_provider_enabled("cursor")?;
// Inline blocking — same pattern as `list_slash_commands`.
queries::fetch_cursor_models(sidecar.inner(), api_key)
}
Expand All @@ -237,6 +237,7 @@ pub async fn list_opencode_models(
sidecar: tauri::State<'_, crate::sidecar::ManagedSidecar>,
force_reload: Option<bool>,
) -> CmdResult<Vec<queries::OpencodeModelEntry>> {
provider_capabilities::ensure_agent_provider_enabled("opencode")?;
// force_reload restarts the opencode server to pick up a just-written config.
queries::fetch_opencode_models(sidecar.inner(), force_reload.unwrap_or(false))
}
Expand All @@ -253,6 +254,8 @@ pub async fn send_agent_message_stream(
return Err(anyhow::anyhow!("Prompt cannot be empty.").into());
}

provider_capabilities::ensure_agent_provider_enabled(&request.provider)?;

// Inject triage priming as a hidden prefix; consumed flag flips only after sidecar accepts.
let priming_session_to_consume: Option<String> = match request.helmor_session_id.as_deref() {
Some(session_id) => match crate::triage::load_priming_prefix_for_session(session_id) {
Expand Down
27 changes: 18 additions & 9 deletions src-tauri/src/agents/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,24 @@ fn model_sections_for_inputs(
cursor_prefs: Option<CursorPrefs>,
opencode_prefs: Option<OpencodePrefs>,
) -> Vec<AgentModelSection> {
let mut claude_section = official_claude_section();
claude_section
.options
.extend(custom_provider_options(custom));
let mut sections = vec![claude_section];
sections.push(codex_section());
sections.push(opencode_section_from_prefs(opencode_prefs));
if let Some(cursor) = cursor_section_from_prefs(cursor_prefs) {
sections.push(cursor);
let mut sections = Vec::new();
if super::provider_capabilities::is_agent_provider_enabled("claude") {
let mut claude_section = official_claude_section();
claude_section
.options
.extend(custom_provider_options(custom));
sections.push(claude_section);
}
if super::provider_capabilities::is_agent_provider_enabled("codex") {
sections.push(codex_section());
}
if super::provider_capabilities::is_agent_provider_enabled("opencode") {
sections.push(opencode_section_from_prefs(opencode_prefs));
}
if super::provider_capabilities::is_agent_provider_enabled("cursor") {
if let Some(cursor) = cursor_section_from_prefs(cursor_prefs) {
sections.push(cursor);
}
}

sections
Expand Down
48 changes: 48 additions & 0 deletions src-tauri/src/agents/provider_capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,33 @@ pub fn capabilities_for_provider(provider: &str) -> ProviderCapabilities {
/// tests use it to assert there are no holes in the matrix.
pub const KNOWN_PROVIDERS: &[&str] = &["claude", "codex", "cursor", "opencode"];

/// Agent providers disabled as Helmor agents. Bundled CLIs stay shipped;
/// edit this list to hide a provider from the composer without removing
/// its binary from `vendor/`.
pub const DISABLED_AGENT_PROVIDERS: &[&str] = &[];

/// Whether `provider` is a shipping agent Helmor exposes in the composer.
pub fn is_agent_provider_enabled(provider: &str) -> bool {
KNOWN_PROVIDERS.contains(&provider) && !DISABLED_AGENT_PROVIDERS.contains(&provider)
}

/// Shipping providers minus [`DISABLED_AGENT_PROVIDERS`]. Drives the
/// composer catalog and capability table exposed to the frontend.
pub fn enabled_providers() -> impl Iterator<Item = &'static str> {
KNOWN_PROVIDERS
.iter()
.copied()
.filter(|provider| is_agent_provider_enabled(provider))
}

pub fn ensure_agent_provider_enabled(provider: &str) -> anyhow::Result<()> {
if is_agent_provider_enabled(provider) {
Ok(())
} else {
anyhow::bail!("{provider} agent is disabled in this build")
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -234,4 +261,25 @@ mod tests {
let raw = serde_json::to_string(&caps).unwrap();
assert!(!raw.contains('_'), "snake_case field leaked: {raw}");
}

#[test]
fn empty_disabled_list_enables_every_known_provider() {
assert!(DISABLED_AGENT_PROVIDERS.is_empty());
let enabled: Vec<_> = enabled_providers().collect();
assert_eq!(enabled, KNOWN_PROVIDERS);
for provider in KNOWN_PROVIDERS {
assert!(is_agent_provider_enabled(provider));
}
}

#[test]
fn disabled_provider_is_filtered_from_enabled_list() {
let without_opencode: Vec<_> = KNOWN_PROVIDERS
.iter()
.copied()
.filter(|provider| *provider != "opencode")
.collect();
assert!(!without_opencode.contains(&"opencode"));
assert_eq!(without_opencode.len(), KNOWN_PROVIDERS.len() - 1);
}
}
3 changes: 3 additions & 0 deletions src-tauri/src/commands/opencode_config_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use crate::agents::opencode_config::{self, OpencodeCustomProvider};

#[tauri::command]
pub async fn get_opencode_custom_providers() -> CmdResult<Vec<OpencodeCustomProvider>> {
crate::agents::provider_capabilities::ensure_agent_provider_enabled("opencode")?;
run_blocking(opencode_config::read_custom_providers).await
}

Expand All @@ -13,10 +14,12 @@ pub async fn upsert_opencode_custom_provider(
provider: OpencodeCustomProvider,
preset: bool,
) -> CmdResult<()> {
crate::agents::provider_capabilities::ensure_agent_provider_enabled("opencode")?;
run_blocking(move || opencode_config::upsert_custom_provider(&provider, preset)).await
}

#[tauri::command]
pub async fn delete_opencode_custom_provider(id: String) -> CmdResult<()> {
crate::agents::provider_capabilities::ensure_agent_provider_enabled("opencode")?;
run_blocking(move || opencode_config::delete_custom_provider(&id)).await
}
21 changes: 17 additions & 4 deletions src-tauri/src/commands/system_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1139,10 +1139,14 @@ pub async fn get_agent_login_status() -> CmdResult<AgentLoginStatus> {
run_blocking(|| {
let codex = codex_auth_status();
Ok(AgentLoginStatus {
claude: claude_login_ready(),
codex: codex.ready,
cursor: cursor_login_ready(),
opencode: opencode_login_ready(),
claude: agent_login_ready("claude", claude_login_ready),
codex: if crate::agents::provider_capabilities::is_agent_provider_enabled("codex") {
codex.ready
} else {
false
},
cursor: agent_login_ready("cursor", cursor_login_ready),
opencode: agent_login_ready("opencode", opencode_login_ready),
codex_provider: codex.provider,
codex_auth_method: codex.auth_method.map(str::to_string),
})
Expand Down Expand Up @@ -1366,7 +1370,16 @@ fn env_var_is_present(key: &str) -> bool {
.unwrap_or(false)
}

fn agent_login_ready(provider: &str, enabled_check: fn() -> bool) -> bool {
if crate::agents::provider_capabilities::is_agent_provider_enabled(provider) {
enabled_check()
} else {
false
}
}

fn agent_login_command(provider: &str) -> anyhow::Result<String> {
crate::agents::provider_capabilities::ensure_agent_provider_enabled(provider)?;
let args = match provider {
"claude" => "auth login",
"codex" => "login",
Expand Down
Loading
Loading