Desktop File Watchers skill for Codex
Process each dropped file once, fully written. Codex builds folder-triggered automation that waits until a file has stopped changing, moves it into a processing folder before touching it, and files it under done or failed with a reason. A startup scan catches files that arrived while the watcher was down, and a bundled standard-library poller handles network shares and sync folders where native events cannot be trusted.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- desktop-file-watchers Installs to ~/.agents/skills/desktop-file-watchers/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install desktop-file-watchers The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project desktop-file-watchers Installs to .agents/skills/desktop-file-watchers/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/desktop-file-watchers.zipunzip -o desktop-file-watchers.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) desktop-file-watchers PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $desktop-file-watchers, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds folder-triggered automation that processes files dropped into a directory - watchdog (Python), chokidar (Node), inotifywait, fswatch, launchd WatchPaths, systemd path units and PowerShell FileSystemWatcher - with write-completion detection, debouncing, an inbox/processing/done layout and restart-safe catch-up. Use when the user wants something to happen whenever a file lands in a folder (Downloads, a scanner inbox, a synced share), a watcher fires on half-written files or twice per file, or a watcher misses files after reboot.
Use it when
- Something should run automatically when files appear in a folder
- A watcher fires on half-written files or twice per file
- Files dropped while the machine was off or the watcher was down are missed
- The folder is on a network share or cloud-synced drive
Use something else when
- You need a one-off bulk rename or cleanup, not a watcher: use file-organization
- You are watching websites or feeds rather than folders: use rss-feed-monitoring
How to check the result
Drop small, slowly copied, temporary-named, duplicate-named and corrupt files and confirm each ends up once in done or failed, then stop the watcher, add files and confirm the catch-up scan handles them.
The mistake it prevents
Processing inside the created event, which reads files that are still being copied and handles one save several times.
Done well means: each file is processed exactly once, only after it has finished being
written, files that arrived while the watcher was down are picked up at start, and a bad
file lands in failed/ with a reason instead of crashing the loop. Native file events are
hints, not facts; the folder’s contents are the truth.
Pick the mechanism
| Platform / need | Default | Notes |
|---|---|---|
| Cross-platform script | Python watchdog or Node chokidar | Easy to test; needs a process kept alive |
| Linux shell pipeline | inotifywait -m -e close_write -e moved_to | close_write means the writer closed the file |
| Linux, no long-running process | systemd .path unit | systemd starts a service on change |
| macOS, no long-running process | launchd agent with WatchPaths or QueueDirectories | Built-in, survives reboot |
| macOS shell pipeline | fswatch (Homebrew) | FSEvents; coalesces events |
| Windows | PowerShell FileSystemWatcher or a Task Scheduler + poll | See $windows-automation for scheduling |
| Network share, NAS, cloud-synced folder | Polling with a settle check | Events from SMB/NFS/sync clients are unreliable |
When in doubt, poll. A scan every 2 to 5 seconds with a settle check is simple, works on every filesystem, and is what the bundled script does.
Folder layout that makes “exactly once” easy
Inbox/ <- users and apps drop files here
.processing/ <- worker moves a file here before touching it (same volume: atomic rename)
done/ <- successful originals (or delete only if the user asked)
failed/ <- file + file.error.txt with the reason
Moving the file out of the inbox before processing is the lock: a second event, a second worker or a restart cannot pick it up again. Keep the subfolders on the same volume so the move is an atomic rename; across volumes it becomes a copy plus delete.
Workflow
- [ ] 1. Confirm the folder, file types, what "process" means, and where results go
- [ ] 2. Check the filesystem: local disk, network share or sync client (decides events vs polling)
- [ ] 3. Write process(path) as a plain function; test it on sample files
- [ ] 4. Add the watcher: startup scan + events (or polling) + settle check
- [ ] 5. Add the inbox/.processing/done/failed moves and a log file
- [ ] 6. Run it in the foreground, drop test files (small, large, partial copy, duplicate name)
- [ ] 7. Install it as a service (launchd/systemd/Task Scheduler) after the user agrees
Installing a launch agent, systemd unit or scheduled task changes the user’s machine and may touch paths outside the Codex workspace (sandbox approval needed). Show the unit file and the install command, and wait for a yes.
Write-completion detection
Events fire when a file is created, long before a 2 GB copy finishes. Use one of:
- Writer signals completion:
close_write(inotify) ormoved_towhen the writer writesname.tmpthen renames. Best when you control the writer. - Settle check: size and mtime unchanged for N seconds (3 s locally, 10 to 30 s on network shares). Works everywhere.
- Sidecar marker: the producer writes
file.pdf.doneafterfile.pdf. Best for machine-to-machine drops.
Ignore temporary names: *.part, *.crdownload, *.tmp, ~$* (Office lock files),
.~lock.* (LibreOffice), *.swp, .DS_Store, *.icloud placeholders.
Bundled polling watcher
scripts/settle_watch.py is a standard-library poller with the settle check and the
ignore list built in. It prints one JSON line per ready file and can run a command per file:
python3 scripts/settle_watch.py ~/Scans/Inbox --pattern '*.pdf' --settle 5 \
--exec 'python3 /opt/tools/file_scan.py'
python3 scripts/settle_watch.py ./inbox --once --settle 2 # catch-up run, then exit
--exec runs without a shell and appends the path as the last argument, so file names
with spaces or quotes are safe. Exit code is 1 if any command failed (with --once), 2 for
a bad folder.
Event-driven examples
Python, watchdog (catch-up scan at start, debounce through a settle queue):
import threading, time
from pathlib import Path
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer # PollingObserver for network shares
INBOX = Path.home() / "Scans/Inbox"
pending: dict[Path, float] = {}
lock = threading.Lock() # observer callbacks run on another thread
class Handler(FileSystemEventHandler):
def on_created(self, e): self._touch(e.src_path, e.is_directory)
def on_modified(self, e): self._touch(e.src_path, e.is_directory)
def on_moved(self, e): self._touch(e.dest_path, e.is_directory)
def _touch(self, p, is_dir):
if not is_dir and p.endswith(".pdf"):
with lock:
pending[Path(p)] = time.monotonic()
for p in INBOX.glob("*.pdf"): # files that arrived while we were down
pending[p] = 0.0
obs = Observer(); obs.schedule(Handler(), str(INBOX), recursive=False); obs.start()
try:
while True:
now = time.monotonic()
with lock:
ready = [p for p, t in pending.items() if now - t >= 3]
for p in ready:
pending.pop(p)
for p in ready:
if p.exists():
handle(p) # move to .processing, process, move to done/failed
time.sleep(0.5)
finally:
obs.stop(); obs.join()
handle should re-check size stability before moving if writers are slow, and catch
every exception so one bad file does not stop the observer.
Node, chokidar (v4+ dropped glob patterns; filter in code):
import chokidar from 'chokidar';
chokidar.watch('/srv/inbox', {
ignoreInitial: false, // emit existing files at start
awaitWriteFinish: { stabilityThreshold: 3000, pollInterval: 200 },
ignored: (p, stats) => stats?.isFile() && !p.endsWith('.csv'),
depth: 0,
}).on('add', path => queue.push(path));
Linux shell:
inotifywait -m -e close_write -e moved_to --format '%w%f' /srv/inbox |
while IFS= read -r f; do
case "$f" in *.csv) ./ingest.sh "$f" || mv -- "$f" /srv/inbox/failed/ ;; esac
done
macOS launchd agent (~/Library/LaunchAgents/com.example.scan-inbox.plist):
<plist version="1.0"><dict>
<key>Label</key><string>com.example.scan-inbox</string>
<key>ProgramArguments</key><array>
<string>/usr/bin/python3</string><string>/Users/me/bin/process_inbox.py</string>
</array>
<key>QueueDirectories</key><array><string>/Users/me/Scans/Inbox</string></array>
<key>StandardErrorPath</key><string>/Users/me/Library/Logs/scan-inbox.log</string>
</dict></plist>
QueueDirectories keeps relaunching the job while the folder is non-empty, so the script
must move every file out (to done or failed) or it loops. Load with
launchctl bootstrap gui/$(id -u) <plist>.
systemd user units: inbox.path with [Path] DirectoryNotEmpty=%h/inbox and
Unit=inbox.service; the service runs a one-shot catch-up script. Enable with
systemctl --user enable --now inbox.path.
Gotchas
- One save in an editor or Office produces several events (temp file, rename, modify). Always debounce; never process directly inside the event callback.
- macOS privacy (TCC) blocks background processes from Desktop, Documents and Downloads until the user grants access to the interpreter or app in System Settings. A launchd job that “does nothing” is often this; check its stderr log.
- Linux inotify watch limits (
fs.inotify.max_user_watches) cap recursive watches on big trees; raising it is a root-level sysctl change that needs the user’s approval. - Windows
FileSystemWatcherdrops events when its buffer overflows during bursts; handle itsErrorevent by rescanning the folder. - Cloud sync clients (Dropbox, OneDrive, iCloud) create placeholders and partial files and may re-touch files after upload. Use a longer settle time and ignore placeholder names.
- Never delete originals by default; move them to
done/. Deleting needs explicit consent. - Name collisions in
done/: append a timestamp or hash instead of overwriting.
Verification
- Drop: a small file, a large file copied slowly, a
.partthat is later renamed, two files with the same name in sequence, and a corrupt file. Each ends in exactly one of done or failed, and the log shows one processing line each. - Stop the watcher, drop files, start it: the catch-up scan processes them.
- Reboot or log out and in (for launchd/systemd installs) and repeat one drop.
Handover
**Watches:** ~/Scans/Inbox (*.pdf), launchd QueueDirectories agent com.example.scan-inbox
**Ready rule:** size and mtime stable for 5 s; temp and placeholder names ignored
**Flow:** Inbox -> .processing -> done/ (or failed/ + .error.txt)
**Logs:** ~/Library/Logs/scan-inbox.log
**Uninstall:** launchctl bootout gui/$(id -u)/com.example.scan-inbox; remove the plist What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Desktop File Watchers"
short_description: "Process each dropped file once, fully written"
default_prompt: "$desktop-file-watchers Whenever a PDF lands in my Scans folder, OCR it and move it to Archive" scripts/settle_watch.py
#!/usr/bin/env python3
"""Poll a folder and report files only once they have stopped changing.
Standard library only. Works on local disks, network shares and synced folders where
native file events are missing or unreliable.
Each settled file is printed as one JSON line:
{"path": "...", "size": 1234, "mtime": 1730000000.0}
Usage:
python3 settle_watch.py INBOX [--pattern '*.pdf'] [--settle 3] [--interval 1]
[--recursive] [--exec 'python3 handle.py'] [--once]
[--timeout 60]
--exec appends the file path as the last argument and runs without a shell.
--once processes files present at start, waits until they settle, then exits
(exit 1 if any --exec command failed or files never settled before --timeout).
"""
from __future__ import annotations
import argparse
import fnmatch
import json
import os
import shlex
import subprocess
import sys
import time
from pathlib import Path
IGNORE = [
"*.part", "*.crdownload", "*.download", "*.tmp", "*.temp", "*.swp", "*.swx",
"~$*", ".~lock.*", ".DS_Store", "Thumbs.db", "desktop.ini", ".*.sb-*", "*.icloud",
]
def scan(root: Path, patterns: list[str], recursive: bool) -> dict[str, tuple[int, float]]:
found: dict[str, tuple[int, float]] = {}
walker = os.walk(root) if recursive else [(str(root), [], os.listdir(root))]
for dirpath, _dirs, files in walker:
for name in files:
if any(fnmatch.fnmatch(name, pat) for pat in IGNORE):
continue
if patterns and not any(fnmatch.fnmatch(name, pat) for pat in patterns):
continue
path = os.path.join(dirpath, name)
try:
st = os.stat(path)
except FileNotFoundError:
continue # vanished between listdir and stat (moved or deleted)
if not os.path.isfile(path):
continue
found[path] = (st.st_size, st.st_mtime)
return found
def run_exec(cmd: str, path: str) -> int:
argv = shlex.split(cmd) + [path]
try:
return subprocess.run(argv, check=False).returncode
except OSError as exc:
print(f"exec failed for {path}: {exc}", file=sys.stderr)
return 127
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("folder")
ap.add_argument("--pattern", action="append", default=[], help="glob, repeatable")
ap.add_argument("--settle", type=float, default=3.0, help="seconds unchanged before ready")
ap.add_argument("--interval", type=float, default=1.0, help="seconds between scans")
ap.add_argument("--recursive", action="store_true")
ap.add_argument("--exec", dest="exec_cmd", help="command to run per settled file")
ap.add_argument("--once", action="store_true", help="handle files present now, then exit")
ap.add_argument("--timeout", type=float, default=0, help="stop after N seconds (0 = never)")
args = ap.parse_args()
root = Path(args.folder).expanduser()
if not root.is_dir():
print(f"not a directory: {root}", file=sys.stderr)
return 2
first_seen: dict[str, tuple[tuple[int, float], float]] = {} # path -> (signature, since)
emitted: dict[str, tuple[int, float]] = {}
failures = 0
start = time.monotonic()
initial = set(scan(root, args.pattern, args.recursive)) if args.once else set()
try:
while True:
now = time.monotonic()
current = scan(root, args.pattern, args.recursive)
if args.once:
current = {p: s for p, s in current.items() if p in initial}
for path, sig in current.items():
prev = first_seen.get(path)
if prev is None or prev[0] != sig:
first_seen[path] = (sig, now) # new or still changing: restart clock
continue
if emitted.get(path) == sig:
continue
if now - prev[1] >= args.settle:
emitted[path] = sig
print(json.dumps({"path": path, "size": sig[0], "mtime": sig[1]}), flush=True)
if args.exec_cmd:
rc = run_exec(args.exec_cmd, path)
if rc != 0:
failures += 1
print(f"exec exit {rc} for {path}", file=sys.stderr)
for gone in set(first_seen) - set(current):
first_seen.pop(gone, None)
emitted.pop(gone, None)
if args.once and all(p in emitted for p in current):
return 1 if failures else 0
if args.timeout and now - start >= args.timeout:
if args.once:
pending = [p for p in current if p not in emitted]
print(f"timed out; {len(pending)} file(s) never settled", file=sys.stderr)
return 1
return 1 if failures else 0
time.sleep(args.interval)
except KeyboardInterrupt:
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.