pi-soly 1.9.3 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/ask/index.ts +12 -11
  2. package/ask/picker.ts +356 -76
  3. package/ask/prompt.ts +6 -2
  4. package/ask/tests/picker.test.ts +273 -82
  5. package/codemap.ts +276 -0
  6. package/hotreload.ts +239 -0
  7. package/init.ts +302 -0
  8. package/mcp/CHANGELOG.md +384 -0
  9. package/mcp/LICENSE +21 -0
  10. package/mcp/OAUTH.md +355 -0
  11. package/mcp/README.md +410 -0
  12. package/mcp/__tests__/agent-dir-paths.upstream-test.ts +80 -0
  13. package/mcp/__tests__/cli.upstream-test.ts +97 -0
  14. package/mcp/__tests__/commands-onboarding.upstream-test.ts +157 -0
  15. package/mcp/__tests__/config.upstream-test.ts +303 -0
  16. package/mcp/__tests__/consent-manager.upstream-test.ts +151 -0
  17. package/mcp/__tests__/direct-tools-auto-auth.upstream-test.ts +218 -0
  18. package/mcp/__tests__/direct-tools.upstream-test.ts +299 -0
  19. package/mcp/__tests__/elicitation-handler.upstream-test.ts +346 -0
  20. package/mcp/__tests__/elicitation-sdk-integration.upstream-test.ts +140 -0
  21. package/mcp/__tests__/errors.upstream-test.ts +218 -0
  22. package/mcp/__tests__/fixtures/elicitation-server.mjs +98 -0
  23. package/mcp/__tests__/host-html-template.upstream-test.ts +278 -0
  24. package/mcp/__tests__/index-lifecycle.upstream-test.ts +484 -0
  25. package/mcp/__tests__/init-elicitation.upstream-test.ts +86 -0
  26. package/mcp/__tests__/interactive-visualizer-server.upstream-test.ts +28 -0
  27. package/mcp/__tests__/logger.upstream-test.ts +175 -0
  28. package/mcp/__tests__/mcp-auth-flow-client-credentials.upstream-test.ts +612 -0
  29. package/mcp/__tests__/mcp-auth-storage.upstream-test.ts +47 -0
  30. package/mcp/__tests__/mcp-callback-server-unref.upstream-test.ts +264 -0
  31. package/mcp/__tests__/mcp-oauth-provider.upstream-test.ts +216 -0
  32. package/mcp/__tests__/mcp-panel-auth.upstream-test.ts +206 -0
  33. package/mcp/__tests__/mcp-panel-exclude-tools.upstream-test.ts +69 -0
  34. package/mcp/__tests__/mcp-panel-keybindings.upstream-test.ts +186 -0
  35. package/mcp/__tests__/npx-resolver.upstream-test.ts +54 -0
  36. package/mcp/__tests__/oauth-handler.upstream-test.ts +77 -0
  37. package/mcp/__tests__/onboarding-state.upstream-test.ts +52 -0
  38. package/mcp/__tests__/package-manifest.upstream-test.ts +22 -0
  39. package/mcp/__tests__/proxy-modes-auto-auth.upstream-test.ts +253 -0
  40. package/mcp/__tests__/proxy-modes-discovery.upstream-test.ts +84 -0
  41. package/mcp/__tests__/proxy-modes-manual-auth.upstream-test.ts +91 -0
  42. package/mcp/__tests__/proxy-modes-ui-messages.upstream-test.ts +72 -0
  43. package/mcp/__tests__/sampling-handler.upstream-test.ts +285 -0
  44. package/mcp/__tests__/server-manager-http-auth.upstream-test.ts +144 -0
  45. package/mcp/__tests__/server-manager-sampling.upstream-test.ts +236 -0
  46. package/mcp/__tests__/tool-metadata.upstream-test.ts +111 -0
  47. package/mcp/__tests__/tool-result-renderer.upstream-test.ts +147 -0
  48. package/mcp/__tests__/ui-integration.upstream-test.ts +551 -0
  49. package/mcp/__tests__/ui-resource-handler.upstream-test.ts +303 -0
  50. package/mcp/__tests__/ui-server.upstream-test.ts +967 -0
  51. package/mcp/__tests__/ui-session-messages.upstream-test.ts +72 -0
  52. package/mcp/__tests__/ui-streaming.upstream-test.ts +543 -0
  53. package/mcp/agent-dir.ts +20 -0
  54. package/mcp/app-bridge.bundle.js +67 -0
  55. package/mcp/cli.js +184 -0
  56. package/mcp/commands.ts +422 -0
  57. package/mcp/config.ts +666 -0
  58. package/mcp/consent-manager.ts +64 -0
  59. package/mcp/direct-tools.ts +439 -0
  60. package/mcp/elicitation-handler.ts +347 -0
  61. package/mcp/errors.ts +219 -0
  62. package/mcp/glimpse-ui.ts +80 -0
  63. package/mcp/host-html-template.ts +427 -0
  64. package/mcp/index.ts +362 -0
  65. package/mcp/init.ts +362 -0
  66. package/mcp/lifecycle.ts +93 -0
  67. package/mcp/logger.ts +169 -0
  68. package/mcp/mcp-auth-flow.ts +559 -0
  69. package/mcp/mcp-auth-flow.upstream-test.ts +259 -0
  70. package/mcp/mcp-auth.ts +302 -0
  71. package/mcp/mcp-auth.upstream-test.ts +373 -0
  72. package/mcp/mcp-callback-server.ts +372 -0
  73. package/mcp/mcp-callback-server.upstream-test.ts +416 -0
  74. package/mcp/mcp-oauth-provider.ts +369 -0
  75. package/mcp/mcp-oauth-provider.upstream-test.ts +518 -0
  76. package/mcp/mcp-panel.ts +829 -0
  77. package/mcp/mcp-setup-panel.ts +580 -0
  78. package/mcp/metadata-cache.ts +201 -0
  79. package/mcp/notify.ts +111 -0
  80. package/mcp/npx-resolver.ts +424 -0
  81. package/mcp/oauth-handler.ts +57 -0
  82. package/mcp/onboarding-state.ts +68 -0
  83. package/mcp/package.json +106 -0
  84. package/mcp/panel-keys.ts +37 -0
  85. package/mcp/proxy-modes.ts +949 -0
  86. package/mcp/resource-tools.ts +17 -0
  87. package/mcp/sampling-handler.ts +268 -0
  88. package/mcp/server-manager.ts +545 -0
  89. package/mcp/state.ts +41 -0
  90. package/mcp/tool-metadata.ts +216 -0
  91. package/mcp/tool-registrar.ts +46 -0
  92. package/mcp/tool-result-renderer.ts +161 -0
  93. package/mcp/types.ts +448 -0
  94. package/mcp/ui-resource-handler.ts +146 -0
  95. package/mcp/ui-server.ts +623 -0
  96. package/mcp/ui-session.ts +386 -0
  97. package/mcp/ui-stream-types.ts +89 -0
  98. package/mcp/utils.ts +129 -0
  99. package/mcp/vitest.config.ts +14 -0
  100. package/migrate.ts +258 -0
  101. package/notification.ts +218 -0
  102. package/notifications-log.ts +83 -0
  103. package/package.json +20 -3
  104. package/status.ts +140 -0
package/codemap.ts ADDED
@@ -0,0 +1,276 @@
1
+ // =============================================================================
2
+ // codemap.ts — Project layout map for the soly extension
3
+ // =============================================================================
4
+ //
5
+ // Walks cwd once at session_start, builds a compact "## project layout" map
6
+ // of top-level directories, key config files, and code file counts. Injected
7
+ // into the system prompt so the model has immediate awareness of where things
8
+ // live without having to `ls` its way around.
9
+ //
10
+ // Heuristics:
11
+ // - skip noise: node_modules, .git, dist, build, .soly, .next, out, target,
12
+ // coverage, .venv, __pycache__
13
+ // - depth: walk 2 levels deep by default (cwd → dirs → subdirs)
14
+ // - key files: package.json, tsconfig.json, README.md, Cargo.toml, go.mod,
15
+ // pyproject.toml, .env.example, Dockerfile, docker-compose.yml/yaml,
16
+ // Makefile, .github/workflows
17
+ // - code counts: count files by extension at each level
18
+ // =============================================================================
19
+
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+
23
+ const IGNORE_DIRS = new Set([
24
+ "node_modules",
25
+ ".git",
26
+ ".soly",
27
+ "dist",
28
+ "build",
29
+ "out",
30
+ "target",
31
+ "coverage",
32
+ ".next",
33
+ ".nuxt",
34
+ ".venv",
35
+ "venv",
36
+ "__pycache__",
37
+ ".cache",
38
+ ".parcel-cache",
39
+ ".turbo",
40
+ ]);
41
+
42
+ const KEY_FILES = new Set([
43
+ "package.json",
44
+ "tsconfig.json",
45
+ "tsconfig.base.json",
46
+ "jsconfig.json",
47
+ "README.md",
48
+ "README.rst",
49
+ "Cargo.toml",
50
+ "go.mod",
51
+ "pyproject.toml",
52
+ "setup.py",
53
+ "requirements.txt",
54
+ "Pipfile",
55
+ "Gemfile",
56
+ "pom.xml",
57
+ "build.gradle",
58
+ "build.sbt",
59
+ "Makefile",
60
+ "Dockerfile",
61
+ "docker-compose.yml",
62
+ "docker-compose.yaml",
63
+ ".env.example",
64
+ ".nvmrc",
65
+ ".node-version",
66
+ ".tool-versions",
67
+ ".editorconfig",
68
+ ".prettierrc",
69
+ ".eslintrc",
70
+ ".eslintrc.js",
71
+ ".eslintrc.json",
72
+ "biome.json",
73
+ "vitest.config.ts",
74
+ "jest.config.js",
75
+ "jest.config.ts",
76
+ "vite.config.ts",
77
+ "webpack.config.js",
78
+ "rollup.config.js",
79
+ "index.html",
80
+ ".github",
81
+ ]);
82
+
83
+ const CODE_EXTS = new Set([
84
+ "ts", "tsx", "js", "jsx", "mjs", "cjs",
85
+ "py", "pyx",
86
+ "go",
87
+ "rs",
88
+ "java", "kt", "scala",
89
+ "rb",
90
+ "php",
91
+ "cs", "fs", "vb",
92
+ "swift", "m",
93
+ "c", "cc", "cpp", "h", "hpp",
94
+ "sh", "bash", "zsh",
95
+ ]);
96
+
97
+ export interface CodeMap {
98
+ root: string;
99
+ topLevel: { dirs: string[]; files: string[] };
100
+ /** Sub-tree at depth 2: parent → children (dirs only, capped). */
101
+ tree: Map<string, string[]>;
102
+ /** Key files with brief summaries (first line). */
103
+ keyFiles: { relPath: string; summary: string }[];
104
+ /** Code file counts per top-level dir. */
105
+ codeCounts: Record<string, number>;
106
+ hasGithubWorkflows: boolean;
107
+ }
108
+
109
+ function summarizeKeyFile(absPath: string, maxLen = 100): string {
110
+ try {
111
+ const content = fs.readFileSync(absPath, "utf-8");
112
+ // First non-empty, non-frontmatter line
113
+ const lines = content.split(/\r?\n/);
114
+ let inFrontmatter = false;
115
+ for (const l of lines) {
116
+ const t = l.trim();
117
+ if (!t) continue;
118
+ if (t === "---") {
119
+ inFrontmatter = !inFrontmatter;
120
+ continue;
121
+ }
122
+ if (inFrontmatter) continue;
123
+ return t.slice(0, maxLen);
124
+ }
125
+ return "";
126
+ } catch {
127
+ return "";
128
+ }
129
+ }
130
+
131
+ function listChildren(dir: string): { dirs: string[]; files: string[] } {
132
+ try {
133
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
134
+ const dirs: string[] = [];
135
+ const files: string[] = [];
136
+ for (const e of entries) {
137
+ if (e.name.startsWith(".") && e.name !== ".github") continue;
138
+ if (IGNORE_DIRS.has(e.name)) continue;
139
+ if (e.isDirectory()) dirs.push(e.name);
140
+ else if (e.isFile()) files.push(e.name);
141
+ }
142
+ dirs.sort();
143
+ files.sort();
144
+ return { dirs, files };
145
+ } catch {
146
+ return { dirs: [], files: [] };
147
+ }
148
+ }
149
+
150
+ export function buildCodeMap(cwd: string, maxDepth = 2): CodeMap {
151
+ const top = listChildren(cwd);
152
+ const tree = new Map<string, string[]>();
153
+ const codeCounts: Record<string, number> = {};
154
+ const keyFiles: { relPath: string; summary: string }[] = [];
155
+ let hasGithubWorkflows = false;
156
+
157
+ // Top-level key files
158
+ for (const f of top.files) {
159
+ if (KEY_FILES.has(f) || KEY_FILES.has(`-${f}`)) {
160
+ const summary = summarizeKeyFile(path.join(cwd, f));
161
+ keyFiles.push({ relPath: f, summary });
162
+ }
163
+ }
164
+
165
+ // Walk dirs at depth 1
166
+ for (const d of top.dirs) {
167
+ const subPath = path.join(cwd, d);
168
+ const sub = listChildren(subPath);
169
+ tree.set(d, sub.dirs);
170
+
171
+ // Key files inside top-level dirs (e.g. src/index.ts, src/package.json)
172
+ for (const f of sub.files) {
173
+ if (KEY_FILES.has(f)) {
174
+ keyFiles.push({
175
+ relPath: `${d}/${f}`,
176
+ summary: summarizeKeyFile(path.join(subPath, f)),
177
+ });
178
+ }
179
+ }
180
+
181
+ // Code count: walk this dir, count by extension
182
+ let count = 0;
183
+ try {
184
+ const stack = [subPath];
185
+ let depth = 0;
186
+ while (stack.length > 0 && depth <= maxDepth) {
187
+ const cur = stack.pop()!;
188
+ const entries = fs.readdirSync(cur, { withFileTypes: true });
189
+ for (const e of entries) {
190
+ if (e.name.startsWith(".")) continue;
191
+ if (IGNORE_DIRS.has(e.name)) continue;
192
+ if (e.isDirectory()) {
193
+ stack.push(path.join(cur, e.name));
194
+ } else if (e.isFile()) {
195
+ const ext = e.name.split(".").pop()?.toLowerCase();
196
+ if (ext && CODE_EXTS.has(ext)) count++;
197
+ }
198
+ }
199
+ depth++;
200
+ }
201
+ } catch {
202
+ // best effort
203
+ }
204
+ codeCounts[d] = count;
205
+
206
+ // .github/workflows detection
207
+ if (d === ".github") {
208
+ const wfDir = path.join(subPath, "workflows");
209
+ if (fs.existsSync(wfDir)) {
210
+ try {
211
+ const wfEntries = fs.readdirSync(wfDir);
212
+ if (wfEntries.length > 0) hasGithubWorkflows = true;
213
+ } catch {
214
+ // ignore
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ return {
221
+ root: cwd,
222
+ topLevel: top,
223
+ tree,
224
+ keyFiles,
225
+ codeCounts,
226
+ hasGithubWorkflows,
227
+ };
228
+ }
229
+
230
+ /** Render a compact "## project layout" section for the system prompt. */
231
+ export function buildCodeMapSection(map: CodeMap): string {
232
+ if (map.topLevel.dirs.length === 0 && map.topLevel.files.length === 0) {
233
+ return "";
234
+ }
235
+
236
+ const lines: string[] = ["", "## project layout", ""];
237
+
238
+ // Top-level tree (1-line ASCII)
239
+ const allTop = [...map.topLevel.dirs, ...map.topLevel.files].slice(0, 20);
240
+ lines.push(`\`\`\``);
241
+ lines.push(`${path.basename(map.root)}/`);
242
+ for (const name of allTop) {
243
+ const isDir = map.topLevel.dirs.includes(name);
244
+ const count = map.codeCounts[name];
245
+ const suffix = isDir && count ? ` (${count} code file${count === 1 ? "" : "s"})` : "";
246
+ lines.push(`├── ${name}${isDir ? "/" : ""}${suffix}`);
247
+ }
248
+ if (map.topLevel.dirs.length + map.topLevel.files.length > 20) {
249
+ lines.push(`└── … (${map.topLevel.dirs.length + map.topLevel.files.length - 20} more)`);
250
+ }
251
+ lines.push(`\`\`\``);
252
+ lines.push("");
253
+
254
+ // Key files
255
+ if (map.keyFiles.length > 0) {
256
+ lines.push("**Key files:**");
257
+ for (const kf of map.keyFiles.slice(0, 8)) {
258
+ lines.push(`- \`${kf.relPath}\`${kf.summary ? ` — ${kf.summary}` : ""}`);
259
+ }
260
+ if (map.keyFiles.length > 8) {
261
+ lines.push(`- … and ${map.keyFiles.length - 8} more`);
262
+ }
263
+ lines.push("");
264
+ }
265
+
266
+ // CI hint
267
+ if (map.hasGithubWorkflows) {
268
+ lines.push("- **CI**: GitHub Actions workflows present");
269
+ }
270
+
271
+ lines.push(
272
+ "Use `soly_snippet(path, offset, limit)` or `soly_doc_search(query)` to read specific files without loading the whole tree.",
273
+ );
274
+
275
+ return lines.join("\n");
276
+ }
package/hotreload.ts ADDED
@@ -0,0 +1,239 @@
1
+ // =============================================================================
2
+ // hotreload.ts — Live file watcher for soly rules
3
+ // =============================================================================
4
+ //
5
+ // Watches all rule source directories (project-soly, global-soly) for
6
+ // changes. When a .md file in any of them is created, modified, or
7
+ // deleted, the extension reloads its rule set and re-renders the status
8
+ // bar — all within the same session, no /reload.
9
+ //
10
+ // Why this matters: editing rules is a tight feedback loop. Without hot
11
+ // reload, you have to wait for the next turn_end (or restart) to see
12
+ // effects. With it, saving a rule file is instantly reflected in the
13
+ // "rules N" counter in the status bar.
14
+ //
15
+ // Two reload strategies in parallel:
16
+ // 1. fs.watch with debounce 100ms — fast, but unreliable on Windows
17
+ // (recursive watch is buggy, some editor save patterns miss events).
18
+ // 2. Polling fallback every `pollMs` ms — checks mtime of every rule
19
+ // file. Slower (constant disk I/O) but reliable everywhere.
20
+ // Both feed into the same debounced onChange callback. If fs.watch
21
+ // fires, it preempts the next poll tick (no double reload).
22
+ //
23
+ // Watchers are cleaned up on session_shutdown.
24
+ // =============================================================================
25
+
26
+ import * as fs from "node:fs";
27
+ import * as path from "node:path";
28
+ import type { SourceSpec } from "./core.ts";
29
+
30
+ export interface HotReloadOptions {
31
+ /** Called when any watched file changes (debounced). The `reason` argument
32
+ * carries a human-readable description of what triggered the reload
33
+ * (e.g. `poll:soly/code-style.md`). */
34
+ onChange: (reason: string) => void;
35
+ /** Debounce window in ms — coalesce burst writes from editors. */
36
+ debounceMs?: number;
37
+ /** Polling interval in ms — fallback when fs.watch is unreliable. */
38
+ pollMs?: number;
39
+ }
40
+
41
+ /** Set of FSWatchers + cleanup helper. */
42
+ export interface HotReloadHandle {
43
+ stop: () => void;
44
+ /** Manually trigger a refresh (e.g. from turn_end fallback). */
45
+ tick: (reason: string) => void;
46
+ /** Install a user-facing notifier that gets coalesced rapid-fire
47
+ * changes into a single message. Optional — the raw onChange still
48
+ * fires per-change for status updates. */
49
+ setNotifyHandler: (handler: (reason: string) => void) => void;
50
+ }
51
+
52
+ /** Walk a source dir and return every .md file with its mtime. */
53
+ function snapshotMtimes(dir: string): Map<string, number> {
54
+ const out = new Map<string, number>();
55
+ if (!fs.existsSync(dir)) return out;
56
+ const walk = (d: string) => {
57
+ let entries: fs.Dirent[];
58
+ try {
59
+ entries = fs.readdirSync(d, { withFileTypes: true });
60
+ } catch {
61
+ return;
62
+ }
63
+ for (const e of entries) {
64
+ if (e.name.startsWith(".")) continue;
65
+ if (e.name === "node_modules") continue;
66
+ const full = path.join(d, e.name);
67
+ if (e.isDirectory()) {
68
+ walk(full);
69
+ } else if (e.isFile() && e.name.endsWith(".md")) {
70
+ try {
71
+ const stat = fs.statSync(full);
72
+ out.set(full, stat.mtimeMs);
73
+ } catch {
74
+ // skip unreadable
75
+ }
76
+ }
77
+ }
78
+ };
79
+ walk(dir);
80
+ return out;
81
+ }
82
+
83
+ export function startHotReload(
84
+ sources: SourceSpec[],
85
+ opts: HotReloadOptions,
86
+ ): HotReloadHandle {
87
+ const debounceMs = opts.debounceMs ?? 100;
88
+ const pollMs = opts.pollMs ?? 2000;
89
+
90
+ const watchers: fs.FSWatcher[] = [];
91
+ let pendingTimer: NodeJS.Timeout | null = null;
92
+ let pollTimer: NodeJS.Timeout | null = null;
93
+ let stopped = false;
94
+
95
+ // Notification coalescer — editors often save in 2–3 quick bursts
96
+ // (write to .tmp, rename, touch), which would each fire onChange.
97
+ // We batch them into a single user-visible notify by accumulating the
98
+ // most recent reason until the debounce window expires.
99
+ let pendingNotify: { reasons: string[]; timer: NodeJS.Timeout | null } = {
100
+ reasons: [],
101
+ timer: null,
102
+ };
103
+ const NOTIFY_COALESCE_MS = 500;
104
+ let notifyHandler: ((reason: string) => void) | null = null;
105
+
106
+ // Wrap user's onChange so that the actual callback still fires per-change
107
+ // (we can't break that contract — index.ts updates status synchronously),
108
+ // but a user-facing notify is coalesced. The wrapper just buffers reasons.
109
+ const wrappedOnChange = (reason: string) => {
110
+ opts.onChange(reason);
111
+ if (notifyHandler) {
112
+ pendingNotify.reasons.push(reason);
113
+ if (pendingNotify.timer) clearTimeout(pendingNotify.timer);
114
+ pendingNotify.timer = setTimeout(() => {
115
+ const reasons = pendingNotify.reasons;
116
+ pendingNotify = { reasons: [], timer: null };
117
+ if (reasons.length === 1) {
118
+ notifyHandler!(reasons[0]!);
119
+ } else if (reasons.length > 1) {
120
+ notifyHandler!(
121
+ `${reasons.length} rapid change(s) (last: ${reasons[reasons.length - 1]})`,
122
+ );
123
+ }
124
+ }, NOTIFY_COALESCE_MS);
125
+ }
126
+ };
127
+
128
+ const schedule = (reason: string) => {
129
+ if (stopped) return;
130
+ if (pendingTimer) clearTimeout(pendingTimer);
131
+ pendingTimer = setTimeout(() => {
132
+ pendingTimer = null;
133
+ if (stopped) return;
134
+ try {
135
+ wrappedOnChange(reason);
136
+ // After reload, update snapshot so polling doesn't re-fire
137
+ // on the same state until files change again.
138
+ lastMtimes = new Map();
139
+ for (const spec of sources) {
140
+ for (const [p, t] of snapshotMtimes(spec.dir)) {
141
+ lastMtimes.set(p, t);
142
+ }
143
+ }
144
+ } catch {
145
+ // Swallow — the consumer's onChange is best-effort
146
+ }
147
+ }, debounceMs);
148
+ };
149
+
150
+ // Snapshot of file mtimes for polling. Resets after each reload so we
151
+ // don't repeatedly fire on the same state.
152
+ let lastMtimes: Map<string, number> = new Map();
153
+ for (const spec of sources) {
154
+ for (const [p, t] of snapshotMtimes(spec.dir)) {
155
+ lastMtimes.set(p, t);
156
+ }
157
+ }
158
+
159
+ for (const spec of sources) {
160
+ if (!fs.existsSync(spec.dir)) continue;
161
+ try {
162
+ // recursive: true walks subdirs so nested rule directories work
163
+ const watcher = fs.watch(
164
+ spec.dir,
165
+ { recursive: true },
166
+ (_event, filename) => {
167
+ if (!filename) return;
168
+ // Ignore non-markdown noise (.swp, .tmp, lock files)
169
+ const name = String(filename);
170
+ if (!name.endsWith(".md")) return;
171
+ schedule(`${spec.sourceLabel}:${name}`);
172
+ },
173
+ );
174
+ watchers.push(watcher);
175
+ } catch {
176
+ // Some filesystems don't support recursive watch — polling
177
+ // fallback below still catches changes.
178
+ }
179
+ }
180
+
181
+ // Polling fallback — reliable on Windows / network mounts / editors
182
+ // that don't trigger fs.watch. Coalesces into the same debounce.
183
+ pollTimer = setInterval(() => {
184
+ if (stopped) return;
185
+ let changed: string | null = null;
186
+ const current = new Map<string, number>();
187
+ for (const spec of sources) {
188
+ const snap = snapshotMtimes(spec.dir);
189
+ for (const [p, t] of snap) {
190
+ current.set(p, t);
191
+ const prev = lastMtimes.get(p);
192
+ if (prev !== t) {
193
+ changed = `${spec.sourceLabel}:${path.basename(p)}`;
194
+ }
195
+ }
196
+ }
197
+ // Detect deletions (file present in lastMtimes but missing in current)
198
+ if (!changed) {
199
+ for (const p of lastMtimes.keys()) {
200
+ if (!current.has(p)) {
201
+ changed = `${path.basename(p)}:deleted`;
202
+ break;
203
+ }
204
+ }
205
+ }
206
+ if (changed) schedule(`poll:${changed}`);
207
+ }, pollMs);
208
+
209
+ return {
210
+ stop: () => {
211
+ stopped = true;
212
+ if (pendingTimer) {
213
+ clearTimeout(pendingTimer);
214
+ pendingTimer = null;
215
+ }
216
+ if (pendingNotify.timer) {
217
+ clearTimeout(pendingNotify.timer);
218
+ pendingNotify = { reasons: [], timer: null };
219
+ }
220
+ if (pollTimer) {
221
+ clearInterval(pollTimer);
222
+ pollTimer = null;
223
+ }
224
+ for (const w of watchers) {
225
+ try {
226
+ w.close();
227
+ } catch {
228
+ // best effort
229
+ }
230
+ }
231
+ },
232
+ tick: (reason) => schedule(reason),
233
+ /** Install a user-facing notifier that gets coalesced rapid-fire
234
+ * changes into a single message. Call once from the consumer. */
235
+ setNotifyHandler: (handler) => {
236
+ notifyHandler = handler;
237
+ },
238
+ };
239
+ }