Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
1065 lines
42 KiB
Rust
1065 lines
42 KiB
Rust
use std::collections::HashSet;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::Duration;
|
|
|
|
use notify::RecursiveMode;
|
|
use notify_debouncer_mini::{DebounceEventResult, Debouncer, new_debouncer_opt};
|
|
use tokio::sync::mpsc;
|
|
|
|
const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(1000);
|
|
|
|
/// A [`notify::Watcher`] that drops `EventKind::Access` before it reaches the
|
|
/// debouncer, breaking the MCP/skills reload storm.
|
|
///
|
|
/// `notify`'s inotify backend emits an `Access` event on every *read*, and the
|
|
/// leader re-reads the files it watches on each reload — so unfiltered, a
|
|
/// reload's own reads schedule the next reload, a ~1/sec self-sustaining loop.
|
|
/// Dropping `Access` is safe: writes still emit `Modify`/`Create` and chmod
|
|
/// emits `Modify(Metadata)`; only reads are `Access`-only.
|
|
pub struct AccessFilteredWatcher(notify::RecommendedWatcher);
|
|
|
|
impl notify::Watcher for AccessFilteredWatcher {
|
|
fn new<F: notify::EventHandler>(
|
|
mut event_handler: F,
|
|
config: notify::Config,
|
|
) -> notify::Result<Self>
|
|
where
|
|
Self: Sized,
|
|
{
|
|
let inner = notify::RecommendedWatcher::new(
|
|
move |res: notify::Result<notify::Event>| match &res {
|
|
Ok(event) if matches!(event.kind, notify::EventKind::Access(_)) => {}
|
|
_ => event_handler.handle_event(res),
|
|
},
|
|
config,
|
|
)?;
|
|
Ok(Self(inner))
|
|
}
|
|
|
|
fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> notify::Result<()> {
|
|
self.0.watch(path, recursive_mode)
|
|
}
|
|
|
|
fn unwatch(&mut self, path: &Path) -> notify::Result<()> {
|
|
self.0.unwatch(path)
|
|
}
|
|
|
|
fn configure(&mut self, option: notify::Config) -> notify::Result<bool> {
|
|
self.0.configure(option)
|
|
}
|
|
|
|
fn kind() -> notify::WatcherKind
|
|
where
|
|
Self: Sized,
|
|
{
|
|
notify::RecommendedWatcher::kind()
|
|
}
|
|
}
|
|
|
|
/// `new_debouncer` equivalent that builds the debouncer on top of
|
|
/// [`AccessFilteredWatcher`] instead of the raw `RecommendedWatcher`.
|
|
fn new_filtered_debouncer<F: notify_debouncer_mini::DebounceEventHandler>(
|
|
timeout: Duration,
|
|
event_handler: F,
|
|
) -> Result<Debouncer<AccessFilteredWatcher>, notify::Error> {
|
|
let config = notify_debouncer_mini::Config::default().with_timeout(timeout);
|
|
new_debouncer_opt::<F, AccessFilteredWatcher>(config, event_handler)
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ConfigChangeEvent {
|
|
AuthChanged,
|
|
GlobalConfigChanged,
|
|
/// `~/.kigi/models_cache.json` changed — the on-disk `/v1/models`
|
|
/// catalog cache was rewritten, possibly by **another** grok process
|
|
/// sharing the same `~/.kigi` (the writer may also be this process;
|
|
/// the [`ModelsManager`](crate::agent::models::ModelsManager) dedupes
|
|
/// by content before applying).
|
|
ModelsCacheChanged,
|
|
ProjectConfigChanged {
|
|
path: PathBuf,
|
|
},
|
|
/// A project-scoped MCP config file changed
|
|
/// (`<cwd>/.mcp.json` or `<cwd>/.claude.json` where `<cwd>` is a
|
|
/// project root, **not** `$HOME`). Project `<cwd>` is derived
|
|
/// from `path.parent()` by the reloader.
|
|
McpConfigChanged {
|
|
path: PathBuf,
|
|
},
|
|
/// The user's **home-level** `~/.claude.json` changed. Distinct
|
|
/// from [`Self::McpConfigChanged`] because `~/.claude.json` is
|
|
/// loaded for **every** session regardless of cwd (see
|
|
/// `load_claude_json_mcp_servers_as_configs`), so the reload
|
|
/// must broadcast through the legacy unit
|
|
/// [`super::reloader::ConfigUpdate::McpServersChanged`] arm —
|
|
/// routing it through `ProjectMcpServersChanged { cwd: $HOME }`
|
|
/// would silently skip sessions whose cwd doesn't sit under
|
|
/// `$HOME`.
|
|
HomeClaudeJsonChanged,
|
|
}
|
|
|
|
/// Watches `~/.kigi/` for `auth.json`, `config.toml`, and `models_cache.json`
|
|
/// changes, plus any extra paths (project `.kigi/config.toml`, `.mcp.json`,
|
|
/// etc.) provided at startup.
|
|
///
|
|
/// Uses `notify-debouncer-mini` for built-in debounce that coalesces rapid
|
|
/// editor writes (including write-then-rename patterns).
|
|
///
|
|
/// Self-write suppression is intentionally omitted. When the agent writes
|
|
/// `auth.json` or `config.toml`, the watcher will fire and the
|
|
/// [`ConfigReloader`](super::reloader::ConfigReloader) will re-read the file.
|
|
/// The reloader's own content-based deduplication (auth key hash, toml value
|
|
/// comparison) skips the update when nothing actually changed, so the
|
|
/// redundant read is harmless. This avoids a class of bugs where an
|
|
/// optimistic suppression window accidentally swallows writes from external
|
|
/// processes (e.g. `grok login` in another terminal).
|
|
///
|
|
/// Adds two **non-recursive** watches per `cwd` argument:
|
|
/// `<cwd>/` (catches `.mcp.json` and `.claude.json` at the project root) and
|
|
/// `<cwd>/.kigi/` (catches `<cwd>/.kigi/config.toml`). Recursing on `<cwd>`
|
|
/// would walk `node_modules/`, `target/`, `.git/`, etc. and blow through
|
|
/// `fs.inotify.max_user_watches` on large repos. Use [`Self::watch_path`]
|
|
/// to register additional cwds at runtime when new sessions open in
|
|
/// previously-unwatched directories.
|
|
pub struct ConfigFileWatcher {
|
|
debouncer: Debouncer<AccessFilteredWatcher>,
|
|
/// Project cwds currently registered (via [`Self::start`]'s `cwd`
|
|
/// argument or [`Self::watch_path`]). Tracked so that
|
|
/// (a) [`Self::watch_path`] is idempotent at our layer instead of
|
|
/// relying on `notify`'s internal de-dup, and
|
|
/// (b) [`Self::unwatch_path`] can drop the OS watches for a cwd
|
|
/// that is no longer needed, bounding inotify-watch accumulation
|
|
/// as sessions churn across directories.
|
|
watched_cwds: HashSet<PathBuf>,
|
|
}
|
|
|
|
impl ConfigFileWatcher {
|
|
/// Start watching. Returns `None` if the OS watcher fails to initialize.
|
|
///
|
|
/// `cwd`, when `Some`, adds two non-recursive watches: `<cwd>/` and
|
|
/// `<cwd>/.kigi/`. Use [`Self::watch_path`] later to register additional
|
|
/// project cwds for sessions that open in previously-unwatched
|
|
/// directories.
|
|
pub fn start(
|
|
kigi_home: &Path,
|
|
extra_paths: &[PathBuf],
|
|
cwd: Option<&Path>,
|
|
debounce: Option<Duration>,
|
|
) -> Option<(Self, mpsc::UnboundedReceiver<ConfigChangeEvent>)> {
|
|
let debounce = debounce.unwrap_or(DEFAULT_DEBOUNCE);
|
|
let (tx, rx) = mpsc::unbounded_channel();
|
|
let kigi_home_buf = kigi_home.to_path_buf();
|
|
// `~/.claude.json` is consumed by **every**
|
|
// session (see `load_claude_json_mcp_servers_as_configs`), so
|
|
// a write to it must broadcast through the unit
|
|
// `McpServersChanged` arm — NOT through the per-cwd
|
|
// `ProjectMcpServersChanged { cwd: $HOME }` arm, which
|
|
// `cwd_matches` would silently filter for sessions outside
|
|
// `$HOME`. We snapshot `$HOME` here so the closure can
|
|
// discriminate `<home>/.claude.json` from a project-level
|
|
// `<cwd>/.claude.json` purely by path.
|
|
//
|
|
// Canonicalize `$HOME` ONCE up front. `notify`
|
|
// backends may deliver canonicalized event paths (e.g. macOS
|
|
// FSEvents resolves symlinks, returning `/private/var/...`
|
|
// where `dirs::home_dir()` returned `/var/...`), so a raw byte
|
|
// compare against an un-canonicalized `$HOME` would mis-route
|
|
// `~/.claude.json` to the per-cwd path. The per-event side is
|
|
// canonicalized in `parent_is_dir`.
|
|
let user_home_buf: Option<PathBuf> =
|
|
dirs::home_dir().map(|h| dunce::canonicalize(&h).unwrap_or(h));
|
|
|
|
let mut debouncer = new_filtered_debouncer(debounce, move |res: DebounceEventResult| {
|
|
let Ok(events) = res else { return };
|
|
|
|
let mut batch_events: Vec<ConfigChangeEvent> = Vec::new();
|
|
for event in events {
|
|
let path = &event.path;
|
|
let name = path.file_name().and_then(|n| n.to_str());
|
|
let parent = path.parent();
|
|
|
|
let change = match name {
|
|
Some("auth.json") if parent == Some(kigi_home_buf.as_path()) => {
|
|
Some(ConfigChangeEvent::AuthChanged)
|
|
}
|
|
Some("config.toml") if parent == Some(kigi_home_buf.as_path()) => {
|
|
Some(ConfigChangeEvent::GlobalConfigChanged)
|
|
}
|
|
Some("models_cache.json") if parent == Some(kigi_home_buf.as_path()) => {
|
|
Some(ConfigChangeEvent::ModelsCacheChanged)
|
|
}
|
|
Some("config.toml") => {
|
|
Some(ConfigChangeEvent::ProjectConfigChanged { path: path.clone() })
|
|
}
|
|
// `~/.claude.json` routes through
|
|
// the dedicated home-level variant so the
|
|
// reloader can broadcast. Project-level
|
|
// `<cwd>/.claude.json` (and any `.mcp.json`)
|
|
// continues to be a per-cwd reload.
|
|
Some(".claude.json")
|
|
if user_home_buf
|
|
.as_deref()
|
|
.is_some_and(|h| parent_is_dir(parent, h)) =>
|
|
{
|
|
Some(ConfigChangeEvent::HomeClaudeJsonChanged)
|
|
}
|
|
Some(".mcp.json") | Some(".claude.json") => {
|
|
Some(ConfigChangeEvent::McpConfigChanged { path: path.clone() })
|
|
}
|
|
_ => None,
|
|
};
|
|
|
|
if let Some(evt) = change
|
|
&& !batch_events.contains(&evt)
|
|
{
|
|
batch_events.push(evt);
|
|
}
|
|
}
|
|
for evt in batch_events {
|
|
let _ = tx.send(evt);
|
|
}
|
|
})
|
|
.map_err(|e| tracing::warn!(error = %e, "failed to create config file watcher"))
|
|
.ok()?;
|
|
|
|
debouncer
|
|
.watcher()
|
|
.watch(kigi_home, RecursiveMode::NonRecursive)
|
|
.map_err(|e| {
|
|
tracing::warn!(
|
|
path = %kigi_home.display(),
|
|
error = %e,
|
|
"failed to watch grok home directory"
|
|
)
|
|
})
|
|
.ok()?;
|
|
|
|
for p in extra_paths {
|
|
if let Some(parent) = p.parent() {
|
|
let _ = debouncer
|
|
.watcher()
|
|
.watch(parent, RecursiveMode::NonRecursive);
|
|
}
|
|
}
|
|
|
|
// Add the two narrow non-recursive cwd watches
|
|
// promoted to first-class watch targets. Both are non-fatal —
|
|
// a missing directory just means the corresponding files don't
|
|
// exist yet and will be picked up by `watch_path` on the next
|
|
// session that opens in this cwd.
|
|
//
|
|
// When the leader's own cwd is also covered by
|
|
// `extra_paths` (e.g. `find_project_configs(cwd)` already
|
|
// includes `<cwd>/.kigi/config.toml` so the loop above
|
|
// watches `<cwd>/.kigi/`), the call below installs a
|
|
// duplicate watch on the same directory. `notify` dedupes
|
|
// silently in its `RecommendedWatcher` (last-write-wins for
|
|
// the recursion mode), so this is cosmetic — both
|
|
// additions remain non-recursive, no event amplification.
|
|
let mut watched_cwds = HashSet::new();
|
|
if let Some(cwd) = cwd {
|
|
watch_cwd_dirs(&mut debouncer, cwd);
|
|
watched_cwds.insert(cwd.to_path_buf());
|
|
}
|
|
|
|
tracing::info!(
|
|
kigi_home = %kigi_home.display(),
|
|
extra_paths = extra_paths.len(),
|
|
cwd = ?cwd,
|
|
debounce_ms = debounce.as_millis(),
|
|
"config file watcher started"
|
|
);
|
|
|
|
Some((
|
|
Self {
|
|
debouncer,
|
|
watched_cwds,
|
|
},
|
|
rx,
|
|
))
|
|
}
|
|
|
|
/// Register `<cwd>/` and `<cwd>/.kigi/` as **non-recursive** watch
|
|
/// targets, in addition to whatever was passed to [`Self::start`].
|
|
///
|
|
/// Intended for the session-open path: when a session opens in a cwd
|
|
/// the leader hasn't seen before, calling this method ensures edits to
|
|
/// `<cwd>/.mcp.json` and `<cwd>/.kigi/config.toml` trigger a
|
|
/// [`ConfigChangeEvent`] (and downstream [`ConfigUpdate::
|
|
/// ProjectMcpServersChanged`](super::reloader::ConfigUpdate::
|
|
/// ProjectMcpServersChanged)) within the debounce window.
|
|
///
|
|
/// **Non-recursive by design.** Watching `<cwd>` recursively would
|
|
/// walk `node_modules/`, `target/`, `.git/`, etc. and easily exhaust
|
|
/// the per-user inotify quota (`fs.inotify.max_user_watches`,
|
|
/// commonly 8192 by default) on a large repo. If `notify` cannot register the watch (e.g.
|
|
/// the directory doesn't exist yet, or the OS quota is reached) the
|
|
/// error is logged and swallowed — the leader continues to rely on
|
|
/// the user-triggered refresh as the fallback.
|
|
pub fn watch_path(&mut self, cwd: &Path) {
|
|
// Idempotent at our layer: skip the redundant
|
|
// `notify` watch-add when this cwd is already registered, so
|
|
// re-opening sessions in the same directory doesn't churn the
|
|
// OS watcher. `notify` de-dups internally too, but tracking the
|
|
// set here also enables `unwatch_path`.
|
|
if self.watched_cwds.contains(cwd) {
|
|
return;
|
|
}
|
|
watch_cwd_dirs(&mut self.debouncer, cwd);
|
|
self.watched_cwds.insert(cwd.to_path_buf());
|
|
}
|
|
|
|
/// Remove the two non-recursive watches (`<cwd>/` and
|
|
/// `<cwd>/.kigi/`) previously registered for `cwd` via
|
|
/// [`Self::start`] / [`Self::watch_path`].
|
|
///
|
|
/// Best-effort and idempotent: a `cwd` that was never registered
|
|
/// (or already unwatched) is a no-op. Intended for the
|
|
/// session-teardown path so a long-lived leader that opens sessions
|
|
/// across many directories doesn't accumulate inotify watches for
|
|
/// cwds with no live sessions. **Callers must ref-count**: only
|
|
/// unwatch once the *last* session sharing this cwd closes —
|
|
/// `ConfigFileWatcher` tracks distinct cwds, not session counts.
|
|
pub fn unwatch_path(&mut self, cwd: &Path) {
|
|
if !self.watched_cwds.remove(cwd) {
|
|
return;
|
|
}
|
|
unwatch_cwd_dirs(&mut self.debouncer, cwd);
|
|
}
|
|
}
|
|
|
|
/// Component-aware "is `parent` the directory `dir`?" that tolerates
|
|
/// symlink / canonicalization differences between a `notify`-delivered
|
|
/// event path and a `dirs::home_dir()`-style reference. `dir` is
|
|
/// expected to be already canonicalized (see `ConfigFileWatcher::start`).
|
|
fn parent_is_dir(parent: Option<&Path>, dir: &Path) -> bool {
|
|
let Some(parent) = parent else {
|
|
return false;
|
|
};
|
|
parent == dir || dunce::canonicalize(parent).is_ok_and(|p| p == dir)
|
|
}
|
|
|
|
/// Add the two non-recursive watches for a project root.
|
|
///
|
|
/// Both watches are best-effort and log-and-continue on failure (missing
|
|
/// directory, quota exhausted, permission denied, etc.) — the caller has
|
|
/// no reasonable recovery path beyond the existing user-triggered refresh.
|
|
///
|
|
/// **Known limitation:** if `<cwd>/.kigi/` does not yet
|
|
/// exist at session-open time, the `.kigi/` watch fails ENOENT and is
|
|
/// swallowed at `debug!`. A later `mkdir <cwd>/.kigi/` followed by a
|
|
/// write to `<cwd>/.kigi/config.toml` will NOT be observed — the
|
|
/// `<cwd>/` watch is non-recursive, so subdirectory creation isn't
|
|
/// surfaced as a watch-add trigger. Users hitting this case must hit
|
|
/// the explicit refresh button. A robust fix (re-attempt on parent-
|
|
/// directory create) is out of scope here.
|
|
fn watch_cwd_dirs(debouncer: &mut Debouncer<AccessFilteredWatcher>, cwd: &Path) {
|
|
if let Err(e) = debouncer.watcher().watch(cwd, RecursiveMode::NonRecursive) {
|
|
log_watch_error(&e, "failed to watch project cwd (non-recursive)");
|
|
}
|
|
let grok_dir = cwd.join(".kigi");
|
|
if let Err(e) = debouncer
|
|
.watcher()
|
|
.watch(&grok_dir, RecursiveMode::NonRecursive)
|
|
{
|
|
log_watch_error(
|
|
&e,
|
|
"failed to watch project .kigi directory (non-recursive)",
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Remove the two non-recursive watches added by [`watch_cwd_dirs`].
|
|
/// Best-effort: a `WatchNotFound` (never watched / already removed) is
|
|
/// expected and logged at `debug!`.
|
|
fn unwatch_cwd_dirs(debouncer: &mut Debouncer<AccessFilteredWatcher>, cwd: &Path) {
|
|
if let Err(e) = debouncer.watcher().unwatch(cwd) {
|
|
tracing::debug!(error = %e, "failed to unwatch project cwd");
|
|
}
|
|
let grok_dir = cwd.join(".kigi");
|
|
if let Err(e) = debouncer.watcher().unwatch(&grok_dir) {
|
|
tracing::debug!(error = %e, "failed to unwatch project .kigi directory");
|
|
}
|
|
}
|
|
|
|
/// Log a `notify` watch failure, distinguishing the benign
|
|
/// "directory doesn't exist yet" case (logged at `debug!` — it's
|
|
/// expected for a freshly-opened session whose `<cwd>/.kigi/` hasn't
|
|
/// been created) from genuinely actionable failures like
|
|
/// `fs.inotify.max_user_watches` exhaustion or permission denied
|
|
/// (logged at `warn!` — these mean live edits will be silently
|
|
/// missed). Don't swallow every error at the same level.
|
|
fn log_watch_error(err: ¬ify::Error, msg: &str) {
|
|
let not_found = matches!(err.kind, notify::ErrorKind::PathNotFound)
|
|
|| matches!(&err.kind, notify::ErrorKind::Io(io) if io.kind() == std::io::ErrorKind::NotFound);
|
|
if not_found {
|
|
tracing::debug!(error = %err, "{msg} (path not found)");
|
|
} else {
|
|
tracing::warn!(error = %err, "{msg}");
|
|
}
|
|
}
|
|
|
|
/// Watches skill directories (`~/.kigi/skills/`, `<repo>/.kigi/skills/`, etc.)
|
|
/// for new, modified, or removed `SKILL.md` files.
|
|
///
|
|
/// When a change is detected the receiver gets a `()` signal. The caller is
|
|
/// responsible for triggering a skill re-advertisement to connected clients.
|
|
pub struct SkillsFileWatcher {
|
|
_debouncer: Debouncer<AccessFilteredWatcher>,
|
|
}
|
|
|
|
const SKILLS_DEBOUNCE: Duration = Duration::from_secs(2);
|
|
|
|
/// True if an event path is a skill/command change worth reloading: a
|
|
/// `SKILL.md`, anything under `skills/`, or a `*.md` inside `commands/`.
|
|
/// Mirrors discovery (`find_skill_paths`/`find_command_paths`).
|
|
fn is_skill_change_path(p: &Path) -> bool {
|
|
p.file_name().is_some_and(|n| n == "SKILL.md")
|
|
|| p.ancestors()
|
|
.any(|a| a.file_name().is_some_and(|n| n == "skills"))
|
|
|| (p.extension().is_some_and(|e| e == "md")
|
|
&& p.parent()
|
|
.is_some_and(|par| par.file_name().is_some_and(|n| n == "commands")))
|
|
}
|
|
|
|
/// True for a global/home-level config dir that must never be watched
|
|
/// recursively: `kigi_home` (`~/.kigi`, or `$KIGI_SHARE_DIR`) or a known vendor dir
|
|
/// directly under `$HOME` ([`HOME_VENDOR_DIRS`]).
|
|
///
|
|
/// These hold large non-skill trees — `~/.kigi` alone has `worktrees/`,
|
|
/// `sessions/`, `logs/`, `upload_queue/` — so recursing them exhausted the
|
|
/// inotify quota (~780k watches on a devbox) and, since each worktree is a full
|
|
/// checkout, fired skill reloads on ordinary repo activity. They get scoped
|
|
/// watches instead ([`watch_skill_subdirs`]); project/repo dirs — and
|
|
/// user-supplied `[skills].paths` entries, which discovery walks in full — stay
|
|
/// recursive. Matching only these specific names (not "any dir whose parent is
|
|
/// `$HOME`") is what keeps a `[skills].paths = ["~/my-skills"]` fully watched.
|
|
fn is_global_config_dir(dir: &Path, kigi_home: &Path) -> bool {
|
|
#[allow(deprecated)]
|
|
let home = std::env::home_dir();
|
|
is_global_config_dir_impl(dir, kigi_home, home.as_deref())
|
|
}
|
|
|
|
/// Vendor config dir names that sit directly under `$HOME` and carry large
|
|
/// non-skill trees. Kept in sync with the home-level dirs added by
|
|
/// `collect_skill_config_dirs`.
|
|
const HOME_VENDOR_DIRS: &[&str] = &[".kigi", ".agents", ".claude", ".cursor"];
|
|
|
|
/// Testable core of [`is_global_config_dir`] with `$HOME` injected.
|
|
fn is_global_config_dir_impl(dir: &Path, kigi_home: &Path, home: Option<&Path>) -> bool {
|
|
let canon = |p: &Path| dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
|
|
if canon(dir) == canon(kigi_home) {
|
|
return true;
|
|
}
|
|
let Some(home) = home else { return false };
|
|
if dir.parent().map(canon) != Some(canon(home)) {
|
|
return false;
|
|
}
|
|
dir.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.is_some_and(|n| HOME_VENDOR_DIRS.contains(&n))
|
|
}
|
|
|
|
/// Watch only a config dir's skill subtrees — `<dir>/skills` recursively and
|
|
/// `<dir>/commands` flat — never the dir root (see [`is_global_config_dir`]).
|
|
/// Returns the number of watches registered; a missing subdir is skipped (for
|
|
/// `~/.kigi` these exist at startup; later creation is caught on restart).
|
|
fn watch_skill_subdirs(
|
|
debouncer: &mut Debouncer<AccessFilteredWatcher>,
|
|
config_dir: &Path,
|
|
) -> usize {
|
|
let mut watched = 0;
|
|
for (subdir, mode) in [
|
|
("skills", RecursiveMode::Recursive),
|
|
("commands", RecursiveMode::NonRecursive),
|
|
] {
|
|
let dir = config_dir.join(subdir);
|
|
if dir.is_dir() {
|
|
match debouncer.watcher().watch(&dir, mode) {
|
|
Ok(()) => watched += 1,
|
|
Err(e) => log_watch_error(&e, "failed to watch skill subdir"),
|
|
}
|
|
}
|
|
}
|
|
watched
|
|
}
|
|
|
|
impl SkillsFileWatcher {
|
|
/// Start watching skill directories. Returns `None` if the OS watcher
|
|
/// fails to initialize or no directories exist to watch.
|
|
///
|
|
/// Uses [`collect_skill_config_dirs`](kigi_agent::prompt::skills::collect_skill_config_dirs)
|
|
/// as the canonical directory source so the watcher covers the same
|
|
/// locations as skill discovery.
|
|
pub fn start(
|
|
cwd: Option<&Path>,
|
|
monorepo_user_dir: Option<&Path>,
|
|
config_paths: &[String],
|
|
) -> Option<(Self, mpsc::UnboundedReceiver<()>)> {
|
|
let (tx, rx) = mpsc::unbounded_channel();
|
|
|
|
let mut debouncer =
|
|
new_filtered_debouncer(SKILLS_DEBOUNCE, move |res: DebounceEventResult| {
|
|
let Ok(events) = res else { return };
|
|
if events.iter().any(|e| is_skill_change_path(&e.path)) {
|
|
let _ = tx.send(());
|
|
}
|
|
})
|
|
.map_err(|e| tracing::warn!(error = %e, "failed to create skills file watcher"))
|
|
.ok()?;
|
|
|
|
let kigi_home = kigi_tools::util::kigi_home::kigi_home();
|
|
// Watch the full superset of vendor dirs (all-on compat). This watcher
|
|
// is leader-global (no per-session compat resolved here); the actual
|
|
// per-session discovery gating happens downstream, so watching a
|
|
// currently-disabled vendor dir is harmless (a change just re-runs the
|
|
// gated discovery) and avoids ever missing a watch if a toggle flips.
|
|
let dirs_to_watch = kigi_agent::prompt::skills::collect_skill_config_dirs(
|
|
cwd,
|
|
monorepo_user_dir,
|
|
&kigi_home,
|
|
config_paths,
|
|
kigi_tools::types::compat::CompatConfig::default(),
|
|
);
|
|
|
|
let mut watched = 0;
|
|
for dir in &dirs_to_watch {
|
|
if is_global_config_dir(dir, &kigi_home) {
|
|
// Home dir: watch skill subtrees only, never the root
|
|
// (worktrees/, sessions/, logs/ — see `is_global_config_dir`).
|
|
watched += watch_skill_subdirs(&mut debouncer, dir);
|
|
} else {
|
|
// Project/repo dir: bounded, so recurse to catch new `skills/`
|
|
// dirs created mid-session as well as edits to existing files.
|
|
match debouncer.watcher().watch(dir, RecursiveMode::Recursive) {
|
|
Ok(()) => watched += 1,
|
|
Err(e) => log_watch_error(&e, "failed to watch directory for skill changes"),
|
|
}
|
|
}
|
|
}
|
|
|
|
if watched == 0 {
|
|
tracing::debug!("no config directories found to watch for skills");
|
|
return None;
|
|
}
|
|
|
|
tracing::info!(dirs = watched, "skills file watcher started");
|
|
|
|
Some((
|
|
Self {
|
|
_debouncer: debouncer,
|
|
},
|
|
rx,
|
|
))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
use tempfile::TempDir;
|
|
|
|
fn wait_ms(ms: u64) {
|
|
std::thread::sleep(Duration::from_millis(ms));
|
|
}
|
|
|
|
/// `is_global_config_dir` must scope down only kigi_home and the known
|
|
/// vendor dirs under `$HOME` — NOT arbitrary `[skills].paths` entries such
|
|
/// as `~/my-skills`, whose skills discovery walks in full and so must stay
|
|
/// recursively watched.
|
|
#[test]
|
|
fn is_global_config_dir_matches_only_kigi_home_and_vendor_dirs() {
|
|
let home = TempDir::new().unwrap();
|
|
let home = home.path();
|
|
let kigi_home = home.join(".kigi");
|
|
|
|
let g = |dir: &Path| is_global_config_dir_impl(dir, &kigi_home, Some(home));
|
|
|
|
// kigi_home and vendor dirs directly under $HOME: scoped (global).
|
|
assert!(g(&kigi_home));
|
|
assert!(g(&home.join(".claude")));
|
|
assert!(g(&home.join(".cursor")));
|
|
assert!(g(&home.join(".agents")));
|
|
|
|
// A user [skills].paths entry under $HOME: NOT global (stays recursive).
|
|
assert!(!g(&home.join("my-skills")));
|
|
assert!(!g(&home.join(".config")));
|
|
// A project/repo config dir (parent isn't $HOME): NOT global.
|
|
assert!(!g(&home.join("repo").join(".kigi")));
|
|
}
|
|
|
|
/// Regression for the ~/.kigi inotify-exhaustion / worktree-noise bug: a
|
|
/// `SKILL.md` under a sibling subtree (e.g. `~/.kigi/worktrees/`) must not
|
|
/// drive a reload, while a real `<dir>/skills/**/SKILL.md` change still does.
|
|
#[test]
|
|
#[cfg(target_os = "linux")]
|
|
fn skills_watcher_scopes_to_subdirs_not_dir_root() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let global = tmp.path();
|
|
|
|
// Real global skill under <dir>/skills/.
|
|
let alpha = global.join("skills").join("alpha");
|
|
fs::create_dir_all(&alpha).unwrap();
|
|
fs::write(alpha.join("SKILL.md"), "# alpha").unwrap();
|
|
|
|
// A SKILL.md buried in a worktree checkout under the (unwatched) root.
|
|
let wt_skill = global
|
|
.join("worktrees")
|
|
.join("wt1")
|
|
.join(".kigi")
|
|
.join("skills")
|
|
.join("beta");
|
|
fs::create_dir_all(&wt_skill).unwrap();
|
|
fs::write(wt_skill.join("SKILL.md"), "# beta").unwrap();
|
|
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
let mut debouncer = new_filtered_debouncer(
|
|
Duration::from_millis(50),
|
|
move |res: DebounceEventResult| {
|
|
let Ok(events) = res else { return };
|
|
if events.iter().any(|e| is_skill_change_path(&e.path)) {
|
|
let _ = tx.send(());
|
|
}
|
|
},
|
|
)
|
|
.expect("debouncer should build");
|
|
|
|
let watched = watch_skill_subdirs(&mut debouncer, global);
|
|
assert!(watched >= 1, "should watch the <dir>/skills subdir");
|
|
wait_ms(150);
|
|
while rx.try_recv().is_ok() {} // drain startup noise
|
|
|
|
// Editing a SKILL.md under the unwatched worktrees/ subtree must NOT fire.
|
|
fs::write(wt_skill.join("SKILL.md"), "# beta v2").unwrap();
|
|
wait_ms(250);
|
|
assert!(
|
|
rx.try_recv().is_err(),
|
|
"changes under an unwatched sibling subtree (worktrees/) must not \
|
|
trigger a skills reload — proves the dir root is not watched"
|
|
);
|
|
|
|
// Editing the real skill under <dir>/skills must still fire.
|
|
fs::write(alpha.join("SKILL.md"), "# alpha v2").unwrap();
|
|
wait_ms(250);
|
|
assert!(
|
|
rx.try_recv().is_ok(),
|
|
"changes under <dir>/skills must trigger a skills reload"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[cfg_attr(
|
|
target_os = "macos",
|
|
ignore = "flaky on macOS: FSEvents does not reliably deliver events in test harness"
|
|
)]
|
|
fn watcher_detects_auth_json_change() {
|
|
let tmp = TempDir::new().unwrap();
|
|
fs::write(tmp.path().join("auth.json"), "{}").unwrap();
|
|
|
|
let (_w, mut rx) =
|
|
ConfigFileWatcher::start(tmp.path(), &[], None, Some(Duration::from_millis(50)))
|
|
.expect("watcher should start");
|
|
|
|
fs::write(tmp.path().join("auth.json"), r#"{"new":"token"}"#).unwrap();
|
|
wait_ms(300);
|
|
|
|
let mut found = false;
|
|
while let Ok(evt) = rx.try_recv() {
|
|
if evt == ConfigChangeEvent::AuthChanged {
|
|
found = true;
|
|
}
|
|
}
|
|
assert!(found, "should detect auth.json change");
|
|
}
|
|
|
|
/// Regression test for the MCP/skills reload storm (feedback loop):
|
|
/// merely *reading* a watched config file must NOT produce a
|
|
/// `ConfigChangeEvent`. Linux inotify delivers `IN_OPEN`/`IN_ACCESS`
|
|
/// for reads, `notify` subscribes to `OPEN`, and `notify-debouncer-mini`
|
|
/// forwards every kind — so without [`AccessFilteredWatcher`] each
|
|
/// leader-initiated reload's own re-reads of `config.toml` would
|
|
/// schedule the next debounce tick and re-fire forever. A write
|
|
/// afterwards must still be detected (the filter only drops `Access`).
|
|
#[test]
|
|
#[cfg(target_os = "linux")]
|
|
fn watcher_ignores_reads_of_watched_files() {
|
|
let tmp = TempDir::new().unwrap();
|
|
fs::write(tmp.path().join("config.toml"), "a = 1").unwrap();
|
|
fs::write(tmp.path().join("auth.json"), "{}").unwrap();
|
|
|
|
let (_w, mut rx) =
|
|
ConfigFileWatcher::start(tmp.path(), &[], None, Some(Duration::from_millis(50)))
|
|
.expect("watcher should start");
|
|
wait_ms(150);
|
|
while rx.try_recv().is_ok() {} // drain any startup noise
|
|
|
|
// Simulate what the leader does on every reload: read the watched
|
|
// files. Repeatedly, to defeat any incidental coalescing.
|
|
for _ in 0..5 {
|
|
let _ = fs::read(tmp.path().join("config.toml")).unwrap();
|
|
let _ = fs::read(tmp.path().join("auth.json")).unwrap();
|
|
wait_ms(20);
|
|
}
|
|
wait_ms(300);
|
|
|
|
let mut read_events = Vec::new();
|
|
while let Ok(evt) = rx.try_recv() {
|
|
read_events.push(evt);
|
|
}
|
|
assert!(
|
|
read_events.is_empty(),
|
|
"reads of watched files must not emit config-change events \
|
|
(reload-storm feedback loop); got {read_events:?}"
|
|
);
|
|
|
|
// Sanity: a real write is still observed through the filter.
|
|
fs::write(tmp.path().join("config.toml"), "a = 2").unwrap();
|
|
wait_ms(300);
|
|
let mut found = false;
|
|
while let Ok(evt) = rx.try_recv() {
|
|
if evt == ConfigChangeEvent::GlobalConfigChanged {
|
|
found = true;
|
|
}
|
|
}
|
|
assert!(found, "a write must still be detected after read filtering");
|
|
}
|
|
|
|
#[test]
|
|
#[cfg_attr(
|
|
target_os = "macos",
|
|
ignore = "flaky on macOS: FSEvents does not reliably deliver events in test harness"
|
|
)]
|
|
fn watcher_detects_config_toml_change() {
|
|
let tmp = TempDir::new().unwrap();
|
|
fs::write(tmp.path().join("config.toml"), "").unwrap();
|
|
|
|
let (_w, mut rx) =
|
|
ConfigFileWatcher::start(tmp.path(), &[], None, Some(Duration::from_millis(50)))
|
|
.expect("watcher should start");
|
|
|
|
fs::write(tmp.path().join("config.toml"), "[ui]\ntheme = \"dark\"").unwrap();
|
|
wait_ms(300);
|
|
|
|
let mut found = false;
|
|
while let Ok(evt) = rx.try_recv() {
|
|
if evt == ConfigChangeEvent::GlobalConfigChanged {
|
|
found = true;
|
|
}
|
|
}
|
|
assert!(found, "should detect config.toml change");
|
|
}
|
|
|
|
/// A write to `<kigi_home>/models_cache.json` must surface as
|
|
/// `ConfigChangeEvent::ModelsCacheChanged` so a long-running leader can
|
|
/// hot-load a catalog fetched by another grok process.
|
|
#[test]
|
|
#[cfg_attr(
|
|
target_os = "macos",
|
|
ignore = "flaky on macOS: FSEvents does not reliably deliver events in test harness"
|
|
)]
|
|
fn watcher_detects_models_cache_change() {
|
|
let tmp = TempDir::new().unwrap();
|
|
fs::write(tmp.path().join("models_cache.json"), "{}").unwrap();
|
|
|
|
let (_w, mut rx) =
|
|
ConfigFileWatcher::start(tmp.path(), &[], None, Some(Duration::from_millis(50)))
|
|
.expect("watcher should start");
|
|
|
|
fs::write(
|
|
tmp.path().join("models_cache.json"),
|
|
r#"{"fetched_at":"2026-01-01T00:00:00Z","models":{}}"#,
|
|
)
|
|
.unwrap();
|
|
wait_ms(300);
|
|
|
|
let mut found = false;
|
|
while let Ok(evt) = rx.try_recv() {
|
|
if evt == ConfigChangeEvent::ModelsCacheChanged {
|
|
found = true;
|
|
}
|
|
}
|
|
assert!(found, "should detect models_cache.json change");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "flaky on CI: OS file watcher may fail to initialize"]
|
|
fn watcher_ignores_unrelated_files() {
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
let (_w, mut rx) =
|
|
ConfigFileWatcher::start(tmp.path(), &[], None, Some(Duration::from_millis(50)))
|
|
.expect("watcher should start");
|
|
|
|
fs::write(tmp.path().join("leader.log"), "log line").unwrap();
|
|
fs::write(tmp.path().join("leader.lock"), "12345").unwrap();
|
|
wait_ms(300);
|
|
|
|
assert!(
|
|
rx.try_recv().is_err(),
|
|
"should not emit events for unrelated files"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn watcher_debounces_rapid_writes() {
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
// Use a long debounce (500ms) so all rapid writes (50ms total)
|
|
// land in a single debounce window regardless of platform.
|
|
let (_w, mut rx) =
|
|
ConfigFileWatcher::start(tmp.path(), &[], None, Some(Duration::from_millis(500)))
|
|
.expect("watcher should start");
|
|
|
|
wait_ms(200);
|
|
|
|
// 5 rapid writes — total ~50ms, well within the 500ms debounce window
|
|
for i in 0..5 {
|
|
fs::write(tmp.path().join("config.toml"), format!("version = {i}")).unwrap();
|
|
wait_ms(10);
|
|
}
|
|
// Wait for the single debounce tick to fire
|
|
wait_ms(800);
|
|
|
|
let mut count = 0;
|
|
while rx.try_recv().is_ok() {
|
|
count += 1;
|
|
}
|
|
// All writes should coalesce into a small number of events
|
|
// (1 per debounce tick, or a few if OS delivers events in
|
|
// separate batches within the window).
|
|
assert!(count >= 1, "expected at least 1 event, got {count}");
|
|
assert!(count <= 3, "expected coalesced events (<=3), got {count}");
|
|
}
|
|
|
|
/// A write to `<cwd>/.kigi/config.toml` must surface as
|
|
/// a `ConfigChangeEvent::ProjectConfigChanged` so the reloader emits
|
|
/// `ConfigUpdate::ProjectMcpServersChanged { cwd }`. Uses a longer
|
|
/// debounce and explicit poll loop so it survives the slower-than-
|
|
/// usual FSEvents delivery on macOS CI.
|
|
#[test]
|
|
#[cfg_attr(
|
|
target_os = "macos",
|
|
ignore = "flaky on macOS: FSEvents does not reliably deliver events in test harness"
|
|
)]
|
|
fn project_cwd_toml_triggers_reload() {
|
|
let kigi_home = TempDir::new().unwrap();
|
|
let cwd = TempDir::new().unwrap();
|
|
let project_grok = cwd.path().join(".kigi");
|
|
fs::create_dir_all(&project_grok).unwrap();
|
|
// Seed the file before the watcher starts so we observe the
|
|
// modification rather than the creation event.
|
|
fs::write(project_grok.join("config.toml"), "").unwrap();
|
|
|
|
let (_w, mut rx) = ConfigFileWatcher::start(
|
|
kigi_home.path(),
|
|
&[],
|
|
Some(cwd.path()),
|
|
Some(Duration::from_millis(100)),
|
|
)
|
|
.expect("watcher should start");
|
|
|
|
fs::write(
|
|
project_grok.join("config.toml"),
|
|
"[mcp_servers.x]\ncommand = \"/bin/true\"",
|
|
)
|
|
.unwrap();
|
|
|
|
// Poll up to 2s for the event.
|
|
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
|
let mut found = false;
|
|
while std::time::Instant::now() < deadline {
|
|
if let Ok(evt) = rx.try_recv()
|
|
&& matches!(evt, ConfigChangeEvent::ProjectConfigChanged { .. })
|
|
{
|
|
found = true;
|
|
break;
|
|
}
|
|
wait_ms(50);
|
|
}
|
|
assert!(
|
|
found,
|
|
"expected ProjectConfigChanged for <cwd>/.kigi/config.toml within 2s"
|
|
);
|
|
}
|
|
|
|
/// A write to `<cwd>/.mcp.json` must surface as a
|
|
/// `ConfigChangeEvent::McpConfigChanged` so the reloader can fan out
|
|
/// a `ProjectMcpServersChanged { cwd }`. Same FSEvents caveat as
|
|
/// [`project_cwd_toml_triggers_reload`].
|
|
#[test]
|
|
#[cfg_attr(
|
|
target_os = "macos",
|
|
ignore = "flaky on macOS: FSEvents does not reliably deliver events in test harness"
|
|
)]
|
|
fn project_mcp_json_triggers_reload() {
|
|
let kigi_home = TempDir::new().unwrap();
|
|
let cwd = TempDir::new().unwrap();
|
|
fs::write(cwd.path().join(".mcp.json"), "{}").unwrap();
|
|
|
|
let (_w, mut rx) = ConfigFileWatcher::start(
|
|
kigi_home.path(),
|
|
&[],
|
|
Some(cwd.path()),
|
|
Some(Duration::from_millis(100)),
|
|
)
|
|
.expect("watcher should start");
|
|
|
|
fs::write(
|
|
cwd.path().join(".mcp.json"),
|
|
r#"{"mcpServers": {"x": {"command": "/bin/true"}}}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
|
let mut found = false;
|
|
while std::time::Instant::now() < deadline {
|
|
if let Ok(evt) = rx.try_recv()
|
|
&& matches!(evt, ConfigChangeEvent::McpConfigChanged { .. })
|
|
{
|
|
found = true;
|
|
break;
|
|
}
|
|
wait_ms(50);
|
|
}
|
|
assert!(
|
|
found,
|
|
"expected McpConfigChanged for <cwd>/.mcp.json within 2s"
|
|
);
|
|
}
|
|
|
|
/// The cwd watch is **non-recursive** by design. This writes a
|
|
/// file that the watcher's name filter **would** route
|
|
/// (`.mcp.json`) into a deeply nested subdir. If a future
|
|
/// regression flips `RecursiveMode::NonRecursive` → `Recursive`,
|
|
/// recursive notify would surface the write, the name filter would
|
|
/// map it to `McpConfigChanged`, and the test would fail. The file
|
|
/// name must match the filter (`.mcp.json`, not e.g. `file.txt`)
|
|
/// or the filter drops it regardless of recursion mode, so this is
|
|
/// the test that actually guards the constraint.
|
|
#[test]
|
|
#[ignore = "flaky on CI: OS file watcher may fail to initialize"]
|
|
fn nested_subdir_change_does_not_trigger() {
|
|
let kigi_home = TempDir::new().unwrap();
|
|
let cwd = TempDir::new().unwrap();
|
|
let nested = cwd.path().join("some").join("deep").join("nested");
|
|
fs::create_dir_all(&nested).unwrap();
|
|
|
|
let (_w, mut rx) = ConfigFileWatcher::start(
|
|
kigi_home.path(),
|
|
&[],
|
|
Some(cwd.path()),
|
|
Some(Duration::from_millis(100)),
|
|
)
|
|
.expect("watcher should start");
|
|
|
|
// Write a file whose name DOES match the watcher filter —
|
|
// under recursive mode this would surface
|
|
// as a `ConfigChangeEvent`; under non-recursive mode no
|
|
// event must reach `rx`.
|
|
fs::write(
|
|
nested.join(".mcp.json"),
|
|
r#"{"mcpServers": {"x": {"command": "/bin/true"}}}"#,
|
|
)
|
|
.unwrap();
|
|
wait_ms(500);
|
|
|
|
assert!(
|
|
rx.try_recv().is_err(),
|
|
"non-recursive watch must not surface .mcp.json events from <cwd>/some/deep/nested/"
|
|
);
|
|
}
|
|
|
|
/// [`ConfigFileWatcher::watch_path`] registered after
|
|
/// `start` must light up `<new_cwd>/.kigi/config.toml` writes
|
|
/// identically to a cwd passed in at `start`. Exercises the
|
|
/// session-open registration path where the leader learns about a
|
|
/// new project root after the watcher is already running.
|
|
#[test]
|
|
#[cfg_attr(
|
|
target_os = "macos",
|
|
ignore = "flaky on macOS: FSEvents does not reliably deliver events in test harness"
|
|
)]
|
|
fn watch_path_dynamic_registration() {
|
|
let kigi_home = TempDir::new().unwrap();
|
|
let new_cwd = TempDir::new().unwrap();
|
|
let project_grok = new_cwd.path().join(".kigi");
|
|
fs::create_dir_all(&project_grok).unwrap();
|
|
fs::write(project_grok.join("config.toml"), "").unwrap();
|
|
|
|
let (mut watcher, mut rx) = ConfigFileWatcher::start(
|
|
kigi_home.path(),
|
|
&[],
|
|
None,
|
|
Some(Duration::from_millis(100)),
|
|
)
|
|
.expect("watcher should start");
|
|
|
|
watcher.watch_path(new_cwd.path());
|
|
|
|
fs::write(
|
|
project_grok.join("config.toml"),
|
|
"[mcp_servers.y]\ncommand = \"/bin/true\"",
|
|
)
|
|
.unwrap();
|
|
|
|
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
|
let mut found = false;
|
|
while std::time::Instant::now() < deadline {
|
|
if let Ok(evt) = rx.try_recv()
|
|
&& matches!(evt, ConfigChangeEvent::ProjectConfigChanged { .. })
|
|
{
|
|
found = true;
|
|
break;
|
|
}
|
|
wait_ms(50);
|
|
}
|
|
assert!(
|
|
found,
|
|
"watch_path-registered cwd must surface ProjectConfigChanged within 2s"
|
|
);
|
|
}
|
|
|
|
/// Bookkeeping-only (no OS event delivery, so deterministic on
|
|
/// every platform): `watch_path` records the cwd in `watched_cwds`
|
|
/// and is idempotent; `unwatch_path` removes it and is a no-op for
|
|
/// an unknown cwd. Guards the set that backs `unwatch_path` and the
|
|
/// `watch_path` de-dup.
|
|
#[test]
|
|
fn watch_and_unwatch_path_bookkeeping() {
|
|
let kigi_home = TempDir::new().unwrap();
|
|
let cwd = TempDir::new().unwrap();
|
|
let Some((mut watcher, _rx)) = ConfigFileWatcher::start(
|
|
kigi_home.path(),
|
|
&[],
|
|
None,
|
|
Some(Duration::from_millis(100)),
|
|
) else {
|
|
// OS watcher unavailable in this environment; nothing to assert.
|
|
return;
|
|
};
|
|
let p = cwd.path();
|
|
assert!(!watcher.watched_cwds.contains(p));
|
|
|
|
watcher.watch_path(p);
|
|
assert!(watcher.watched_cwds.contains(p));
|
|
|
|
// Idempotent: a second registration doesn't duplicate the entry.
|
|
watcher.watch_path(p);
|
|
assert_eq!(
|
|
watcher
|
|
.watched_cwds
|
|
.iter()
|
|
.filter(|c| c.as_path() == p)
|
|
.count(),
|
|
1,
|
|
);
|
|
|
|
// Unwatch removes it; a second unwatch is a no-op.
|
|
watcher.unwatch_path(p);
|
|
assert!(!watcher.watched_cwds.contains(p));
|
|
watcher.unwatch_path(p);
|
|
assert!(!watcher.watched_cwds.contains(p));
|
|
}
|
|
}
|