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.