basemind 0.22.7

Full AI context layer over MCP — tree-sitter code-map, document RAG (PDF/Office/HTML/email + OCR + reranker), shared agent memory, on-demand web crawl, git history + blame + per-symbol diff. 300+ languages, 10+ coding-agent harnesses, content-addressed Fjall + LanceDB.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
//! MCP server exposing the basemind code map + git context to AI agents.
//!
//! The server opens the store writably and is the canonical Fjall owner: it holds the exclusive
//! lock so the in-process `rescan` tool (and the background watcher) can refresh the index. While
//! a server is running, standalone `basemind scan` / `basemind watch` against the same repo fail
//! fast with a lock error rather than racing it. Tools return JSON so the agent can navigate by
//! file path + line numbers without opening source files.
//!
//! Transport: stdio (the canonical MCP transport). Spawn via `basemind serve`.

mod background;
mod budget;
mod completions;
pub(crate) mod cursor;
#[cfg(all(feature = "comms", any(unix, windows)))]
mod daemon_forward;
mod helpers;
mod helpers_admin;
mod helpers_archmap;
mod helpers_calls;
mod helpers_calls_scan;
#[cfg(feature = "code-search")]
mod helpers_code;
#[cfg(all(feature = "comms", any(unix, windows)))]
mod helpers_comms;
mod helpers_compress;
#[cfg(feature = "documents")]
mod helpers_documents;
mod helpers_files;
mod helpers_fingerprint;
mod helpers_git;
#[cfg(feature = "memory")]
mod helpers_governance;
mod helpers_graph;
mod helpers_grep;
mod helpers_impls;
mod helpers_intel;
#[cfg(feature = "memory")]
mod helpers_proposals;
#[cfg(all(feature = "comms", any(unix, windows)))]
mod helpers_registry;
#[cfg(all(feature = "shells", any(unix, windows)))]
mod helpers_shells;
mod helpers_telemetry;
#[cfg(feature = "crawl")]
mod helpers_web;
#[cfg(all(feature = "comms", any(unix, windows)))]
mod host;
mod identity;
mod kneedle;
mod lean;
mod lenient;
mod map_fingerprint;
#[cfg(any(feature = "memory", feature = "documents", feature = "code-search"))]
mod memory;
#[cfg(feature = "memory")]
pub(crate) mod memory_ops;
mod notifications;
mod prompts;
#[cfg(feature = "memory")]
pub(crate) mod proposals_ops;
mod savings;
mod server_handler;
mod shared_state;
mod state;
mod telemetry;
mod tokens;
mod tools;
mod tools_admin;
mod tools_archmap;
mod tools_code;
#[cfg(all(feature = "comms", any(unix, windows)))]
mod tools_comms;
mod tools_compress;
mod tools_git;
mod tools_governance;
mod tools_memory;
#[cfg(all(feature = "comms", any(unix, windows)))]
mod tools_registry;
#[cfg(all(feature = "shells", any(unix, windows)))]
mod tools_shells;
#[cfg(feature = "crawl")]
mod tools_web;
mod toon;
mod types;
mod types_admin;
mod types_archmap;
mod types_code;
#[cfg(all(feature = "comms", any(unix, windows)))]
mod types_comms;
mod types_compress;
mod types_documents;
mod types_git;
pub(crate) mod types_governance;
mod types_graph;
mod types_impls;
pub(crate) mod types_memory;
#[cfg(all(feature = "comms", any(unix, windows)))]
mod types_registry;
#[cfg(all(feature = "shells", any(unix, windows)))]
mod types_shells;
#[cfg(feature = "crawl")]
mod types_web;

use std::path::PathBuf;
use std::sync::{Arc, Mutex};

use lru::LruCache;
use rmcp::handler::server::router::prompt::PromptRouter;
use rmcp::handler::server::tool::ToolRouter;

use crate::extract::FileMapL1;
use crate::lang::LangId;
use crate::store::Store;

#[cfg(all(feature = "comms", any(unix, windows)))]
pub(crate) use host::HostBackend;
pub(crate) use shared_state::SharedReadStack;
pub(crate) use state::{Lifecycle, MapCache, ServerState};

/// Public re-export of every tool `*Params` type plus the `Parameters` wrapper, so the
/// in-process CLI (`src/cli/`) can build tool arguments and call the `#[tool]` methods
/// directly. This is the parity-by-construction surface: the CLI runs the identical tool
/// code an MCP client would dispatch.
pub mod params {
    pub use rmcp::handler::server::wrapper::Parameters;

    pub(crate) use super::lenient::Lenient;

    pub use super::types::{
        BlameFileParams, BlameSymbolParams, CommitsTouchingParams, DependentsParams, DiffFileParams, DiffOutlineParams,
        FindCallersParams, FindCommitsByPathParams, FindFilesParams, FindReferencesParams, GotoDefinitionParams,
        HotFilesParams, ListFilesParams, OutlineParams, RecentChangesParams, RepoInfoParams, RescanParams,
        SearchDocumentsParams, SearchGitHistoryParams, SearchSymbolsParams, StatusParams, SymbolHistoryParams,
        TelemetrySummaryParams, WorkingTreeStatusParams, WorkspaceGrepParams,
    };
    #[cfg(feature = "crawl")]
    pub use super::types::{WebCrawlParams, WebMapParams, WebScrapeParams};
    pub use super::types_admin::{CacheClearParams, CacheGcParams, CacheStatsParams};
    pub use super::types_archmap::ArchitectureMapParams;
    pub use super::types_code::{GetChunkParams, SearchCodeParams};
    pub use super::types_compress::ExpandParams;
    pub use super::types_governance::{
        MemoryAuditParams, ProposalAcceptParams, ProposalRejectParams, ProposalsListParams, ProposalsMineParams,
    };
    pub use super::types_graph::CallGraphParams;
    pub use super::types_impls::FindImplementationsParams;
    pub use super::types_memory::{
        MemoryDeleteParams, MemoryGetParams, MemoryListParams, MemoryPutParams, MemorySearchParams, Visibility,
    };
    #[cfg(all(feature = "shells", any(unix, windows)))]
    pub use super::types_shells::{
        ShellBroadcastParams, ShellCaptureParams, ShellEnv, ShellKillParams, ShellListParams, ShellSendParams,
        ShellSpawnParams,
    };
}

pub use params::Parameters;

/// Whether `basemind serve` must run the MCP server IN-PROCESS rather than relaying to the shared
/// daemon, because this invocation carries per-client configuration the daemon cannot honor.
///
/// Today that is the lean tool surface (`BASEMIND_MCP_LEAN`): the surface is decided from the
/// serving process's own environment, but a shared daemon advertises the full surface identically to
/// every connection, so a lean client must serve itself. `serve` checks this before dialing the
/// daemon and falls back to the in-process path when it is true.
#[cfg(all(feature = "comms", any(unix, windows)))]
pub fn serve_requires_in_process() -> bool {
    lean::lean_mode_enabled()
}

/// In-memory cache for `symbol_history`-style workflows: given a blob's git OID and the
/// language we'd extract with, hold onto the parsed `FileMapL1` and the source bytes so
/// repeated visits to the same blob (across commits, modes, or tool calls) skip the
/// tree-sitter parse entirely. Memory-only — blob OIDs are content-addressed and immutable,
/// so cache invalidation is implicit (a new blob = a new key).
///
/// Cap chosen to bound steady-state memory at a few MB for typical repositories: 512
/// entries × ~few KiB per `FileMapL1` + Arc'd source = on the order of 1–10 MiB.
pub(crate) const OUTLINE_CACHE_CAP: usize = 512;

/// Per-category LRU capacity for a daemon-hosted workspace's git cache (commit_files, log, blame).
/// Matches the `basemind serve --git-cache-mem` default (1024): the daemon holds one git cache per
/// hot workspace, shared across all connections, so the same budget an in-process serve used per
/// process now serves every client of that workspace.
#[cfg(all(feature = "comms", any(unix, windows)))]
pub(crate) const HOSTED_GIT_CACHE_MEM: usize = 1024;

pub(crate) struct OutlineEntry {
    pub map: Arc<FileMapL1>,
    pub source: Arc<Vec<u8>>,
}

pub(crate) type OutlineCache = Mutex<LruCache<(gix::ObjectId, LangId), Arc<OutlineEntry>>>;

/// Shared MCP server state. `ToolRouter<Self>` is Clone (Arc inside), so we hold it directly
/// on the struct as the `#[tool_handler]` macro expects.
#[derive(Clone)]
pub struct BasemindServer {
    pub(crate) state: Arc<ServerState>,
    #[allow(dead_code)]
    tool_router: ToolRouter<Self>,
    /// Reusable prompt templates (`prompts/list` + `prompts/get`). Built by the
    /// `#[prompt_router]` macro in [`prompts`]; `list_prompts` / `get_prompt` delegate here.
    prompt_router: PromptRouter<Self>,
}

/// Construction-time switches for [`BasemindServer`].
///
/// `serve` wants every background facility running; a one-shot CLI query wants
/// none of them (no auto-scan, no view watcher, no background GC) so the process
/// exits the instant the single tool call returns.
///
/// NOTE: this is intentionally a struct of named bools rather than a bare flag —
/// a future workstream (the live FS watcher / `--no-watch`) will extend it with
/// finer-grained switches (e.g. `auto_scan` vs `watch` decoupled). Keep new knobs
/// additive and defaulted so callers that only care about `background` stay terse.
#[derive(Debug, Clone, Copy)]
pub struct ServerOptions {
    /// When true, spawn the empty-index auto-scan, the view watcher thread, and
    /// the background blob GC. When false, the server is a pure one-shot query
    /// handle: it preloads the in-RAM map cache and nothing else.
    pub background: bool,
    /// When true (and `background` is on, and the served view is the working
    /// view), spawn a live filesystem watcher that funnels changed paths into
    /// `scan_and_refresh` so the in-RAM map stays current as the agent edits.
    /// When false, fall back to the passive view watcher (which only reacts to
    /// external scans writing `index.msgpack`). Disabled for one-shot queries.
    ///
    /// `--no-watch` on `basemind serve` flips this off — useful for very large
    /// repos (e.g. the ~81k-file TypeScript tree) or CI, where the continuous
    /// incremental re-scan is not worth the cost.
    pub watch: bool,
    /// When true, the store was opened read-only (it does NOT hold the write
    /// lock) because another `serve` owns it for this repo (issue #27). The
    /// server still answers every read tool from the shared index, but it must
    /// not write: the empty-index auto-scan and the active filesystem watcher
    /// are suppressed, and the `rescan` tool returns a clean error instead of
    /// scanning. The passive view watcher still runs, so the in-RAM map tracks
    /// the lock-holding writer's `index.msgpack` updates.
    pub read_only: bool,
    /// When true, this serve forwards every write (auto-scan, watcher rescan, `rescan` tool) to
    /// the machine daemon (the sole fjall writer) rather than writing locally, and rebuilds its
    /// in-RAM map from the daemon-written `index.msgpack`. Set only by the real `serve` binary on
    /// a `comms` build; always false for the in-process one-shot and non-comms builds.
    pub daemon_writer: bool,
    /// When true, defer [`MapCache::build`] to the first tool that actually reads the map, instead
    /// of running it at construction. Set only for the one-shot CLI — see
    /// [`ServerState::lazy_cache`].
    pub lazy_cache: bool,
}

impl Default for ServerOptions {
    fn default() -> Self {
        Self {
            background: true,
            watch: true,
            read_only: false,
            daemon_writer: false,
            lazy_cache: false,
        }
    }
}

impl BasemindServer {
    /// Construct a server with all background facilities running (the `serve` path).
    pub fn new(
        store: Store,
        root: PathBuf,
        config: Arc<crate::config::Config>,
        repo: Option<Arc<crate::git::Repo>>,
        git_cache: Arc<crate::git_cache::GitCache>,
    ) -> Self {
        Self::new_with_options(store, root, config, repo, git_cache, ServerOptions::default())
    }

    /// Construct a one-shot server with every background facility disabled.
    ///
    /// Used by the `basemind` CLI to run a single MCP tool in-process and exit — no auto-scan, no
    /// view watcher, no background GC. The in-RAM map cache is built LAZILY: a CLI process answers
    /// one tool call, and most tools never read the map, so preloading it charged every invocation
    /// the whole-corpus blob-deserialization cost (seconds on a large monorepo) for nothing. The
    /// first tool that does read the map still builds it in full, so results are identical to what
    /// an MCP client would see.
    pub fn new_oneshot(
        store: Store,
        root: PathBuf,
        config: Arc<crate::config::Config>,
        repo: Option<Arc<crate::git::Repo>>,
        git_cache: Arc<crate::git_cache::GitCache>,
    ) -> Self {
        Self::new_with_options(
            store,
            root,
            config,
            repo,
            git_cache,
            ServerOptions {
                background: false,
                watch: false,
                read_only: false,
                daemon_writer: false,
                lazy_cache: true,
            },
        )
    }

    /// Shared constructor honoring [`ServerOptions`]. `new` / `new_oneshot` are
    /// the public entry points; this threads the `background` switch through the
    /// three spawn sites + the initial auto-scan.
    pub fn new_with_options(
        store: Store,
        root: PathBuf,
        config: Arc<crate::config::Config>,
        repo: Option<Arc<crate::git::Repo>>,
        git_cache: Arc<crate::git_cache::GitCache>,
        options: ServerOptions,
    ) -> Self {
        let agent_id = identity::resolve_agent_id(&config, &store);
        let history_dir = crate::git_history::shared_history_basemind_dir(&root);
        let git_history = Self::open_git_history(
            &root,
            &history_dir,
            repo.is_some(),
            &agent_id,
            &options,
            // ~keep In-process serve / CLI: no daemon hosting us, so history reads use the socket-forward
            // ~keep path (or a local handle) — never the in-process seam.
            #[cfg(all(feature = "comms", any(unix, windows)))]
            None,
        );
        // ~keep Boot decisions depend on the opened store, so compute them before it moves into the stack.
        let (needs_initial_scan, defer_warm) = shared_state::boot_plan(&store, &options);
        let shared = Arc::new(SharedReadStack::new(
            store,
            root,
            config,
            repo,
            git_cache,
            git_history,
            options,
            // ~keep In-process serve / CLI: no daemon-hosted pool, so the `daemon_writer` FORWARD path
            // ~keep (not this seam) handles writes when a daemon is up.
            #[cfg(all(feature = "comms", any(unix, windows)))]
            None,
        ));
        let state = Arc::new(ServerState {
            shared,
            agent_id,
            #[cfg(all(feature = "comms", any(unix, windows)))]
            comms_clients: tokio::sync::Mutex::new(ahash::AHashMap::new()),
            log_level: std::sync::atomic::AtomicU8::new(notifications::DEFAULT_LOG_ORDINAL),
        });
        if options.background {
            let view_is_working = {
                match state.shared.store.try_read() {
                    Ok(g) => g.view == crate::store::VIEW_WORKING,
                    Err(_) => false,
                }
            };
            if options.watch && (options.daemon_writer || !options.read_only) && view_is_working {
                background::spawn_serve_watcher(Arc::clone(&state));
            } else {
                background::spawn_view_watcher(Arc::clone(&state));
            }
            Self::spawn_git_history_sync(&state, &history_dir);
            if needs_initial_scan {
                background::spawn_initial_scan(Arc::clone(&state));
            } else {
                if defer_warm {
                    background::spawn_cache_warm(Arc::clone(&state));
                }
                let gc_state = Arc::clone(&state);
                tokio::spawn(async move {
                    background::run_background_gc(gc_state).await;
                });
            }
        }
        Self {
            state,
            tool_router: Self::assemble_router(),
            prompt_router: Self::prompt_router(),
        }
    }

    /// Assemble the full tool router (every feature-gated tool group). Shared by
    /// [`new_with_options`](Self::new_with_options) and [`from_shared`](Self::from_shared) so a
    /// daemon-hosted per-connection server exposes exactly the same tool surface as an in-process
    /// one — the router is pure (no per-connection state), so building it per connection is a µs
    /// pointer-assembly, not a rebuild of anything heavy.
    fn assemble_router() -> ToolRouter<Self> {
        #[allow(unused_mut)]
        let mut router = Self::tool_router_core()
            + Self::tool_router_archmap()
            + Self::tool_router_git()
            + Self::tool_router_memory()
            + Self::tool_router_code()
            + Self::tool_router_governance()
            + Self::tool_router_admin()
            + Self::tool_router_compress();
        #[cfg(feature = "crawl")]
        {
            router += Self::tool_router_web();
        }
        #[cfg(all(feature = "comms", any(unix, windows)))]
        {
            router += Self::tool_router_comms();
            router += Self::tool_router_registry();
        }
        #[cfg(all(feature = "shells", any(unix, windows)))]
        {
            router += Self::tool_router_shells();
        }
        router
    }

    /// Build a per-connection server that SHARES a workspace's [`SharedReadStack`] rather than
    /// opening its own. This is the daemon-hosted seam: the daemon builds one shared read stack per
    /// hot workspace (see [`build_hosted_read_stack`](Self::build_hosted_read_stack)) and hands every
    /// accepted relay connection its own `BasemindServer` over that one stack — so the heavy state
    /// (in-RAM `MapCache`, LanceDB, ONNX, git caches) is resident once per workspace, not once per
    /// client. Spawns NO background facilities: freshness is owned by the workspace's single warden
    /// (built alongside the shared stack), never per connection. `agent_id` comes from the
    /// connection's [`RelayHello`](crate::comms::relay::RelayHello), so each client keeps its own
    /// memory-owner identity even while sharing the read stack.
    #[cfg(all(feature = "comms", any(unix, windows)))]
    pub(crate) fn from_shared(shared: Arc<SharedReadStack>, agent_id: String) -> Self {
        Self {
            state: Arc::new(ServerState::for_connection(shared, agent_id)),
            tool_router: Self::assemble_router(),
            prompt_router: Self::prompt_router(),
        }
    }

    /// Build (once) the shared read stack the daemon hosts for one workspace, plus its single
    /// "warden" — a background-running [`ServerState`] that owns freshness for the whole workspace:
    /// the empty-index auto-scan, the live filesystem watcher, cache warming, and the git-history
    /// sync. The warden shares the returned [`SharedReadStack`] by `Arc`, so when its watcher
    /// rebuilds the in-RAM map every hosted connection sees the refreshed snapshot at once. This is
    /// the N-watchers-per-workspace → one-watcher-per-workspace consolidation: per-connection
    /// servers ([`from_shared`](Self::from_shared)) spawn nothing.
    ///
    /// The warden uses the same topology as today's thin `serve` (`read_only` + `daemon_writer`), so
    /// its scans forward to the daemon's sole-writer pool over the existing path — no new scan/write
    /// machinery. The store is [`open_read_only_no_index`](crate::store::Store::open_read_only_no_index)
    /// (no fjall lock), so the shared `MapCache` still builds its in-RAM call/impl indexes exactly as
    /// a thin serve does.
    #[cfg(all(feature = "comms", any(unix, windows)))]
    pub(crate) fn build_hosted_read_stack(
        root: &std::path::Path,
        host: Arc<dyn HostBackend>,
        git_history_host: Arc<dyn crate::git_history::remote::HistoryHost>,
    ) -> anyhow::Result<Arc<SharedReadStack>> {
        use anyhow::Context as _;

        let view = crate::store::VIEW_WORKING;
        let store = crate::store::Store::open_read_only_no_index(root, view).context("open hosted read stack store")?;
        debug_assert!(
            store.index_db.is_none(),
            "hosted read stack must be index-less so MapCache builds in-RAM call/impl indexes"
        );
        let basemind_dir = crate::store::workspace_cache_dir(root);
        let config = Arc::new(match crate::config::load_with_overrides(root, None, None) {
            Ok(loaded) => loaded.config,
            Err(crate::config::ConfigError::NotFound(_)) => crate::config::default_for_root(root),
            Err(error) => return Err(anyhow::Error::new(error).context("load hosted workspace config")),
        });
        let repo = crate::git::Repo::discover(root).ok().map(Arc::new);
        let git_cache = Arc::new(
            crate::git_cache::GitCache::open(&basemind_dir, HOSTED_GIT_CACHE_MEM, true)
                .context("open hosted git cache")?,
        );
        let options = ServerOptions {
            background: true,
            watch: true,
            read_only: true,
            daemon_writer: true,
            lazy_cache: false,
        };
        let agent_id = identity::resolve_agent_id(&config, &store);
        let history_dir = crate::git_history::shared_history_basemind_dir(root);
        let git_history = Self::open_git_history(
            root,
            &history_dir,
            repo.is_some(),
            &agent_id,
            &options,
            Some(git_history_host),
        );
        let (needs_initial_scan, defer_warm) = shared_state::boot_plan(&store, &options);
        let shared = Arc::new(SharedReadStack::new(
            store,
            root.to_path_buf(),
            config,
            repo,
            git_cache,
            git_history,
            options,
            // ~keep The daemon-hosted seam: hosted writes / rescans / resolved-refs run directly through
            // ~keep the pool instead of looping back over the daemon's own socket.
            Some(host),
        ));
        // ~keep The warden shares the stack by Arc and owns every background facility for the workspace.
        let warden = Arc::new(ServerState::for_connection(Arc::clone(&shared), agent_id));
        background::spawn_serve_watcher(Arc::clone(&warden));
        Self::spawn_git_history_sync(&warden, &history_dir);
        if needs_initial_scan {
            background::spawn_initial_scan(Arc::clone(&warden));
        } else if defer_warm {
            background::spawn_cache_warm(Arc::clone(&warden));
        }
        tracing::info!(root = %root.display(), needs_initial_scan, "daemon hosting read stack for workspace");
        Ok(shared)
    }

    /// The git-history handle this session gets, if any. Fjall's directory lock is exclusive — even a
    /// read-only open takes it — so `git-history.fjall/` has exactly one holder machine-wide, and the
    /// only question here is whether that holder is us or the daemon:
    ///
    /// * **a daemon is up**: the DAEMON holds the database, so every session — a `daemon_writer`
    ///   serve, and equally the one-shot CLI, which runs these same tool bodies in-process — takes a
    ///   forwarding handle. It must not try to open the index: it cannot win the lock, so it would
    ///   burn the retry ladder on every invocation and then silently live-walk, on the exact machine
    ///   where the index is built and fresh. Serve knows a daemon is up by construction (it brings
    ///   one up); the CLI has to ask, which is one `stat` when there is no daemon and one ping when
    ///   there is (see [`crate::git_history::remote::daemon_is_up`]).
    /// * **no daemon** (a standalone process, or a non-`comms` build): this process holds the
    ///   database and builds it in-process, as before. Nobody else can, and history tools would
    ///   otherwise permanently live-walk.
    /// * **read-only fallback** (another process owns the write lock, no daemon): no handle — history
    ///   tools live-walk, visibly (`partial: true`). Unchanged.
    fn open_git_history(
        root: &std::path::Path,
        history_dir: &std::path::Path,
        has_repo: bool,
        agent_id: &str,
        options: &ServerOptions,
        // ~keep A daemon-hosted connection passes the daemon itself as the in-process history host, so
        // ~keep its reads run through the shared handle instead of looping back over the daemon's socket.
        #[cfg(all(feature = "comms", any(unix, windows)))] git_history_host: Option<
            Arc<dyn crate::git_history::remote::HistoryHost>,
        >,
    ) -> Option<Arc<crate::git_history::GitHistoryIndex>> {
        if !has_repo || !crate::git_history::index_enabled() {
            return None;
        }
        #[cfg(all(feature = "comms", any(unix, windows)))]
        if options.daemon_writer || crate::git_history::remote::daemon_is_up() {
            let agent = crate::comms::ids::AgentId::parse(agent_id.to_string())
                .inspect_err(|error| tracing::warn!(%error, "git-history: bad agent id; tools will live-walk"))
                .ok()?;
            if let Some(host) = git_history_host {
                return Some(Arc::new(crate::git_history::GitHistoryIndex::hosted(
                    root.to_path_buf(),
                    agent,
                    host,
                )));
            }
            return Some(Arc::new(crate::git_history::GitHistoryIndex::remote(
                root.to_path_buf(),
                agent,
            )));
        }
        let _ = (root, agent_id);
        if options.read_only {
            return None;
        }
        match crate::git_history::GitHistoryIndex::open(history_dir) {
            Ok(index) => Some(Arc::new(index)),
            Err(error) => {
                tracing::warn!(?error, "git-history index unavailable; tools will live-walk");
                None
            }
        }
    }

    /// Kick the git-history index up to date, off the MCP thread. A session whose handle is
    /// daemon-backed ASKS the daemon to do it (which serializes the build per repo, so N sessions
    /// cause one walk); a session that holds the database does it in-process, as it always has.
    ///
    /// Keyed off the handle the routing above actually produced, not off `daemon_writer`: a
    /// non-`daemon_writer` session that found a live daemon also holds a forwarding handle, and
    /// asking the builder to write through it is a guaranteed
    /// [`NotLocal`](crate::git_history::GitHistoryError::NotLocal).
    ///
    /// Only ever reached with `background: true` (i.e. `serve`). The one-shot CLI requests no sync at
    /// all: it exits in milliseconds, and a first build on a deep repo is a minutes-long walk.
    fn spawn_git_history_sync(state: &Arc<ServerState>, history_dir: &std::path::Path) {
        let Some(index) = state.shared.git_history.as_deref() else {
            return;
        };
        let _ = index;
        // ~keep A daemon-hosted handle syncs in-process through the seam (the daemon's own build lock),
        // ~keep never over the socket back to itself.
        #[cfg(all(feature = "comms", any(unix, windows)))]
        if let Some(host) = index.history_host() {
            let root = state.shared.root.clone();
            tokio::spawn(async move {
                match host
                    .run_history(root, crate::git_history::proto::GitHistoryOp::Sync)
                    .await
                {
                    Ok(reply) => tracing::info!(?reply, "git-history index synced in-process (hosted)"),
                    Err(error) => tracing::warn!(%error, "git-history in-process sync failed; history tools live-walk"),
                }
            });
            return;
        }
        #[cfg(all(feature = "comms", any(unix, windows)))]
        if index.is_daemon_backed() {
            let root = state.shared.root.clone();
            let agent_id = state.agent_id.clone();
            tokio::spawn(async move {
                let Ok(agent) = crate::comms::ids::AgentId::parse(agent_id) else {
                    return;
                };
                match crate::git_history::remote::request_sync(root, agent).await {
                    Some(outcome) => tracing::info!(?outcome, "git-history index synced by the daemon"),
                    None => tracing::warn!("git-history index sync unavailable; history tools live-walk"),
                }
            });
            return;
        }
        if let (Some(git_history), Some(repo)) = (state.shared.git_history.clone(), state.shared.repo.clone()) {
            let history_dir = history_dir.to_path_buf();
            tokio::task::spawn_blocking(move || {
                match crate::git_history::builder::sync(&git_history, &repo, &history_dir) {
                    Ok(outcome) => tracing::info!(?outcome, "git-history index sync complete"),
                    Err(error) => tracing::warn!(%error, "git-history index sync failed; tools live-walk"),
                }
            });
        }
    }

    /// Names of every tool this server advertises via `tools/list` (the full router, ignoring the
    /// `BASEMIND_MCP_LEAN` wrapper mode). Exposed for the `tests/cli_parity.rs` guard, which asserts
    /// each advertised tool has a CLI counterpart. The set follows the compiled feature flags.
    pub fn tool_names(&self) -> Vec<String> {
        self.tool_router
            .list_all()
            .into_iter()
            .map(|tool| tool.name.to_string())
            .collect()
    }
}

#[cfg(test)]
#[path = "lazy_cache_tests.rs"]
mod lazy_cache_tests;

#[cfg(test)]
mod map_cache_tests {
    use super::*;
    use std::fs;
    use std::path::Path;

    fn sym_names(cache: &MapCache, rel: &str) -> Vec<String> {
        let key = crate::path::RelPath::from(rel);
        cache
            .by_path
            .get(&key)
            .map(|l1| l1.symbols.iter().map(|s| s.name.clone()).collect())
            .unwrap_or_default()
    }

    /// The lifecycle precedence is BuildingIndex > WarmingUp > Rescanning > Ready — a from-scratch
    /// scan outranks a preload, which outranks a watcher refresh. Guards the ordering a read tool
    /// relies on to label a possibly-incomplete result correctly.
    #[test]
    fn lifecycle_from_flags_applies_precedence() {
        assert_eq!(Lifecycle::from_flags(false, false, false), Lifecycle::Ready);
        assert_eq!(Lifecycle::from_flags(false, false, true), Lifecycle::Rescanning);
        assert_eq!(Lifecycle::from_flags(false, true, true), Lifecycle::WarmingUp);
        assert_eq!(Lifecycle::from_flags(true, true, true), Lifecycle::BuildingIndex);
        assert_eq!(Lifecycle::from_flags(true, false, false), Lifecycle::BuildingIndex);
    }

    /// A notice is emitted for every non-ready state (with the stable machine tag and the right retry
    /// hint) and suppressed when ready, so a healthy response carries no `notice` field.
    #[test]
    fn lifecycle_notice_maps_state_to_tag_and_retry() {
        assert!(types::LifecycleNotice::for_state(Lifecycle::Ready).is_none());
        let warming = types::LifecycleNotice::for_state(Lifecycle::WarmingUp).expect("warming notice");
        assert_eq!(warming.state, "warming_up");
        assert!(warming.retry, "warming asks the caller to retry for complete results");
        let building = types::LifecycleNotice::for_state(Lifecycle::BuildingIndex).expect("building notice");
        assert_eq!(building.state, "building_index");
        assert!(building.retry);
        let rescanning = types::LifecycleNotice::for_state(Lifecycle::Rescanning).expect("rescan notice");
        assert_eq!(rescanning.state, "rescanning");
        assert!(!rescanning.retry, "rescan results are usable, no retry required");
    }

    /// `with_delta` must re-read only the changed blobs, preserve untouched entries, drop removed
    /// ones, and keep `imports_index` consistent — the incremental refresh the serve watcher uses
    /// instead of a whole-corpus rebuild (issue #33).
    #[test]
    fn with_delta_patches_updated_and_removed_paths_only() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        fs::write(root.join("a.rs"), b"pub fn alpha() {}\n").unwrap();
        fs::write(root.join("b.rs"), b"pub fn beta() {}\n").unwrap();
        let cfg = crate::config::ConfigV1::with_defaults();

        let mut store = crate::store::Store::open(root, crate::store::VIEW_WORKING).unwrap();
        crate::scanner::scan(
            root,
            &mut store,
            &cfg,
            crate::scanner::ScanSource::WorkingTree,
            crate::scanner::EmbedMode::Inline,
        )
        .unwrap();

        let cache = MapCache::build(&store);
        assert_eq!(sym_names(&cache, "a.rs"), vec!["alpha".to_string()]);
        assert_eq!(sym_names(&cache, "b.rs"), vec!["beta".to_string()]);
        assert!(cache.calls.is_none() && cache.impls.is_none());

        fs::write(root.join("a.rs"), b"pub fn alpha2() {}\npub fn alpha3() {}\n").unwrap();
        let report = crate::scanner::scan_paths(
            root,
            &mut store,
            &cfg,
            &[root.join("a.rs")],
            crate::scanner::EmbedMode::Inline,
        )
        .unwrap();
        assert_eq!(report.stats.updated, 1);

        let updated = vec![crate::path::RelPath::from("a.rs")];
        let next = cache.with_delta(&store, &updated, &[]);
        assert_eq!(
            sym_names(&next, "a.rs"),
            vec!["alpha2".to_string(), "alpha3".to_string()],
            "updated path reflects fresh L1"
        );
        assert_eq!(
            sym_names(&next, "b.rs"),
            vec!["beta".to_string()],
            "untouched path preserved without re-reading its blob"
        );

        let removed = vec![crate::path::RelPath::from("b.rs")];
        let after = next.with_delta(&store, &[], &removed);
        assert!(
            !after.by_path.contains_key(&crate::path::RelPath::from("b.rs")),
            "removed path dropped from by_path"
        );
        assert!(
            after.by_path.contains_key(&crate::path::RelPath::from("a.rs")),
            "other path kept"
        );
        assert!(
            !after.imports_index.iter().any(|(p, _)| p == Path::new("b.rs")),
            "imports_index must not retain a removed path"
        );
    }
}