Description
The daemon's file-watch feature does not work correctly on Windows. Path-scoped file.changed subscriptions either never fire, or fire on files outside their scope silently, with nothing logged either way.
What's happening: chokidar reports changed files using the platform separator, so on Windows it emits docs\guide.md. The router's glob matcher (globToRegex, source/events/event-router.ts:111) is written for POSIX only it treats / as a literal and compiles * to [^/]. So it tries to match the pattern docs/** against the path docs\guide.md, and the two can never meet. FileWatcherSource.emit (source/events/sources/file-watcher.ts:68) forwards the platform path unchanged, so nothing normalizes it in between.
This produces two distinct failures:
- Silent under-match any pattern containing a path segment (
docs/**, src/**/*.ts) never matches. The daemon starts cleanly, the watcher attaches, events are emitted and evaluated normally. The subscription just never runs, and nothing reports a problem.
- Silent over-match
[^/] does not exclude \, so * crosses directory boundaries on Windows. A root-scoped pattern like *.md matches the nested sub\a.md, dispatching an unattended agent run against a file the user deliberately scoped out.
Every path example in the documentation is affected. docs/features/skills.md publishes paths: ["docs/**"] (lines 48 and 136) and paths: ["k8s/**/*.yaml"] (line 82):
SILENT docs/** vs "docs\\guide.md"
SILENT docs/** vs "docs\\api\\ref.md"
SILENT k8s/**/*.yaml vs "k8s\\deploy.yaml"
SILENT k8s/**/*.yaml vs "k8s\\prod\\deploy.yaml"
MATCH *.md vs "sub\\a.md" <- should NOT match, file is nested
A Windows user who copies the documented example gets a daemon that runs, reports healthy, and does nothing.
Not every pattern fails, which is worth stating plainly: patterns starting with ** match by accident, because .* spans a backslash happily. So **/*.md works while docs/** does not. That inconsistency makes the failure look intermittent rather than systematic, and is likely why it has gone unnoticed.
There is a smaller downstream consequence: SkillDispatcher JSON-stringifies the raw payload into the model's prompt (source/skills/dispatcher.ts:179-180), so a triggered agent receives "docs\\guide.md" with escaped separators rather than the path shape the rest of the tooling uses.
Environment
- OS: Windows 11 (any Windows; macOS and Linux are unaffected)
- Node version: v22.22.3
- Nanocoder version: 1.29.0
- Provider: N/A
- Model: N/A reproducible with no LLM involved; the event router is provider-agnostic
Steps to Reproduce
- On Windows, create a skill with
subscribe: [{kind: file.changed, paths: ["docs/**"]}] the example straight from docs/features/skills.md
- Run
nanocoder daemon start
- Edit any file under
docs/
- The subscription never fires.
nanocoder daemon status shows the daemon healthy and nanocoder daemon logs shows nothing, because no error occurred
Expected Behavior
A paths: ["docs/**"] subscription fires when a file under docs/ changes, on every platform. A root-scoped pattern such as *.md does not match nested files.
Actual Behavior
On Windows the subscription never fires, and root-scoped patterns match out of scope. Output from the test above:
platform : win32
emitted paths : ["docs\\guide.md"]
contains "\\" : true
contains "/" : false
matchGlob("docs/**", emitted) = false
matchGlob("docs/*.md", emitted) = false
matchGlob("**/*.md", emitted) = true
matchGlob("*.md", "sub\\a.md") = true <- should be false, file is nested
The first line confirms the emitted path really does contain a backslash and no forward slash this is the actual output of the shipped FileWatcherSource, not a constructed string.
Logs/Screenshots
Nothing is logged. The daemon starts, the watcher attaches, events are emitted and evaluated normally the pattern simply never matches, so no code path treats it as an error. The absence of any log line is part of the bug: there is no signal that a subscription is dead. The test output above is the evidence.
Additional Context
Proposed spec
Fix: normalize separators to / in FileWatcherSource.emit, and defensively in matchGlob for both pattern and path. Once the path uses /, [^/] stops at a real separator so the over-match disappears along with the under-match.
Why the watcher and not just the matcher: normalizing at the boundary means the router, activity reports, and the payload handed to the model all speak one path shape.
On picomatch: the comment at event-router.ts:100 proposes deleting this helper in favour of chokidar's picomatch. Not free under pnpm's strict linking it isn't resolvable as a transitive dependency, so it would need adding as a direct one. Fine as a follow-up; shouldn't gate this fix.
Tests: table-driven cases in both directions docs/** matches docs\guide.md, *.md does not match sub\a.md. The suite covers POSIX paths only today, which is why CI is green.
Description
The daemon's file-watch feature does not work correctly on Windows. Path-scoped
file.changedsubscriptions either never fire, or fire on files outside their scope silently, with nothing logged either way.What's happening: chokidar reports changed files using the platform separator, so on Windows it emits
docs\guide.md. The router's glob matcher (globToRegex,source/events/event-router.ts:111) is written for POSIX only it treats/as a literal and compiles*to[^/]. So it tries to match the patterndocs/**against the pathdocs\guide.md, and the two can never meet.FileWatcherSource.emit(source/events/sources/file-watcher.ts:68) forwards the platform path unchanged, so nothing normalizes it in between.This produces two distinct failures:
docs/**,src/**/*.ts) never matches. The daemon starts cleanly, the watcher attaches, events are emitted and evaluated normally. The subscription just never runs, and nothing reports a problem.[^/]does not exclude\, so*crosses directory boundaries on Windows. A root-scoped pattern like*.mdmatches the nestedsub\a.md, dispatching an unattended agent run against a file the user deliberately scoped out.Every path example in the documentation is affected.
docs/features/skills.mdpublishespaths: ["docs/**"](lines 48 and 136) andpaths: ["k8s/**/*.yaml"](line 82):A Windows user who copies the documented example gets a daemon that runs, reports healthy, and does nothing.
Not every pattern fails, which is worth stating plainly: patterns starting with
**match by accident, because.*spans a backslash happily. So**/*.mdworks whiledocs/**does not. That inconsistency makes the failure look intermittent rather than systematic, and is likely why it has gone unnoticed.There is a smaller downstream consequence:
SkillDispatcherJSON-stringifies the raw payload into the model's prompt (source/skills/dispatcher.ts:179-180), so a triggered agent receives"docs\\guide.md"with escaped separators rather than the path shape the rest of the tooling uses.Environment
Steps to Reproduce
subscribe: [{kind: file.changed, paths: ["docs/**"]}]the example straight fromdocs/features/skills.mdnanocoder daemon startdocs/nanocoder daemon statusshows the daemon healthy andnanocoder daemon logsshows nothing, because no error occurredExpected Behavior
A
paths: ["docs/**"]subscription fires when a file underdocs/changes, on every platform. A root-scoped pattern such as*.mddoes not match nested files.Actual Behavior
On Windows the subscription never fires, and root-scoped patterns match out of scope. Output from the test above:
The first line confirms the emitted path really does contain a backslash and no forward slash this is the actual output of the shipped
FileWatcherSource, not a constructed string.Logs/Screenshots
Nothing is logged. The daemon starts, the watcher attaches, events are emitted and evaluated normally the pattern simply never matches, so no code path treats it as an error. The absence of any log line is part of the bug: there is no signal that a subscription is dead. The test output above is the evidence.
Additional Context
Proposed spec
Fix: normalize separators to
/inFileWatcherSource.emit, and defensively inmatchGlobfor both pattern and path. Once the path uses/,[^/]stops at a real separator so the over-match disappears along with the under-match.Why the watcher and not just the matcher: normalizing at the boundary means the router, activity reports, and the payload handed to the model all speak one path shape.
On picomatch: the comment at
event-router.ts:100proposes deleting this helper in favour of chokidar's picomatch. Not free under pnpm's strict linking it isn't resolvable as a transitive dependency, so it would need adding as a direct one. Fine as a follow-up; shouldn't gate this fix.Tests: table-driven cases in both directions
docs/**matchesdocs\guide.md,*.mddoes not matchsub\a.md. The suite covers POSIX paths only today, which is why CI is green.