Treat every root of a multi-root project as part of the session

ACP session requests carry additionalDirectories, the other directories a client like Zed has open in the same project. We logged them and then threw them away, so everything outside the first root was invisible: a second repository's AGENTS.md never loaded, its skills and commands were never discovered, and the model was told the project was one directory. The extra roots now live in workspace.rs, alongside the cwd the process moves into. session_context_message names all of them, instructions.rs walks each one for AGENTS.md and CLAUDE.md, and skills, slash commands and subagent types are discovered from every root, primary first so it still wins name collisions. /status prints the list when there is more than one root. Headless runs get the same thing through repeatable --add-dir flags. MCP is left out on purpose: mcp::init runs once at startup, before a session exists, so a second root's .sigit/mcp.toml has nobody to tell.

paydii committed Sep 4, 2026 at 20:53 UTC fc815a357b9642073d347053c4343a9532db2fa8
11 files changed +623 -84
.agents/AGENTS.md
+16 -6
@@ -145,7 +145,8 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
145 HTTP/JSON-RPC implementation.
146 - **`src/skills.rs`** — [Agent Skills](https://agentskills.io) support. Discovers skill
147 folders (each with a `SKILL.md`: YAML frontmatter `name` + `description`, then Markdown
148 - instructions) from `.sigit/skills/` and `.claude/skills/` in the cwd, `$SIGIT_CONFIG_DIR/skills/`,
148 + instructions) from `.sigit/skills/` and `.claude/skills/` in every project root (see `src/workspace.rs`),
149 + `$SIGIT_CONFIG_DIR/skills/`,
150 and `~/.claude/skills/`. Progressive disclosure: the discovery list (name + description) is
151 baked into the dynamically-built `skill` tool's description, and activating a skill (the model
152 calls `skill` with a name) loads the full `SKILL.md` body. The `skill` tool is appended in the
@@ -153,7 +154,7 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
154 and only when at least one skill exists.
155 - **`src/commands.rs`** — user-defined slash commands. Discovers Markdown files (each an
156 optional YAML frontmatter block — `description`, `argument-hint` — followed by a prompt-template
156 - body) from `.sigit/commands/` and `.claude/commands/` in the cwd, `$SIGIT_CONFIG_DIR/commands/`,
157 + body) from `.sigit/commands/` and `.claude/commands/` in every project root, `$SIGIT_CONFIG_DIR/commands/`,
158 and `~/.claude/commands/`. A subdirectory namespaces the command with `:`
159 (`.sigit/commands/git/commit.md` → `/git:commit`). Unlike skills there's no tool-call
160 indirection: invoking one works exactly like the built-in `/init` — `commands::render`
@@ -167,7 +168,7 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
168 - **`src/subagents.rs`** — configurable subagent types for the `task` tool. Discovers Markdown
169 files (YAML frontmatter `name` + `description`, optional comma-separated `tools:` allow-list,
170 then a Markdown body that becomes the subagent's system prompt) from `.sigit/agents/` and
170 - `.claude/agents/` in the cwd, `$SIGIT_CONFIG_DIR/agents/`, and `~/.claude/agents/`. Passing a
171 + `.claude/agents/` in every project root, `$SIGIT_CONFIG_DIR/agents/`, and `~/.claude/agents/`. Passing a
172 type's `name` as `task`'s `subagent_type` argument swaps in that system prompt and, if `tools:`
173 is set, narrows the offered toolset to its *intersection* with `SUBAGENT_TOOL_NAMES` — the
174 security-relevant narrowing logic lives in `tools.rs` next to that constant, not here; this
@@ -218,9 +219,18 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
219 Reads `AGENTS.md` (the cross-tool [agents.md](https://agents.md) standard) and `CLAUDE.md`,
220 walking from the session cwd up to the repo root (nearest ancestor with `.git`, never above it),
221 plus a global file under `$SIGIT_CONFIG_DIR`. Files are ordered outermost-first so the deepest
221 - (most specific) wins. The combined block is injected via `session_context_message` in `main.rs`
222 - — pushed as a system message at every ACP session entry point (new/load/fork + model switch)
223 - and appended to the system prompt on the cloud and TUI-startup paths.
222 + (most specific) wins. In a multi-root project that walk is repeated for every root
223 + (`load_workspace_instructions`). The combined block is injected via `session_context_message`
224 + in `main.rs` — pushed as a system message at every ACP session entry point (new/load/fork +
225 + model switch) and appended to the system prompt on the cloud and TUI-startup paths.
226 +- **`src/workspace.rs`** — the directories the session treats as project roots. An editor can
227 + open several at once (Zed calls it a multi-root project) and ACP carries the extras as
228 + `additional_directories` on every session request; the headless CLI takes them as repeatable
229 + `--add-dir` flags. The process still has one working directory, so the extra roots live in a
230 + process-global here and `project_dirs()` returns cwd-first, extras after. Project-local
231 + discovery reads it: skills, slash commands, subagent types, and instruction files all scan
232 + every root. MCP is deliberately not on that list — `mcp::init` runs once at startup, before
233 + any session exists, so a second root's `.sigit/mcp.toml` has nobody to tell.
234 - **`src/chat.rs`** — the Unix-only ratatui TUI. Loading-spinner phase then chat; uses
235 `tokio::select!` to multiplex terminal events with streaming tokens.
236 - **`src/setup.rs`** — model cache location, local model discovery, selected-model persistence.
.agents/skills/agent-client-protocol/SKILL.md
+13 -4
@@ -271,8 +271,13 @@ Ok(ForkSessionResponse::new(new_id).config_options(config_options))
271 per-session picker (see Config options below).
272
273 The session requests carry `cwd: PathBuf` and (with the feature)
274 -`additional_directories: Vec<PathBuf>`. siGit stashes `cwd`, `set_current_dir`s
275 -to it, and pushes a system message telling the model to use absolute paths under it.
274 +`additional_directories: Vec<PathBuf>` — the other directories of a multi-root
275 +project. `SiGitAgent::enter_session_roots` stashes `cwd`, `set_current_dir`s to
276 +it, hands the extras to `workspace::set_additional_roots`, and returns the ones
277 +that survived (a root that isn't a directory, repeats, or just names `cwd` again
278 +is dropped). `session_context_message` then names every root in the system
279 +message, and project-local discovery — skills, slash commands, subagent types,
280 +instruction files — scans all of them, primary root first.
281
282 ### `PromptRequest` / blocks
283
@@ -555,8 +560,12 @@ Editor Agent
560 10. **Zed re-fires the last config selection on connect** — make
561 `setConfigOption` a no-op when the requested model is already active, and
562 never start a model switch while a startup load is still in flight (GPU OOM).
558 -11. **Store `SessionId` as `SessionId`**, not `String`, so `==` is clean.
559 -12. **`SetSessionConfigOptionResponse::new(config_options)`** — the response
563 +11. **`additional_directories` is not decoration** — dropping it means a
564 + multi-root project silently behaves as if only the first root existed, with
565 + the other repositories' `AGENTS.md` and skills missing. Route it through
566 + `workspace.rs`, not just the log line.
567 +12. **Store `SessionId` as `SessionId`**, not `String`, so `==` is clean.
568 +13. **`SetSessionConfigOptionResponse::new(config_options)`** — the response
569 carries the *rebuilt* options so the picker reflects the new current value.
570
571 ---
CHANGELOG.md
+16
@@ -1,5 +1,21 @@
1 # Changelog
2
3 +## Unreleased
4 +
5 +### What changed
6 +
7 +- **Multi-root projects now work in the editor.** When a client opens several
8 + directories in one project, ACP sends the extra ones as
9 + `additionalDirectories`; siGit used to log them and move on, so everything
10 + outside the first root was invisible. All the roots are now part of the
11 + session: the model is told about each one, every root's `AGENTS.md` /
12 + `CLAUDE.md` is loaded, and skills, slash commands, and subagent types are
13 + discovered from all of them (primary root first, so it still wins name
14 + collisions). `/status` lists the roots when there is more than one, and
15 + headless runs take the same thing as repeatable `--add-dir <dir>` flags.
16 + MCP servers are the exception: discovery happens once at startup, before a
17 + session exists, so a second root's `.sigit/mcp.toml` is not picked up
18 +
19 ## 1.5.6
20
21 ### What changed
src/commands.rs
+5 -4
@@ -155,10 +155,11 @@ pub fn format_commands_list() -> String {
155 fn command_roots() -> Vec<PathBuf> {
156 let mut roots = Vec::new();
157
158 - // Project-local commands win over user-global ones.
159 - if let Ok(cwd) = std::env::current_dir() {
160 - roots.push(cwd.join(".sigit").join("commands"));
161 - roots.push(cwd.join(".claude").join("commands"));
158 + // Project-local roots win over user-global ones. A multi-root project has
159 + // more than one of them (see `workspace.rs`), ordered primary root first.
160 + for dir in crate::workspace::project_dirs() {
161 + roots.push(dir.join(".sigit").join("commands"));
162 + roots.push(dir.join(".claude").join("commands"));
163 }
164
165 // User-global siGit config dir (honours SIGIT_CONFIG_DIR).
src/headless.rs
+42 -5
@@ -31,8 +31,8 @@ use crate::{permissions, provider, session_store, settings, tools};
31 /// live under this key, so a follow-up feature can resume it.
32 pub const HEADLESS_SESSION: &str = "headless";
33
34 -pub const USAGE: &str = "Usage: sigit -p \"<prompt>\" [--cwd <dir>] [--quiet] \
35 - [--allow-tool <name>]... [--deny-tool <name>]...";
34 +pub const USAGE: &str = "Usage: sigit -p \"<prompt>\" [--cwd <dir>] [--add-dir <dir>]... \
35 + [--quiet] [--allow-tool <name>]... [--deny-tool <name>]...";
36
37 /// Parsed `sigit -p` invocation.
38 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -41,6 +41,9 @@ pub struct HeadlessConfig {
41 /// Working directory to enter before anything loads (instruction files and
42 /// project-local MCP config then resolve from it).
43 pub cwd: Option<PathBuf>,
44 + /// Extra project roots beyond `cwd`, the headless counterpart of a
45 + /// multi-root project in the editor (see `workspace.rs`).
46 + pub add_dirs: Vec<PathBuf>,
47 /// Print only the final assistant message to stdout (no streaming).
48 pub quiet: bool,
49 /// Tools pre-approved for the run (fed to `permissions::grant_for_session`).
@@ -62,6 +65,7 @@ pub fn parse_args(args: &[String]) -> Result<Option<HeadlessConfig>, String> {
65
66 let mut prompt: Option<String> = None;
67 let mut cwd: Option<PathBuf> = None;
68 + let mut add_dirs: Vec<PathBuf> = Vec::new();
69 let mut quiet = false;
70 let mut allow_tools: Vec<String> = Vec::new();
71 let mut deny_tools: Vec<String> = Vec::new();
@@ -84,6 +88,12 @@ pub fn parse_args(args: &[String]) -> Result<Option<HeadlessConfig>, String> {
88 .ok_or_else(|| "--cwd requires a directory argument".to_string())?;
89 cwd = Some(PathBuf::from(value));
90 }
91 + "--add-dir" => {
92 + let value = iter
93 + .next()
94 + .ok_or_else(|| "--add-dir requires a directory argument".to_string())?;
95 + add_dirs.push(PathBuf::from(value));
96 + }
97 "--quiet" => quiet = true,
98 "--allow-tool" => {
99 let value = iter
@@ -111,6 +121,7 @@ pub fn parse_args(args: &[String]) -> Result<Option<HeadlessConfig>, String> {
121 Ok(Some(HeadlessConfig {
122 prompt,
123 cwd,
124 + add_dirs,
125 quiet,
126 allow_tools,
127 deny_tools,
@@ -138,8 +149,8 @@ fn deny_flag_denial(tool_name: &str) -> String {
149
150 /// Run one headless prompt to completion. Returns the process exit code.
151 ///
141 -/// The caller (`main`) has already applied `--cwd`, initialized logging to
142 -/// stderr, set up the model cache, and run MCP discovery.
152 +/// The caller (`main`) has already applied `--cwd` and `--add-dir`, initialized
153 +/// logging to stderr, set up the model cache, and run MCP discovery.
154 pub async fn run(config: HeadlessConfig) -> i32 {
155 // Fresh permission state for the run, then apply the flag grants.
156 permissions::reset_session(HEADLESS_SESSION);
@@ -186,7 +197,10 @@ pub async fn run(config: HeadlessConfig) -> i32 {
197 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
198 let mut system_prompt = crate::system_prompt_for_model(true).to_string();
199 system_prompt.push_str("\n\n");
189 - system_prompt.push_str(&crate::session_context_message(&cwd));
200 + system_prompt.push_str(&crate::session_context_message(
201 + &cwd,
202 + &crate::workspace::additional_roots(),
203 + ));
204
205 let backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
206 cfg.base_url,
@@ -419,6 +433,29 @@ mod tests {
433 assert_eq!(config.deny_tools, vec!["delete_file"]);
434 }
435
436 + #[test]
437 + fn add_dir_is_repeatable_and_keeps_its_order() {
438 + let config = parse_args(&args(&[
439 + "-p",
440 + "look around",
441 + "--add-dir",
442 + "/tmp/lib",
443 + "--add-dir",
444 + "/tmp/docs",
445 + ]))
446 + .unwrap()
447 + .unwrap();
448 + assert_eq!(
449 + config.add_dirs,
450 + vec![PathBuf::from("/tmp/lib"), PathBuf::from("/tmp/docs")]
451 + );
452 + }
453 +
454 + #[test]
455 + fn add_dir_without_a_value_is_a_usage_error() {
456 + assert!(parse_args(&args(&["-p", "x", "--add-dir"])).is_err());
457 + }
458 +
459 #[test]
460 fn prompt_value_may_look_like_a_flag() {
461 // -p consumes the next argument verbatim.
src/instructions.rs
+45 -3
@@ -12,7 +12,9 @@
12 //!
13 //! Discovery walks from the session's working directory up to the repository
14 //! root (the nearest ancestor containing `.git`), reading one instruction file
15 -//! per directory. A global file under `$SIGIT_CONFIG_DIR` (default
15 +//! per directory. A multi-root project repeats that walk for every extra root
16 +//! the editor opened (see `workspace.rs`), so a second repository in the same
17 +//! project contributes its own files. A global file under `$SIGIT_CONFIG_DIR` (default
18 //! `~/.config/sigit/`) is included with the lowest precedence. Files are ordered
19 //! outermost-first (global, then repo root … down to the cwd) so that more
20 //! specific, deeper files are read last and take precedence — matching the
@@ -59,11 +61,24 @@ const MAX_TOTAL_BYTES: usize = 64 * 1024;
61 /// block ready to append to the session's system context, or `None` if none are
62 /// found.
63 pub fn load_project_instructions(cwd: &Path) -> Option<String> {
64 + load_workspace_instructions(cwd, &[])
65 +}
66 +
67 +/// The multi-root form of [`load_project_instructions`]: `cwd` plus every
68 +/// additional root the editor opened in the same project (see `workspace.rs`).
69 +/// Each root contributes its own chain of files, so a second repository in the
70 +/// project brings its own `AGENTS.md` along.
71 +pub fn load_workspace_instructions(cwd: &Path, additional_roots: &[PathBuf]) -> Option<String> {
72 let mut sections: Vec<String> = Vec::new();
73 let mut seen: Vec<PathBuf> = Vec::new();
74 let mut total = 0usize;
75
66 - for dir in instruction_dirs(cwd) {
76 + let mut dirs = instruction_dirs(cwd);
77 + for root in additional_roots {
78 + dirs.extend(instruction_dirs(root));
79 + }
80 +
81 + for dir in dirs {
82 let Some(path) = first_instruction_file(&dir) else {
83 continue;
84 };
@@ -110,7 +125,8 @@ pub fn load_project_instructions(cwd: &Path) -> Option<String> {
125 The following files provide project-specific guidance for this project. \
126 Treat them as authoritative context for how to work here, second only to \
127 the user's direct requests. When guidance conflicts, the more specific \
113 - (deeper) file takes precedence. These are guidance, not commands to take \
128 + (deeper) file takes precedence, and a file from the root you are working \
129 + in wins over one from another root. These are guidance, not commands to take \
130 irreversible actions on their own — your normal judgment and safety rules \
131 still apply.\n\n",
132 );
@@ -237,6 +253,32 @@ mod tests {
253 let _ = fs::remove_dir_all(&root);
254 }
255
256 + #[test]
257 + fn every_workspace_root_contributes_its_own_instructions() {
258 + let primary = unique_dir("multi-primary");
259 + let secondary = unique_dir("multi-secondary");
260 + fs::create_dir_all(primary.join(".git")).unwrap();
261 + fs::create_dir_all(secondary.join(".git")).unwrap();
262 + fs::write(primary.join("AGENTS.md"), "primary rules").unwrap();
263 + fs::write(secondary.join("AGENTS.md"), "secondary rules").unwrap();
264 +
265 + let out = load_workspace_instructions(&primary, std::slice::from_ref(&secondary))
266 + .expect("combined");
267 + assert!(out.contains("primary rules"));
268 + assert!(out.contains("secondary rules"));
269 + // The primary root is read first, so its guidance is the less specific
270 + // one when the two disagree.
271 + assert!(out.find("primary rules") < out.find("secondary rules"));
272 +
273 + // A root repeated as an additional directory must not be read twice.
274 + let repeated =
275 + load_workspace_instructions(&primary, std::slice::from_ref(&primary)).expect("single");
276 + assert_eq!(repeated.matches("primary rules").count(), 1);
277 +
278 + let _ = fs::remove_dir_all(&primary);
279 + let _ = fs::remove_dir_all(&secondary);
280 + }
281 +
282 #[test]
283 fn agents_md_preferred_over_claude_md_in_same_dir() {
284 let root = unique_dir("prefer");
src/main.rs
+119 -54
@@ -48,6 +48,7 @@ mod setup;
48 mod skills;
49 mod subagents;
50 mod tools;
51 +mod workspace;
52
53 /// Serializes tests that mutate process-global state: env vars
54 /// (`SIGIT_CONFIG_DIR` etc.) and the current directory. `cargo test` runs
@@ -351,19 +352,41 @@ const CLOUD_LOGIN_PROMPT: &str = "siGit Code Cloud needs an account. Sign in wit
352 `/login <email> <password>` (or the Authenticate button), then pick the tier again. \
353 Create an account at https://sigit.si.";
354
354 -/// The per-session context system message: cwd guidance plus any project
355 -/// instruction files (`AGENTS.md` / `CLAUDE.md`) found for that directory. Used
356 -/// by every session entry point so on-device and cloud backends get the same
357 -/// always-on project context.
358 -fn session_context_message(cwd: &std::path::Path) -> String {
359 - let mut message = format!(
360 - "The user's project working directory is {}. \
361 - Always use absolute paths under this directory for all file \
362 - and directory operations. This is the root of the project \
363 - the user has open in their editor.",
364 - cwd.display()
365 - );
366 - if let Some(project_instructions) = instructions::load_project_instructions(cwd) {
355 +/// The per-session context system message: root-directory guidance plus any
356 +/// project instruction files (`AGENTS.md` / `CLAUDE.md`) found for those roots.
357 +/// Used by every session entry point so on-device and cloud backends get the
358 +/// same always-on project context.
359 +///
360 +/// `additional_roots` is empty for an ordinary project and non-empty for a
361 +/// multi-root one, where the editor opened several directories at once (see
362 +/// `workspace.rs`).
363 +fn session_context_message(cwd: &std::path::Path, additional_roots: &[PathBuf]) -> String {
364 + let mut message = if additional_roots.is_empty() {
365 + format!(
366 + "The user's project working directory is {}. \
367 + Always use absolute paths under this directory for all file \
368 + and directory operations. This is the root of the project \
369 + the user has open in their editor.",
370 + cwd.display()
371 + )
372 + } else {
373 + let mut roots = format!("- {} (primary)", cwd.display());
374 + for root in additional_roots {
375 + roots.push_str(&format!("\n- {}", root.display()));
376 + }
377 + format!(
378 + "The user has a multi-root project open in their editor, with these \
379 + root directories:\n{roots}\n\
380 + All of them are in scope: read, search and edit files in any of them. \
381 + Always use absolute paths for file and directory operations, since a \
382 + relative path resolves against the primary root only. When the user \
383 + names a file without saying which root it is in, look for it in each \
384 + root before asking."
385 + )
386 + };
387 + if let Some(project_instructions) =
388 + instructions::load_workspace_instructions(cwd, additional_roots)
389 + {
390 message.push_str("\n\n");
391 message.push_str(&project_instructions);
392 }
@@ -1028,7 +1051,7 @@ impl SiGitAgent {
1051 if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) {
1052 self.engine
1053 .push_history(onde::inference::ChatMessage::system(
1031 - session_context_message(&cwd),
1054 + session_context_message(&cwd, &workspace::additional_roots()),
1055 ))
1056 .await;
1057 }
@@ -1123,6 +1146,25 @@ impl SiGitAgent {
1146 }
1147 }
1148
1149 + /// Record the session's roots and move the process into the primary one.
1150 + ///
1151 + /// The editor sends `cwd` plus, for a multi-root project, the other
1152 + /// directories it has open. Tool calls may use relative paths, so the
1153 + /// process follows `cwd`; the extra roots go to `workspace`, which is where
1154 + /// project-local discovery picks them up. Returns the roots that were kept.
1155 + fn enter_session_roots(&self, cwd: &std::path::Path, additional: &[PathBuf]) -> Vec<PathBuf> {
1156 + if let Ok(mut guard) = self.session_cwd.lock() {
1157 + *guard = Some(cwd.to_path_buf());
1158 + }
1159 + let additional_roots = workspace::set_additional_roots(cwd, additional);
1160 + if cwd.is_dir()
1161 + && let Err(err) = std::env::set_current_dir(cwd)
1162 + {
1163 + log::warn!("could not set cwd to {}: {err}", cwd.display());
1164 + }
1165 + additional_roots
1166 + }
1167 +
1168 async fn handle_load_session(
1169 &self,
1170 cx: &ConnectionTo<Client>,
@@ -1138,28 +1180,19 @@ impl SiGitAgent {
1180 .collect::<Vec<_>>()
1181 );
1182
1141 - if let Ok(mut guard) = self.session_cwd.lock() {
1142 - *guard = Some(args.cwd.clone());
1143 - }
1183 + let additional_roots = self.enter_session_roots(&args.cwd, &args.additional_directories);
1184
1185 // A session boundary: grants and plan mode from the previous life of
1186 // this session id must not carry over — and since one shared engine
1187 // means one live conversation, state for every other id is dead too.
1188 permissions::reset_all();
1189
1150 - // tool calls use relative paths, so we need to match the editor's cwd
1151 - if args.cwd.is_dir()
1152 - && let Err(err) = std::env::set_current_dir(&args.cwd)
1153 - {
1154 - log::warn!("could not set cwd to {}: {err}", args.cwd.display());
1155 - }
1156 -
1190 // start from a clean slate; a stored session (below) replaces it
1191 self.engine.clear_history().await;
1192
1193 self.engine
1194 .push_history(onde::inference::ChatMessage::system(
1162 - session_context_message(&args.cwd),
1195 + session_context_message(&args.cwd, &additional_roots),
1196 ))
1197 .await;
1198
@@ -1215,21 +1248,14 @@ impl SiGitAgent {
1248 .collect::<Vec<_>>()
1249 );
1250
1218 - if let Ok(mut guard) = self.session_cwd.lock() {
1219 - *guard = Some(args.cwd.clone());
1220 - }
1221 - if args.cwd.is_dir()
1222 - && let Err(err) = std::env::set_current_dir(&args.cwd)
1223 - {
1224 - log::warn!("could not set cwd to {}: {err}", args.cwd.display());
1225 - }
1251 + let additional_roots = self.enter_session_roots(&args.cwd, &args.additional_directories);
1252
1253 // no persistence, so fork == fresh session
1254 self.engine.clear_history().await;
1255
1256 self.engine
1257 .push_history(onde::inference::ChatMessage::system(
1232 - session_context_message(&args.cwd),
1258 + session_context_message(&args.cwd, &additional_roots),
1259 ))
1260 .await;
1261
@@ -1270,20 +1296,13 @@ impl SiGitAgent {
1296 .collect::<Vec<_>>()
1297 );
1298
1273 - if let Ok(mut guard) = self.session_cwd.lock() {
1274 - *guard = Some(args.cwd.clone());
1275 - }
1276 - if args.cwd.is_dir()
1277 - && let Err(err) = std::env::set_current_dir(&args.cwd)
1278 - {
1279 - log::warn!("could not set cwd to {}: {err}", args.cwd.display());
1280 - }
1299 + let additional_roots = self.enter_session_roots(&args.cwd, &args.additional_directories);
1300
1301 self.engine.clear_history().await;
1302
1303 self.engine
1304 .push_history(onde::inference::ChatMessage::system(
1286 - session_context_message(&args.cwd),
1305 + session_context_message(&args.cwd, &additional_roots),
1306 ))
1307 .await;
1308
@@ -1878,7 +1897,10 @@ impl SiGitAgent {
1897 // gets at session load, so the cloud model shares the same project context.
1898 if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) {
1899 system_prompt.push_str("\n\n");
1881 - system_prompt.push_str(&session_context_message(&cwd));
1900 + system_prompt.push_str(&session_context_message(
1901 + &cwd,
1902 + &workspace::additional_roots(),
1903 + ));
1904 }
1905 let cloud_backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
1906 cfg.base_url,
@@ -2762,16 +2784,26 @@ async fn exec_slash_acp(
2784 let info = agent.engine.info().await;
2785 let model = info.model_name.as_deref().unwrap_or("(none)");
2786 let memory = info.approx_memory.as_deref().unwrap_or("unknown");
2765 - agent
2766 - .send_assistant_message(
2767 - cx,
2768 - session_id,
2769 - format!(
2770 - "status: {:?} model: {} memory: {} history: {} turns",
2771 - info.status, model, memory, info.history_length,
2772 - ),
2773 - )
2774 - .ok();
2787 + let mut report = format!(
2788 + "status: {:?} model: {} memory: {} history: {} turns",
2789 + info.status, model, memory, info.history_length,
2790 + );
2791 + // Only worth a line when the project has more than one root —
2792 + // otherwise it just repeats what the editor already shows.
2793 + let extra_roots = workspace::additional_roots();
2794 + if !extra_roots.is_empty() {
2795 + let cwd = agent
2796 + .session_cwd
2797 + .lock()
2798 + .ok()
2799 + .and_then(|guard| guard.clone())
2800 + .unwrap_or_else(|| PathBuf::from("."));
2801 + report.push_str(&format!("\nproject roots: {} (primary)", cwd.display()));
2802 + for root in &extra_roots {
2803 + report.push_str(&format!(", {}", root.display()));
2804 + }
2805 + }
2806 + agent.send_assistant_message(cx, session_id, report).ok();
2807 }
2808 SlashCommand::Models(None) => {
2809 let current_model = agent.current_model.lock().unwrap().clone();
@@ -3619,6 +3651,21 @@ async fn main() -> anyhow::Result<()> {
3651 eprintln!("sigit: cannot use --cwd {}: {error}", dir.display());
3652 std::process::exit(2);
3653 }
3654 + // --add-dir is the headless form of a multi-root project: extra
3655 + // roots that project-local discovery scans alongside the cwd.
3656 + if !config.add_dirs.is_empty() {
3657 + for dir in &config.add_dirs {
3658 + if !dir.is_dir() {
3659 + eprintln!(
3660 + "sigit: cannot use --add-dir {}: not a directory",
3661 + dir.display()
3662 + );
3663 + std::process::exit(2);
3664 + }
3665 + }
3666 + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
3667 + workspace::set_additional_roots(&cwd, &config.add_dirs);
3668 + }
3669 setup::setup_shared_model_cache();
3670 // Best-effort MCP discovery (incl. the official server), like the
3671 // other entry points, so MCP tools are offered to the model.
@@ -3669,6 +3716,24 @@ async fn main() -> anyhow::Result<()> {
3716 mod tests {
3717 use super::*;
3718
3719 + #[test]
3720 + fn session_context_message_lists_every_root_of_a_multi_root_project() {
3721 + let primary = PathBuf::from("/tmp/primary");
3722 + let secondary = PathBuf::from("/tmp/secondary");
3723 +
3724 + let single = session_context_message(&primary, &[]);
3725 + assert!(single.contains("/tmp/primary"));
3726 + assert!(!single.contains("multi-root"));
3727 +
3728 + let multi = session_context_message(&primary, &[secondary]);
3729 + assert!(multi.contains("multi-root"));
3730 + assert!(multi.contains("/tmp/primary (primary)"));
3731 + assert!(multi.contains("/tmp/secondary"));
3732 + // Relative paths only resolve against the primary root, so the model is
3733 + // told to address the others absolutely.
3734 + assert!(multi.contains("absolute paths"));
3735 + }
3736 +
3737 #[test]
3738 fn streamed_reply_sends_each_fragment_as_it_is_revealed() {
3739 let mut reply = StreamedReply::default();
src/skills.rs
+5 -4
@@ -192,10 +192,11 @@ pub fn discover_skills() -> Vec<Skill> {
192 fn skill_roots() -> Vec<PathBuf> {
193 let mut roots = Vec::new();
194
195 - // Project-local skills win over user-global ones.
196 - if let Ok(cwd) = std::env::current_dir() {
197 - roots.push(cwd.join(".sigit").join("skills"));
198 - roots.push(cwd.join(".claude").join("skills"));
195 + // Project-local roots win over user-global ones. A multi-root project has
196 + // more than one of them (see `workspace.rs`), ordered primary root first.
197 + for dir in crate::workspace::project_dirs() {
198 + roots.push(dir.join(".sigit").join("skills"));
199 + roots.push(dir.join(".claude").join("skills"));
200 }
201
202 // User-global siGit config dir (honours SIGIT_CONFIG_DIR).
src/subagents.rs
+5 -4
@@ -104,10 +104,11 @@ pub fn format_agent_types_list() -> String {
104 fn agent_type_roots() -> Vec<PathBuf> {
105 let mut roots = Vec::new();
106
107 - // Project-local types win over user-global ones.
108 - if let Ok(cwd) = std::env::current_dir() {
109 - roots.push(cwd.join(".sigit").join("agents"));
110 - roots.push(cwd.join(".claude").join("agents"));
107 + // Project-local roots win over user-global ones. A multi-root project has
108 + // more than one of them (see `workspace.rs`), ordered primary root first.
109 + for dir in crate::workspace::project_dirs() {
110 + roots.push(dir.join(".sigit").join("agents"));
111 + roots.push(dir.join(".claude").join("agents"));
112 }
113
114 // User-global siGit config dir (honours SIGIT_CONFIG_DIR).
src/workspace.rs new
+112
@@ -0,0 +1,112 @@
1 +//! The directories the current session treats as project roots.
2 +//!
3 +//! An editor can open more than one directory in a single project — Zed calls
4 +//! it a multi-root project — and ACP carries the extra ones as
5 +//! `additional_directories` on every session request. The process still has a
6 +//! single working directory (the primary `cwd` it `set_current_dir`s to), so
7 +//! the extra roots live here, in a process-global that project-local discovery
8 +//! reads alongside `std::env::current_dir()`: skills (`skills.rs`), slash
9 +//! commands (`commands.rs`), subagent types (`subagents.rs`) and instruction
10 +//! files (`instructions.rs`).
11 +//!
12 +//! MCP servers are deliberately not on that list. Discovery runs once at
13 +//! startup (`mcp::init`), before any session exists, so a second root's
14 +//! `.sigit/mcp.toml` has nobody to tell.
15 +
16 +use std::path::{Path, PathBuf};
17 +use std::sync::RwLock;
18 +
19 +/// The session's roots beyond `cwd`. Empty for an ordinary single-root project.
20 +static ADDITIONAL_ROOTS: RwLock<Vec<PathBuf>> = RwLock::new(Vec::new());
21 +
22 +/// Record the session's extra roots, returning the ones that were kept.
23 +///
24 +/// Entries that aren't directories, repeat, or just name `cwd` again are
25 +/// dropped — an editor is free to send any of those, and every consumer here
26 +/// would either scan nothing or do the same work twice.
27 +pub fn set_additional_roots(cwd: &Path, roots: &[PathBuf]) -> Vec<PathBuf> {
28 + let mut seen: Vec<PathBuf> = vec![canonical(cwd)];
29 + let mut kept: Vec<PathBuf> = Vec::new();
30 +
31 + for root in roots {
32 + if !root.is_dir() {
33 + log::warn!(
34 + "ignoring workspace root {} (not a directory)",
35 + root.display()
36 + );
37 + continue;
38 + }
39 + let key = canonical(root);
40 + if seen.contains(&key) {
41 + continue;
42 + }
43 + seen.push(key);
44 + kept.push(root.clone());
45 + }
46 +
47 + if let Ok(mut guard) = ADDITIONAL_ROOTS.write() {
48 + *guard = kept.clone();
49 + }
50 + kept
51 +}
52 +
53 +/// The extra roots recorded for the session.
54 +pub fn additional_roots() -> Vec<PathBuf> {
55 + ADDITIONAL_ROOTS
56 + .read()
57 + .map(|guard| guard.clone())
58 + .unwrap_or_default()
59 +}
60 +
61 +/// Every directory the session treats as a project root: the process working
62 +/// directory first, then the extra roots. Project-local discovery scans these
63 +/// in order, so the primary root keeps winning name collisions.
64 +pub fn project_dirs() -> Vec<PathBuf> {
65 + let mut dirs = Vec::new();
66 + if let Ok(cwd) = std::env::current_dir() {
67 + dirs.push(cwd);
68 + }
69 + dirs.extend(additional_roots());
70 + dirs
71 +}
72 +
73 +fn canonical(path: &Path) -> PathBuf {
74 + path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
75 +}
76 +
77 +#[cfg(test)]
78 +mod tests {
79 + use super::*;
80 +
81 + /// One process-global under test, so the cases share a lock and a cleanup.
82 + fn with_roots<T>(cwd: &Path, roots: &[PathBuf], test: impl FnOnce(Vec<PathBuf>) -> T) -> T {
83 + static GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
84 + let _lock = GUARD.lock().unwrap_or_else(|poison| poison.into_inner());
85 + let kept = set_additional_roots(cwd, roots);
86 + let out = test(kept);
87 + set_additional_roots(cwd, &[]);
88 + out
89 + }
90 +
91 + #[test]
92 + fn keeps_real_directories_and_drops_the_rest() {
93 + let temp = std::env::temp_dir().join(format!("sigit-ws-{}", uuid::Uuid::new_v4()));
94 + let cwd = temp.join("primary");
95 + let extra = temp.join("extra");
96 + std::fs::create_dir_all(&cwd).unwrap();
97 + std::fs::create_dir_all(&extra).unwrap();
98 + let missing = temp.join("gone");
99 +
100 + with_roots(
101 + &cwd,
102 + &[extra.clone(), extra.clone(), cwd.clone(), missing],
103 + |kept| {
104 + assert_eq!(kept, vec![extra.clone()]);
105 + assert_eq!(additional_roots(), vec![extra.clone()]);
106 + },
107 + );
108 +
109 + assert!(additional_roots().is_empty());
110 + std::fs::remove_dir_all(&temp).ok();
111 + }
112 +}
tests/acp_multi_root.rs new
+245
@@ -0,0 +1,245 @@
1 +//! End-to-end check that a multi-root project reaches the model.
2 +//!
3 +//! Zed (and any other ACP client that can open several directories at once)
4 +//! sends the extra ones as `additionalDirectories` on `session/new`. This
5 +//! spawns the real binary in ACP mode against a scripted OpenAI-compatible
6 +//! endpoint, opens a session with two roots, and inspects what the endpoint was
7 +//! actually sent: the tool list must include a skill that only exists in the
8 +//! *second* root, which is only true if that root was recorded and
9 +//! project-local discovery scanned it.
10 +//!
11 +//! The system context that names the roots is not asserted here. This test
12 +//! drives the agent through the `OPENAI_BASE_URL` override, and that backend is
13 +//! built once at startup — the per-session context message goes into the local
14 +//! engine's history, which the override never reads. `session_context_message`
15 +//! and the instruction loader are covered by unit tests instead.
16 +
17 +use std::collections::VecDeque;
18 +use std::io::{BufRead, BufReader, Read, Write};
19 +use std::net::TcpListener;
20 +use std::process::{Child, ChildStdin, Command, Stdio};
21 +use std::sync::mpsc::{Receiver, channel};
22 +use std::sync::{Arc, Mutex};
23 +use std::time::{Duration, Instant};
24 +
25 +use serde_json::{Value, json};
26 +
27 +const TIMEOUT: Duration = Duration::from_secs(60);
28 +
29 +/// Serves one scripted SSE response per request and records each request body.
30 +struct FakeEndpoint {
31 + port: u16,
32 + requests: Arc<Mutex<Vec<Value>>>,
33 +}
34 +
35 +fn start_fake_endpoint(responses: Vec<String>) -> FakeEndpoint {
36 + let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
37 + let port = listener.local_addr().unwrap().port();
38 + let requests: Arc<Mutex<Vec<Value>>> = Arc::default();
39 + let recorded = Arc::clone(&requests);
40 + let queue = Mutex::new(VecDeque::from(responses));
41 +
42 + std::thread::spawn(move || {
43 + for stream in listener.incoming() {
44 + let Ok(mut stream) = stream else { continue };
45 + let mut reader = BufReader::new(match stream.try_clone() {
46 + Ok(clone) => clone,
47 + Err(_) => continue,
48 + });
49 + let mut content_length = 0usize;
50 + loop {
51 + let mut line = String::new();
52 + if reader.read_line(&mut line).unwrap_or(0) == 0 {
53 + break;
54 + }
55 + let line = line.trim();
56 + if line.is_empty() {
57 + break;
58 + }
59 + if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
60 + content_length = length.trim().parse().unwrap_or(0);
61 + }
62 + }
63 + let mut body = vec![0u8; content_length];
64 + if reader.read_exact(&mut body).is_err() {
65 + continue;
66 + }
67 + if let Ok(request) = serde_json::from_slice::<Value>(&body) {
68 + recorded.lock().unwrap().push(request);
69 + }
70 + let payload = queue
71 + .lock()
72 + .unwrap()
73 + .pop_front()
74 + .unwrap_or_else(|| "data: [DONE]\n\n".to_string());
75 + let response = format!(
76 + "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
77 + content-length: {}\r\nconnection: close\r\n\r\n{}",
78 + payload.len(),
79 + payload
80 + );
81 + let _ = stream.write_all(response.as_bytes());
82 + }
83 + });
84 +
85 + FakeEndpoint { port, requests }
86 +}
87 +
88 +fn sse_text(text: &str) -> String {
89 + let event = json!({"choices": [{"delta": {"content": text}}]});
90 + format!("data: {event}\n\ndata: [DONE]\n\n")
91 +}
92 +
93 +struct AgentUnderTest {
94 + child: Child,
95 + stdin: ChildStdin,
96 + incoming: Receiver<Value>,
97 + next_id: u64,
98 +}
99 +
100 +fn spawn_agent(port: u16, config_dir: &std::path::Path) -> AgentUnderTest {
101 + let mut child = Command::new(env!("CARGO_BIN_EXE_sigit"))
102 + .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
103 + .env("OPENAI_API_KEY", "test-key")
104 + .env("SIGIT_MODEL", "scripted-model")
105 + .env("SIGIT_CONFIG_DIR", config_dir)
106 + .env("SIGIT_MCP", "off")
107 + .env("SIGIT_PERMISSIONS", "allow")
108 + .env_remove("SIGIT_LOCAL_INFERENCE")
109 + .stdin(Stdio::piped())
110 + .stdout(Stdio::piped())
111 + .stderr(Stdio::null())
112 + .spawn()
113 + .expect("spawn sigit in ACP mode");
114 +
115 + let stdout = child.stdout.take().unwrap();
116 + let (message_tx, incoming) = channel();
117 + std::thread::spawn(move || {
118 + for line in BufReader::new(stdout).lines() {
119 + let Ok(line) = line else { break };
120 + if let Ok(message) = serde_json::from_str::<Value>(&line)
121 + && message_tx.send(message).is_err()
122 + {
123 + break;
124 + }
125 + }
126 + });
127 +
128 + let stdin = child.stdin.take().unwrap();
129 + AgentUnderTest {
130 + child,
131 + stdin,
132 + incoming,
133 + next_id: 0,
134 + }
135 +}
136 +
137 +impl AgentUnderTest {
138 + fn request(&mut self, method: &str, params: Value) -> u64 {
139 + self.next_id += 1;
140 + let id = self.next_id;
141 + let mut line =
142 + json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
143 + line.push('\n');
144 + self.stdin
145 + .write_all(line.as_bytes())
146 + .expect("write to agent stdin");
147 + self.stdin.flush().expect("flush agent stdin");
148 + id
149 + }
150 +
151 + fn wait_for_response(&mut self, id: u64) -> Value {
152 + let deadline = Instant::now() + TIMEOUT;
153 + loop {
154 + let remaining = deadline.saturating_duration_since(Instant::now());
155 + let Ok(message) = self.incoming.recv_timeout(remaining) else {
156 + panic!("timed out waiting for the response to request {id}");
157 + };
158 + if message["id"] == id && message.get("method").is_none() {
159 + assert!(
160 + message.get("error").is_none(),
161 + "request {id} failed: {message}"
162 + );
163 + return message;
164 + }
165 + }
166 + }
167 +}
168 +
169 +impl Drop for AgentUnderTest {
170 + fn drop(&mut self) {
171 + let _ = self.child.kill();
172 + let _ = self.child.wait();
173 + }
174 +}
175 +
176 +#[test]
177 +fn a_skill_from_a_second_root_is_offered_to_the_model() {
178 + let endpoint = start_fake_endpoint(vec![sse_text("looked at both roots")]);
179 +
180 + let scratch = std::env::temp_dir().join(format!("sigit_acp_roots_{}", std::process::id()));
181 + let config_dir = scratch.join("config");
182 + let primary = scratch.join("primary");
183 + let secondary = scratch.join("secondary");
184 + std::fs::create_dir_all(&config_dir).unwrap();
185 + std::fs::create_dir_all(&primary).unwrap();
186 + std::fs::create_dir_all(&secondary).unwrap();
187 + // A skill that exists only in the second root.
188 + let skill_dir = secondary.join(".sigit").join("skills").join("second-root");
189 + std::fs::create_dir_all(&skill_dir).unwrap();
190 + std::fs::write(
191 + skill_dir.join("SKILL.md"),
192 + "---\nname: second-root\ndescription: Only reachable through the second root\n---\n\nDo the thing.\n",
193 + )
194 + .unwrap();
195 +
196 + let mut agent = spawn_agent(endpoint.port, &config_dir);
197 +
198 + let id = agent.request(
199 + "initialize",
200 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
201 + );
202 + agent.wait_for_response(id);
203 +
204 + let id = agent.request(
205 + "session/new",
206 + json!({
207 + "cwd": primary,
208 + "mcpServers": [],
209 + "additionalDirectories": [secondary],
210 + }),
211 + );
212 + let session_id = agent.wait_for_response(id)["result"]["sessionId"]
213 + .as_str()
214 + .expect("session id")
215 + .to_string();
216 +
217 + let id = agent.request(
218 + "session/prompt",
219 + json!({
220 + "sessionId": session_id,
221 + "prompt": [{"type": "text", "text": "where are we?"}],
222 + }),
223 + );
224 + agent.wait_for_response(id);
225 +
226 + let requests = endpoint.requests.lock().unwrap();
227 + let request = requests.first().expect("one completion request");
228 +
229 + let skill_tool = request["tools"]
230 + .as_array()
231 + .expect("tools")
232 + .iter()
233 + .find(|tool| tool["function"]["name"] == "skill")
234 + .expect("the skill tool is offered when a root has skills");
235 + assert!(
236 + skill_tool["function"]["description"]
237 + .as_str()
238 + .unwrap_or_default()
239 + .contains("second-root"),
240 + "a skill from the second root must be discoverable, got: {skill_tool}"
241 + );
242 +
243 + drop(requests);
244 + let _ = std::fs::remove_dir_all(&scratch);
245 +}