Skip to content

[Bug] Windows: event-router glob matching assumes "/" separators, so file.changed subscriptions silently miss files or fire out of scope #964

Description

@addyCooks

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

  1. On Windows, create a skill with subscribe: [{kind: file.changed, paths: ["docs/**"]}] the example straight from docs/features/skills.md
  2. Run nanocoder daemon start
  3. Edit any file under docs/
  4. 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.

  • I have searched existing issues to ensure this is not a duplicate
  • I can reproduce this issue consistently
  • This issue occurs with the latest version of nanocoder

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions