From cfddb1bd40e5ea48409daf66557c82725bfb17fb Mon Sep 17 00:00:00 2001 From: damocles Date: Fri, 11 Sep 2026 21:40:01 +0200 Subject: [PATCH] subagent tool: add option to run in a different directory --- hive-subagent-mcp/src/mcp.rs | 30 ++++++++++++++++++++-- hive-subagent-mcp/src/session.rs | 44 ++++++++++++++++++++++++-------- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/hive-subagent-mcp/src/mcp.rs b/hive-subagent-mcp/src/mcp.rs index e8d5491e..f424fad8 100644 --- a/hive-subagent-mcp/src/mcp.rs +++ b/hive-subagent-mcp/src/mcp.rs @@ -37,6 +37,15 @@ struct StartArgs { /// belongs in `prompt_file`, not here. #[serde(default = "default_trigger")] trigger: String, + /// Working directory for the subagent's session — e.g. a git worktree + /// you've already prepared for it, so a parallel batch of subagents + /// never race on the same working tree. Must exist. Omit to inherit + /// this daemon's own working directory (today's default). Claude + /// derives its per-project session storage from this path, so + /// `continue`/`status` against this name must pass this exact same + /// `dir` again to find the session — see those tools' own docs. + #[serde(default)] + dir: Option, } fn default_trigger() -> String { @@ -53,12 +62,22 @@ struct ContinueArgs { /// default — this does not have to match whatever model `start` used. #[serde(default)] model: Option, + /// The same `dir` given at `start`, if any — sessions are stored keyed + /// by directory, so a different (or omitted) `dir` here looks in the + /// wrong place and reports no session found under `name` even though + /// one exists. + #[serde(default)] + dir: Option, } #[derive(Debug, Deserialize, JsonSchema)] struct StatusArgs { /// The subagent name to check. name: String, + /// The same `dir` given at `start`, if any — see `continue`'s `dir` doc + /// for why this has to match. + #[serde(default)] + dir: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -95,6 +114,7 @@ impl SubagentMcp { args.model, &args.prompt_file, args.trigger, + args.dir.as_deref(), ) { Ok(msg) => msg, Err(e) => format!("start error: {e:#}"), @@ -110,7 +130,13 @@ impl SubagentMcp { `start`. Refuses a name with no session on disk at all, or one already running." )] fn r#continue(&self, Parameters(args): Parameters) -> String { - match session::continue_(&self.state, &args.name, args.prompt, args.model) { + match session::continue_( + &self.state, + &args.name, + args.prompt, + args.model, + args.dir.as_deref(), + ) { Ok(msg) => msg, Err(e) => format!("continue error: {e:#}"), } @@ -138,7 +164,7 @@ impl SubagentMcp { `continue` to give it another turn), and no such session at all." )] fn status(&self, Parameters(args): Parameters) -> String { - match session::status(&self.state, &args.name) { + match session::status(&self.state, &args.name, args.dir.as_deref()) { Ok(msg) => msg, Err(e) => format!("status error: {e:#}"), } diff --git a/hive-subagent-mcp/src/session.rs b/hive-subagent-mcp/src/session.rs index 8b2c5e90..c7dd6b7a 100644 --- a/hive-subagent-mcp/src/session.rs +++ b/hive-subagent-mcp/src/session.rs @@ -118,9 +118,17 @@ fn subagent_otel_attrs(name: &str) -> String { /// Build the `Config` one subagent turn runs against. `prompt_file`, when /// given, becomes `--append-system-prompt-file` — the subagent's task -/// instructions. Always `--dangerously-skip-permissions --strict-mcp-config` -/// (no `--mcp-config` override — a safety property, not a knob). -fn build_config(name: &str, model: Option, prompt_file: Option<&str>) -> Config { +/// instructions. `dir`, when given, becomes `Config::cwd` (e.g. a worktree +/// the caller already prepared); `None` inherits this daemon's own working +/// directory, same as before this field existed. Always +/// `--dangerously-skip-permissions --strict-mcp-config` (no `--mcp-config` +/// override — a safety property, not a knob). +fn build_config( + name: &str, + model: Option, + prompt_file: Option<&str>, + dir: Option<&str>, +) -> Config { let mut extra_args = vec!["--dangerously-skip-permissions".to_owned()]; if let Some(path) = prompt_file { extra_args.push("--append-system-prompt-file".to_owned()); @@ -128,6 +136,7 @@ fn build_config(name: &str, model: Option, prompt_file: Option<&str>) -> } Config { model, + cwd: dir.map(PathBuf::from), strict_mcp_config: true, extra_args, env: vec![( @@ -167,12 +176,13 @@ pub fn start( model: Option, prompt_file: &str, trigger: String, + dir: Option<&str>, ) -> anyhow::Result { validate_name(name)?; if !state.reserve(name) { anyhow::bail!("subagent `{name}` is already running — use `continue` or `interrupt`"); } - let result = start_reserved(state, name, model, prompt_file, trigger); + let result = start_reserved(state, name, model, prompt_file, trigger, dir); if result.is_err() { state.release_reservation(name); } @@ -189,8 +199,9 @@ fn start_reserved( model: Option, prompt_file: &str, trigger: String, + dir: Option<&str>, ) -> anyhow::Result { - let config = build_config(name, model, Some(prompt_file)); + let config = build_config(name, model, Some(prompt_file), dir); let store = build_store(&config)?; if store.find_by_title(name).is_some() { tracing::info!( @@ -225,6 +236,7 @@ pub fn continue_( name: &str, prompt: String, model: Option, + dir: Option<&str>, ) -> anyhow::Result { validate_name(name)?; if !state.reserve(name) { @@ -232,7 +244,7 @@ pub fn continue_( "subagent `{name}` is already running — use `interrupt` first if you meant to redirect it" ); } - let result = continue_reserved(state, name, prompt, model); + let result = continue_reserved(state, name, prompt, model, dir); if result.is_err() { state.release_reservation(name); } @@ -247,8 +259,9 @@ fn continue_reserved( name: &str, prompt: String, model: Option, + dir: Option<&str>, ) -> anyhow::Result { - let config = build_config(name, model, None); + let config = build_config(name, model, None, dir); let store = build_store(&config)?; if store.find_by_title(name).is_none() { anyhow::bail!( @@ -321,7 +334,7 @@ fn spawn_and_track( /// # Errors /// /// An invalid name, or no session — running or on disk — under `name`. -pub fn status(state: &State, name: &str) -> anyhow::Result { +pub fn status(state: &State, name: &str, dir: Option<&str>) -> anyhow::Result { validate_name(name)?; match state.occupancy(name) { Some(true) => return Ok(format!("subagent `{name}` is running")), @@ -332,7 +345,7 @@ pub fn status(state: &State, name: &str) -> anyhow::Result { } None => {} } - let config = build_config(name, None, None); + let config = build_config(name, None, None, dir); let store = build_store(&config)?; if store.find_by_title(name).is_some() { Ok(format!( @@ -472,18 +485,27 @@ mod tests { #[test] fn build_config_only_appends_system_prompt_when_given() { - let with = build_config("n", None, Some("/tmp/p.md")); + let with = build_config("n", None, Some("/tmp/p.md"), None); assert!( with.extra_args .contains(&"--append-system-prompt-file".to_owned()) ); assert!(with.extra_args.contains(&"/tmp/p.md".to_owned())); - let without = build_config("n", None, None); + let without = build_config("n", None, None, None); assert!( !without .extra_args .contains(&"--append-system-prompt-file".to_owned()) ); } + + #[test] + fn build_config_sets_cwd_only_when_a_dir_is_given() { + let with = build_config("n", None, None, Some("/tmp/some-worktree")); + assert_eq!(with.cwd, Some(PathBuf::from("/tmp/some-worktree"))); + + let without = build_config("n", None, None, None); + assert_eq!(without.cwd, None); + } }