viewable-one 0.0.0-stage → 0.12.2

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 (89) hide show
  1. package/CHANGELOG.md +1483 -0
  2. package/LICENSE +21 -0
  3. package/README.md +738 -2
  4. package/SPAWN.md +2108 -0
  5. package/glytch.mjs +517 -0
  6. package/lib/acp.mjs +1018 -0
  7. package/lib/admission.mjs +105 -0
  8. package/lib/agent-selection.mjs +124 -0
  9. package/lib/agents.mjs +914 -0
  10. package/lib/atomic.mjs +96 -0
  11. package/lib/auth.mjs +1491 -0
  12. package/lib/authenticatedHttp.mjs +75 -0
  13. package/lib/boardside.mjs +225 -0
  14. package/lib/child.mjs +1292 -0
  15. package/lib/cli-output.mjs +176 -0
  16. package/lib/cloudflared-release.json +25 -0
  17. package/lib/cloudflared.mjs +81 -0
  18. package/lib/codex-rpc.mjs +92 -0
  19. package/lib/codex.mjs +767 -0
  20. package/lib/codexApps.mjs +344 -0
  21. package/lib/codexPlanWindow.mjs +84 -0
  22. package/lib/codexToolInputs.mjs +139 -0
  23. package/lib/codingChecks.mjs +55 -0
  24. package/lib/compose.mjs +76 -0
  25. package/lib/computerApps.mjs +110 -0
  26. package/lib/conversationRules.mjs +45 -0
  27. package/lib/detect.mjs +629 -0
  28. package/lib/devserver.mjs +670 -0
  29. package/lib/digest.mjs +290 -0
  30. package/lib/doctor.mjs +464 -0
  31. package/lib/exitClass.mjs +64 -0
  32. package/lib/hosted.mjs +112 -0
  33. package/lib/imageAttachments.mjs +31 -0
  34. package/lib/init-connect.mjs +418 -0
  35. package/lib/kill.mjs +104 -0
  36. package/lib/logindaemon.mjs +976 -0
  37. package/lib/logs.mjs +120 -0
  38. package/lib/mcpjson.mjs +242 -0
  39. package/lib/memberPreview.mjs +173 -0
  40. package/lib/multiwatch.mjs +428 -0
  41. package/lib/pagePictures.mjs +396 -0
  42. package/lib/pageRoutes.mjs +70 -0
  43. package/lib/paths.mjs +10 -0
  44. package/lib/permissionAsk.mjs +418 -0
  45. package/lib/picker.mjs +300 -0
  46. package/lib/platform.mjs +60 -0
  47. package/lib/previewGateway.mjs +130 -0
  48. package/lib/previewTunnel.mjs +85 -0
  49. package/lib/procs.mjs +428 -0
  50. package/lib/project-mcp.mjs +288 -0
  51. package/lib/projectPaths.mjs +31 -0
  52. package/lib/refusal.mjs +70 -0
  53. package/lib/registry.mjs +204 -0
  54. package/lib/repomap.mjs +323 -0
  55. package/lib/restartPass.mjs +151 -0
  56. package/lib/retired-address.mjs +78 -0
  57. package/lib/rowsAnswers.mjs +388 -0
  58. package/lib/rowsEvents.mjs +86 -0
  59. package/lib/run-engine.mjs +16 -0
  60. package/lib/runner-health.mjs +57 -0
  61. package/lib/runner.mjs +1640 -0
  62. package/lib/runnerAdmission.mjs +35 -0
  63. package/lib/runnerApi.mjs +312 -0
  64. package/lib/runnerCompletion.mjs +406 -0
  65. package/lib/runnerLease.mjs +37 -0
  66. package/lib/runnerMaintenance.mjs +27 -0
  67. package/lib/runnerPace.mjs +283 -0
  68. package/lib/runnerPerformance.mjs +26 -0
  69. package/lib/runnerRealtime.mjs +340 -0
  70. package/lib/runnerRegister.mjs +96 -0
  71. package/lib/runnerSettings.mjs +40 -0
  72. package/lib/runnerSettlement.mjs +145 -0
  73. package/lib/runnerWorkspace.mjs +177 -0
  74. package/lib/serverSweep.mjs +90 -0
  75. package/lib/sharedPreview.mjs +292 -0
  76. package/lib/signIn.mjs +47 -0
  77. package/lib/spawn.mjs +330 -0
  78. package/lib/sweep.mjs +161 -0
  79. package/lib/sync.mjs +484 -0
  80. package/lib/uninstall-project.mjs +589 -0
  81. package/lib/uninstall.mjs +464 -0
  82. package/lib/update.mjs +302 -0
  83. package/lib/usageLimit.mjs +108 -0
  84. package/lib/viewableFolder.mjs +105 -0
  85. package/lib/windows-command.mjs +85 -0
  86. package/lib/worktree.mjs +1142 -0
  87. package/lib/writePolicy.mjs +45 -0
  88. package/package.json +58 -4
  89. package/spawn-runner.mjs +167 -0
@@ -0,0 +1,428 @@
1
+ // ONE DAEMON, EVERY REGISTERED FOLDER.
2
+ //
3
+ // The runner was written to watch the folder it was started in, because it
4
+ // was started by a person standing in that folder. A LaunchAgent is started
5
+ // by launchd at login, standing nowhere, so something has to say which
6
+ // folders it is for - the registry (lib/registry.mjs) - and the watcher has
7
+ // to hold all of them at once.
8
+ //
9
+ // WHAT DOES NOT CHANGE, and this is the part that matters: each folder gets
10
+ // the SAME `watch()` it always got, writing its own mark file in its own
11
+ // `.glytch/inbox/spawn/.runner.json`. The app's contract is per folder and
12
+ // it is untouched - an app that has never heard of this module reads exactly
13
+ // the file it read before. Nothing about a session, a claim, a receipt or a
14
+ // budget becomes machine-wide.
15
+ //
16
+ // ONE RUNNER ID FOR THE PROCESS (#813). Every folder used to get its own, but
17
+ // one `register` names every folder with the id of whichever folder sent it,
18
+ // so the rows' runner id flipped between them. The app now matches the
19
+ // runner's presence on its push topic against that id, so it is one id.
20
+ //
21
+ // A FOLDER THAT HAS DISAPPEARED IS ONE LOG LINE, NEVER AN EXIT. A machine-
22
+ // wide daemon meets deleted and moved project folders as an ordinary event,
23
+ // and quitting over one would stop the other five.
24
+ //
25
+ // PM_BOARD_ROOT is NOT handled here: with it set, `glytch runner watch` hands
26
+ // straight over to spawn-runner.mjs, so that path is exactly today's, down to
27
+ // the same process.
28
+ //
29
+ // THE REGISTRY IS READ AGAIN WHILE THE DAEMON RUNS (2026-09-06, ruling
30
+ // B). The first test pass of brief 19 found the install-once promise broken
31
+ // for the SECOND project on a machine: `glytch init` there added the folder
32
+ // to folders.json and said "folder registered", `glytch doctor` said "the
33
+ // daemon watches this folder", and nothing did - this module read the
34
+ // registry once at start, so the new folder needed `launchctl kickstart -k`
35
+ // by hand. Option A, having `init` restart the daemon, was rejected: a
36
+ // restart kills whatever session is running under it in another project. So
37
+ // the daemon watches the registry FILE, and a folder registered while it runs
38
+ // gets its sync and its watcher without anything being restarted.
39
+
40
+ import { makeRunnerHealth } from "./runner-health.mjs";
41
+ import { watch as fsWatch } from "node:fs";
42
+ import { stat } from "node:fs/promises";
43
+ import { basename, dirname } from "node:path";
44
+ import { makeAgentProbe } from "./detect.mjs";
45
+ import { platformMark } from "./platform.mjs";
46
+ import { ensureViewableFolder } from "./viewableFolder.mjs";
47
+ import { readRegistryChecked, registryPath } from "./registry.mjs";
48
+ import { watch, newRunnerId } from "./runner.mjs";
49
+ import { RUNNER_BEAT_MS } from "./spawn.mjs";
50
+ import { makeHeartbeatHub, makeRegistrar, REGISTER_REFRESH_MS } from "./runnerPace.mjs";
51
+ import { beatBaseMs, makeRunnerRealtime } from "./runnerRealtime.mjs";
52
+ import { sweepAll } from "./sweep.mjs";
53
+ import { syncFolder } from "./sync.mjs";
54
+ import { makeUpdateChecker, packageVersion } from "./update.mjs";
55
+
56
+ export const SWEEP_INTERVAL_MS = 86_400_000;
57
+ /** How often the registry is re-read when fs.watch tells us nothing. The
58
+ * floor, not the mechanism: fs.watch answers in milliseconds when it fires at
59
+ * all, and this is what catches the cases where it does not - a network home
60
+ * directory, an editor that swaps the file in a way the watch misses, a
61
+ * platform that drops the event. A minute is invisible to a person typing
62
+ * `viewable init` and then switching to the app. */
63
+ export const REGISTRY_POLL_MS = 60_000;
64
+
65
+ const isDir = async (path) => {
66
+ try {
67
+ return (await stat(path)).isDirectory();
68
+ } catch {
69
+ return false;
70
+ }
71
+ };
72
+
73
+ /** Watch every registered folder until `signal` aborts.
74
+ *
75
+ * Returns {watched, missing} after signal-driven shutdown and after every
76
+ * folder's watch has ended. An empty registry still waits for new folders. */
77
+ export async function watchRegistered({
78
+ env = process.env,
79
+ settings,
80
+ signal,
81
+ log = () => {},
82
+ folders,
83
+ sweep = sweepAll,
84
+ sync = syncFolder,
85
+ updater = makeUpdateChecker({ log }),
86
+ // WHAT CODING AGENTS THIS MACHINE HAS (brief 19 stage 2). Same shape as
87
+ // `updater`: a cache with its own clock, so the liveness beat never waits
88
+ // on `claude --version`. Injected for the tests, which must not spawn three
89
+ // child processes to find out which folders got watched.
90
+ probe = makeAgentProbe({ log }),
91
+ // WHAT MACHINE THIS IS (brief 21 session 5, ruling 3A). Injected for
92
+ // the same reason `probe` is, and for one more: the mark's contents are
93
+ // asserted verbatim in the tests, and a key whose value is whichever
94
+ // machine the suite happens to be running on would make those assertions
95
+ // say something different on the windows-latest runner.
96
+ platform = platformMark(),
97
+ sweepIntervalMs = SWEEP_INTERVAL_MS,
98
+ registryPollMs = REGISTRY_POLL_MS,
99
+ // The one seam the tests use. `watch()` polls until it is aborted and does
100
+ // real filesystem and process work per pass; what this module has to be
101
+ // held to is WHICH folders it starts one for, with what settings - so the
102
+ // watcher itself is injectable and the default is the real one.
103
+ watchOne = watch,
104
+ // THE PUSH CHANNEL (#813), one per process. Injected for the tests, which
105
+ // pass a fake, or `() => null` for none (every 10 s, as before).
106
+ makeRealtime = makeRunnerRealtime,
107
+ } = {}) {
108
+ // An injected list is the whole list: a caller that says which folders to
109
+ // watch is not asking the registry anything, so the live re-read below
110
+ // stands down for it. Nothing in the package passes this; the tests do.
111
+ const pinned = folders !== undefined;
112
+ // The Glytch folder (viewableFolder.mjs) is made and registered first, so a
113
+ // computer with no folder linked yet still has one to work in.
114
+ if (!pinned) await ensureViewableFolder(env, log);
115
+ // When the registry file was last written, read before the list is: a
116
+ // rewrite that keeps the same folders is `glytch init` asking for a beat.
117
+ const registryStamp = async () => (await stat(registryPath(env)).catch(() => null))?.mtimeMs ?? null;
118
+ let stampSeen = pinned ? null : await registryStamp();
119
+ const registered = folders ?? (await readRegistryChecked(env)).folders;
120
+ const watched = [];
121
+ const missing = [];
122
+ const first = [];
123
+ for (const folder of registered) {
124
+ if (await isDir(folder)) first.push(folder);
125
+ else missing.push(folder);
126
+ }
127
+ for (const gone of missing) log(`skipping ${gone}: the folder no longer exists`);
128
+
129
+ if (first.length === 0) {
130
+ log("no folders registered - run `viewable init` in a project folder first.");
131
+ }
132
+
133
+ // SYNC ON EVERY START (ruling 5), and on every folder that joins later.
134
+ // Put back .mcp.json, the opencode registration, the registry entry and the
135
+ // login daemon's captured PATH before the first session of the day rather
136
+ // than after it fails.
137
+ //
138
+ // daemon:true is load-bearing (2026-09-06 finding): THIS process is the
139
+ // login daemon the OS is holding, so if the sync's fourth row ever
140
+ // restarted it from in here, it would stop the very process running this
141
+ // loop mid-sync. `daemon:true` keeps that row read-only for this one
142
+ // caller; `glytch init` and `glytch doctor`, which are never the daemon,
143
+ // still restart a stopped one.
144
+ //
145
+ // 2026-09-07, brief 21 session 4: the row now goes through
146
+ // lib/logindaemon.mjs, and the guard covers all three implementations
147
+ // rather than launchd alone - `launchctl load`, `schtasks /run` and
148
+ // `systemctl --user enable --now` would each restart this process.
149
+ const syncOne = async (folder) => {
150
+ try {
151
+ for (const r of await sync(folder, { env, daemon: true })) {
152
+ if (!r.ok) log(`[${folder}] ${r.label}: ${r.detail}`);
153
+ }
154
+ } catch (error) {
155
+ log(`[${folder}] sync failed: ${error instanceof Error ? error.message : String(error)}`);
156
+ }
157
+ };
158
+ for (const folder of first) await syncOne(folder);
159
+
160
+ // THE SWEEP, on start and once a day. Both are the same call; nothing about
161
+ // it is a schedule the app can see.
162
+ const runSweep = async () => {
163
+ try {
164
+ await sweep({ env, log });
165
+ } catch (error) {
166
+ log(`sweep failed: ${error instanceof Error ? error.message : String(error)}`);
167
+ }
168
+ };
169
+ await runSweep();
170
+ const sweeping = setInterval(() => void runSweep(), sweepIntervalMs);
171
+ sweeping.unref?.();
172
+
173
+ // AT START, per the brief, and every five minutes after: the probe's own
174
+ // timer does the second half, and this await does the first, so the very
175
+ // first mark a person sees already carries the `agents` block rather than
176
+ // arriving bare and filling in a beat later.
177
+ await probe.once();
178
+
179
+ // The mark file gains `version`, `updateAvailable`, `agents` and (brief 21
180
+ // session 5, ruling 3A) `platform` here, and only here: `watch()`
181
+ // writes whatever `markExtra()` returns beside `at`, and both caches answer
182
+ // from memory so the liveness beat never waits on npm or on a child
183
+ // process. `platform` is the machine the SESSION will run on, which is the
184
+ // only thing the app can use to pick the words a spawn prompt carries for
185
+ // starting a detached dev server - the browser may be on another box
186
+ // entirely (lib/platform.mjs says why it is not `navigator`).
187
+ const health = makeRunnerHealth({ env, version: updater.state?.().version ?? packageVersion() });
188
+ const markExtra = () => ({ ...platform, ...updater.mark(), ...probe.mark(), ...health.mark() });
189
+
190
+ // ONE REGISTER AND ONE HEARTBEAT FOR THE WHOLE PROCESS (ticket e8,
191
+ // 2026-10-03; #616, 2026-10-16). Every folder's beat registers the whole
192
+ // folder list and heartbeats its own folder, so five folders sent the same
193
+ // register five times and five heartbeats every ten seconds, and tripped
194
+ // Cloudflare's per-IP rate limit. The folders share one registrar, which
195
+ // repeats an unchanged list every 5 minutes, and one heartbeat hub, which
196
+ // sends one heartbeat for all of them per beat (lib/runnerPace.mjs).
197
+ const beatMs = settings?.beatMs ?? RUNNER_BEAT_MS;
198
+ const registrar = makeRegistrar({ ttlMs: REGISTER_REFRESH_MS, failureTtlMs: 0.9 * beatMs });
199
+ const runnerId = settings?.runnerId ?? newRunnerId();
200
+ // PUSH (#813): joined, every folder beats every 5 minutes and the hub's
201
+ // shared answer lives as long, so the folders still share one heartbeat.
202
+ const realtime = makeRealtime({
203
+ runnerId,
204
+ version: updater.state?.().version ?? packageVersion(),
205
+ runnerApi: settings?.runnerApi,
206
+ env,
207
+ fetchImpl: settings?.fetchImpl,
208
+ log,
209
+ });
210
+ const heartbeats = makeHeartbeatHub({
211
+ ttlMs: () => 0.9 * beatBaseMs(realtime, beatMs, settings?.pushBeatMs),
212
+ failureTtlMs: 0.9 * beatMs,
213
+ });
214
+ realtime?.start();
215
+ // A BEAT ON REQUEST (#813 review). `glytch init` in a folder this daemon
216
+ // already watches leaves it running and waits for a beat that came after
217
+ // it started (runner-health.mjs); joined, the next one could be 5 minutes
218
+ // away. init rewrites the registry instead, and every watcher beats once,
219
+ // sharing one heartbeat (one `at` for all of them).
220
+ const beatAsks = new Set();
221
+ const beatRequests = { subscribe: (fn) => { beatAsks.add(fn); return () => beatAsks.delete(fn); } };
222
+ const askBeat = () => {
223
+ const at = Date.now();
224
+ for (const fn of [...beatAsks]) fn(at);
225
+ };
226
+
227
+ // ONE ENTRY PER FOLDER, AND NEVER TWO. `running` is what makes "never watch
228
+ // a folder twice" a fact rather than an intention: every start goes through
229
+ // startFolder, and startFolder returns at once if the folder is already in
230
+ // here. A dead watcher retries after one ordinary heartbeat, independently
231
+ // of registry polling or push availability. The delay prevents a broken
232
+ // folder from spinning; removal and shutdown cancel it.
233
+ const running = new Map();
234
+ // Woken whenever the set of watchers changes, so the wait at the bottom
235
+ // notices a folder that joined after it started waiting.
236
+ let wake;
237
+ let fireWake = () => {};
238
+ const armWake = () => {
239
+ wake = new Promise((resolve) => {
240
+ fireWake = resolve;
241
+ });
242
+ };
243
+ armWake();
244
+ const bump = () => {
245
+ const fire = fireWake;
246
+ armWake();
247
+ fire();
248
+ };
249
+ signal?.addEventListener("abort", bump, { once: true });
250
+
251
+ const startFolder = (folder) => {
252
+ if (running.has(folder)) return;
253
+ if (signal?.aborted) return;
254
+ // A STOP THAT IS A REAL STOP. `watch()`'s only way out is its signal (see
255
+ // its doc comment: "there is no other way out, because a runner that
256
+ // stops itself on a condition is a runner that starts itself on one"), so
257
+ // each folder gets its own AbortController chained to the daemon's. That
258
+ // is a clean stop and not a leak: the watcher drains the sessions it
259
+ // already started, writes their receipts, and clears the folder's mark
260
+ // file on the way out, exactly as Ctrl-C does today.
261
+ const controller = new AbortController();
262
+ const onParentAbort = () => controller.abort();
263
+ signal?.addEventListener("abort", onParentAbort, { once: true });
264
+ const entry = { controller, done: false, leaving: false };
265
+ let started;
266
+ try {
267
+ started = watchOne(folder, { ...settings, runnerId, realtime, onHealthy: (path) => health.beat(path), markExtra, registrar, heartbeats, beatRequests, refreshAgents: () => probe.once() }, {
268
+ signal: controller.signal,
269
+ log: (line) => log(`[${folder}] ${line}`),
270
+ });
271
+ } catch (error) {
272
+ started = Promise.reject(error);
273
+ }
274
+ entry.promise = Promise.resolve(started)
275
+ .catch((error) => {
276
+ // One folder's watcher dying must not take the others down: a board
277
+ // root that becomes unreadable is exactly the case this daemon
278
+ // exists to survive.
279
+ log(`[${folder}] watcher stopped: ${error instanceof Error ? error.message : String(error)}`);
280
+ })
281
+ .finally(() => {
282
+ entry.done = true;
283
+ signal?.removeEventListener("abort", onParentAbort);
284
+ if (entry.leaving && running.get(folder) === entry) running.delete(folder);
285
+ else if (!signal?.aborted) {
286
+ entry.retry = setTimeout(() => {
287
+ if (entry.leaving || signal?.aborted || running.get(folder) !== entry) return;
288
+ running.delete(folder);
289
+ startFolder(folder);
290
+ }, beatMs);
291
+ entry.retry.unref?.();
292
+ }
293
+ bump();
294
+ });
295
+ running.set(folder, entry);
296
+ if (!watched.includes(folder)) watched.push(folder);
297
+ bump();
298
+ };
299
+
300
+ for (const folder of first) startFolder(folder);
301
+
302
+ // THE REGISTRY, RE-READ. Everything below exists so that `glytch init` in a
303
+ // second project is watched without a restart (2026-09-06, ruling B).
304
+ const announcedMissing = new Set(missing); // logged once each, not once a minute
305
+ let complained = false;
306
+ let refreshing = false;
307
+ let refreshAgain = false;
308
+ const refresh = async () => {
309
+ if (pinned) return;
310
+ if (refreshing) {
311
+ refreshAgain = true;
312
+ return;
313
+ }
314
+ refreshing = true;
315
+ try {
316
+ do {
317
+ refreshAgain = false;
318
+ const stamp = await registryStamp();
319
+ const rewritten = stamp !== null && stamp !== stampSeen;
320
+ stampSeen = stamp;
321
+ const { folders: now, ok } = await readRegistryChecked(env);
322
+ if (!ok) {
323
+ // A REGISTRY WE CANNOT READ IS NOT NEWS. Half-written, hand-edited
324
+ // into invalid JSON, briefly absent: none of those mean the folders
325
+ // were unregistered, and acting on the empty list they parse to
326
+ // would stop every watcher on the machine. Keep the last good list,
327
+ // say so once, and let the next good read speak.
328
+ if (!complained) {
329
+ complained = true;
330
+ log(`${registryPath(env)} could not be read as a folder list - keeping the folders already watched`);
331
+ }
332
+ continue;
333
+ }
334
+ complained = false;
335
+ const wanted = new Set(now);
336
+ for (const [folder, entry] of running) {
337
+ if (wanted.has(folder) || entry.leaving) continue;
338
+ entry.leaving = true;
339
+ clearTimeout(entry.retry);
340
+ if (entry.done) running.delete(folder);
341
+ log(`${folder} left the registry - stopping its watcher`);
342
+ entry.controller.abort();
343
+ }
344
+ for (const folder of now) {
345
+ if (running.has(folder)) continue;
346
+ if (!(await isDir(folder))) {
347
+ if (!announcedMissing.has(folder)) {
348
+ announcedMissing.add(folder);
349
+ missing.push(folder);
350
+ log(`skipping ${folder}: the folder no longer exists`);
351
+ }
352
+ continue;
353
+ }
354
+ announcedMissing.delete(folder);
355
+ log(`${folder} joined the registry - syncing and watching it`);
356
+ await syncOne(folder);
357
+ startFolder(folder);
358
+ }
359
+ if (rewritten) askBeat();
360
+ } while (refreshAgain);
361
+ } catch (error) {
362
+ // Never an exit. The poll comes round again in a minute.
363
+ log(`re-reading the registry failed: ${error instanceof Error ? error.message : String(error)}`);
364
+ } finally {
365
+ refreshing = false;
366
+ }
367
+ };
368
+
369
+ // BOTH THE FILE AND ITS DIRECTORY. `folders.json` is written by
370
+ // writeFileAtomic, which renames a temp file over it, so the inode a watch
371
+ // on the file is holding is not the one the next read opens - the file
372
+ // watch sees the first write and then nothing. The directory watch sees the
373
+ // rename. Neither is trusted: setInterval below is the floor, and a
374
+ // platform where fs.watch does nothing at all still picks a new folder up
375
+ // within registryPollMs.
376
+ const regPath = registryPath(env);
377
+ const regName = basename(regPath);
378
+ const fsWatchers = [];
379
+ const attach = (target, onlyRegistry) => {
380
+ try {
381
+ const w = fsWatch(target, (_event, filename) => {
382
+ if (onlyRegistry && filename && basename(String(filename)) !== regName) return;
383
+ void refresh();
384
+ });
385
+ w.on("error", () => {}); // a dead watch is not an exit; the poll covers it
386
+ w.unref?.();
387
+ fsWatchers.push(w);
388
+ } catch {
389
+ // The file may not exist yet, the directory may not be watchable: the
390
+ // poll is the answer to both.
391
+ }
392
+ };
393
+ if (!pinned) {
394
+ attach(regPath, false);
395
+ attach(dirname(regPath), true);
396
+ }
397
+ const polling = setInterval(() => void refresh(), registryPollMs);
398
+ polling.unref?.();
399
+
400
+ // Registry watches, polling and wake promises are unref'd or bare. Keep
401
+ // the daemon alive even before its first folder or after its last watcher.
402
+ const keepAlive = setInterval(() => {}, 2 ** 30);
403
+ try {
404
+ // Wait for abort, then drain every watcher. Until then an empty set must
405
+ // still wait for registry changes rather than ending the daemon.
406
+ for (;;) {
407
+ const pending = [];
408
+ for (const entry of running.values()) if (!entry.done) pending.push(entry.promise);
409
+ if (signal?.aborted && pending.length === 0) break;
410
+ await Promise.race([...pending, wake]);
411
+ }
412
+ } finally {
413
+ clearInterval(keepAlive);
414
+ for (const entry of running.values()) clearTimeout(entry.retry);
415
+ await realtime?.close();
416
+ signal?.removeEventListener("abort", bump);
417
+ clearInterval(sweeping);
418
+ clearInterval(polling);
419
+ for (const w of fsWatchers) {
420
+ try {
421
+ w.close();
422
+ } catch {
423
+ // closing twice, or after an error, is not a failure worth a line
424
+ }
425
+ }
426
+ }
427
+ return { watched, missing };
428
+ }