subagent tool: add option to run in a different directory

This commit is contained in:
damocles 2026-09-11 21:40:01 +02:00
commit cfddb1bd40
2 changed files with 61 additions and 13 deletions

View file

@ -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<String>,
}
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<String>,
/// 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<String>,
}
#[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<String>,
}
#[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<ContinueArgs>) -> 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<StatusArgs>) -> 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:#}"),
}

View file

@ -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<String>, 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<String>,
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<String>, 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<String>,
prompt_file: &str,
trigger: String,
dir: Option<&str>,
) -> anyhow::Result<String> {
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<String>,
prompt_file: &str,
trigger: String,
dir: Option<&str>,
) -> anyhow::Result<String> {
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<String>,
dir: Option<&str>,
) -> anyhow::Result<String> {
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<String>,
dir: Option<&str>,
) -> anyhow::Result<String> {
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<String> {
pub fn status(state: &State, name: &str, dir: Option<&str>) -> anyhow::Result<String> {
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<String> {
}
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);
}
}