@sigitcodereview-old / sigit / commits / af3b786
Fork 0

A modular local inference LLM coding agent. Core component of siGit Factory.

Keep each ACP session's roots and conversation separate

Zed runs one sigit process for every open thread, but the process had a single cwd, workspace root list, backend conversation, and background task table. The most recent session owned all of them, so a prompt from an older thread ran against another repo with another thread's history. main.rs now keeps a SessionState per session id. Before a prompt or config change runs, activate_session parks the live conversation and installs the requested session's cwd, extra roots, and conversation, rebuilding the system prompt from its roots. new/load/fork go through open_session, and fork now carries the source thread's conversation. Background tasks record the session that started them, and command_output/kill_command only see that session's tasks. Opening a session no longer wipes every other session's permission grants, and /clear clears the backend that actually holds the thread. Fixes #106 Co-Authored-By: siGit Code <noreply@sigit.si>

Seto Elkahfi committed Sep 16, 2026 at 22:46 UTC af3b786b927c17047d13a3e4922418095938a914
6 files changed +503 -162
.agents/AGENTS.md
+7 -1
@@ -243,7 +243,13 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
243 `handle_initialize` is what makes the rest of this reachable — without it Zed keeps the first
244 root, drops the others, and shows "This agent doesn't currently support multi-root workspaces".
245 The process still has one working directory, so the extra roots live in a
246 - process-global here and `project_dirs()` returns cwd-first, extras after. Project-local
246 + process-global here and `project_dirs()` returns cwd-first, extras after.
247 + One process also serves every thread the editor has open, so that global
248 + (with the cwd, the backend conversation, and the background-task owner in
249 + `tools.rs`) always belongs to the *live* session: `main.rs` keeps a
250 + `SessionState` per session id and `activate_session` parks the live one and
251 + installs the requested one before any prompt or config change runs. Don't
252 + read another session's roots from the global. Project-local
253 discovery reads it: skills, slash commands, subagent types, and instruction files all scan
254 every root. MCP is deliberately not on that list — `mcp::init` runs once at startup, before
255 any session exists, so a second root's `.sigit/mcp.toml` has nobody to tell.
src/backend.rs
-37
@@ -1200,21 +1200,6 @@ pub fn carryover_history(snapshot: Vec<serde_json::Value>) -> Vec<serde_json::Va
1200 carried
1201 }
1202
1203 -/// Drop everything from `backend`'s history except the system messages it
1204 -/// seeded for itself. Used at a session boundary: a remote backend keeps its own
1205 -/// history, so clearing the on-device engine alone leaves the previous thread
1206 -/// (compaction summary included) live there, and the next model switch or
1207 -/// startup routing carries it into the new session.
1208 -pub async fn clear_conversation(backend: &dyn InferenceBackend) {
1209 - let seeded: Vec<serde_json::Value> = backend
1210 - .history_snapshot()
1211 - .await
1212 - .into_iter()
1213 - .take_while(|message| message["role"] == "system")
1214 - .collect();
1215 - backend.restore_history(seeded).await;
1216 -}
1217 -
1203 /// Replay `carried` (from [`carryover_history`]) into `backend`, on top of the
1204 /// system messages `backend` seeded for itself. Used when a model switch
1205 /// installs a new backend — or reloads the on-device engine, which wipes its
@@ -1493,28 +1478,6 @@ mod tests {
1478 assert_eq!(history[0]["content"], "new prompt");
1479 }
1480
1496 - #[tokio::test]
1497 - async fn clear_conversation_keeps_only_the_seeded_system_prompt() {
1498 - let backend = OpenAiBackend::new("http://localhost", "", "m", Some("prompt".into()));
1499 - adopt_carryover(
1500 - &backend,
1501 - vec![
1502 - serde_json::json!({ "role": "user", "content": "[Conversation summary]\nold" }),
1503 - serde_json::json!({ "role": "assistant", "content": "ok" }),
1504 - ],
1505 - )
1506 - .await;
1507 -
1508 - clear_conversation(&backend).await;
1509 -
1510 - let history = backend.history_snapshot().await;
1511 - assert_eq!(history.len(), 1);
1512 - assert_eq!(history[0]["role"], "system");
1513 - assert_eq!(history[0]["content"], "prompt");
1514 - // Nothing left for a later backend switch to carry over.
1515 - assert!(carryover_history(history).is_empty());
1516 - }
1517 -
1481 #[test]
1482 fn estimate_tokens_scales_with_serialized_size() {
1483 assert_eq!(estimate_tokens(&[]), 0);
src/main.rs
+371 -66
@@ -593,6 +593,13 @@ const CLOUD_LOGIN_PROMPT: &str = "siGit Code Cloud needs an account. Sign in wit
593 /// `additional_roots` is empty for an ordinary project and non-empty for a
594 /// multi-root one, where the editor opened several directories at once (see
595 /// `workspace.rs`).
596 +/// The single system message a remote backend gets: the full agent prompt
597 +/// followed by the session context. One message rather than two, because
598 +/// `compact_history` keeps only the first system message.
599 +fn remote_system_prompt(session_context: &str) -> String {
600 + format!("{}\n\n{session_context}", system_prompt_for_model(true))
601 +}
602 +
603 fn session_context_message(cwd: &std::path::Path, additional_roots: &[PathBuf]) -> String {
604 let mut message = if additional_roots.is_empty() {
605 format!(
@@ -745,13 +752,38 @@ fn initialize_meta() -> Meta {
752 meta
753 }
754
755 +/// What one ACP session owns, kept per session id.
756 +///
757 +/// An editor runs a single sigit process for every thread it has open, but the
758 +/// process has one working directory, one set of workspace roots, and one
759 +/// backend conversation. Only one session can be live at a time (turns are
760 +/// serialized by `turn_lock`), so each session's roots and conversation are
761 +/// parked here while another session holds the process, and swapped back in
762 +/// by `activate_session` before its next request runs.
763 +#[derive(Clone, Debug, Default)]
764 +struct SessionState {
765 + cwd: PathBuf,
766 + additional_roots: Vec<PathBuf>,
767 + /// The conversation without its system messages (see
768 + /// `backend::carryover_history`). Those are rebuilt from the roots each
769 + /// time the session is installed, so they always name this session's
770 + /// project. Stale while the session is live: the backend holds the
771 + /// current copy then.
772 + conversation: Vec<serde_json::Value>,
773 +}
774 +
775 struct SiGitAgent {
776 engine: Arc<ChatEngine>,
777 /// The active inference backend. `LocalBackend` by default; swapped to an
778 /// `OpenAiBackend` when the user selects a siGit Code Cloud tier in the panel.
779 backend: tokio::sync::Mutex<Arc<dyn InferenceBackend>>,
753 - /// cwd from the editor — tool calls run here, not where the process started
780 + /// cwd from the editor — tool calls run here, not where the process started.
781 + /// Always the live session's `cwd`.
782 session_cwd: std::sync::Mutex<Option<PathBuf>>,
783 + /// Every session this process has opened, by id.
784 + sessions: std::sync::Mutex<std::collections::HashMap<String, SessionState>>,
785 + /// The session whose roots and conversation are installed right now.
786 + active_session: std::sync::Mutex<Option<String>>,
787 current_model: std::sync::Mutex<GgufModelConfig>,
788 /// flipped once the startup model finishes (success or failure)
789 model_ready: Arc<AtomicBool>,
@@ -793,6 +825,8 @@ impl SiGitAgent {
825 engine,
826 backend: tokio::sync::Mutex::new(backend),
827 session_cwd: std::sync::Mutex::new(None),
828 + sessions: std::sync::Mutex::new(std::collections::HashMap::new()),
829 + active_session: std::sync::Mutex::new(None),
830 current_model: std::sync::Mutex::new(initial_model),
831 model_ready,
832 startup_model_load_started,
@@ -1421,48 +1455,169 @@ impl SiGitAgent {
1455 }
1456 }
1457
1424 - /// Record the session's roots and move the process into the primary one.
1458 + /// Build the state for a session request: its primary `cwd` plus the extra
1459 + /// roots worth keeping (see `workspace::set_additional_roots`).
1460 + fn session_state(cwd: &std::path::Path, additional: &[PathBuf]) -> SessionState {
1461 + SessionState {
1462 + cwd: cwd.to_path_buf(),
1463 + additional_roots: workspace::filter_additional_roots(cwd, additional),
1464 + conversation: Vec::new(),
1465 + }
1466 + }
1467 +
1468 + /// Move the live conversation into the state of the session that owns it,
1469 + /// so installing another session can't lose or leak it.
1470 + async fn park_active_session(&self) {
1471 + let Some(active) = self.active_session.lock().unwrap().clone() else {
1472 + return;
1473 + };
1474 + let snapshot = self.backend.lock().await.history_snapshot().await;
1475 + if let Some(state) = self.sessions.lock().unwrap().get_mut(&active) {
1476 + state.conversation = backend::carryover_history(snapshot);
1477 + }
1478 + }
1479 +
1480 + /// Register `state` under `session_id` and make it the live session.
1481 ///
1426 - /// The editor sends `cwd` plus, for a multi-root project, the other
1427 - /// directories it has open. Tool calls may use relative paths, so the
1428 - /// process follows `cwd`; the extra roots go to `workspace`, which is where
1429 - /// project-local discovery picks them up. Returns the roots that were kept.
1430 - fn enter_session_roots(&self, cwd: &std::path::Path, additional: &[PathBuf]) -> Vec<PathBuf> {
1482 + /// Used by the session entry points (new/load/fork). The outgoing session
1483 + /// is parked first, and the Local Inference routing runs last, so a cloud
1484 + /// backend installed here starts from this session's conversation and
1485 + /// system prompt and nobody else's.
1486 + async fn open_session(&self, session_id: &SessionId, state: SessionState) {
1487 + let key = session_id.to_string();
1488 + self.park_active_session().await;
1489 + self.sessions
1490 + .lock()
1491 + .unwrap()
1492 + .insert(key.clone(), state.clone());
1493 + self.install_session(&key, state).await;
1494 +
1495 + // Honor the persisted Local Inference toggle (off + signed in → cloud).
1496 + self.apply_startup_inference_mode().await;
1497 + }
1498 +
1499 + /// Make `session_id` the live session before one of its requests runs.
1500 + ///
1501 + /// A no-op when it already is. Otherwise the live session is parked and
1502 + /// this one's roots and conversation are installed in its place. An id this
1503 + /// process has never seen (the editor kept a thread open across a sigit
1504 + /// restart) is rebuilt from the session store when it can be.
1505 + async fn activate_session(&self, session_id: &SessionId) {
1506 + let key = session_id.to_string();
1507 + if self.active_session.lock().unwrap().as_deref() == Some(key.as_str()) {
1508 + return;
1509 + }
1510 +
1511 + let known = self.sessions.lock().unwrap().get(&key).cloned();
1512 + let state = match known {
1513 + Some(state) => state,
1514 + None => {
1515 + let state = self.recover_session(&key);
1516 + self.sessions
1517 + .lock()
1518 + .unwrap()
1519 + .insert(key.clone(), state.clone());
1520 + state
1521 + }
1522 + };
1523 +
1524 + log::info!(
1525 + "session({key}): activating (cwd={}, {} extra root(s), {} message(s))",
1526 + state.cwd.display(),
1527 + state.additional_roots.len(),
1528 + state.conversation.len()
1529 + );
1530 + self.park_active_session().await;
1531 + self.install_session(&key, state).await;
1532 + }
1533 +
1534 + /// State for a session id this process didn't open: the saved conversation
1535 + /// and roots when the store has them, otherwise an empty conversation in the
1536 + /// current directory. Never the live session's conversation.
1537 + fn recover_session(&self, key: &str) -> SessionState {
1538 + let entry = session_store::list()
1539 + .into_iter()
1540 + .find(|entry| entry.id == key);
1541 + let cwd = entry
1542 + .as_ref()
1543 + .and_then(|entry| entry.cwd.clone())
1544 + .or_else(|| self.session_cwd.lock().ok().and_then(|guard| guard.clone()))
1545 + .or_else(|| std::env::current_dir().ok())
1546 + .unwrap_or_else(|| PathBuf::from("."));
1547 + let additional = entry
1548 + .map(|entry| entry.additional_directories)
1549 + .unwrap_or_default();
1550 + let mut state = Self::session_state(&cwd, &additional);
1551 + state.conversation = session_store::load(key)
1552 + .map(backend::carryover_history)
1553 + .unwrap_or_default();
1554 + log::warn!(
1555 + "session({key}) was not opened by this process; recovered {} message(s) in {}",
1556 + state.conversation.len(),
1557 + cwd.display()
1558 + );
1559 + state
1560 + }
1561 +
1562 + /// Point the process at `state`'s roots and load its conversation.
1563 + ///
1564 + /// The process follows `cwd` because tool calls may use relative paths; the
1565 + /// extra roots go to `workspace`, where project-local discovery reads them.
1566 + /// Background tasks are scoped to the live session too (see
1567 + /// `tools::set_active_session`).
1568 + async fn install_session(&self, key: &str, state: SessionState) {
1569 + let SessionState {
1570 + cwd,
1571 + additional_roots,
1572 + conversation,
1573 + } = state;
1574 +
1575 if let Ok(mut guard) = self.session_cwd.lock() {
1432 - *guard = Some(cwd.to_path_buf());
1576 + *guard = Some(cwd.clone());
1577 }
1434 - let additional_roots = workspace::set_additional_roots(cwd, additional);
1578 + workspace::replace_additional_roots(additional_roots.clone());
1579 if cwd.is_dir()
1436 - && let Err(err) = std::env::set_current_dir(cwd)
1580 + && let Err(err) = std::env::set_current_dir(&cwd)
1581 {
1582 log::warn!("could not set cwd to {}: {err}", cwd.display());
1583 }
1440 - additional_roots
1441 - }
1584 + *self.active_session.lock().unwrap() = Some(key.to_string());
1585 + tools::set_active_session(Some(key));
1586
1443 - /// Start a clean conversation for a session entry point (new/load/fork):
1444 - /// wipe the previous thread from the engine *and* the active backend, seed
1445 - /// the session context, then route inference per the Local Inference mode.
1446 - ///
1447 - /// The backend has to be cleared too. A cloud tier or provider override
1448 - /// keeps its own history, and `switch_to_cloud_tier` carries whatever it
1449 - /// finds there into the backend it installs — so a new thread would open
1450 - /// holding the last one's conversation.
1451 - async fn begin_conversation(&self, cwd: &std::path::Path, additional_roots: &[PathBuf]) {
1452 - self.engine.clear_history().await;
1453 - self.engine
1454 - .push_history(onde::inference::ChatMessage::system(
1455 - session_context_message(cwd, additional_roots),
1456 - ))
1587 + self.seed_conversation(&cwd, &additional_roots, conversation)
1588 .await;
1589 + }
1590
1591 + /// Replace the conversation on the engine and the active backend with
1592 + /// `conversation`, under system context naming `cwd` and `additional_roots`.
1593 + ///
1594 + /// Both are seeded even when only one serves inference: switching between
1595 + /// on-device and cloud carries the conversation across and keeps the target's
1596 + /// own system messages, so each has to name the live session's project.
1597 + async fn seed_conversation(
1598 + &self,
1599 + cwd: &std::path::Path,
1600 + additional_roots: &[PathBuf],
1601 + conversation: Vec<serde_json::Value>,
1602 + ) {
1603 + let context = session_context_message(cwd, additional_roots);
1604 let backend = self.backend.lock().await.clone();
1605 +
1606 if backend.is_remote() {
1461 - backend::clear_conversation(backend.as_ref()).await;
1607 + self.engine.clear_history().await;
1608 + self.engine
1609 + .push_history(onde::inference::ChatMessage::system(context.clone()))
1610 + .await;
1611 }
1612
1464 - // Honor the persisted Local Inference toggle (off + signed in → cloud).
1465 - self.apply_startup_inference_mode().await;
1613 + let system_prompt = if backend.is_remote() {
1614 + remote_system_prompt(&context)
1615 + } else {
1616 + context
1617 + };
1618 + let mut history = vec![serde_json::json!({ "role": "system", "content": system_prompt })];
1619 + history.extend(conversation);
1620 + backend.restore_history(history).await;
1621 }
1622
1623 /// Save a session's history plus the sidecar that says where it ran.
@@ -1479,8 +1634,9 @@ impl SiGitAgent {
1634 log::warn!("session({session_id}) save failed: {error}");
1635 return;
1636 }
1482 - match self.session_cwd.lock().ok().and_then(|guard| guard.clone()) {
1483 - Some(cwd) => session_store::save_meta(&key, &cwd, &workspace::additional_roots()),
1637 + let state = self.sessions.lock().unwrap().get(&key).cloned();
1638 + match state {
1639 + Some(state) => session_store::save_meta(&key, &state.cwd, &state.additional_roots),
1640 None => log::warn!(
1641 "session({session_id}) has no recorded cwd — saved, but it won't be listed"
1642 ),
@@ -1544,26 +1700,21 @@ impl SiGitAgent {
1700 .collect::<Vec<_>>()
1701 );
1702
1547 - let additional_roots = self.enter_session_roots(&args.cwd, &args.additional_directories);
1703 + let mut state = Self::session_state(&args.cwd, &args.additional_directories);
1704
1705 // A session boundary: grants and plan mode from the previous life of
1550 - // this session id must not carry over — and since one shared engine
1551 - // means one live conversation, state for every other id is dead too.
1552 - permissions::reset_all();
1553 -
1554 - // start from a clean slate; a stored session (below) replaces it
1555 - self.begin_conversation(&args.cwd, &additional_roots).await;
1706 + // this session id must not carry over. Other sessions keep theirs; the
1707 + // editor may still have them open.
1708 + permissions::reset_session(&args.session_id.to_string());
1709
1710 // Durable sessions: when this session id was saved before, restore its
1558 - // history into the active backend. The snapshot includes the system
1559 - // messages that were live when it was saved, so restore replaces the
1560 - // freshly seeded state wholesale.
1711 + // conversation. The saved system messages are dropped; they are rebuilt
1712 + // from the roots the editor sent now, which may differ from last time.
1713 if let Some(history) = session_store::load(&args.session_id.to_string()) {
1714 let restored = history.len();
1715
1564 - // Replay before restoring: the client draws the reopened thread
1565 - // from these notifications alone, and `restore_history` consumes
1566 - // the snapshot.
1716 + // The client draws the reopened thread from these notifications
1717 + // alone.
1718 let updates = history_replay_updates(&history);
1719 let replayed = updates.len();
1720 for update in updates {
@@ -1571,14 +1722,15 @@ impl SiGitAgent {
1722 .ok();
1723 }
1724
1574 - let backend = self.backend.lock().await.clone();
1575 - backend.restore_history(history).await;
1725 + state.conversation = backend::carryover_history(history);
1726 log::info!(
1727 "load_session: restored {restored} message(s), replayed {replayed} update(s) for {}",
1728 args.session_id
1729 );
1730 }
1731
1732 + self.open_session(&args.session_id, state).await;
1733 +
1734 let config_options = {
1735 let guard = self.current_model.lock().unwrap();
1736 build_config_options(&guard, &args.session_id.to_string())
@@ -1601,9 +1753,6 @@ impl SiGitAgent {
1753 args: ForkSessionRequest,
1754 ) -> agent_client_protocol::Result<ForkSessionResponse> {
1755 let new_id = SessionId::new(uuid::Uuid::new_v4().to_string());
1604 - // Session boundary: permission grants and plan mode never cross it
1605 - // (see handle_load_session), so a fork starts with a clean slate.
1606 - permissions::reset_all();
1756 log::info!(
1757 "fork_session: from={} new={new_id}, cwd={}, additional_directories={:?}",
1758 args.session_id,
@@ -1614,10 +1763,24 @@ impl SiGitAgent {
1763 .collect::<Vec<_>>()
1764 );
1765
1617 - let additional_roots = self.enter_session_roots(&args.cwd, &args.additional_directories);
1618 -
1619 - // no persistence, so fork == fresh session
1620 - self.begin_conversation(&args.cwd, &additional_roots).await;
1766 + // The fork continues the source thread's conversation under the roots
1767 + // it was forked into. Permission grants and plan mode stay behind: the
1768 + // new id has none. The source's conversation is current only after
1769 + // parking when the source is the live session.
1770 + self.park_active_session().await;
1771 + let source = self
1772 + .sessions
1773 + .lock()
1774 + .unwrap()
1775 + .get(&args.session_id.to_string())
1776 + .map(|source| source.conversation.clone())
1777 + .or_else(|| {
1778 + session_store::load(&args.session_id.to_string()).map(backend::carryover_history)
1779 + })
1780 + .unwrap_or_default();
1781 + let mut state = Self::session_state(&args.cwd, &args.additional_directories);
1782 + state.conversation = source;
1783 + self.open_session(&new_id, state).await;
1784
1785 let config_options = {
1786 let guard = self.current_model.lock().unwrap();
@@ -1641,9 +1804,6 @@ impl SiGitAgent {
1804 args: NewSessionRequest,
1805 ) -> agent_client_protocol::Result<NewSessionResponse> {
1806 let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());
1644 - // Session boundary: permission grants and plan mode never cross it
1645 - // (see handle_load_session), so stale ids stop accumulating state.
1646 - permissions::reset_all();
1807 log::info!(
1808 "new_session: id={session_id}, cwd={}, additional_directories={:?}",
1809 args.cwd.display(),
@@ -1653,9 +1813,8 @@ impl SiGitAgent {
1813 .collect::<Vec<_>>()
1814 );
1815
1656 - let additional_roots = self.enter_session_roots(&args.cwd, &args.additional_directories);
1657 -
1658 - self.begin_conversation(&args.cwd, &additional_roots).await;
1816 + let state = Self::session_state(&args.cwd, &args.additional_directories);
1817 + self.open_session(&session_id, state).await;
1818
1819 let config_options = {
1820 let guard = self.current_model.lock().unwrap();
@@ -1680,6 +1839,10 @@ impl SiGitAgent {
1839 ) -> agent_client_protocol::Result<PromptResponse> {
1840 let session_id = args.session_id.clone();
1841
1842 + // Everything below (slash commands included) acts on the live session,
1843 + // so it has to be this one. Resource links are read relative to its cwd.
1844 + self.activate_session(&session_id).await;
1845 +
1846 // log every block so we can debug @ references and file context
1847 for (i, block) in args.prompt.iter().enumerate() {
1848 match block {
@@ -2283,16 +2446,15 @@ impl SiGitAgent {
2446 /// for login). Shared by the panel picker and the `/models` slash command.
2447 async fn switch_to_cloud_tier(&self, tier: &str) -> Option<String> {
2448 let cfg = crate::provider::cloud_tier_provider(tier)?;
2286 - let mut system_prompt = system_prompt_for_model(true).to_string();
2449 // Mirror the cwd guidance and project instruction files the local engine
2450 // gets at session load, so the cloud model shares the same project context.
2289 - if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) {
2290 - system_prompt.push_str("\n\n");
2291 - system_prompt.push_str(&session_context_message(
2451 + let system_prompt = match self.session_cwd.lock().ok().and_then(|g| g.clone()) {
2452 + Some(cwd) => remote_system_prompt(&session_context_message(
2453 &cwd,
2454 &workspace::additional_roots(),
2294 - ));
2295 - }
2455 + )),
2456 + None => system_prompt_for_model(true).to_string(),
2457 + };
2458 let cloud_backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
2459 cfg.base_url,
2460 cfg.api_key,
@@ -2451,6 +2613,10 @@ impl SiGitAgent {
2613 args.value
2614 );
2615
2616 + // A model switch carries the live conversation onto the new backend, so
2617 + // the live conversation has to be the one whose picker changed.
2618 + self.activate_session(&args.session_id).await;
2619 +
2620 // ── Local Inference toggle ──────────────────────────────────────────
2621 if args.config_id.0.as_ref() == LOCAL_INFERENCE_CONFIG_ID {
2622 let enabled = match args.value.as_value_id().map(|v| v.0.as_ref()) {
@@ -3247,7 +3413,24 @@ async fn exec_slash_acp(
3413 .ok();
3414 }
3415 SlashCommand::Clear => {
3250 - let cleared = agent.engine.clear_history().await;
3416 + // Wipe the conversation on whichever backend holds it, keeping the
3417 + // session context. Clearing only the engine left a cloud thread intact.
3418 + let snapshot = agent.backend.lock().await.history_snapshot().await;
3419 + let cleared = snapshot
3420 + .iter()
3421 + .filter(|message| message["role"] == "user")
3422 + .count();
3423 + let (cwd, roots) = {
3424 + let sessions = agent.sessions.lock().unwrap();
3425 + match sessions.get(&session_id.to_string()) {
3426 + Some(state) => (state.cwd.clone(), state.additional_roots.clone()),
3427 + None => (
3428 + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
3429 + workspace::additional_roots(),
3430 + ),
3431 + }
3432 + };
3433 + agent.seed_conversation(&cwd, &roots, Vec::new()).await;
3434 permissions::reset_session(&session_id.to_string());
3435 // The saved session must not resurrect what the user just wiped.
3436 session_store::delete(&session_id.to_string());
@@ -4289,6 +4472,128 @@ mod tests {
4472 assert!(!same_dir(&link, &real));
4473 }
4474
4475 + /// Text of every message in `history`, for containment checks.
4476 + fn history_text(history: &[serde_json::Value]) -> String {
4477 + history
4478 + .iter()
4479 + .filter_map(|message| message["content"].as_str())
4480 + .collect::<Vec<_>>()
4481 + .join("\n")
4482 + }
4483 +
4484 + /// Append a user message to the live conversation.
4485 + async fn say(agent: &SiGitAgent, text: &str) {
4486 + let backend = agent.backend.lock().await.clone();
4487 + let mut history = backend.history_snapshot().await;
4488 + history.push(serde_json::json!({ "role": "user", "content": text }));
4489 + backend.restore_history(history).await;
4490 + }
4491 +
4492 + async fn live(agent: &SiGitAgent) -> Vec<serde_json::Value> {
4493 + agent.backend.lock().await.history_snapshot().await
4494 + }
4495 +
4496 + #[tokio::test]
4497 + #[allow(clippy::await_holding_lock)] // the lock guards process-global cwd/env for the whole test
4498 + async fn each_session_keeps_its_own_roots_and_conversation() {
4499 + let _guard = crate::ENV_TEST_LOCK
4500 + .lock()
4501 + .unwrap_or_else(|poisoned| poisoned.into_inner());
4502 + let temp = std::env::temp_dir().join(format!("sigit-sessions-{}", uuid::Uuid::new_v4()));
4503 + let (repo_a, repo_b, extra_b) = (
4504 + temp.join("repo-a"),
4505 + temp.join("repo-b"),
4506 + temp.join("extra-b"),
4507 + );
4508 + for dir in [&repo_a, &repo_b, &extra_b] {
4509 + std::fs::create_dir_all(dir).unwrap();
4510 + }
4511 + let prev_cwd = std::env::current_dir().unwrap();
4512 + // SAFETY: serialized by ENV_TEST_LOCK and restored below.
4513 + unsafe {
4514 + std::env::set_var("SIGIT_CONFIG_DIR", temp.join("config"));
4515 + std::env::set_var("SIGIT_LOCAL_INFERENCE", "on");
4516 + }
4517 +
4518 + let agent = SiGitAgent::new(
4519 + Arc::new(ChatEngine::new()),
4520 + default_local_model_config(),
4521 + Arc::new(AtomicBool::new(true)),
4522 + Arc::new(AtomicBool::new(false)),
4523 + Arc::new(std::sync::Mutex::new(None)),
4524 + false,
4525 + false,
4526 + );
4527 + // A remote backend keeps its own history, which is where threads used
4528 + // to bleed into each other. No request is ever sent.
4529 + *agent.backend.lock().await = Arc::new(OpenAiBackend::new(
4530 + "http://127.0.0.1:9",
4531 + "",
4532 + "m",
4533 + Some("base prompt".into()),
4534 + ));
4535 + let thread_a = SessionId::new("thread-a");
4536 + let thread_b = SessionId::new("thread-b");
4537 + agent
4538 + .open_session(&thread_a, SiGitAgent::session_state(&repo_a, &[]))
4539 + .await;
4540 + say(&agent, "working on repo a").await;
4541 +
4542 + agent
4543 + .open_session(
4544 + &thread_b,
4545 + SiGitAgent::session_state(&repo_b, std::slice::from_ref(&extra_b)),
4546 + )
4547 + .await;
4548 + let history = history_text(&live(&agent).await);
4549 + assert!(!history.contains("working on repo a"), "{history}");
4550 + assert!(history.contains(&repo_b.display().to_string()), "{history}");
4551 + assert!(
4552 + history.contains(&extra_b.display().to_string()),
4553 + "{history}"
4554 + );
4555 + assert_eq!(workspace::additional_roots(), vec![extra_b.clone()]);
4556 + say(&agent, "working on repo b").await;
4557 +
4558 + // Prompting thread A again puts its roots and conversation back.
4559 + agent.activate_session(&thread_a).await;
4560 + let history = history_text(&live(&agent).await);
4561 + assert!(history.contains("working on repo a"), "{history}");
4562 + assert!(!history.contains("working on repo b"), "{history}");
4563 + assert!(
4564 + !history.contains(&repo_b.display().to_string()),
4565 + "{history}"
4566 + );
4567 + assert!(workspace::additional_roots().is_empty());
4568 + assert_eq!(
4569 + workspace::canonical_key(&std::env::current_dir().unwrap()),
4570 + workspace::canonical_key(&repo_a)
4571 + );
4572 +
4573 + agent.activate_session(&thread_b).await;
4574 + let history = history_text(&live(&agent).await);
4575 + assert!(history.contains("working on repo b"), "{history}");
4576 + assert!(!history.contains("working on repo a"), "{history}");
4577 +
4578 + // The system prompt is rebuilt, not stacked, on every switch.
4579 + let systems = live(&agent)
4580 + .await
4581 + .iter()
4582 + .filter(|message| message["role"] == "system")
4583 + .count();
4584 + assert_eq!(systems, 1);
4585 +
4586 + std::env::set_current_dir(prev_cwd).unwrap();
4587 + tools::set_active_session(None);
4588 + workspace::replace_additional_roots(Vec::new());
4589 + // SAFETY: serialized by ENV_TEST_LOCK.
4590 + unsafe {
4591 + std::env::remove_var("SIGIT_CONFIG_DIR");
4592 + std::env::remove_var("SIGIT_LOCAL_INFERENCE");
4593 + }
4594 + std::fs::remove_dir_all(&temp).ok();
4595 + }
4596 +
4597 #[test]
4598 fn session_context_message_lists_every_root_of_a_multi_root_project() {
4599 let primary = PathBuf::from("/tmp/primary");
src/permissions.rs
-12
@@ -401,18 +401,6 @@ pub fn reset_session(session: &str) {
401 map.remove(session);
402 }
403
404 -/// Drop the recorded state for *every* session. Called at ACP session
405 -/// boundaries (new/load/fork): the agent drives one shared engine, so only one
406 -/// conversation is live at a time and grants must never cross a boundary. This
407 -/// also keeps the map from accumulating entries for session ids that will
408 -/// never be used again.
409 -pub fn reset_all() {
410 - let mut map = sessions()
411 - .lock()
412 - .unwrap_or_else(|poisoned| poisoned.into_inner());
413 - map.clear();
414 -}
415 -
404 /// Status summary for `/permissions` and `/status`: the default mode, the
405 /// session mode override (if any), plan mode, the granular session grants,
406 /// and the active rule lists.
src/tools.rs
+111 -43
@@ -7,7 +7,7 @@ use std::fs;
7 use std::path::{Path, PathBuf};
8 use std::process::Command;
9 use std::sync::atomic::{AtomicU64, Ordering};
10 -use std::sync::{Arc, Mutex, OnceLock};
10 +use std::sync::{Arc, Mutex, OnceLock, RwLock};
11
12 use crate::backend::{InferenceBackend, ToolResult, ToolSpec};
13 use crate::subagents;
@@ -472,15 +472,20 @@ async fn execute_tool_impl(name: &str, arguments: &str) -> String {
472 let tool = name.to_owned();
473 let arguments = arguments.to_owned();
474 let running = tool.clone();
475 - tokio::task::spawn_blocking(move || execute_sync_tool(&running, &arguments))
476 - .await
477 - // A `JoinError` means the tool panicked (or the runtime is
478 - // shutting down). Report it as the tool's result rather than
479 - // propagating: a panicking tool should cost the model one bad
480 - // tool result, not the whole turn. Note this is strictly safer
481 - // than running the tool inline, where the panic would unwind
482 - // through the turn and take the ACP connection with it.
483 - .unwrap_or_else(|err| format!("Error: {tool} task failed: {err}"))
475 + // Read on this side of the thread hop, while the caller's session
476 + // is still the live one.
477 + let owner = active_session();
478 + tokio::task::spawn_blocking(move || {
479 + execute_sync_tool(&running, &arguments, owner.as_deref())
480 + })
481 + .await
482 + // A `JoinError` means the tool panicked (or the runtime is
483 + // shutting down). Report it as the tool's result rather than
484 + // propagating: a panicking tool should cost the model one bad
485 + // tool result, not the whole turn. Note this is strictly safer
486 + // than running the tool inline, where the panic would unwind
487 + // through the turn and take the ACP connection with it.
488 + .unwrap_or_else(|err| format!("Error: {tool} task failed: {err}"))
489 }
490 }
491 }
@@ -492,7 +497,9 @@ async fn execute_tool_impl(name: &str, arguments: &str) -> String {
497 /// async context belongs in [`execute_tool_impl`] alongside `task` and
498 /// `web_search` instead — and then owes its own answer to the question this
499 /// dispatch exists for: not blocking the caller's task for its whole run.
495 -fn execute_sync_tool(name: &str, arguments: &str) -> String {
500 +///
501 +/// `owner` is the session the call runs for; background tasks are scoped to it.
502 +fn execute_sync_tool(name: &str, arguments: &str, owner: Option<&str>) -> String {
503 match name {
504 "read_file" => exec_read_file(arguments),
505 "list_directory" => exec_list_directory(arguments),
@@ -506,9 +513,9 @@ fn execute_sync_tool(name: &str, arguments: &str) -> String {
513 "write_todos" => exec_write_todos(arguments),
514 "remember" => exec_remember(arguments),
515 "delete_file" => exec_delete_file(arguments),
509 - "run_command" => exec_run_command(arguments),
510 - "command_output" => exec_command_output(arguments),
511 - "kill_command" => exec_kill_command(arguments),
516 + "run_command" => exec_run_command(arguments, owner),
517 + "command_output" => exec_command_output(arguments, owner),
518 + "kill_command" => exec_kill_command(arguments, owner),
519 "skill" => crate::skills::activate_skill(arguments),
520 _ => format!("Unknown tool: {name}"),
521 }
@@ -2071,7 +2078,7 @@ fn ensure_commit_co_author(cwd: &Path) -> Option<String> {
2078 /// `run_in_background` is set, in which case the child is registered as a
2079 /// background task and polled with `command_output` / stopped with
2080 /// `kill_command`.
2074 -fn exec_run_command(arguments: &str) -> String {
2081 +fn exec_run_command(arguments: &str, owner: Option<&str>) -> String {
2082 let args: Value = match serde_json::from_str(arguments) {
2083 Ok(v) => v,
2084 Err(err) => return format!("Error: failed to parse arguments: {err}"),
@@ -2102,7 +2109,7 @@ fn exec_run_command(arguments: &str) -> String {
2109 log::info!("run_command: `{command_str}` in `{cwd_str}` (background: {run_in_background})");
2110
2111 if run_in_background {
2105 - return start_background_task(command_str, &cwd_path);
2112 + return start_background_task(command_str, &cwd_path, owner.map(str::to_string));
2113 }
2114
2115 // Co-author attribution: note where HEAD is before a command that looks
@@ -2228,6 +2235,9 @@ struct BackgroundTask {
2235 exit_code: Option<i32>,
2236 /// Set when the task was stopped via `kill_command`.
2237 killed: bool,
2238 + /// The session that started the task (see [`set_active_session`]). Only
2239 + /// that session can poll or kill it.
2240 + owner: Option<String>,
2241 }
2242
2243 /// Output accumulated for a background task since the last poll.
@@ -2238,6 +2248,27 @@ struct TaskOutput {
2248 dropped: bool,
2249 }
2250
2251 +/// The ACP session whose turn is running, or `None` outside ACP. One process
2252 +/// serves every thread an editor has open, and task ids are small sequential
2253 +/// numbers, so without an owner check a thread can poll or kill a task another
2254 +/// thread started just by guessing its id.
2255 +static ACTIVE_SESSION: RwLock<Option<String>> = RwLock::new(None);
2256 +
2257 +/// Record which session the next tool calls run for.
2258 +pub fn set_active_session(session: Option<&str>) {
2259 + let mut guard = ACTIVE_SESSION
2260 + .write()
2261 + .unwrap_or_else(|poisoned| poisoned.into_inner());
2262 + *guard = session.map(str::to_string);
2263 +}
2264 +
2265 +fn active_session() -> Option<String> {
2266 + ACTIVE_SESSION
2267 + .read()
2268 + .unwrap_or_else(|poisoned| poisoned.into_inner())
2269 + .clone()
2270 +}
2271 +
2272 /// Process-global background task table (same pattern as `mcp::MCP`).
2273 fn tasks() -> &'static Mutex<HashMap<u64, BackgroundTask>> {
2274 static TASKS: OnceLock<Mutex<HashMap<u64, BackgroundTask>>> = OnceLock::new();
@@ -2287,7 +2318,7 @@ fn spawn_output_reader<R: std::io::Read + Send + 'static>(
2318 }
2319
2320 /// Background branch of `run_command`: spawn, register, return immediately.
2290 -fn start_background_task(command_str: &str, cwd_path: &Path) -> String {
2321 +fn start_background_task(command_str: &str, cwd_path: &Path, owner: Option<String>) -> String {
2322 let mut child = match spawn_shell(command_str, cwd_path) {
2323 Ok(c) => c,
2324 Err(err) => return format!("Error: failed to spawn command: {err}"),
@@ -2310,6 +2341,7 @@ fn start_background_task(command_str: &str, cwd_path: &Path) -> String {
2341 output,
2342 exit_code: None,
2343 killed: false,
2344 + owner,
2345 },
2346 );
2347
@@ -2368,14 +2400,17 @@ fn dropped_note(dropped: bool) -> &'static str {
2400 }
2401
2402 /// `command_output` tool: output since the last poll + running/exited status.
2371 -fn exec_command_output(arguments: &str) -> String {
2403 +fn exec_command_output(arguments: &str, owner: Option<&str>) -> String {
2404 let task_id = match parse_task_id(arguments) {
2405 Ok(id) => id,
2406 Err(err) => return err,
2407 };
2408
2409 let mut map = lock_tasks();
2378 - let Some(task) = map.get_mut(&task_id) else {
2410 + let Some(task) = map
2411 + .get_mut(&task_id)
2412 + .filter(|task| task.owner.as_deref() == owner)
2413 + else {
2414 return unknown_task(task_id);
2415 };
2416
@@ -2409,14 +2444,17 @@ fn exec_command_output(arguments: &str) -> String {
2444 }
2445
2446 /// `kill_command` tool: stop a background task and report its output tail.
2412 -fn exec_kill_command(arguments: &str) -> String {
2447 +fn exec_kill_command(arguments: &str, owner: Option<&str>) -> String {
2448 let task_id = match parse_task_id(arguments) {
2449 Ok(id) => id,
2450 Err(err) => return err,
2451 };
2452
2453 let mut map = lock_tasks();
2419 - let Some(task) = map.get_mut(&task_id) else {
2454 + let Some(task) = map
2455 + .get_mut(&task_id)
2456 + .filter(|task| task.owner.as_deref() == owner)
2457 + else {
2458 return unknown_task(task_id);
2459 };
2460
@@ -3109,7 +3147,7 @@ mod tests {
3147
3148 #[test]
3149 fn test_run_command_missing_command() {
3112 - let result = exec_run_command("{}");
3150 + let result = exec_run_command("{}", None);
3151 assert!(
3152 result.contains("missing required parameter"),
3153 "got: {result}"
@@ -3118,7 +3156,7 @@ mod tests {
3156
3157 #[test]
3158 fn test_run_command_success() {
3121 - let result = exec_run_command(r#"{"command": "echo hello"}"#);
3159 + let result = exec_run_command(r#"{"command": "echo hello"}"#, None);
3160 assert!(result.contains("hello"), "got: {result}");
3161 assert!(result.contains("Exit code 0"), "got: {result}");
3162 }
@@ -3173,7 +3211,7 @@ mod tests {
3211 let args = json!({"command": command, "cwd": dir.to_str().unwrap()}).to_string();
3212
3213 let started = std::time::Instant::now();
3176 - let result = exec_run_command(&args);
3214 + let result = exec_run_command(&args, None);
3215
3216 assert!(
3217 started.elapsed() < std::time::Duration::from_secs(30),
@@ -3198,7 +3236,7 @@ mod tests {
3236 let args = json!({"command": "cat", "cwd": dir.to_str().unwrap()}).to_string();
3237
3238 let started = std::time::Instant::now();
3201 - let result = exec_run_command(&args);
3239 + let result = exec_run_command(&args, None);
3240
3241 assert!(
3242 started.elapsed() < std::time::Duration::from_secs(30),
@@ -3220,7 +3258,7 @@ mod tests {
3258 })
3259 .to_string();
3260
3223 - let result = exec_run_command(&args);
3261 + let result = exec_run_command(&args, None);
3262 assert!(result.contains("co-author trailer"), "got: {result}");
3263
3264 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
@@ -3260,7 +3298,7 @@ mod tests {
3298 })
3299 .to_string();
3300
3263 - let result = exec_run_command(&args);
3301 + let result = exec_run_command(&args, None);
3302 assert!(
3303 !result.contains("[siGit Code]"),
3304 "no amend expected: {result}"
@@ -3294,7 +3332,7 @@ mod tests {
3332 })
3333 .to_string();
3334
3297 - let result = exec_run_command(&args);
3335 + let result = exec_run_command(&args, None);
3336 assert!(
3337 !result.contains("[siGit Code]"),
3338 "no amend expected: {result}"
@@ -3318,7 +3356,7 @@ mod tests {
3356 let command = "exit /b 1";
3357
3358 let args = serde_json::json!({ "command": command }).to_string();
3321 - let result = exec_run_command(&args);
3359 + let result = exec_run_command(&args, None);
3360 assert!(result.contains("failed"), "got: {result}");
3361 }
3362
@@ -3338,7 +3376,7 @@ mod tests {
3376 "cwd": dir
3377 })
3378 .to_string();
3341 - let result = exec_run_command(&args);
3379 + let result = exec_run_command(&args, None);
3380 // The output should contain the temp dir path.
3381 assert!(
3382 result.contains(&dir.to_string_lossy().to_string()),
@@ -3358,7 +3396,7 @@ mod tests {
3396 "cwd": missing_dir
3397 })
3398 .to_string();
3361 - let result = exec_run_command(&args);
3399 + let result = exec_run_command(&args, None);
3400 assert!(result.contains("does not exist"), "got: {result}");
3401 }
3402
@@ -3370,7 +3408,7 @@ mod tests {
3408 let command = "echo err 1>&2";
3409
3410 let args = serde_json::json!({ "command": command }).to_string();
3373 - let result = exec_run_command(&args);
3411 + let result = exec_run_command(&args, None);
3412 assert!(result.contains("err"), "got: {result}");
3413 }
3414
@@ -3422,7 +3460,7 @@ mod tests {
3460 })
3461 .to_string();
3462 let spawned = std::time::Instant::now();
3425 - let result = exec_run_command(&args);
3463 + let result = exec_run_command(&args, None);
3464 // Spawning must return immediately, not wait the ~2s the command takes.
3465 assert!(
3466 spawned.elapsed() < std::time::Duration::from_secs(1),
@@ -3434,14 +3472,14 @@ mod tests {
3472
3473 // Polling while the command is still sleeping reports it as running.
3474 let poll_args = serde_json::json!({ "task_id": task_id }).to_string();
3437 - let poll = exec_command_output(&poll_args);
3475 + let poll = exec_command_output(&poll_args, None);
3476 assert!(poll.contains("still running"), "got: {poll}");
3477 let mut combined = poll;
3478
3479 wait_for_background_exit(task_id);
3480 // Grace period so the reader threads finish draining the pipes.
3481 std::thread::sleep(std::time::Duration::from_millis(300));
3444 - let final_poll = exec_command_output(&poll_args);
3482 + let final_poll = exec_command_output(&poll_args, None);
3483 assert!(
3484 final_poll.contains("exited with code 0"),
3485 "got: {final_poll}"
@@ -3466,12 +3504,12 @@ mod tests {
3504 "run_in_background": true
3505 })
3506 .to_string();
3469 - let result = exec_run_command(&args);
3507 + let result = exec_run_command(&args, None);
3508 let task_id = background_task_id(&result);
3509
3510 let kill_args = serde_json::json!({ "task_id": task_id }).to_string();
3511 let killed_at = std::time::Instant::now();
3474 - let kill_result = exec_kill_command(&kill_args);
3512 + let kill_result = exec_kill_command(&kill_args, None);
3513 assert!(
3514 kill_result.contains(&format!("Killed task {task_id}")),
3515 "got: {kill_result}"
@@ -3484,13 +3522,42 @@ mod tests {
3522 );
3523
3524 // A later poll reports the task as killed, not still running.
3487 - let poll = exec_command_output(&serde_json::json!({ "task_id": task_id }).to_string());
3525 + let poll =
3526 + exec_command_output(&serde_json::json!({ "task_id": task_id }).to_string(), None);
3527 assert!(poll.contains("was killed"), "got: {poll}");
3528 }
3529
3530 + #[test]
3531 + fn background_tasks_are_only_visible_to_the_session_that_started_them() {
3532 + #[cfg(unix)]
3533 + let command = "sleep 30";
3534 + #[cfg(windows)]
3535 + let command = "ping -n 31 127.0.0.1 > nul";
3536 +
3537 + let result = start_background_task(command, &std::env::temp_dir(), Some("thread-a".into()));
3538 + let task_id = background_task_id(&result);
3539 + let args = serde_json::json!({ "task_id": task_id }).to_string();
3540 +
3541 + // Another thread (or no thread at all) can't see it, let alone kill it.
3542 + for other in [Some("thread-b"), None] {
3543 + let poll = exec_command_output(&args, other);
3544 + assert!(poll.contains("no background task with id"), "got: {poll}");
3545 + let kill = exec_kill_command(&args, other);
3546 + assert!(kill.contains("no background task with id"), "got: {kill}");
3547 + }
3548 +
3549 + let poll = exec_command_output(&args, Some("thread-a"));
3550 + assert!(poll.contains("still running"), "got: {poll}");
3551 + let kill = exec_kill_command(&args, Some("thread-a"));
3552 + assert!(
3553 + kill.contains(&format!("Killed task {task_id}")),
3554 + "got: {kill}"
3555 + );
3556 + }
3557 +
3558 #[test]
3559 fn test_command_output_unknown_task() {
3493 - let result = exec_command_output(r#"{"task_id": 9999999}"#);
3560 + let result = exec_command_output(r#"{"task_id": 9999999}"#, None);
3561 assert!(
3562 result.contains("no background task with id"),
3563 "got: {result}"
@@ -3558,7 +3625,7 @@ mod tests {
3625
3626 #[test]
3627 fn test_kill_command_unknown_task() {
3561 - let result = exec_kill_command(r#"{"task_id": 9999999}"#);
3628 + let result = exec_kill_command(r#"{"task_id": 9999999}"#, None);
3629 assert!(
3630 result.contains("no background task with id"),
3631 "got: {result}"
@@ -3619,7 +3686,7 @@ mod tests {
3686
3687 #[test]
3688 fn test_command_output_missing_task_id() {
3622 - let result = exec_command_output("{}");
3689 + let result = exec_command_output("{}", None);
3690 assert!(
3691 result.contains("missing required parameter"),
3692 "got: {result}"
@@ -3653,14 +3720,15 @@ mod tests {
3720 "run_in_background": true
3721 })
3722 .to_string();
3656 - let result = exec_run_command(&args);
3723 + let result = exec_run_command(&args, None);
3724 let task_id = background_task_id(&result);
3725
3726 wait_for_background_exit(task_id);
3727 // Grace period so the reader threads finish draining the pipes.
3728 std::thread::sleep(std::time::Duration::from_millis(300));
3729
3663 - let poll = exec_command_output(&serde_json::json!({ "task_id": task_id }).to_string());
3730 + let poll =
3731 + exec_command_output(&serde_json::json!({ "task_id": task_id }).to_string(), None);
3732 assert!(poll.contains("exited with code 0"), "got: {poll}");
3733 assert!(
3734 poll.contains("earlier output was dropped"),
src/workspace.rs
+14 -3
@@ -20,11 +20,18 @@ use std::sync::RwLock;
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 +pub fn set_additional_roots(cwd: &Path, roots: &[PathBuf]) -> Vec<PathBuf> {
24 + let kept = filter_additional_roots(cwd, roots);
25 + replace_additional_roots(kept.clone());
26 + kept
27 +}
28 +
29 +/// The extra roots worth keeping out of what an editor sent.
30 ///
31 /// Entries that aren't directories, repeat, or just name `cwd` again are
32 /// dropped — an editor is free to send any of those, and every consumer here
33 /// would either scan nothing or do the same work twice.
27 -pub fn set_additional_roots(cwd: &Path, roots: &[PathBuf]) -> Vec<PathBuf> {
34 +pub fn filter_additional_roots(cwd: &Path, roots: &[PathBuf]) -> Vec<PathBuf> {
35 let mut seen: Vec<PathBuf> = vec![canonical_key(cwd)];
36 let mut kept: Vec<PathBuf> = Vec::new();
37
@@ -43,11 +50,15 @@ pub fn set_additional_roots(cwd: &Path, roots: &[PathBuf]) -> Vec<PathBuf> {
50 seen.push(key);
51 kept.push(root.clone());
52 }
53 + kept
54 +}
55
56 +/// Install already-filtered extra roots, replacing the previous session's.
57 +/// The ACP agent calls this each time it switches the live session.
58 +pub fn replace_additional_roots(roots: Vec<PathBuf>) {
59 if let Ok(mut guard) = ADDITIONAL_ROOTS.write() {
48 - *guard = kept.clone();
60 + *guard = roots;
61 }
50 - kept
62 }
63
64 /// The extra roots recorded for the session.