diff --git a/claude-plugins/plugins/base/skills/claude-subagents/SKILL.md b/claude-plugins/plugins/base/skills/claude-subagents/SKILL.md index c329de04..f3b6b04b 100644 --- a/claude-plugins/plugins/base/skills/claude-subagents/SKILL.md +++ b/claude-plugins/plugins/base/skills/claude-subagents/SKILL.md @@ -37,7 +37,8 @@ actually attached, so a `continue` naming a session that isn't there fails the tool call outright instead of looking like it worked; the error says which directory it searched, which is usually the fix (pass `dir`). `dir` defaults to your work dir, `/agents//work`, which has no -`CLAUDE.md` above it, so a subagent started there does not load yours. +`CLAUDE.md` above it, so a subagent started there does not load yours +(until that directory exists, the daemon's cwd is used). Use `status` for a zero-cost "is it still going" check — for a running subagent it also reports how long since that turn last produced output, so a few seconds diff --git a/docs/tools/subagent.md b/docs/tools/subagent.md index 79160f73..ad6ed715 100644 --- a/docs/tools/subagent.md +++ b/docs/tools/subagent.md @@ -76,7 +76,7 @@ claude the bare name — see below. Because the remembered directory goes with the rest of it, a `continue` after a restart has to re-supply `dir` when the session lives anywhere -other than the agent's work dir (the default) — and a restart is the +other than the agent's work dir (the default when it exists) — and a restart is the situation you reach for `continue` in most often. ## Roles diff --git a/hive-subagent-mcp/src/mcp.rs b/hive-subagent-mcp/src/mcp.rs index 71c89124..e9430b02 100644 --- a/hive-subagent-mcp/src/mcp.rs +++ b/hive-subagent-mcp/src/mcp.rs @@ -81,7 +81,8 @@ struct StartArgs { /// you've already prepared for it, so a parallel batch of subagents /// never race on the same working tree. Must exist. Omit to use your /// work dir, `/agents//work` (it has no `CLAUDE.md` above it, so the - /// subagent does not load yours). The daemon + /// subagent does not load yours); until that directory exists (a container + /// that predates its bind) the daemon's cwd is used. The daemon /// remembers whichever `dir` you give here against `name`, so a later /// `continue`/`status` for the same name doesn't need to repeat it — /// only pass it there again if you want to point at a *different* diff --git a/hive-subagent-mcp/src/paths.rs b/hive-subagent-mcp/src/paths.rs index fbc3e1f0..558ed6fd 100644 --- a/hive-subagent-mcp/src/paths.rs +++ b/hive-subagent-mcp/src/paths.rs @@ -32,13 +32,21 @@ pub fn state_dir() -> PathBuf { /// The agent's persistent work dir, a sibling of [`state_dir`] /// (`/agents//work`). It has no `CLAUDE.md` above it, so a subagent -/// started here does not load the agent's own. This is the working -/// directory a subagent gets when `start` is given no `dir`. +/// started here does not load the agent's own. #[must_use] pub fn work_dir() -> PathBuf { state_dir().with_file_name("work") } +/// The working directory a subagent gets when `start` is given no `dir`: +/// [`work_dir`] when it exists, else `None` (the daemon's own cwd). The +/// directory only exists once the container has restarted with its bind, and +/// a spawn into a missing cwd fails with a bare ENOENT. +#[must_use] +pub fn default_cwd() -> Option { + Some(work_dir()).filter(|p| p.is_dir()) +} + /// Where this agent's named subagent role files live, one markdown file per /// role under the agent's own [`state_dir`]. Per-agent rather than /// hive-wide: the clauses a role carries came out of one agent's own prompt diff --git a/hive-subagent-mcp/src/session.rs b/hive-subagent-mcp/src/session.rs index da604f7d..513b3af4 100644 --- a/hive-subagent-mcp/src/session.rs +++ b/hive-subagent-mcp/src/session.rs @@ -15,7 +15,7 @@ //! session, found again by name. `continue` is how a caller reattaches to //! it, whether that's "give it a new turn" or "the daemon restarted and I //! want to pick this back up" — after a restart, re-supply `dir` once if -//! the session isn't in the agent's work dir (the default). +//! the session isn't in the agent's work dir (the default when it exists). //! //! **A running turn is not necessarily a working turn.** A wedged child //! satisfies "running" as fully as a busy one, so every line of the child's @@ -1184,8 +1184,8 @@ fn subagent_otel_attrs(name: &str) -> String { /// same "cheaper than you" cost-consciousness the `base:claude-subagents` /// skill already asks of `model`. `prompt_file`, when given, becomes /// `--append-system-prompt-file` — the subagent's task instructions. -/// `dir`, when given, becomes `Config::cwd` (e.g. a worktree the caller -/// already prepared); `None` falls back to [`crate::paths::work_dir`]. +/// `dir`, when given, becomes `Config::cwd` (e.g. a prepared worktree); `None` +/// is [`crate::paths::default_cwd`]. /// Always `--dangerously-skip-permissions --strict-mcp-config --tools` — two /// gates covering one half each, neither substituting for the other: /// `strict_mcp_config` over **MCP** tools (no ambient discovery, so exactly @@ -1239,7 +1239,7 @@ fn build_config( Config { model, effort: Some(effort.unwrap_or_else(|| "medium".to_owned())), - cwd: Some(dir.map_or_else(crate::paths::work_dir, PathBuf::from)), + cwd: dir.map(PathBuf::from).or_else(crate::paths::default_cwd), mcp_config: crate::mcp_config::build(name, signal_url), strict_mcp_config: true, extra_args, @@ -1387,7 +1387,8 @@ pub struct StartRequest { pub role: Option, /// The first turn's prompt. A goal, when given, is appended to it. pub trigger: String, - /// Working directory for the session; `None` means the agent's work dir. + /// Working directory for the session; `None` means the agent's work dir when it + /// exists, else the daemon's cwd. pub dir: Option, /// What this session is being continued *toward*. `None` keeps the /// pre-continuation shape: one turn, one todo, no re-prompting. @@ -3349,8 +3350,34 @@ mod tests { let with = build_config("n", None, None, None, Some("/tmp/some-worktree"), None); assert_eq!(with.cwd, Some(PathBuf::from("/tmp/some-worktree"))); - let without = build_config("n", None, None, None, None, None); - assert_eq!(without.cwd, Some(crate::paths::work_dir())); + let root = std::env::temp_dir().join(format!( + "hive-subagent-mcp-test-work-dir-{}", + std::process::id() + )); + let state = root.join("state"); + let work = root.join("work"); + std::fs::create_dir_all(&state).unwrap(); + // SAFETY: test-only env mutation; the other `build_config` tests pass + // an explicit `dir` or read nothing derived from the state dir. + unsafe { + std::env::set_var("HYPERHIVE_STATE_DIR", &state); + } + + let absent = build_config("n", None, None, None, None, None); + std::fs::create_dir_all(&work).unwrap(); + let present = build_config("n", None, None, None, None, None); + + // SAFETY: see above. + unsafe { + std::env::remove_var("HYPERHIVE_STATE_DIR"); + } + let _ = std::fs::remove_dir_all(&root); + + assert_eq!( + absent.cwd, None, + "a work dir that isn't there yet must not become a spawn cwd" + ); + assert_eq!(present.cwd, Some(work)); } #[test]