@uniqbit/mate-core 0.16.0 → 0.17.0-canary.10

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 (49) hide show
  1. package/package.json +2 -2
  2. package/src/cli/commands/companion/companion.ts +8 -0
  3. package/src/cli/commands/companion/prepare.ts +76 -0
  4. package/src/cli/commands/companion/register.ts +47 -0
  5. package/src/cli/commands/launch/claude.ts +4 -3
  6. package/src/cli/commands/launch/opencode.ts +4 -3
  7. package/src/cli/commands/launch/shared.ts +41 -28
  8. package/src/cli/commands/shared/companion-selection.ts +3 -1
  9. package/src/cli/commands/studio/index.ts +85 -6
  10. package/src/cli/commands/studio/routes.ts +1 -1
  11. package/src/cli/commands/studio/selection.ts +11 -2
  12. package/src/cli/commands/studio/server.ts +212 -17
  13. package/src/cli/commands/studio/vault.ts +607 -0
  14. package/src/cli/commands/studio/views/client.ts +141 -1
  15. package/src/cli/commands/studio/views/companion-selector.tsx +1 -1
  16. package/src/cli/commands/studio/views/document.tsx +137 -3
  17. package/src/cli/commands/studio/views/model.ts +13 -0
  18. package/src/cli/commands/studio/views/styles.ts +17 -0
  19. package/src/cli/commands/studio/views/workflow/steps.ts +10 -3
  20. package/src/cli/commands/update.ts +6 -6
  21. package/src/cli/main.ts +56 -4
  22. package/src/cli/usage.ts +6 -3
  23. package/src/index.ts +1 -0
  24. package/src/lib/context-mode-package.ts +7 -4
  25. package/src/lib/node-engine-version.ts +15 -0
  26. package/src/lib/opencode-plugin-package.ts +10 -4
  27. package/src/lib/orchestrator/adapters/base.ts +65 -10
  28. package/src/lib/orchestrator/adapters/opencode.ts +27 -2
  29. package/src/lib/orchestrator/companion-registration.ts +73 -0
  30. package/src/lib/orchestrator/framework-context.ts +29 -3
  31. package/src/lib/orchestrator/launcher.ts +46 -22
  32. package/src/lib/orchestrator/projection-record.ts +24 -0
  33. package/src/lib/orchestrator/types.ts +11 -0
  34. package/src/lib/package-root.ts +43 -0
  35. package/src/lib/prebuilt-workspace-prepare.ts +152 -0
  36. package/src/lib/prebuilt-workspace.ts +246 -0
  37. package/src/lib/preinstalled-plugins.ts +106 -0
  38. package/src/lib/update-checker.ts +39 -0
  39. package/src/opencode/companion-hooks.ts +45 -20
  40. package/src/playbooks/companion-guidance.ts +1 -0
  41. package/src/runtime/companion-guidance.ts +61 -15
  42. package/src/runtime/env.ts +3 -3
  43. package/src/runtime/index.ts +1 -0
  44. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  45. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +127 -77
  46. package/src/tools/setup/capabilities/context-mode.ts +34 -2
  47. package/src/tools/setup/capabilities/openspec.ts +12 -2
  48. package/src/tools/setup/plugin.ts +7 -1
  49. package/src/tools/setup/providers/opencode.ts +24 -5
@@ -0,0 +1,607 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import fs, { type Dirent, type FSWatcher, type Stats } from "node:fs";
3
+ import * as fsp from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { execFile as execFileCallback } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+
8
+ const execFile = promisify(execFileCallback);
9
+
10
+ export interface VaultTreeNode {
11
+ name: string;
12
+ path: string;
13
+ kind: "file" | "directory";
14
+ children?: VaultTreeNode[];
15
+ }
16
+
17
+ export interface VaultFile {
18
+ path: string;
19
+ content: string;
20
+ token: string;
21
+ }
22
+
23
+ export interface VaultConflict {
24
+ kind: "conflict";
25
+ path: string;
26
+ reason: string;
27
+ content: string;
28
+ token: string;
29
+ }
30
+
31
+ export interface VaultSaved {
32
+ kind: "saved";
33
+ path: string;
34
+ token: string;
35
+ }
36
+
37
+ export interface VaultWatchEvent {
38
+ kind: "changed" | "removed" | "overwritten";
39
+ path: string;
40
+ content?: string;
41
+ token?: string;
42
+ recoveredContent?: string;
43
+ recoveredToken?: string;
44
+ }
45
+
46
+ export interface VaultTreeResult {
47
+ tree: VaultTreeNode[];
48
+ watching: boolean;
49
+ warning: string | null;
50
+ }
51
+
52
+ export interface VaultPath {
53
+ root: string;
54
+ absolute: string;
55
+ relative: string;
56
+ }
57
+
58
+ export class VaultPathError extends Error {
59
+ readonly code = "VAULT_PATH_REFUSED";
60
+
61
+ constructor(message: string) {
62
+ super(message);
63
+ this.name = "VaultPathError";
64
+ }
65
+ }
66
+
67
+ export interface VaultWatcher {
68
+ close(): void;
69
+ }
70
+
71
+ export interface VaultDeps {
72
+ watch?: (
73
+ root: string,
74
+ listener: (event: string, filename: string | Buffer | null) => void,
75
+ ) => VaultWatcher;
76
+ beforeWrite?: (absolutePath: string) => Promise<void> | void;
77
+ }
78
+
79
+ const MARKDOWN_EXTENSIONS = new Set([".md", ".markdown"]);
80
+
81
+ function isMarkdown(requestedPath: string): boolean {
82
+ return MARKDOWN_EXTENSIONS.has(path.extname(requestedPath).toLowerCase());
83
+ }
84
+
85
+ function inside(root: string, candidate: string): boolean {
86
+ const relative = path.relative(root, candidate);
87
+ return (
88
+ relative === "" ||
89
+ (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
90
+ );
91
+ }
92
+
93
+ async function realpathContaining(root: string, candidate: string): Promise<string> {
94
+ let current = candidate;
95
+ while (true) {
96
+ try {
97
+ return await fsp.realpath(current);
98
+ } catch (error) {
99
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
100
+ const parent = path.dirname(current);
101
+ if (parent === current) throw error;
102
+ current = parent;
103
+ }
104
+ }
105
+ }
106
+
107
+ /** Resolves links before containment is checked, including paths that do not exist yet. */
108
+ export async function resolveVaultPath(
109
+ companionRoot: string,
110
+ requestedPath: string,
111
+ ): Promise<VaultPath> {
112
+ if (!requestedPath || requestedPath.includes("\0") || path.isAbsolute(requestedPath)) {
113
+ throw new VaultPathError("vault paths must be relative markdown paths inside the companion");
114
+ }
115
+ if (!isMarkdown(requestedPath)) {
116
+ throw new VaultPathError("the vault only exposes markdown files");
117
+ }
118
+
119
+ const root = await fsp.realpath(companionRoot).catch(() => {
120
+ throw new VaultPathError("the selected Companion Repository is unavailable");
121
+ });
122
+ const lexical = path.resolve(root, requestedPath);
123
+ if (!inside(root, lexical))
124
+ throw new VaultPathError("the requested path leaves the companion root");
125
+
126
+ let absolute: string;
127
+ try {
128
+ absolute = await fsp.realpath(lexical);
129
+ } catch (error) {
130
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
131
+ const containing = await realpathContaining(root, path.dirname(lexical));
132
+ if (!inside(root, containing))
133
+ throw new VaultPathError("the requested path leaves the companion root");
134
+ absolute = lexical;
135
+ }
136
+ if (!inside(root, absolute))
137
+ throw new VaultPathError("the requested path leaves the companion root");
138
+
139
+ try {
140
+ if (!(await fsp.stat(absolute)).isFile())
141
+ throw new VaultPathError("the requested path is not a file");
142
+ } catch (error) {
143
+ if (error instanceof VaultPathError) throw error;
144
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
145
+ }
146
+
147
+ return { root, absolute, relative: path.relative(root, absolute) || requestedPath };
148
+ }
149
+
150
+ async function ignored(root: string, relative: string): Promise<boolean> {
151
+ try {
152
+ await execFile("git", ["-C", root, "check-ignore", "--no-index", "--quiet", "--", relative]);
153
+ return true;
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ async function collectFiles(
160
+ root: string,
161
+ current: string,
162
+ relativeDir: string,
163
+ output: string[],
164
+ ): Promise<void> {
165
+ let entries: Dirent[];
166
+ try {
167
+ entries = await fsp.readdir(current, { withFileTypes: true });
168
+ } catch {
169
+ return;
170
+ }
171
+ entries.sort((a, b) => a.name.localeCompare(b.name));
172
+ for (const entry of entries) {
173
+ if (entry.name === ".git") continue;
174
+ const relative = path.join(relativeDir, entry.name);
175
+ if (await ignored(root, relative)) continue;
176
+ const absolute = path.join(current, entry.name);
177
+ let resolved: string;
178
+ try {
179
+ resolved = await fsp.realpath(absolute);
180
+ if (!inside(root, resolved)) continue;
181
+ } catch {
182
+ continue;
183
+ }
184
+ let stats: Stats;
185
+ try {
186
+ stats = await fsp.stat(resolved);
187
+ } catch {
188
+ continue;
189
+ }
190
+ if (stats.isDirectory()) await collectFiles(root, resolved, relative, output);
191
+ else if (stats.isFile() && isMarkdown(relative)) output.push(relative);
192
+ }
193
+ }
194
+
195
+ function asTree(paths: string[]): VaultTreeNode[] {
196
+ const roots: VaultTreeNode[] = [];
197
+ for (const filePath of paths.sort((a, b) => a.localeCompare(b))) {
198
+ let level = roots;
199
+ const parts = filePath.split(path.sep);
200
+ for (let index = 0; index < parts.length; index += 1) {
201
+ const name = parts[index]!;
202
+ const currentPath = parts.slice(0, index + 1).join(path.sep);
203
+ let node = level.find((candidate) => candidate.name === name);
204
+ if (!node) {
205
+ node = {
206
+ name,
207
+ path: currentPath,
208
+ kind: index === parts.length - 1 ? "file" : "directory",
209
+ ...(index === parts.length - 1 ? {} : { children: [] }),
210
+ };
211
+ level.push(node);
212
+ }
213
+ if (node.children) level = node.children;
214
+ }
215
+ }
216
+ return roots;
217
+ }
218
+
219
+ /** Collects every non-ignored markdown file under a companion root. */
220
+ export async function collectMarkdownTree(companionRoot: string): Promise<VaultTreeNode[]> {
221
+ const root = await fsp.realpath(companionRoot);
222
+ const files: string[] = [];
223
+ await collectFiles(root, root, "", files);
224
+ return asTree(files);
225
+ }
226
+
227
+ /** The token is opaque to callers and changes whenever the bytes change. */
228
+ export function versionToken(content: string): string {
229
+ return createHash("sha256").update(content).digest("hex");
230
+ }
231
+
232
+ export const createVersionToken = versionToken;
233
+
234
+ export async function readVaultFile(
235
+ companionRoot: string,
236
+ requestedPath: string,
237
+ ): Promise<VaultFile> {
238
+ const resolved = await resolveVaultPath(companionRoot, requestedPath);
239
+ const content = await fsp.readFile(resolved.absolute, "utf8");
240
+ return { path: resolved.relative, content, token: versionToken(content) };
241
+ }
242
+
243
+ function defaultWatcher(
244
+ root: string,
245
+ listener: (event: string, filename: string | Buffer | null) => void,
246
+ ): VaultWatcher {
247
+ const watcher: FSWatcher = fs.watch(root, { recursive: true }, listener);
248
+ return { close: () => watcher.close() };
249
+ }
250
+
251
+ interface SaveRecord {
252
+ path: string;
253
+ content: string;
254
+ token: string;
255
+ currentToken: string;
256
+ windowOpen: boolean;
257
+ expires: ReturnType<typeof setTimeout>;
258
+ }
259
+
260
+ interface Subscriber {
261
+ path: string;
262
+ notify: (event: VaultWatchEvent) => void;
263
+ }
264
+
265
+ export interface VaultManager {
266
+ tree(companionRoot: string, refresh?: boolean): Promise<VaultTreeResult>;
267
+ open(companionRoot: string, requestedPath: string): Promise<VaultFile>;
268
+ save(
269
+ companionRoot: string,
270
+ requestedPath: string,
271
+ content: string,
272
+ token?: string,
273
+ ): Promise<VaultSaved | VaultConflict>;
274
+ subscribe(
275
+ companionRoot: string,
276
+ requestedPath: string,
277
+ notify: (event: VaultWatchEvent) => void,
278
+ ): () => void;
279
+ refresh(companionRoot: string): Promise<VaultTreeResult>;
280
+ deactivate(): void;
281
+ stop(): void;
282
+ getRecovery(companionRoot: string, requestedPath: string): VaultWatchEvent | null;
283
+ }
284
+
285
+ /** Owns one tree cache and one recursive watcher per selected companion process. */
286
+ export function createVaultManager(deps: VaultDeps = {}): VaultManager {
287
+ const watch = deps.watch ?? defaultWatcher;
288
+ const trees = new Map<string, VaultTreeNode[]>();
289
+ const warnings = new Map<string, string | null>();
290
+ const watchers = new Map<string, VaultWatcher>();
291
+ let activeRoot: string | null = null;
292
+ const subscribers = new Map<string, Set<Subscriber>>();
293
+ const pendingEvents = new Map<string, ReturnType<typeof setTimeout>>();
294
+ const locks = new Map<string, Promise<unknown>>();
295
+ const recentSaves = new Map<string, SaveRecord>();
296
+ const recoveries = new Map<string, VaultWatchEvent>();
297
+
298
+ const rootKey = async (root: string) => fsp.realpath(root);
299
+
300
+ const invalidate = (root: string) => {
301
+ trees.delete(root);
302
+ };
303
+
304
+ const notify = async (
305
+ root: string,
306
+ relative: string,
307
+ observed: Promise<VaultFile | null> = readVaultFile(root, relative).catch(() => null),
308
+ saveWasActive = false,
309
+ ) => {
310
+ const key = `${root}\0${relative}`;
311
+ const prior = pendingEvents.get(key);
312
+ if (prior) clearTimeout(prior);
313
+ pendingEvents.set(
314
+ key,
315
+ setTimeout(() => {
316
+ pendingEvents.delete(key);
317
+ void (async () => {
318
+ let event: VaultWatchEvent;
319
+ try {
320
+ const current = await observed;
321
+ if (!current) throw new Error("file removed");
322
+ event = {
323
+ kind: "changed",
324
+ path: current.path,
325
+ content: current.content,
326
+ token: current.token,
327
+ };
328
+ const save = recentSaves.get(`${root}\0${current.path}`);
329
+ if (
330
+ save &&
331
+ saveWasActive &&
332
+ save.token !== current.token &&
333
+ save.currentToken !== current.token
334
+ ) {
335
+ event = {
336
+ kind: "overwritten",
337
+ path: current.path,
338
+ content: current.content,
339
+ token: current.token,
340
+ recoveredContent: current.content,
341
+ recoveredToken: current.token,
342
+ };
343
+ recoveries.set(`${root}\0${current.path}`, event);
344
+ }
345
+ } catch {
346
+ event = { kind: "removed", path: relative };
347
+ }
348
+ const listeners = subscribers.get(key);
349
+ for (const subscriber of listeners ?? []) subscriber.notify(event);
350
+ })();
351
+ }, 25),
352
+ );
353
+ };
354
+
355
+ const observeNow = (root: string, relative: string): Promise<VaultFile | null> => {
356
+ try {
357
+ const candidate = path.resolve(root, relative);
358
+ const resolved = fs.realpathSync(candidate);
359
+ if (!inside(root, resolved) || !isMarkdown(relative)) return Promise.resolve(null);
360
+ const content = fs.readFileSync(resolved, "utf8");
361
+ return Promise.resolve({
362
+ path: path.relative(root, resolved),
363
+ content,
364
+ token: versionToken(content),
365
+ });
366
+ } catch {
367
+ return Promise.resolve(null);
368
+ }
369
+ };
370
+
371
+ const startWatching = (root: string) => {
372
+ if (activeRoot && activeRoot !== root) {
373
+ watchers.get(activeRoot)?.close();
374
+ watchers.delete(activeRoot);
375
+ warnings.delete(root);
376
+ }
377
+ activeRoot = root;
378
+ if (watchers.has(root) || warnings.has(root)) return;
379
+ try {
380
+ watchers.set(
381
+ root,
382
+ watch(root, (_event, filename) => {
383
+ if (!filename) return;
384
+ const value = String(filename);
385
+ const relative = path.relative(
386
+ root,
387
+ path.isAbsolute(value) ? value : path.resolve(root, value),
388
+ );
389
+ if (
390
+ !relative ||
391
+ relative.startsWith("..") ||
392
+ !isMarkdown(relative) ||
393
+ relative.split(path.sep).includes(".git")
394
+ )
395
+ return;
396
+ const observed = observeNow(root, relative);
397
+ const saveWasActive = recentSaves.get(`${root}\0${relative}`)?.windowOpen === true;
398
+ void ignored(root, relative).then((isIgnored) => {
399
+ if (isIgnored) return;
400
+ invalidate(root);
401
+ void notify(root, relative, observed, saveWasActive);
402
+ });
403
+ }),
404
+ );
405
+ warnings.set(root, null);
406
+ } catch (error) {
407
+ warnings.set(
408
+ root,
409
+ `Live updates are unavailable: ${error instanceof Error ? error.message : String(error)}`,
410
+ );
411
+ }
412
+ };
413
+
414
+ const readConflict = async (resolved: VaultPath, reason: string): Promise<VaultConflict> => {
415
+ try {
416
+ const content = await fsp.readFile(resolved.absolute, "utf8");
417
+ return {
418
+ kind: "conflict",
419
+ path: resolved.relative,
420
+ reason,
421
+ content,
422
+ token: versionToken(content),
423
+ };
424
+ } catch {
425
+ return {
426
+ kind: "conflict",
427
+ path: resolved.relative,
428
+ reason,
429
+ content: "",
430
+ token: versionToken(""),
431
+ };
432
+ }
433
+ };
434
+
435
+ const runSave = async (
436
+ root: string,
437
+ requestedPath: string,
438
+ content: string,
439
+ token?: string,
440
+ ): Promise<VaultSaved | VaultConflict> => {
441
+ const resolved = await resolveVaultPath(root, requestedPath);
442
+ startWatching(root);
443
+ await fsp.mkdir(path.dirname(resolved.absolute), { recursive: true });
444
+
445
+ if (token === undefined) {
446
+ const temporary = path.join(
447
+ path.dirname(resolved.absolute),
448
+ `.${path.basename(resolved.absolute)}.${randomUUID()}.tmp`,
449
+ );
450
+ await fsp.writeFile(temporary, content, "utf8");
451
+ try {
452
+ await deps.beforeWrite?.(resolved.absolute);
453
+ await fsp.link(temporary, resolved.absolute);
454
+ } catch (error) {
455
+ await fsp.unlink(temporary).catch(() => {});
456
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
457
+ return readConflict(resolved, "a file already exists at this path");
458
+ }
459
+ throw error;
460
+ }
461
+ await fsp.unlink(temporary).catch(() => {});
462
+ const savedToken = versionToken(content);
463
+ invalidate(root);
464
+ return { kind: "saved", path: resolved.relative, token: savedToken };
465
+ }
466
+
467
+ let current: string;
468
+ try {
469
+ current = await fsp.readFile(resolved.absolute, "utf8");
470
+ } catch {
471
+ return readConflict(resolved, "the file no longer exists");
472
+ }
473
+ const currentToken = versionToken(current);
474
+ if (currentToken !== token)
475
+ return {
476
+ kind: "conflict",
477
+ path: resolved.relative,
478
+ reason: "the file changed elsewhere",
479
+ content: current,
480
+ token: currentToken,
481
+ };
482
+
483
+ const savedToken = versionToken(content);
484
+ const temporary = path.join(
485
+ path.dirname(resolved.absolute),
486
+ `.${path.basename(resolved.absolute)}.${randomUUID()}.tmp`,
487
+ );
488
+ const record: SaveRecord = {
489
+ path: resolved.relative,
490
+ content,
491
+ token: savedToken,
492
+ currentToken,
493
+ windowOpen: true,
494
+ expires: setTimeout(() => recentSaves.delete(`${root}\0${resolved.relative}`), 2_000),
495
+ };
496
+ recentSaves.set(`${root}\0${resolved.relative}`, record);
497
+ try {
498
+ await deps.beforeWrite?.(resolved.absolute);
499
+ await fsp.writeFile(temporary, content, "utf8");
500
+ await fsp.rename(temporary, resolved.absolute);
501
+ record.windowOpen = false;
502
+ } catch (error) {
503
+ clearTimeout(record.expires);
504
+ recentSaves.delete(`${root}\0${resolved.relative}`);
505
+ await fsp.unlink(temporary).catch(() => {});
506
+ throw error;
507
+ }
508
+ invalidate(root);
509
+ return { kind: "saved", path: resolved.relative, token: savedToken };
510
+ };
511
+
512
+ const save = async (root: string, requestedPath: string, content: string, token?: string) => {
513
+ const key = `${path.resolve(root)}\0${path.normalize(requestedPath)}`;
514
+ const previous = locks.get(key) ?? Promise.resolve();
515
+ const current = previous.then(async () => {
516
+ const canonical = await resolveVaultPath(root, requestedPath);
517
+ return runSave(canonical.root, canonical.relative, content, token);
518
+ });
519
+ locks.set(key, current);
520
+ try {
521
+ return await current;
522
+ } finally {
523
+ if (locks.get(key) === current) locks.delete(key);
524
+ }
525
+ };
526
+
527
+ return {
528
+ async tree(companionRoot, refresh = false) {
529
+ const root = await rootKey(companionRoot);
530
+ startWatching(root);
531
+ if (refresh || !trees.has(root)) trees.set(root, await collectMarkdownTree(root));
532
+ return {
533
+ tree: trees.get(root) ?? [],
534
+ watching: watchers.has(root),
535
+ warning: warnings.get(root) ?? null,
536
+ };
537
+ },
538
+ async open(companionRoot, requestedPath) {
539
+ const root = await rootKey(companionRoot);
540
+ startWatching(root);
541
+ return readVaultFile(root, requestedPath);
542
+ },
543
+ save,
544
+ subscribe(companionRoot, requestedPath, notifySubscriber) {
545
+ let active = true;
546
+ let key = `${path.resolve(companionRoot)}\0${requestedPath}`;
547
+ const subscriber = { path: requestedPath, notify: notifySubscriber };
548
+ const set = subscribers.get(key) ?? new Set<Subscriber>();
549
+ set.add(subscriber);
550
+ subscribers.set(key, set);
551
+ void rootKey(companionRoot).then((root) => {
552
+ if (!active || root === path.resolve(companionRoot)) {
553
+ if (active) startWatching(root);
554
+ return;
555
+ }
556
+ const oldSet = subscribers.get(key);
557
+ oldSet?.delete(subscriber);
558
+ if (oldSet?.size === 0) subscribers.delete(key);
559
+ key = `${root}\0${requestedPath}`;
560
+ const newSet = subscribers.get(key) ?? new Set<Subscriber>();
561
+ newSet.add(subscriber);
562
+ subscribers.set(key, newSet);
563
+ startWatching(root);
564
+ });
565
+ return () => {
566
+ active = false;
567
+ const currentSubscribers = subscribers.get(key);
568
+ if (!currentSubscribers) return;
569
+ currentSubscribers.delete(subscriber);
570
+ if (currentSubscribers.size === 0) subscribers.delete(key);
571
+ };
572
+ },
573
+ async refresh(companionRoot) {
574
+ return this.tree(companionRoot, true);
575
+ },
576
+ deactivate() {
577
+ if (activeRoot) {
578
+ watchers.get(activeRoot)?.close();
579
+ watchers.delete(activeRoot);
580
+ warnings.delete(activeRoot);
581
+ }
582
+ activeRoot = null;
583
+ subscribers.clear();
584
+ },
585
+ stop() {
586
+ for (const watcher of watchers.values()) watcher.close();
587
+ watchers.clear();
588
+ activeRoot = null;
589
+ for (const timer of pendingEvents.values()) clearTimeout(timer);
590
+ for (const record of recentSaves.values()) clearTimeout(record.expires);
591
+ pendingEvents.clear();
592
+ recentSaves.clear();
593
+ subscribers.clear();
594
+ },
595
+ getRecovery(companionRoot, requestedPath) {
596
+ const root = path.resolve(companionRoot);
597
+ return (
598
+ recoveries.get(`${root}\0${requestedPath}`) ??
599
+ [...recoveries.entries()].find(
600
+ ([key]) => key.startsWith(`${root}\0`) && key.endsWith(`\0${requestedPath}`),
601
+ )?.[1] ??
602
+ [...recoveries.values()].find((event) => event.path === requestedPath) ??
603
+ null
604
+ );
605
+ },
606
+ };
607
+ }