Files
galaxy/app/src/pane_group/pane/local_harness_launch.rs
T

286 lines
12 KiB
Rust

use std::collections::HashMap;
use std::ffi::OsString;
use std::path::PathBuf;
use std::sync::Arc;
use galaxy_cli::agent::Harness;
use galaxy_managed_secrets::ManagedSecretValue;
use shell_words::quote as shell_quote;
use uuid::Uuid;
use crate::ai::agent_sdk::driver::harness::claude_code::prepare_claude_environment_config;
use crate::ai::agent_sdk::driver::harness::{
harness_kind, harness_model_env_vars, remove_claude_externally_managed_listener_env_vars,
HarnessKind,
};
use crate::ai::agent_sdk::driver::AgentDriverError;
use crate::ai::agent_sdk::{task_env_vars, validate_cli_installed};
use crate::ai::ambient_agents::task::{
normalize_orchestrator_agent_name, HarnessConfig, HarnessModelConfig,
};
use crate::ai::ambient_agents::{AgentConfigSnapshot, AmbientAgentTaskId};
use crate::ai::local_harness_setup::local_harness_product_disabled_message;
use crate::server::server_api::ai::AIClient;
use crate::terminal::cli_agent_sessions::plugin_manager::{
plugin_manager_for, CliAgentPluginManager,
};
use crate::terminal::shell::ShellType;
#[derive(Clone)]
pub(super) struct PreparedLocalHarnessLaunch {
pub command: String,
pub env_vars: HashMap<OsString, OsString>,
pub run_id: String,
pub task_id: AmbientAgentTaskId,
}
async fn ensure_local_claude_child_plugins(manager: &dyn CliAgentPluginManager) {
// Most environments should follow the standard Claude plugin setup path so
// hidden local children retain the same notification support as regular
// Claude sessions. The exception is local marketplace override testing:
// installing/updating the notification plugin re-adds the public
// claude-code-warp marketplace, which clobbers a developer's local
// claude-code-warp-internal override used for oz-harness-support testing.
if !manager.has_local_marketplace_override() {
let plugin_result = if manager.needs_update() {
manager.update().await
} else if !manager.is_installed() {
manager.install().await
} else {
Ok(())
};
if let Err(error) = plugin_result {
log::warn!("Claude notification plugin setup failed for child harness: {error}");
}
}
let platform_plugin_result = if manager.platform_plugin_needs_update() {
manager.update_platform_plugin().await
} else if !manager.is_platform_plugin_installed() {
manager.install_platform_plugin().await
} else {
Ok(())
};
if let Err(error) = platform_plugin_result {
log::warn!("Claude platform plugin setup failed for child harness: {error}");
}
}
pub(super) fn normalize_local_child_harness(harness_type: &str) -> Option<Harness> {
Harness::parse_local_child_harness(harness_type)
}
pub(super) fn validate_local_harness_shell(shell_type: Option<ShellType>) -> Result<(), String> {
match shell_type {
Some(ShellType::Bash) | Some(ShellType::Zsh) | Some(ShellType::Fish) => Ok(()),
Some(ShellType::PowerShell) => Err(
"Local child harnesses currently require bash, zsh, or fish; PowerShell is not supported."
.to_string(),
),
None => Err(
"Local child harnesses currently require a detected bash, zsh, or fish session."
.to_string(),
),
}
}
const LOCAL_CLAUDE_CHILD_ORCHESTRATION_INSTRUCTIONS: &str = r#"You are a local Claude Code child agent launched by a lead agent in Galaxy.
Coordinate with the lead agent through Galaxy's local CLI messaging environment:
- Your run id is in OZ_RUN_ID.
- The lead agent id is in OZ_PARENT_RUN_ID.
- The Galaxy CLI command is in OZ_CLI (the compatibility environment variable name).
If OZ_CLI, OZ_RUN_ID, or OZ_PARENT_RUN_ID is missing, report that blocker in your final response.
Do not use Claude Code Agent or SendMessage tools to contact the lead agent; use the Galaxy CLI commands below.
Do not ask to inspect help before messaging. The command shapes below are complete.
Send a message to the lead agent at start, when blocked, and when complete:
"$OZ_CLI" run message send --sender-run-id "$OZ_RUN_ID" --to "$OZ_PARENT_RUN_ID" --subject "<subject>" --body "<body>"
All four send arguments are required: --sender-run-id "$OZ_RUN_ID", --to "$OZ_PARENT_RUN_ID", --subject, and --body.
Do not pass "$OZ_PARENT_RUN_ID" as a positional argument to send.
After sending a message, and before ending or standing by, check recent inbox messages:
"$OZ_CLI" run message list "$OZ_RUN_ID" --limit 25
The plugin may already have read incoming messages while staging them, so do not rely on --unread.
If recent messages from "$OZ_PARENT_RUN_ID" are present and you have not handled them, read them and use the latest lead-agent mailbox message as task context:
"$OZ_CLI" run message read "$MESSAGE_ID"
If a surfaced message requires acknowledgement, mark it delivered:
"$OZ_CLI" run message mark-delivered "$MESSAGE_ID"
"#;
pub(super) fn local_claude_child_prompt(task_prompt: &str) -> String {
format!(
"{LOCAL_CLAUDE_CHILD_ORCHESTRATION_INSTRUCTIONS}\nTask:\n{}",
task_prompt
)
}
pub(super) fn build_local_claude_child_command(prompt: &str) -> String {
let session_id = Uuid::new_v4();
let quoted_prompt = shell_quote(prompt);
// Local child harness panes are launched off-screen. We intentionally skip
// Claude's own permission prompts here so the child can start unattended
// instead of hanging on an approval UI the user cannot see in that hidden
// pane.
format!("claude --session-id {session_id} --dangerously-skip-permissions {quoted_prompt}")
}
pub(super) fn build_local_opencode_child_command(prompt: &str) -> String {
let quoted_prompt = shell_quote(prompt);
format!("opencode --prompt {quoted_prompt}")
}
pub(super) fn build_local_codex_child_command(prompt: &str) -> String {
let quoted_prompt = shell_quote(prompt);
format!("codex --dangerously-bypass-approvals-and-sandbox {quoted_prompt}")
}
pub(super) fn local_child_task_config(
harness: Harness,
agent_name: Option<String>,
) -> Option<AgentConfigSnapshot> {
let agent_name = agent_name
.as_deref()
.and_then(normalize_orchestrator_agent_name);
match harness {
Harness::Oz | Harness::Unknown => None,
Harness::Claude | Harness::OpenCode | Harness::Gemini | Harness::Codex => {
Some(AgentConfigSnapshot {
name: agent_name,
harness: Some(HarnessConfig::from_harness_type(harness)),
..Default::default()
})
}
}
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn prepare_local_harness_child_launch(
prompt: String,
harness_type: String,
model_id: Option<String>,
parent_run_id: Option<String>,
agent_name: Option<String>,
shell_type: Option<ShellType>,
startup_directory: Option<PathBuf>,
ai_client: Arc<dyn AIClient>,
) -> Result<PreparedLocalHarnessLaunch, String> {
let harness_model_config =
model_id
.filter(|id| !id.is_empty())
.map(|model_id| HarnessModelConfig {
model_id,
reasoning_level: None,
});
let Some(harness) = normalize_local_child_harness(&harness_type) else {
let harness_name = harness_type.trim();
return Err(if harness_name.is_empty() {
"Local child harness type is missing.".to_string()
} else {
format!("Unsupported local child harness '{harness_name}'.")
});
};
if let Some(message) = local_harness_product_disabled_message(harness) {
return Err(message.to_string());
}
validate_local_harness_shell(shell_type)?;
let command = match harness {
Harness::Oz => unreachable!("normalize_local_child_harness filters out Oz"),
Harness::Unknown => unreachable!("normalize_local_child_harness filters out Unknown"),
Harness::Claude => {
let working_dir = startup_directory
.or_else(|| std::env::current_dir().ok())
.ok_or_else(|| {
format!(
"Could not resolve a working directory for the local {} child.",
harness.display_name()
)
})?;
let HarnessKind::ThirdParty(third_party_harness) =
harness_kind(harness).map_err(|error: AgentDriverError| error.to_string())?
else {
unreachable!("Claude resolves to a third-party harness")
};
third_party_harness
.validate()
.map_err(|error: AgentDriverError| error.to_string())?;
// Local child harness panes inherit the user's existing local
// auth/session state. We still prepare harness config files here,
// but there are no Warp-managed secrets to materialize into the
// hidden child pane.
prepare_claude_environment_config(&working_dir, &HashMap::new())
.map_err(|error| error.to_string())?;
if let Some(manager) = plugin_manager_for(third_party_harness.cli_agent()) {
ensure_local_claude_child_plugins(manager.as_ref()).await;
}
build_local_claude_child_command(&local_claude_child_prompt(&prompt))
}
Harness::Codex => {
let HarnessKind::ThirdParty(third_party_harness) =
harness_kind(harness).map_err(|error: AgentDriverError| error.to_string())?
else {
unreachable!("Codex resolves to a third-party harness")
};
third_party_harness
.validate()
.map_err(|error: AgentDriverError| error.to_string())?;
// Local Codex child panes must rely on the user's existing local
// auth/session state. Do not run the shared Codex environment prep
// here: it can seed OPENAI_API_KEY into ~/.codex/auth.json and
// rewrite ~/.codex/config.toml for the whole machine.
build_local_codex_child_command(&prompt)
}
Harness::OpenCode => {
validate_cli_installed("opencode", Some("https://opencode.ai/docs"))
.map_err(|error: AgentDriverError| error.to_string())?;
build_local_opencode_child_command(&prompt)
}
Harness::Gemini => unreachable!("normalize_local_child_harness filters out Gemini"),
};
let task_id = ai_client
.create_agent_task(
prompt.clone(),
None,
parent_run_id.clone(),
local_child_task_config(harness, agent_name),
)
.await
.map_err(|error| {
format!(
"Failed to create local {} child task: {error}",
harness.display_name()
)
})?;
let mut env_vars = task_env_vars(Some(&task_id), parent_run_id.as_deref(), harness);
if harness == Harness::Claude {
// Local Claude child panes are launched directly in hidden terminals,
// not through AgentDriver's ClaudeHarnessRunner. Let the Claude plugin
// manage its own listener instead of waiting for a non-existent
// external MessageBridge.
remove_claude_externally_managed_listener_env_vars(&mut env_vars);
}
// Propagate the selected model to Claude Code via ANTHROPIC_MODEL.
// Codex local children never receive a model override — the UI
// ensures model_id is empty for local Codex.
env_vars.extend(harness_model_env_vars(
harness,
harness_model_config.as_ref(),
));
Ok(PreparedLocalHarnessLaunch {
command,
env_vars,
run_id: task_id.to_string(),
task_id,
})
}
#[cfg(test)]
#[path = "local_harness_launch_tests.rs"]
mod tests;