Agent skill
notify-file-watcher
Cross-platform file system watching with the notify crate
Install this agent skill to your Project
npx add-skill https://github.com/johnlindquist/script-kit-next/tree/main/.opencode/skill/notify-file-watcher
SKILL.md
notify-file-watcher
Cross-platform filesystem notification library for Rust. Provides native event-driven watching on macOS (FSEvents), Linux (inotify), and Windows (ReadDirectoryChangesW).
Key Types
RecommendedWatcher
Platform-specific watcher selected automatically. Use recommended_watcher() for convenience:
use notify::{recommended_watcher, RecursiveMode, Watcher};
let mut watcher = recommended_watcher(|res| {
match res {
Ok(event) => println!("event: {:?}", event),
Err(e) => println!("watch error: {:?}", e),
}
})?;
watcher.watch(Path::new("/path"), RecursiveMode::Recursive)?;
Event
Contains:
kind: EventKind- What happenedpaths: Vec<PathBuf>- Affected pathsattrs: EventAttributes- Optional metadata (tracker ID, info, source)
EventKind
Top-level event classification:
Any- Catch-all for unknown eventsAccess(AccessKind)- File opened/closed/executed (not all platforms)Create(CreateKind)- File/folder createdModify(ModifyKind)- Content/name/metadata changedRemove(RemoveKind)- File/folder deletedOther- Meta-events about the watch itself
RecursiveMode
Recursive- Watch directory and all subdirectoriesNonRecursive- Watch only the specified directory
Usage in script-kit-gpui
Watcher Architecture
Script-kit uses three specialized watchers in src/watcher.rs:
-
ConfigWatcher - Watches
~/.scriptkit/kit/config.ts- NonRecursive on parent directory
- Filters events to target filename only
-
ThemeWatcher - Watches
~/.scriptkit/kit/theme.json- Same pattern as ConfigWatcher
-
ScriptWatcher - Watches
~/.scriptkit/kit/main/scripts/and/extensions/- Recursive watching
- Filters by extension (.ts, .js, .md)
- Dynamic watch addition when extensions directory appears
Callback Pattern
Events are forwarded to a control channel for processing:
let (control_tx, control_rx) = channel::<ControlMsg>();
let mut watcher = recommended_watcher({
let tx = control_tx.clone();
move |res: notify::Result<notify::Event>| {
let _ = tx.send(ControlMsg::Notify(res));
}
})?;
Filtering Events
Filter irrelevant events early:
fn is_relevant_event_kind(kind: ¬ify::EventKind) -> bool {
!matches!(kind, notify::EventKind::Access(_)) // Ignore access events
}
fn is_relevant_script_file(path: &Path) -> bool {
// Skip hidden files
if path.file_name().and_then(|n| n.to_str()).map(|n| n.starts_with('.')).unwrap_or(false) {
return false;
}
// Check extensions
matches!(path.extension().and_then(|ext| ext.to_str()), Some("ts") | Some("js") | Some("md"))
}
Debouncing
Manual Debouncing (script-kit approach)
Trailing-edge debounce with per-file tracking:
const DEBOUNCE_MS: u64 = 500;
// Per-file pending events with timestamps
let mut pending: HashMap<PathBuf, (Event, Instant)> = HashMap::new();
// On event: update timestamp
pending.insert(path.clone(), (event, Instant::now()));
// On timeout: flush expired events
let now = Instant::now();
pending.retain(|path, (ev, timestamp)| {
if now.duration_since(*timestamp) >= Duration::from_millis(DEBOUNCE_MS) {
emit_event(ev);
false // Remove from pending
} else {
true // Keep waiting
}
});
Built-in Debouncer (notify-debouncer-mini/full)
For simpler use cases:
use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
use std::time::Duration;
let (tx, rx) = channel();
let mut debouncer = new_debouncer(Duration::from_millis(500), tx)?;
debouncer.watcher().watch(path, RecursiveMode::Recursive)?;
notify-debouncer-mini: Lightweight, simple events notify-debouncer-full: Includes file ID tracking, renames, cache
Event Types
Create Events
notify::EventKind::Create(CreateKind::File) // New file
notify::EventKind::Create(CreateKind::Folder) // New directory
notify::EventKind::Create(CreateKind::Any) // Unknown creation
Modify Events
notify::EventKind::Modify(ModifyKind::Data(DataChange::Content)) // File content changed
notify::EventKind::Modify(ModifyKind::Data(DataChange::Size)) // Size changed
notify::EventKind::Modify(ModifyKind::Name(RenameMode::From)) // Renamed from
notify::EventKind::Modify(ModifyKind::Name(RenameMode::To)) // Renamed to
notify::EventKind::Modify(ModifyKind::Metadata(MetadataKind::Any)) // Attributes changed
Remove Events
notify::EventKind::Remove(RemoveKind::File) // File deleted
notify::EventKind::Remove(RemoveKind::Folder) // Directory deleted
Access Events (platform-specific)
notify::EventKind::Access(AccessKind::Open(AccessMode::Read)) // File opened
notify::EventKind::Access(AccessKind::Close(AccessMode::Write)) // File closed after write
Recursive vs Non-recursive
Use Recursive When:
- Watching a scripts/plugins directory with subdirectories
- Unknown directory structure depth
- Need to catch all nested changes
watcher.watch(&scripts_path, RecursiveMode::Recursive)?;
Use NonRecursive When:
- Watching a single config file (watch parent directory)
- Watching for directory creation in a known location
- Performance-critical with known shallow structure
// Watch parent to catch config file changes including atomic saves
let watch_path = config_path.parent().unwrap();
watcher.watch(watch_path, RecursiveMode::NonRecursive)?;
// Then filter to target file
let touches_target = event.paths.iter()
.any(|p| p.file_name() == Some(target_name));
Error Handling
Supervisor Pattern with Exponential Backoff
Script-kit wraps watchers in a supervisor loop:
const INITIAL_BACKOFF_MS: u64 = 100;
const MAX_BACKOFF_MS: u64 = 30_000;
const MAX_NOTIFY_ERRORS: u32 = 10;
fn compute_backoff(attempt: u32) -> Duration {
let delay_ms = INITIAL_BACKOFF_MS.saturating_mul(2u64.saturating_pow(attempt));
Duration::from_millis(delay_ms.min(MAX_BACKOFF_MS))
}
// In supervisor loop
loop {
match watch_loop(...) {
Ok(()) => break, // Normal shutdown
Err(e) => {
let backoff = compute_backoff(attempt);
sleep(backoff);
attempt += 1;
}
}
}
Consecutive Error Threshold
Restart watcher after too many consecutive errors:
if consecutive_errors >= MAX_NOTIFY_ERRORS {
return Err(notify::Error::generic("Too many consecutive notify errors"));
}
Storm Coalescing
When many events arrive quickly (git operations, bulk copy), collapse to a single reload:
const STORM_THRESHOLD: usize = 200;
if pending.len() >= STORM_THRESHOLD {
pending.clear();
full_reload_at = Some(Instant::now()); // Emit single FullReload after debounce
}
Atomic Save Handling
Editors save files differently:
- Truncate: Modify existing file (simple modify event)
- Atomic: Write to temp file, rename/move over original (Delete + Create sequence)
Merge delete+create into FileChanged:
fn merge_script_event(pending: &mut HashMap<PathBuf, (Event, Instant)>, path: &PathBuf, new_event: Event, timestamp: Instant) {
if let Some((existing, _)) = pending.get(path) {
let merged = match (existing, &new_event) {
(FileDeleted(_), FileCreated(_)) | (FileCreated(_), FileDeleted(_)) => {
Some(FileChanged(path.clone()))
}
_ => None,
};
if let Some(merged) = merged {
pending.insert(path.clone(), (merged, timestamp));
return;
}
}
pending.insert(path.clone(), (new_event, timestamp));
}
Anti-patterns
1. Blocking in Callback
Bad: The callback runs on notify's internal thread
// DON'T: blocks notify's event processing
let watcher = recommended_watcher(|res| {
heavy_processing(res); // Blocks!
})?;
Good: Forward to channel, process elsewhere
let watcher = recommended_watcher(move |res| {
let _ = tx.send(res); // Non-blocking
})?;
2. Ignoring Platform Differences
Different platforms emit different event sequences. Always handle:
EventKind::AnyandEventKind::Other- Missing sub-kind information (e.g.,
ModifyKind::Any)
3. Not Handling Watcher Lifetime
The watcher stops when dropped. Keep it alive:
// DON'T
{
let watcher = recommended_watcher(...)?;
} // Watcher dropped, watching stops!
// DO
struct MyApp {
_watcher: Box<dyn Watcher>, // Keep alive
}
4. Watching Non-existent Paths
Notify errors if path doesn't exist. Check first or handle the error:
if path.exists() {
watcher.watch(&path, RecursiveMode::Recursive)?;
}
5. Not Debouncing
Raw events can be noisy. Always debounce for UI/reload triggers.
6. Watching Network Filesystems
NFS, SMB, and WSL paths may not emit events. Use PollWatcher as fallback:
use notify::poll::PollWatcher;
let watcher = PollWatcher::new(callback, Config::default()
.with_poll_interval(Duration::from_secs(2)))?;
Platform-Specific Notes
macOS (FSEvents)
- Requires watching parent directory for single-file changes
- May not emit events for files you don't own
- Use
macos_kqueuefeature for kqueue backend instead
Linux (inotify)
- Has per-user watch limits (
/proc/sys/fs/inotify/max_user_watches) - Increase with:
sysctl fs.inotify.max_user_watches=524288 - Not 100% reliable for large directories
Windows (ReadDirectoryChangesW)
- Generally reliable
- May have issues with network paths
Quick Reference
use notify::{
recommended_watcher,
RecursiveMode,
Result,
Watcher,
Event,
EventKind,
event::{CreateKind, ModifyKind, RemoveKind, AccessKind},
};
use std::path::Path;
use std::sync::mpsc::channel;
fn watch_files() -> Result<()> {
let (tx, rx) = channel();
let mut watcher = recommended_watcher(move |res: Result<Event>| {
if let Ok(event) = res {
let _ = tx.send(event);
}
})?;
watcher.watch(Path::new("./src"), RecursiveMode::Recursive)?;
for event in rx {
match event.kind {
EventKind::Create(_) => println!("Created: {:?}", event.paths),
EventKind::Modify(_) => println!("Modified: {:?}", event.paths),
EventKind::Remove(_) => println!("Removed: {:?}", event.paths),
_ => {}
}
}
Ok(())
}
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
Generate Component Documentation
Based on existing docs styles and specific API implementations, and referencing same name stories, generate comprehensive documentation for the new component.
Generate Component Story
Generate a comprehensive story for a new component for as example.
new-component
How to write a new component of GPUI Component.
troubleshooting
Diagnose and fix common Script Kit issues. Use when the user reports bugs, crashes, missing features, or unexpected behavior in Script Kit GPUI.
script-authoring
Create and manage TypeScript scripts for Script Kit. Use when the user wants to write a new script, edit an existing script, or understand Script Kit's SDK and metadata system.
agents
Create mdflow-backed agent files for Script Kit. Use when the user wants to create AI agents, configure agent backends (Claude, Gemini, Codex), or manage agent metadata.
Didn't find tool you were looking for?