docs/superpowers/specs/2026-04-28-claude-channel-filewatch-design.md
Ref: Size: 11.6 KiB
# `@claude` File-Watcher MCP — Design
**Date:** 2026-04-28
**Status:** Draft, ready for review
## Goal
Let a developer write `@claude <ask>` inside a code comment, save the file, and have a running Claude Code session pick up the ask automatically — with file path, line number, and the comment text — then handle it (answer in-session for questions, edit the file for work asks). Claude removes the marker as part of handling.
## Scope
Solo, local. Single developer machine. One Claude Code session running. Single project directory under watch.
Out of scope for v1: multi-user / team / shared bot, remote triggering (`RemoteTrigger`), GitHub PR integration, response-back-into-the-file workflows, multi-line block-comment markers (single-line comments only).
## Architecture
```
┌──────────────────────────────────────┐
│ Claude Code session │
│ │
│ receives <channel> notifications │
│ reads file, edits, removes marker │
└──────────────┬───────────────────────┘
│ stdio (MCP)
┌──────────────▼───────────────────────┐
│ filewatch-mcp (Go binary) │
│ │
│ • fsnotify watcher (WRITE+CREATE+ │
│ RENAME, re-watch on rename) │
│ • scanner: comment-syntax table, │
│ distinguishes tagged vs. untagged │
│ • rewriter: atomic UUID stamping │
│ • mcp: notifications/claude/channel │
└──────────────────────────────────────┘
```
Single Go binary spawned by Claude Code as an MCP child process over stdio. No state file, no daemons, no IRC.
## Key design decision: file is the state
Untagged marker `// @claude write a test` is the source of "this needs handling." On first detection, the watcher rewrites the line in place to `// @claude[fw-a1b2c3d4] write a test`. The bracketed UUID **is** the dedupe state. Tagged markers are ignored on subsequent scans. Claude removes the whole tagged line when handling the ask, returning the file to a clean state.
Consequences:
- No `state.json`, no `flock`, no multi-session race.
- No feedback-loop suppression: Claude's `Edit` triggers fsnotify, scanner sees a tagged (or absent) marker, no fire.
- The watcher modifies the user's file. Editors will detect the on-disk change and either auto-reload (VS Code, Vim with `autoread`) or prompt (JetBrains). Documented in Open Risks.
## Components
### `internal/watcher`
- Wraps `fsnotify` with op mask `WRITE | CREATE | RENAME`.
- Atomic saves (tmpfile + rename, used by Vim, JetBrains, VS Code with `files.atomicSave`) deliver `RENAME`/`CREATE`, not `WRITE` — all three must trigger a scan.
- On Linux, fsnotify watches inodes; after a rename-replace the watch points at the deleted inode. Watcher must re-establish the watch on the new path when a `RENAME` arrives.
- 500 ms per-file trailing-edge debounce (JetBrains autosave bursts >200 ms apart).
- Symlinks: not followed. Watcher uses `Lstat` and skips them.
- Respects `.gitignore` plus hard-excludes: `.git/`, `node_modules/`, `dist/`, `build/`, `target/`, `.venv/`, `*.swp`, `*.tmp`, `*.lock`.
### `internal/scanner`
- Per-extension comment-syntax table:
- `//` — js, ts, jsx, tsx, go, c, cpp, h, hpp, rs, java, kt, swift, scala, cs
- `#` — py, rb, sh, bash, zsh, yaml, yml, toml, dockerfile, makefile (matched by name)
- `--` — sql, lua, hs, elm
- `/* */` block — c-family, css (single-line `/* ... */` only; multi-line out of scope)
- `<!-- -->` — html, xml, md (single-line only)
- Unrecognized extensions are skipped.
- Size cap: files > 1 MB are skipped.
- Binary sniff: if the first 8 KB contains a null byte, skip.
- A line is a marker iff: it is a single-line comment in the file's language **and** matches one of:
- **Untagged:** `@claude` followed by whitespace + non-empty payload. Fires.
- **Tagged:** `@claude[fw-XXXXXXXX]` (8 hex chars) followed by whitespace + payload. Skipped.
- Returns `[]Marker{ Line int, Text string, Tagged bool }`. `Text` is the marker payload starting at `@claude`, with comment delimiters and trailing block-comment terminators (`*/`, `-->`) stripped.
### `internal/rewriter`
- Given a file path and a list of untagged markers (line numbers + UUIDs to stamp), rewrites the file:
1. Read whole file.
2. For each marker line, splice `[fw-XXXXXXXX]` immediately after `@claude` (preserving leading whitespace, comment delimiter, and the rest of the line).
3. Write to a sibling tmpfile in the same directory.
4. `os.Rename` over the original. Atomic on POSIX.
- Preserves file mode (`Stat` → `Chmod`).
- Aborts and skips the rewrite if the file's mtime changed between read and rename (someone — likely the user's editor — wrote to it concurrently). The marker stays untagged and will be re-attempted on the next save.
### `internal/mcp`
- MCP stdio server using `github.com/modelcontextprotocol/go-sdk`.
- **Init handshake must declare the channel capability**, or Claude Code silently skips registration:
```json
{
"jsonrpc": "2.0", "id": 0,
"result": {
"protocolVersion": "<echo client's>",
"capabilities": {
"experimental": {
"claude/channel": {}
}
},
"serverInfo": { "name": "filewatch-mcp", "version": "..." }
}
}
```
- For each fired (untagged) marker, emits one outbound notification. **All `meta` values must be strings** — Claude Code's schema is `Record<string, string>`; non-string values are silently rejected by validation.
```json
{
"jsonrpc": "2.0",
"method": "notifications/claude/channel",
"params": {
"content": "@claude can you write a test for foo",
"meta": {
"source": "filewatch",
"file": "src/foo.ts",
"line": "42",
"replyTo": "fw-a1b2c3d4"
}
}
}
```
Claude renders this as `<channel source="filewatch" file="src/foo.ts" line="42" replyTo="fw-a1b2c3d4">@claude can you write a test for foo</channel>`. The `replyTo` UUID matches the bracketed tag stamped into the file.
- No reply tool exposed in v1. Claude's `Edit` to remove the marker is the implicit ack.
- Confirmed via spike on Claude Code v2.1.121 with `--dangerously-load-development-channels server:<name>`.
### `internal/ignore`
- `.gitignore` parsing via `github.com/sabhiram/go-gitignore`.
- Hard-exclude list takes precedence.
- `.gitignore` re-read on its own change events (so updates are picked up live).
## Data Flow
1. User edits `src/foo.ts`, adds `// @claude write a test for foo`, saves.
2. Editor writes via tmpfile + rename → fsnotify delivers `CREATE` (and possibly `RENAME`); watcher re-establishes inode watch and triggers scan.
3. Watcher debounces 500 ms; scanner reads the file.
4. Scanner returns one marker: `{ Line: 42, Text: "@claude write a test for foo", Tagged: false }`.
5. MCP generates UUID `fw-a1b2c3d4`, emits `notifications/claude/channel`.
6. Rewriter atomically rewrites line 42 to `// @claude[fw-a1b2c3d4] write a test for foo`.
7. Claude Code session displays the `<channel>` block; Claude reads `src/foo.ts`, writes the test, removes line 42 with `Edit`.
8. Both the rewrite (step 6) and Claude's edit (step 7) trigger fresh fsnotify events. Scanner sees only tagged (or no) markers. Nothing fires.
## Lifecycle
- **Startup:** no full-tree scan. Watch saves going forward only. Pre-existing untagged markers stay dormant until the file is touched.
- **Shutdown:** clean fsnotify close on SIGINT/SIGTERM. No state to persist.
## Configuration
`.mcp.json` registration (this is what users add to their project):
```json
{
"mcpServers": {
"filewatch": {
"command": "filewatch-mcp",
"args": ["--root", "."]
}
}
}
```
Flags:
- `--root` (default `.`) — project root to watch.
- `--debounce` (default `500ms`) — trailing-edge per-file debounce.
No JSON config file in v1. Add when a real need surfaces.
Requires Claude Code with `--channels` enabled.
## Project Layout
```
claudealong/
├── go.mod
├── cmd/filewatch-mcp/main.go
├── internal/
│ ├── watcher/
│ ├── scanner/
│ ├── rewriter/
│ ├── mcp/
│ └── ignore/
├── .mcp.json # template for users
├── Makefile
├── docs/superpowers/specs/
└── README.md
```
## Makefile
Composable targets:
- `make build` — `go build -o bin/filewatch-mcp ./cmd/filewatch-mcp`
- `make test` — `go test ./...`
- `make lint` — `golangci-lint run`
- `make run` — `go run ./cmd/filewatch-mcp --root .`
- `make install` — `go install ./cmd/filewatch-mcp`
- `make clean` — `rm -rf bin/`
## Implementation order
1. **SDK spike (1 day max).** Confirm `github.com/modelcontextprotocol/go-sdk` lets a server send a custom `notifications/claude/channel` outbound notification, end-to-end, into a real Claude Code session with `--channels`. If the SDK rejects custom method names, this design is dead and we revisit (likely: build directly on JSON-RPC 2.0 stdio without the SDK).
2. Scanner + comment-syntax table + tagged/untagged distinction.
3. Rewriter with atomic-rename and mtime-conflict abort.
4. Watcher with full op mask + inode re-watch on rename.
5. MCP wiring + notification emission.
6. Integration test: temp dir, simulated saves via different editors (atomic vs. in-place), assert the right JSON-RPC frames on stdout.
## Testing
- **Unit:**
- `scanner`: comment-syntax detection per extension, tagged-vs-untagged, payload extraction, size cap, binary sniff.
- `rewriter`: atomic write semantics, mtime-conflict abort, mode preservation, idempotency on already-tagged lines.
- `ignore`: `.gitignore` matching, hard-exclude precedence, live re-read on `.gitignore` change.
- **Integration:**
- Boot the MCP server with a temp project dir.
- Simulate atomic save (tmpfile + rename); assert WRITE/CREATE/RENAME all trigger scan.
- Write a file with `// @claude X`, assert the JSON-RPC frame on stdout matches the expected `notifications/claude/channel` shape AND the file is rewritten with `[fw-XXXXXXXX]`.
- Edit the file to remove the marker, assert no refire.
- Edit the file to add a new untagged marker beside the tagged one, assert only the new one fires.
- **Manual smoke:**
- Run against a real Claude Code session with `--channels`. Add `// @claude what does this do` above a function in VS Code, Vim, and a JetBrains IDE in turn; eyeball the channel block in-session and confirm Claude responds and removes the line.
## Open Risks
- **`notifications/claude/channel` is Claude-Code-specific** and the official Go SDK may validate method names against known MCP methods. Mitigated by the day-1 SDK spike (Implementation step 1). Fallback if the SDK rejects: write directly to the stdio JSON-RPC layer without the SDK.
- **Inline UUID stamping modifies the user's file mid-session.** Editors with auto-reload (VS Code, Vim+`autoread`) handle this transparently. JetBrains and others may prompt. Document in README so users are not surprised. Mitigated by atomic-rename + mtime-conflict abort so we never clobber concurrent user writes.
- **fsnotify on Linux watches inodes**, not paths. Atomic-save editors break naive watches. Mitigated by re-establishing the watch on every `RENAME`/`CREATE` event.
- **`.gitignore` semantics are subtle.** Use a vetted parser, don't roll our own.
- **Single-line comments only.** Multi-line block-comment markers (e.g., `/* @claude\n foo */`) silently won't fire. Document in README; revisit if real users hit this.