@minionry/minion 0.7.36 → 0.7.39

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 (120) hide show
  1. package/bin/commands/minions.js +19 -0
  2. package/bin/minion.js +33 -4
  3. package/dist/server/cli/headless/claude-invoker-process.js +23 -7
  4. package/dist/server/cli/headless/claude-invoker-stream.js +8 -3
  5. package/dist/server/cli/headless/haiku-assessments.js +2 -1
  6. package/dist/server/cli/headless/mcp-config.js +2 -1
  7. package/dist/server/engines/claude/claude-command.js +1 -0
  8. package/dist/server/engines/factory.js +2 -0
  9. package/dist/server/engines/hermes/HermesEngine.js +112 -219
  10. package/dist/server/engines/hermes/hermes-gateway-registry.js +467 -0
  11. package/dist/server/index.js +10 -4
  12. package/dist/server/mcp/bouncer-integration.js +3 -2
  13. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js +35 -8
  14. package/dist/server/mcp/classifier/shadow-eval-scheduler.js +42 -0
  15. package/dist/server/mcp/classifier/telemetry.js +0 -0
  16. package/dist/server/mcp/permission-channels.js +176 -0
  17. package/dist/server/mcp/security-analysis.js +9 -1
  18. package/dist/server/mcp/security-audit.js +22 -2
  19. package/dist/server/mcp/security-patterns.js +73 -1
  20. package/dist/server/mcp/server.js +80 -50
  21. package/dist/server/routes/internal.js +6 -1
  22. package/dist/server/routes/notifications.js +6 -18
  23. package/dist/server/server-setup.js +24 -48
  24. package/dist/server/services/analytics.js +4 -2
  25. package/dist/server/services/browser/host-mock.js +6 -1
  26. package/dist/server/services/browser/host.js +27 -3
  27. package/dist/server/services/browser/reasoning-agent.js +3 -1
  28. package/dist/server/services/chain/chain-engine.js +494 -0
  29. package/dist/server/services/chain/chain-store.js +542 -0
  30. package/dist/server/services/git/haiku.js +2 -2
  31. package/dist/server/services/plan/agents/check-injection.md +43 -2
  32. package/dist/server/services/plan/agents/review-code.md +62 -3
  33. package/dist/server/services/plan/agents/review-quality.md +52 -2
  34. package/dist/server/services/plan/board-export.js +4 -0
  35. package/dist/server/services/plan/composer-prompt.js +27 -8
  36. package/dist/server/services/plan/composer.js +10 -4
  37. package/dist/server/services/plan/config-installer.js +4 -83
  38. package/dist/server/services/plan/executor.js +155 -73
  39. package/dist/server/services/plan/issue-effort.js +9 -21
  40. package/dist/server/services/plan/issue-prompt-builder.js +72 -9
  41. package/dist/server/services/plan/parser-core.js +6 -1
  42. package/dist/server/services/plan/quality-delta.js +6 -24
  43. package/dist/server/services/plan/readiness-planner.js +12 -4
  44. package/dist/server/services/plan/record-file.js +52 -0
  45. package/dist/server/services/plan/review-approval.js +64 -0
  46. package/dist/server/services/plan/review-gate.js +186 -77
  47. package/dist/server/services/plan/review-outcome.js +146 -0
  48. package/dist/server/services/plan/review-report.js +28 -1
  49. package/dist/server/services/plan/review-target.js +57 -0
  50. package/dist/server/services/plan/state-reconciler.js +3 -3
  51. package/dist/server/services/plan/template-diff.js +2 -0
  52. package/dist/server/services/plan/template-instantiator.js +4 -0
  53. package/dist/server/services/plan/watcher.js +11 -1
  54. package/dist/server/services/platform-reconnect.js +7 -0
  55. package/dist/server/services/platform-token-lifecycle.js +2 -1
  56. package/dist/server/services/platform.js +293 -50
  57. package/dist/server/services/relay-http-client.js +107 -0
  58. package/dist/server/services/schedule/agent-schedule-store.js +69 -8
  59. package/dist/server/services/schedule/agent-scheduler.js +48 -7
  60. package/dist/server/services/schedule/daily-timing.js +52 -0
  61. package/dist/server/services/schedule/prompt-file.js +55 -0
  62. package/dist/server/services/schedule/run-bootstrap.js +20 -5
  63. package/dist/server/services/schedule/schedule-store.js +54 -4
  64. package/dist/server/services/schedule/schedule-wire.js +17 -0
  65. package/dist/server/services/schedule/scheduler.js +43 -9
  66. package/dist/server/services/sdk/agent-schedule.js +294 -0
  67. package/dist/server/services/sdk/agents.js +95 -13
  68. package/dist/server/services/sdk/app-tools.js +44 -7
  69. package/dist/server/services/sdk/atomic-write.js +75 -0
  70. package/dist/server/services/sdk/browser.js +5 -1
  71. package/dist/server/services/sdk/composer.js +2 -0
  72. package/dist/server/services/sdk/file-change-notify.js +60 -13
  73. package/dist/server/services/sdk/files-app.js +12 -7
  74. package/dist/server/services/sdk/files-search.js +14 -0
  75. package/dist/server/services/sdk/files-transfer.js +1 -1
  76. package/dist/server/services/sdk/files-watch.js +721 -0
  77. package/dist/server/services/sdk/files-workspace.js +37 -11
  78. package/dist/server/services/sdk/inference-claude.js +4 -2
  79. package/dist/server/services/sdk/inference.js +16 -2
  80. package/dist/server/services/sdk/pm-chain.js +0 -0
  81. package/dist/server/services/sdk/pm-schedule.js +72 -15
  82. package/dist/server/services/sdk/pm.js +22 -0
  83. package/dist/server/services/sdk/rate-limits.js +32 -0
  84. package/dist/server/services/sdk/registry.js +40 -2
  85. package/dist/server/services/sdk/terminal.js +1 -1
  86. package/dist/server/services/sentry.js +5 -1
  87. package/dist/server/services/terminal/pty-manager.js +26 -11
  88. package/dist/server/services/terminal/pty-utils.js +12 -1
  89. package/dist/server/services/terminal/terminal-modes.js +87 -0
  90. package/dist/server/services/timeline/assembler.js +2 -2
  91. package/dist/server/services/timeline/index.js +1 -1
  92. package/dist/server/services/websocket/agent-schedule-handlers.js +62 -4
  93. package/dist/server/services/websocket/ask-user-question-bridge.js +2 -2
  94. package/dist/server/services/websocket/browser-agent-runtime.js +3 -1
  95. package/dist/server/services/websocket/browser-handlers.js +5 -2
  96. package/dist/server/services/websocket/browser-viewer-lifecycle.js +8 -0
  97. package/dist/server/services/websocket/file-explorer-handlers.js +17 -0
  98. package/dist/server/services/websocket/file-transfer-http.js +12 -6
  99. package/dist/server/services/websocket/handler.js +185 -33
  100. package/dist/server/services/websocket/msg-id-tracker.js +94 -19
  101. package/dist/server/services/websocket/plan-execution-handlers.js +33 -5
  102. package/dist/server/services/websocket/plan-handlers.js +10 -5
  103. package/dist/server/services/websocket/plan-helpers.js +23 -0
  104. package/dist/server/services/websocket/plan-issue-handlers.js +161 -4
  105. package/dist/server/services/websocket/plan-sprint-handlers.js +3 -2
  106. package/dist/server/services/websocket/schedule-handlers.js +8 -2
  107. package/dist/server/services/websocket/sdk-agent-host.js +5 -1
  108. package/dist/server/services/websocket/sdk-agent-schedule-host.js +30 -0
  109. package/dist/server/services/websocket/sdk-browser-host.js +11 -4
  110. package/dist/server/services/websocket/sdk-handlers.js +6 -1
  111. package/dist/server/services/websocket/sdk-pm-chain-host.js +20 -0
  112. package/dist/server/services/websocket/sdk-pm-execution-host.js +21 -14
  113. package/dist/server/services/websocket/sdk-pm-schedule-host.js +9 -0
  114. package/dist/server/services/websocket/sdk-terminal-host.js +7 -0
  115. package/dist/server/services/websocket/settings-handlers.js +6 -18
  116. package/dist/server/services/websocket/tab-broadcast.js +5 -1
  117. package/dist/server/services/websocket/terminal-handlers.js +51 -6
  118. package/dist/server/services/websocket/types.js +6 -5
  119. package/dist/server/services/websocket/viewer-interest.js +30 -0
  120. package/package.json +6 -6
@@ -0,0 +1,721 @@
1
+ // Copyright (c) 2025-present Minionry, Inc.
2
+ // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
+ import { lstatSync, watch } from 'node:fs';
4
+ import { lstat, readdir } from 'node:fs/promises';
5
+ import { basename, join, sep } from 'node:path';
6
+ import { notifyFileChangedAbsolute, setFileChangeObserver } from './file-change-notify.js';
7
+ import { guardWorkspacePath } from './files-workspace.js';
8
+ export const WATCH_DEBOUNCE_MS = 150;
9
+ export const MAX_WATCHED_ROOTS_PER_APP = 8;
10
+ export const MAX_WATCH_ENTRIES = 50_000;
11
+ export const MAX_CHANGES_PER_WINDOW = 1_000;
12
+ const RESYNC_COOLDOWN_MS = 5_000;
13
+ const NOTIFIED_CHANGE_TTL_MS = 10_000;
14
+ const STAT_BATCH = 64;
15
+ const WATCH_BATCH = 1_000;
16
+ const IGNORED_SEGMENTS = new Set(['.git', 'node_modules', '.minionry']);
17
+ function toEntry(stats) {
18
+ return { ino: stats.ino, mtimeMs: stats.mtimeMs, size: stats.size, dir: stats.isDirectory() };
19
+ }
20
+ const defaultDeps = {
21
+ nativeRecursive: process.platform === 'darwin' || process.platform === 'win32',
22
+ watch: (absDir, recursive, listener) => {
23
+ const options = {
24
+ persistent: false,
25
+ recursive,
26
+ ...(recursive ? { ignore: isIgnoredPath } : {}),
27
+ };
28
+ return watch(absDir, options, listener);
29
+ },
30
+ lstat: (absPath) => lstat(absPath).then(toEntry, () => undefined),
31
+ lstatSync: (absPath) => {
32
+ try {
33
+ return toEntry(lstatSync(absPath));
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ },
39
+ readDir: (absDir) => readdir(absDir),
40
+ now: () => Date.now(),
41
+ setTimeout: (callback, ms) => setTimeout(callback, ms),
42
+ clearTimeout: (handle) => clearTimeout(handle),
43
+ };
44
+ let deps = defaultDeps;
45
+ export function isIgnoredPath(rel) {
46
+ return rel.split(sep).some((segment) => IGNORED_SEGMENTS.has(segment.toLowerCase()));
47
+ }
48
+ export function sameEntry(a, b) {
49
+ return a.ino === b.ino && a.dir === b.dir && (a.dir || (a.mtimeMs === b.mtimeMs && a.size === b.size));
50
+ }
51
+ function sameState(a, b) {
52
+ return a === undefined || b === undefined ? a === b : sameEntry(a, b);
53
+ }
54
+ function isBelow(path, dir) {
55
+ return path.startsWith(dir + sep);
56
+ }
57
+ function parentOf(path) {
58
+ const cut = path.lastIndexOf(sep);
59
+ return cut < 0 ? '' : path.slice(0, cut);
60
+ }
61
+ function hasAncestorIn(path, set) {
62
+ for (let cut = path.lastIndexOf(sep); cut > 0; cut = path.lastIndexOf(sep, cut - 1)) {
63
+ if (set.has(path.slice(0, cut)))
64
+ return true;
65
+ }
66
+ return false;
67
+ }
68
+ function shallowestFirst(paths) {
69
+ const depthOf = (path) => {
70
+ let depth = 0;
71
+ for (let at = path.indexOf(sep); at >= 0; at = path.indexOf(sep, at + 1))
72
+ depth++;
73
+ return depth;
74
+ };
75
+ return [...paths]
76
+ .map((path) => ({ path, depth: depthOf(path) }))
77
+ .sort((a, b) => a.depth - b.depth || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
78
+ .map(({ path }) => path);
79
+ }
80
+ export class PathTree {
81
+ byPath = new Map();
82
+ children = new Map();
83
+ constructor(entries = []) {
84
+ for (const [path, value] of entries)
85
+ this.set(path, value);
86
+ }
87
+ get size() {
88
+ return this.byPath.size;
89
+ }
90
+ get(path) {
91
+ return this.byPath.get(path);
92
+ }
93
+ has(path) {
94
+ return this.byPath.has(path);
95
+ }
96
+ keys() {
97
+ return this.byPath.keys();
98
+ }
99
+ [Symbol.iterator]() {
100
+ return this.byPath.entries();
101
+ }
102
+ set(path, value) {
103
+ this.byPath.set(path, value);
104
+ for (let child = path; child !== ''; child = parentOf(child)) {
105
+ const siblings = this.children.get(parentOf(child));
106
+ if (siblings?.has(child))
107
+ return;
108
+ if (siblings)
109
+ siblings.add(child);
110
+ else
111
+ this.children.set(parentOf(child), new Set([child]));
112
+ }
113
+ }
114
+ delete(path) {
115
+ this.byPath.delete(path);
116
+ this.unlink(path);
117
+ }
118
+ deleteTree(path) {
119
+ const dropped = [];
120
+ const stack = [path];
121
+ for (let node = stack.pop(); node !== undefined; node = stack.pop()) {
122
+ if (this.byPath.has(node)) {
123
+ dropped.push([node, this.byPath.get(node)]);
124
+ this.byPath.delete(node);
125
+ }
126
+ const below = this.children.get(node);
127
+ if (!below)
128
+ continue;
129
+ this.children.delete(node);
130
+ for (const child of below)
131
+ stack.push(child);
132
+ }
133
+ this.unlink(path);
134
+ return dropped;
135
+ }
136
+ move(from, to) {
137
+ const moved = this.deleteTree(from);
138
+ this.deleteTree(to);
139
+ for (const [path, value] of moved)
140
+ this.set(to + path.slice(from.length), value);
141
+ }
142
+ clear() {
143
+ this.byPath.clear();
144
+ this.children.clear();
145
+ }
146
+ unlink(path) {
147
+ for (let node = path; node !== '' && !this.byPath.has(node) && !this.children.has(node); node = parentOf(node)) {
148
+ const siblings = this.children.get(parentOf(node));
149
+ siblings?.delete(node);
150
+ if (siblings !== undefined && siblings.size > 0)
151
+ return;
152
+ this.children.delete(parentOf(node));
153
+ }
154
+ }
155
+ }
156
+ function pairMoves(known, observed, paths) {
157
+ const candidates = new Map();
158
+ for (const path of paths) {
159
+ const after = observed.get(path);
160
+ const before = known.get(path);
161
+ if (after && after.ino !== 0 && (!before || before.ino !== after.ino || before.dir !== after.dir)) {
162
+ const sharing = candidates.get(after.ino);
163
+ if (sharing)
164
+ sharing.push(path);
165
+ else
166
+ candidates.set(after.ino, [path]);
167
+ }
168
+ }
169
+ const moves = new Map();
170
+ const targets = new Set();
171
+ for (const source of paths) {
172
+ const before = known.get(source);
173
+ if (!before || observed.get(source) !== undefined || hasAncestorIn(source, moves))
174
+ continue;
175
+ const target = candidates
176
+ .get(before.ino)
177
+ ?.find((path) => !targets.has(path) && !hasAncestorIn(path, targets) && sameEntry(before, observed.get(path)));
178
+ if (target === undefined)
179
+ continue;
180
+ moves.set(source, target);
181
+ targets.add(target);
182
+ }
183
+ return moves;
184
+ }
185
+ function applyObservation(known, path, after) {
186
+ const before = known.get(path);
187
+ if (!after) {
188
+ if (!before)
189
+ return [];
190
+ known.deleteTree(path);
191
+ return [{ kind: 'deleted', path, dir: before.dir }];
192
+ }
193
+ if (!before) {
194
+ known.set(path, after);
195
+ return [{ kind: 'created', path, dir: after.dir }];
196
+ }
197
+ if (before.dir !== after.dir || (after.dir && before.ino !== after.ino)) {
198
+ known.deleteTree(path);
199
+ known.set(path, after);
200
+ return [
201
+ { kind: 'deleted', path, dir: before.dir },
202
+ { kind: 'created', path, dir: after.dir },
203
+ ];
204
+ }
205
+ known.set(path, after);
206
+ return after.dir || sameEntry(before, after) ? [] : [{ kind: 'changed', path, dir: false }];
207
+ }
208
+ export function classifyWindow(known, observed) {
209
+ const paths = shallowestFirst([...observed.keys()].filter((path) => path !== ''));
210
+ const moves = pairMoves(known, observed, paths);
211
+ const changes = [];
212
+ for (const [source, target] of moves) {
213
+ const entry = observed.get(target);
214
+ known.move(source, target);
215
+ known.set(target, entry);
216
+ changes.push({ kind: 'renamed', path: source, newPath: target, dir: entry.dir });
217
+ }
218
+ const targets = new Set(moves.values());
219
+ for (const path of paths) {
220
+ if (moves.has(path) || targets.has(path))
221
+ continue;
222
+ changes.push(...applyObservation(known, path, observed.get(path)));
223
+ }
224
+ return changes;
225
+ }
226
+ const ONLY_DIRECT = 'only changes made through the Files view or the SDK are reported for this subscription; list the directory to catch up';
227
+ function errorCode(error) {
228
+ const code = error?.code;
229
+ return typeof code === 'string' ? code : 'unknown';
230
+ }
231
+ function thrownGap(error) {
232
+ const code = errorCode(error);
233
+ return code === 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM' || code === 'ENOSYS' || code === 'unknown'
234
+ ? { reason: 'watchUnsupported', message: `This host cannot watch directories (${code}) — ${ONLY_DIRECT}` }
235
+ : { reason: 'watchError', message: `Watching stopped (${code}) — ${ONLY_DIRECT}` };
236
+ }
237
+ const ROOT_GONE = {
238
+ reason: 'watchError',
239
+ message: `The watched directory was removed or replaced — ${ONLY_DIRECT}`,
240
+ };
241
+ const TOO_MANY_ENTRIES = {
242
+ reason: 'watchLimit',
243
+ message: `The directory holds more than ${MAX_WATCH_ENTRIES} entries — ${ONLY_DIRECT}`,
244
+ };
245
+ const TOO_MANY_ROOTS = {
246
+ reason: 'watchLimit',
247
+ message: `This app already watches ${MAX_WATCHED_ROOTS_PER_APP} workspace directories in this Space — ${ONLY_DIRECT}`,
248
+ };
249
+ const OVERFLOW = {
250
+ reason: 'watchOverflow',
251
+ message: 'Too many changes to report one by one — list the directory to catch up',
252
+ };
253
+ const SKIPPABLE_DIR_ERRORS = new Set(['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM']);
254
+ const watches = new Map();
255
+ let emittingWatch;
256
+ class RootWatch {
257
+ key;
258
+ spaceKey;
259
+ realRoot;
260
+ workingDir;
261
+ state = 'priming';
262
+ generation = 0;
263
+ refs = new Map();
264
+ known = new PathTree();
265
+ handles = new PathTree();
266
+ pending = new Map();
267
+ recheck = new Set();
268
+ notified = new Map();
269
+ rootIno = 0;
270
+ timer;
271
+ flushing = false;
272
+ resyncTimer;
273
+ lastResyncAt = Number.NEGATIVE_INFINITY;
274
+ constructor(key, spaceKey, realRoot, workingDir) {
275
+ this.key = key;
276
+ this.spaceKey = spaceKey;
277
+ this.realRoot = realRoot;
278
+ this.workingDir = workingDir;
279
+ }
280
+ describe() {
281
+ return { state: this.state, entries: this.known.size, handles: this.handles.size };
282
+ }
283
+ holds(appId) {
284
+ return [...this.refs.values()].some((ref) => ref.appId === appId);
285
+ }
286
+ addRef(subscriber) {
287
+ const token = Symbol('filesWatchRef');
288
+ this.refs.set(token, subscriber);
289
+ return () => {
290
+ if (this.refs.delete(token) && this.refs.size === 0)
291
+ this.close();
292
+ };
293
+ }
294
+ close() {
295
+ if (this.state !== 'stopped')
296
+ this.state = 'closed';
297
+ this.teardown();
298
+ }
299
+ relativeOf(absPath) {
300
+ if (absPath === this.realRoot)
301
+ return '';
302
+ return isBelow(absPath, this.realRoot) ? absPath.slice(this.realRoot.length + 1) : undefined;
303
+ }
304
+ recordNotified(rel, state) {
305
+ if (this.state === 'stopped' || this.state === 'closed')
306
+ return;
307
+ const now = deps.now();
308
+ for (const [path, record] of this.notified) {
309
+ if (now - record.at <= NOTIFIED_CHANGE_TTL_MS)
310
+ break;
311
+ this.notified.delete(path);
312
+ }
313
+ this.notified.delete(rel);
314
+ this.notified.set(rel, { state, at: now });
315
+ }
316
+ async start() {
317
+ if (await this.prime())
318
+ this.becomeLive();
319
+ }
320
+ async prime() {
321
+ const generation = ++this.generation;
322
+ this.state = 'priming';
323
+ this.known.clear();
324
+ const root = await deps.lstat(this.realRoot);
325
+ if (generation !== this.generation)
326
+ return false;
327
+ if (!root?.dir)
328
+ return this.stop(ROOT_GONE);
329
+ this.rootIno = root.ino;
330
+ if (!(await this.walk('', generation, { watchAsWeGo: false })))
331
+ return false;
332
+ if (deps.nativeRecursive)
333
+ return this.watchDir('');
334
+ const dirs = ['', ...[...this.known].filter(([, entry]) => entry.dir).map(([path]) => path)];
335
+ for (let index = 0; index < dirs.length; index++) {
336
+ if (index > 0 && index % WATCH_BATCH === 0) {
337
+ await new Promise((resolve) => setImmediate(resolve));
338
+ if (generation !== this.generation)
339
+ return false;
340
+ }
341
+ if (!this.watchDir(dirs[index]))
342
+ return false;
343
+ }
344
+ return true;
345
+ }
346
+ becomeLive() {
347
+ this.state = 'live';
348
+ if (this.pending.size > 0)
349
+ this.schedule();
350
+ }
351
+ async walk(from, generation, opts) {
352
+ const queue = [from];
353
+ for (let next = 0; next < queue.length; next++) {
354
+ const dirRel = queue[next];
355
+ if (opts.watchAsWeGo && !deps.nativeRecursive && !this.watchDir(dirRel))
356
+ return false;
357
+ const children = await this.readChildren(dirRel, generation);
358
+ if (children === false)
359
+ return false;
360
+ for (const [rel, entry] of children) {
361
+ if (!this.remember(rel, entry, opts.discovered))
362
+ return false;
363
+ if (entry.dir)
364
+ queue.push(rel);
365
+ }
366
+ }
367
+ return true;
368
+ }
369
+ async readChildren(dirRel, generation) {
370
+ let names;
371
+ try {
372
+ names = await deps.readDir(join(this.realRoot, dirRel));
373
+ }
374
+ catch (error) {
375
+ if (generation !== this.generation)
376
+ return false;
377
+ if (dirRel !== '' && SKIPPABLE_DIR_ERRORS.has(errorCode(error)))
378
+ return [];
379
+ return this.stop(dirRel === '' ? ROOT_GONE : thrownGap(error));
380
+ }
381
+ if (generation !== this.generation)
382
+ return false;
383
+ const rels = names.filter((name) => !IGNORED_SEGMENTS.has(name.toLowerCase())).map((name) => join(dirRel, name));
384
+ const stats = await this.statAll(rels, generation);
385
+ if (stats === false)
386
+ return false;
387
+ return rels.flatMap((rel, position) => {
388
+ const entry = stats[position];
389
+ return entry ? [[rel, entry]] : [];
390
+ });
391
+ }
392
+ async statAll(rels, generation) {
393
+ const stats = [];
394
+ for (let index = 0; index < rels.length; index += STAT_BATCH) {
395
+ const batch = rels.slice(index, index + STAT_BATCH);
396
+ stats.push(...(await Promise.all(batch.map((rel) => deps.lstat(join(this.realRoot, rel))))));
397
+ if (generation !== this.generation)
398
+ return false;
399
+ }
400
+ return stats;
401
+ }
402
+ remember(rel, entry, discovered) {
403
+ if (this.known.has(rel))
404
+ return true;
405
+ this.known.set(rel, entry);
406
+ discovered?.push({ kind: 'created', path: rel, dir: entry.dir });
407
+ return this.known.size <= MAX_WATCH_ENTRIES || this.stop(TOO_MANY_ENTRIES);
408
+ }
409
+ watchDir(rel) {
410
+ if (this.handles.has(rel))
411
+ return true;
412
+ let handle;
413
+ try {
414
+ handle = deps.watch(join(this.realRoot, rel), deps.nativeRecursive, (_eventType, filename) => this.onEvent(rel, filename));
415
+ }
416
+ catch (error) {
417
+ if (rel !== '' && SKIPPABLE_DIR_ERRORS.has(errorCode(error)))
418
+ return true;
419
+ return this.stop(thrownGap(error));
420
+ }
421
+ handle.on('error', (error) => {
422
+ this.stop({ reason: 'watchError', message: `Watching stopped (${errorCode(error)}) — ${ONLY_DIRECT}` });
423
+ });
424
+ this.handles.set(rel, handle);
425
+ return true;
426
+ }
427
+ unwatchTree(rel) {
428
+ if (rel === '')
429
+ return;
430
+ for (const [, handle] of this.handles.deleteTree(rel))
431
+ handle.close();
432
+ }
433
+ onEvent(dirRel, filename) {
434
+ if (this.state !== 'live' && this.state !== 'priming')
435
+ return;
436
+ if (filename === null) {
437
+ this.requestResync();
438
+ return;
439
+ }
440
+ const joined = join(dirRel, filename);
441
+ const rel = joined === '.' ? '' : joined;
442
+ if (isIgnoredPath(rel))
443
+ return;
444
+ if (!deps.nativeRecursive && filename === basename(join(this.realRoot, dirRel))) {
445
+ this.recheck.add(dirRel);
446
+ this.pending.set(dirRel, deps.now() + WATCH_DEBOUNCE_MS);
447
+ }
448
+ this.pending.set(rel, deps.now() + WATCH_DEBOUNCE_MS);
449
+ if (this.pending.size > MAX_WATCH_ENTRIES) {
450
+ this.requestResync();
451
+ return;
452
+ }
453
+ if (this.state === 'live')
454
+ this.schedule();
455
+ }
456
+ schedule() {
457
+ if (this.timer !== undefined || this.flushing)
458
+ return;
459
+ let earliest = Number.POSITIVE_INFINITY;
460
+ for (const due of this.pending.values())
461
+ earliest = Math.min(earliest, due);
462
+ this.timer = deps.setTimeout(() => void this.flush(), Math.max(0, earliest - deps.now()));
463
+ }
464
+ async flush() {
465
+ this.timer = undefined;
466
+ if (this.state !== 'live' || this.pending.size === 0)
467
+ return;
468
+ const now = deps.now();
469
+ let earliest = Number.POSITIVE_INFINITY;
470
+ for (const due of this.pending.values())
471
+ earliest = Math.min(earliest, due);
472
+ if (earliest > now) {
473
+ this.schedule();
474
+ return;
475
+ }
476
+ const paths = [...this.pending.keys()];
477
+ this.pending.clear();
478
+ const generation = this.generation;
479
+ this.flushing = true;
480
+ try {
481
+ await this.processWindow(paths, generation);
482
+ }
483
+ catch (error) {
484
+ console.error('[files-watch] window failed:', error);
485
+ this.stop({ reason: 'watchError', message: `Watching stopped (internal error) — ${ONLY_DIRECT}` });
486
+ }
487
+ finally {
488
+ this.flushing = false;
489
+ if (this.state === 'live' && this.pending.size > 0)
490
+ this.schedule();
491
+ }
492
+ }
493
+ async processWindow(paths, generation) {
494
+ const root = await deps.lstat(this.realRoot);
495
+ if (generation !== this.generation)
496
+ return;
497
+ if (!root?.dir || root.ino !== this.rootIno) {
498
+ this.reportRootGone(root);
499
+ return;
500
+ }
501
+ const stats = await this.statAll(paths, generation);
502
+ if (stats === false)
503
+ return;
504
+ const changes = classifyWindow(this.known, new Map(paths.map((rel, position) => [rel, stats[position]])));
505
+ const discovered = [];
506
+ if (!(await this.followDirectories(changes, generation, discovered)))
507
+ return;
508
+ if (!(await this.reopenRechecked(generation, discovered)))
509
+ return;
510
+ this.report([...changes, ...discovered]);
511
+ }
512
+ async followDirectories(changes, generation, discovered) {
513
+ for (const change of changes) {
514
+ if (!change.dir)
515
+ continue;
516
+ if (change.kind === 'deleted' || change.kind === 'renamed')
517
+ this.unwatchTree(change.path);
518
+ const arrived = change.kind === 'renamed' ? change.newPath : change.kind === 'created' ? change.path : undefined;
519
+ if (arrived !== undefined && !(await this.walk(arrived, generation, { watchAsWeGo: true, discovered })))
520
+ return false;
521
+ }
522
+ return true;
523
+ }
524
+ async reopenRechecked(generation, discovered) {
525
+ for (const rel of [...this.recheck]) {
526
+ this.recheck.delete(rel);
527
+ if (rel !== '' && !this.known.get(rel)?.dir)
528
+ continue;
529
+ this.handles.get(rel)?.close();
530
+ this.handles.delete(rel);
531
+ if (!(await this.walk(rel, generation, { watchAsWeGo: true, discovered })))
532
+ return false;
533
+ }
534
+ return true;
535
+ }
536
+ report(changes) {
537
+ if (this.known.size > MAX_WATCH_ENTRIES) {
538
+ this.stop(TOO_MANY_ENTRIES);
539
+ return;
540
+ }
541
+ const reportable = [];
542
+ for (const change of changes) {
543
+ reportable.push(...this.reportable(change));
544
+ if (reportable.length > MAX_CHANGES_PER_WINDOW) {
545
+ this.gapAll(OVERFLOW);
546
+ return;
547
+ }
548
+ }
549
+ for (const change of reportable)
550
+ this.notify(change);
551
+ }
552
+ reportRootGone(root) {
553
+ if (this.isNews('', root))
554
+ this.notify({ kind: 'deleted', path: '', dir: true });
555
+ this.stop(ROOT_GONE);
556
+ }
557
+ takeNotified(rel) {
558
+ const record = this.notified.get(rel);
559
+ if (!record)
560
+ return undefined;
561
+ this.notified.delete(rel);
562
+ return deps.now() - record.at > NOTIFIED_CHANGE_TTL_MS ? undefined : record;
563
+ }
564
+ isNews(rel, current) {
565
+ const record = this.takeNotified(rel);
566
+ return !record || !sameState(record.state, current);
567
+ }
568
+ isVisible(rel) {
569
+ try {
570
+ guardWorkspacePath(this.workingDir, join(this.realRoot, rel), { quiet: true });
571
+ return true;
572
+ }
573
+ catch {
574
+ return false;
575
+ }
576
+ }
577
+ reportable(change) {
578
+ const news = (rel) => this.isNews(rel, this.known.get(rel)) && this.isVisible(rel);
579
+ if (change.kind !== 'renamed')
580
+ return news(change.path) ? [change] : [];
581
+ const source = news(change.path);
582
+ const target = news(change.newPath);
583
+ if (source && target)
584
+ return [change];
585
+ if (source)
586
+ return [{ kind: 'deleted', path: change.path, dir: change.dir }];
587
+ if (target)
588
+ return [{ kind: 'created', path: change.newPath, dir: change.dir }];
589
+ return [];
590
+ }
591
+ notify(change) {
592
+ const absolute = join(this.realRoot, change.path);
593
+ emittingWatch = this;
594
+ try {
595
+ if (change.kind === 'renamed') {
596
+ notifyFileChangedAbsolute(this.spaceKey, absolute, 'renamed', {
597
+ newPath: join(this.realRoot, change.newPath),
598
+ dir: change.dir,
599
+ });
600
+ }
601
+ else {
602
+ notifyFileChangedAbsolute(this.spaceKey, absolute, change.kind, { dir: change.dir });
603
+ }
604
+ }
605
+ finally {
606
+ emittingWatch = undefined;
607
+ }
608
+ }
609
+ gapAll(gap) {
610
+ for (const ref of this.refs.values())
611
+ ref.onGap(gap);
612
+ }
613
+ requestResync() {
614
+ if (this.resyncTimer !== undefined)
615
+ return;
616
+ this.state = 'resyncing';
617
+ this.pending.clear();
618
+ const wait = Math.max(0, this.lastResyncAt + RESYNC_COOLDOWN_MS - deps.now());
619
+ this.resyncTimer = deps.setTimeout(() => {
620
+ this.resyncTimer = undefined;
621
+ void this.resync();
622
+ }, wait);
623
+ }
624
+ async resync() {
625
+ if (this.state !== 'resyncing')
626
+ return;
627
+ this.lastResyncAt = deps.now();
628
+ this.closeHandles();
629
+ if (!(await this.prime()))
630
+ return;
631
+ this.gapAll(OVERFLOW);
632
+ this.becomeLive();
633
+ }
634
+ stop(gap) {
635
+ if (this.state === 'stopped' || this.state === 'closed')
636
+ return false;
637
+ this.state = 'stopped';
638
+ this.teardown();
639
+ this.gapAll(gap);
640
+ return false;
641
+ }
642
+ closeHandles() {
643
+ for (const [, handle] of this.handles)
644
+ handle.close();
645
+ this.handles.clear();
646
+ }
647
+ teardown() {
648
+ this.generation++;
649
+ this.closeHandles();
650
+ if (this.timer !== undefined)
651
+ deps.clearTimeout(this.timer);
652
+ if (this.resyncTimer !== undefined)
653
+ deps.clearTimeout(this.resyncTimer);
654
+ this.timer = undefined;
655
+ this.resyncTimer = undefined;
656
+ this.pending.clear();
657
+ this.recheck.clear();
658
+ this.known.clear();
659
+ this.notified.clear();
660
+ if (watches.get(this.key) === this)
661
+ watches.delete(this.key);
662
+ }
663
+ }
664
+ function observeNotifiedChange(spaceKey, source, target) {
665
+ if (watches.size === 0)
666
+ return;
667
+ recordInCoveringWatches(spaceKey, source);
668
+ if (target !== undefined)
669
+ recordInCoveringWatches(spaceKey, target);
670
+ }
671
+ function recordInCoveringWatches(spaceKey, absolute) {
672
+ let state = null;
673
+ for (const rootWatch of watches.values()) {
674
+ if (rootWatch.spaceKey !== spaceKey || rootWatch === emittingWatch)
675
+ continue;
676
+ const rel = rootWatch.relativeOf(absolute);
677
+ if (rel === undefined)
678
+ continue;
679
+ if (state === null)
680
+ state = deps.lstatSync(absolute);
681
+ rootWatch.recordNotified(rel, state);
682
+ }
683
+ }
684
+ export function acquireRootWatch(request) {
685
+ setFileChangeObserver(observeNotifiedChange);
686
+ const key = `${request.spaceKey}\0${request.realRoot}`;
687
+ let rootWatch = watches.get(key);
688
+ if (!rootWatch?.holds(request.appId)) {
689
+ const held = [...watches.values()].filter((candidate) => candidate.spaceKey === request.spaceKey && candidate.holds(request.appId)).length;
690
+ if (held >= MAX_WATCHED_ROOTS_PER_APP) {
691
+ request.onGap(TOO_MANY_ROOTS);
692
+ return () => { };
693
+ }
694
+ }
695
+ const fresh = rootWatch === undefined;
696
+ if (rootWatch === undefined) {
697
+ rootWatch = new RootWatch(key, request.spaceKey, request.realRoot, request.workingDir);
698
+ watches.set(key, rootWatch);
699
+ }
700
+ const release = rootWatch.addRef({ appId: request.appId, onGap: request.onGap });
701
+ if (fresh)
702
+ void rootWatch.start();
703
+ let released = false;
704
+ return () => {
705
+ if (released)
706
+ return;
707
+ released = true;
708
+ release();
709
+ };
710
+ }
711
+ export function describeRootWatch(spaceKey, realRoot) {
712
+ return watches.get(`${spaceKey}\0${realRoot}`)?.describe();
713
+ }
714
+ export function setFilesWatchDeps(overrides) {
715
+ deps = { ...defaultDeps, ...overrides };
716
+ }
717
+ export function resetFilesWatch() {
718
+ for (const rootWatch of [...watches.values()])
719
+ rootWatch.close();
720
+ deps = defaultDeps;
721
+ }