@drewpayment/mink 0.1.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 (72) hide show
  1. package/README.md +347 -0
  2. package/package.json +32 -0
  3. package/src/cli.ts +176 -0
  4. package/src/commands/bug-search.ts +32 -0
  5. package/src/commands/config.ts +109 -0
  6. package/src/commands/cron.ts +295 -0
  7. package/src/commands/daemon.ts +46 -0
  8. package/src/commands/dashboard.ts +21 -0
  9. package/src/commands/designqc.ts +160 -0
  10. package/src/commands/detect-waste.ts +81 -0
  11. package/src/commands/framework-advisor.ts +52 -0
  12. package/src/commands/init.ts +159 -0
  13. package/src/commands/post-read.ts +123 -0
  14. package/src/commands/post-write.ts +157 -0
  15. package/src/commands/pre-read.ts +109 -0
  16. package/src/commands/pre-write.ts +136 -0
  17. package/src/commands/reflect.ts +39 -0
  18. package/src/commands/restore.ts +31 -0
  19. package/src/commands/scan.ts +101 -0
  20. package/src/commands/session-start.ts +21 -0
  21. package/src/commands/session-stop.ts +115 -0
  22. package/src/commands/status.ts +152 -0
  23. package/src/commands/update.ts +121 -0
  24. package/src/core/action-log.ts +341 -0
  25. package/src/core/backup.ts +122 -0
  26. package/src/core/bug-memory.ts +223 -0
  27. package/src/core/cron-parser.ts +94 -0
  28. package/src/core/daemon.ts +152 -0
  29. package/src/core/dashboard-api.ts +280 -0
  30. package/src/core/dashboard-server.ts +580 -0
  31. package/src/core/description.ts +232 -0
  32. package/src/core/design-eval/capture.ts +269 -0
  33. package/src/core/design-eval/route-detect.ts +165 -0
  34. package/src/core/design-eval/server-detect.ts +91 -0
  35. package/src/core/framework-advisor/catalog.ts +360 -0
  36. package/src/core/framework-advisor/decision-tree.ts +287 -0
  37. package/src/core/framework-advisor/generate.ts +132 -0
  38. package/src/core/framework-advisor/migration-prompts.ts +502 -0
  39. package/src/core/framework-advisor/validate.ts +137 -0
  40. package/src/core/fs-utils.ts +30 -0
  41. package/src/core/global-config.ts +74 -0
  42. package/src/core/index-store.ts +72 -0
  43. package/src/core/learning-memory.ts +120 -0
  44. package/src/core/paths.ts +86 -0
  45. package/src/core/pattern-engine.ts +108 -0
  46. package/src/core/project-id.ts +19 -0
  47. package/src/core/project-registry.ts +64 -0
  48. package/src/core/reflection.ts +256 -0
  49. package/src/core/scanner.ts +99 -0
  50. package/src/core/scheduler.ts +352 -0
  51. package/src/core/seed.ts +239 -0
  52. package/src/core/session.ts +128 -0
  53. package/src/core/stdin.ts +13 -0
  54. package/src/core/task-registry.ts +202 -0
  55. package/src/core/token-estimate.ts +36 -0
  56. package/src/core/token-ledger.ts +185 -0
  57. package/src/core/waste-detection.ts +214 -0
  58. package/src/core/write-exclusions.ts +24 -0
  59. package/src/types/action-log.ts +20 -0
  60. package/src/types/backup.ts +6 -0
  61. package/src/types/bug-memory.ts +24 -0
  62. package/src/types/config.ts +59 -0
  63. package/src/types/dashboard.ts +104 -0
  64. package/src/types/design-eval.ts +64 -0
  65. package/src/types/file-index.ts +38 -0
  66. package/src/types/framework-advisor.ts +97 -0
  67. package/src/types/hook-input.ts +27 -0
  68. package/src/types/learning-memory.ts +36 -0
  69. package/src/types/scheduler.ts +82 -0
  70. package/src/types/session.ts +50 -0
  71. package/src/types/token-ledger.ts +43 -0
  72. package/src/types/waste-detection.ts +21 -0
@@ -0,0 +1,580 @@
1
+ import { watch, type FSWatcher } from "fs";
2
+ import { existsSync } from "fs";
3
+ import { basename, join, extname } from "path";
4
+ import { projectDir, designCapturesDir } from "./paths";
5
+ import {
6
+ loadOverview,
7
+ loadTokenLedgerPanel,
8
+ loadFileIndexPanel,
9
+ loadSchedulerPanel,
10
+ loadLearningMemoryPanel,
11
+ loadActionLogPanel,
12
+ loadBugLogPanel,
13
+ loadDesignPanel,
14
+ triggerTask,
15
+ triggerDeadLetterRetry,
16
+ triggerRescan,
17
+ } from "./dashboard-api";
18
+ import { listRegisteredProjects, getProjectMeta } from "./project-registry";
19
+ import { generateProjectId } from "./project-id";
20
+ import type { StateFileId, StateChangeEvent } from "../types/dashboard";
21
+ import type { RegisteredProject } from "./project-registry";
22
+
23
+ // ── MIME types for static file serving ────────────────────────────────────
24
+ const MIME_TYPES: Record<string, string> = {
25
+ ".html": "text/html; charset=utf-8",
26
+ ".js": "application/javascript; charset=utf-8",
27
+ ".css": "text/css; charset=utf-8",
28
+ ".json": "application/json; charset=utf-8",
29
+ ".png": "image/png",
30
+ ".jpg": "image/jpeg",
31
+ ".jpeg": "image/jpeg",
32
+ ".svg": "image/svg+xml",
33
+ ".ico": "image/x-icon",
34
+ ".woff": "font/woff",
35
+ ".woff2": "font/woff2",
36
+ ".ttf": "font/ttf",
37
+ ".txt": "text/plain; charset=utf-8",
38
+ };
39
+
40
+ // ── State File Mapping ─────────────────────────────────────────────────────
41
+
42
+ const STATE_FILE_MAP: Record<string, StateFileId> = {
43
+ "token-ledger.json": "token-ledger",
44
+ "file-index.json": "file-index",
45
+ "learning-memory.md": "learning-memory",
46
+ "bug-memory.json": "bug-memory",
47
+ "action-log.md": "action-log",
48
+ "scheduler-manifest.json": "scheduler-manifest",
49
+ "session.json": "session",
50
+ "project-meta.json": "project-meta",
51
+ "design-report.json": "design-report",
52
+ };
53
+
54
+ // ── SSE Manager ────────────────────────────────────────────────────────────
55
+
56
+ interface SSEClient {
57
+ id: string;
58
+ controller: ReadableStreamController<Uint8Array>;
59
+ }
60
+
61
+ const encoder = new TextEncoder();
62
+
63
+ class SSEManager {
64
+ private clients = new Map<string, SSEClient>();
65
+ private keepAliveInterval: ReturnType<typeof setInterval> | null = null;
66
+
67
+ start(): void {
68
+ this.keepAliveInterval = setInterval(() => {
69
+ this.sendRaw(": keepalive\n\n");
70
+ }, 15_000);
71
+ }
72
+
73
+ stop(): void {
74
+ if (this.keepAliveInterval) {
75
+ clearInterval(this.keepAliveInterval);
76
+ this.keepAliveInterval = null;
77
+ }
78
+ for (const [id] of this.clients) {
79
+ this.removeClient(id);
80
+ }
81
+ }
82
+
83
+ addClient(id: string, controller: ReadableStreamController<Uint8Array>): void {
84
+ this.clients.set(id, { id, controller });
85
+ }
86
+
87
+ removeClient(id: string): void {
88
+ const client = this.clients.get(id);
89
+ if (client) {
90
+ try {
91
+ client.controller.close();
92
+ } catch {
93
+ // Already closed
94
+ }
95
+ this.clients.delete(id);
96
+ }
97
+ }
98
+
99
+ broadcast(event: StateChangeEvent): void {
100
+ const data = `data: ${JSON.stringify(event)}\n\n`;
101
+ this.sendRaw(data);
102
+ }
103
+
104
+ private sendRaw(data: string): void {
105
+ const bytes = encoder.encode(data);
106
+ const deadClients: string[] = [];
107
+
108
+ for (const [id, client] of this.clients) {
109
+ try {
110
+ client.controller.enqueue(bytes);
111
+ } catch {
112
+ deadClients.push(id);
113
+ }
114
+ }
115
+
116
+ for (const id of deadClients) {
117
+ this.clients.delete(id);
118
+ }
119
+ }
120
+
121
+ get clientCount(): number {
122
+ return this.clients.size;
123
+ }
124
+ }
125
+
126
+ // ── Project Resolution ────────────────────────────────────────────────────
127
+
128
+ function resolveProjectCwd(
129
+ url: URL,
130
+ defaultCwd: string
131
+ ): string | null {
132
+ const projectId = url.searchParams.get("project");
133
+ if (!projectId) return defaultCwd;
134
+
135
+ // If the requested project matches the currently active project, use it directly
136
+ // (handles startup projects that may not be in the registry yet)
137
+ if (projectId === generateProjectId(defaultCwd)) return defaultCwd;
138
+
139
+ const projects = listRegisteredProjects();
140
+ const match = projects.find((p) => p.id === projectId);
141
+ if (!match) return null;
142
+
143
+ return match.cwd;
144
+ }
145
+
146
+ function getProjectsList(
147
+ startupCwd: string,
148
+ activeCwd: string
149
+ ): {
150
+ projects: RegisteredProject[];
151
+ activeProjectId: string;
152
+ } {
153
+ const activeId = generateProjectId(activeCwd);
154
+ const registered = listRegisteredProjects();
155
+
156
+ // Ensure startup project is always in the list
157
+ const startupId = generateProjectId(startupCwd);
158
+ const hasStartup = registered.some((p) => p.id === startupId);
159
+ if (!hasStartup) {
160
+ const meta = getProjectMeta(projectDir(startupCwd));
161
+ registered.unshift({
162
+ id: startupId,
163
+ cwd: startupCwd,
164
+ name: meta?.name ?? basename(startupCwd),
165
+ version: meta?.version ?? "0.1.0",
166
+ });
167
+ }
168
+
169
+ // Ensure active project is in the list (if different from startup)
170
+ if (activeId !== startupId) {
171
+ const hasActive = registered.some((p) => p.id === activeId);
172
+ if (!hasActive) {
173
+ const meta = getProjectMeta(projectDir(activeCwd));
174
+ registered.unshift({
175
+ id: activeId,
176
+ cwd: activeCwd,
177
+ name: meta?.name ?? basename(activeCwd),
178
+ version: meta?.version ?? "0.1.0",
179
+ });
180
+ }
181
+ }
182
+
183
+ return { projects: registered, activeProjectId: activeId };
184
+ }
185
+
186
+ // ── File Watcher ───────────────────────────────────────────────────────────
187
+
188
+ function createFileWatcher(
189
+ cwd: string,
190
+ onChange: (fileId: StateFileId) => void
191
+ ): { close(): void } {
192
+ const dir = projectDir(cwd);
193
+ const debounceMap = new Map<string, ReturnType<typeof setTimeout>>();
194
+ let watcher: FSWatcher | null = null;
195
+
196
+ try {
197
+ watcher = watch(dir, (eventType, filename) => {
198
+ if (!filename) return;
199
+ const name = basename(filename);
200
+
201
+ // Ignore .tmp files from atomic writes
202
+ if (name.endsWith(".tmp")) return;
203
+
204
+ const fileId = STATE_FILE_MAP[name];
205
+ if (!fileId) return;
206
+
207
+ // Debounce 300ms per file
208
+ const existing = debounceMap.get(name);
209
+ if (existing) clearTimeout(existing);
210
+
211
+ debounceMap.set(
212
+ name,
213
+ setTimeout(() => {
214
+ debounceMap.delete(name);
215
+ onChange(fileId);
216
+ }, 300)
217
+ );
218
+ });
219
+ } catch {
220
+ // fs.watch not available — fallback could go here
221
+ // For now, the dashboard still works via manual refresh
222
+ }
223
+
224
+ return {
225
+ close() {
226
+ if (watcher) {
227
+ watcher.close();
228
+ watcher = null;
229
+ }
230
+ for (const timer of debounceMap.values()) {
231
+ clearTimeout(timer);
232
+ }
233
+ debounceMap.clear();
234
+ },
235
+ };
236
+ }
237
+
238
+ // ── Route Handling ─────────────────────────────────────────────────────────
239
+
240
+ function jsonResponse(data: unknown, status: number = 200): Response {
241
+ return new Response(JSON.stringify(data), {
242
+ status,
243
+ headers: {
244
+ "Content-Type": "application/json",
245
+ "Access-Control-Allow-Origin": "*",
246
+ },
247
+ });
248
+ }
249
+
250
+ function extractPathParam(pathname: string, prefix: string): string | null {
251
+ if (!pathname.startsWith(prefix)) return null;
252
+ const rest = pathname.slice(prefix.length);
253
+ const slashIdx = rest.indexOf("/");
254
+ if (slashIdx === -1) return rest || null;
255
+ return rest.slice(0, slashIdx) || null;
256
+ }
257
+
258
+ // ── Server ─────────────────────────────────────────────────────────────────
259
+
260
+ export interface DashboardServer {
261
+ url: string;
262
+ close(): void;
263
+ }
264
+
265
+ export function startDashboardServer(
266
+ cwd: string,
267
+ options: { port?: number; hostname?: string; open?: boolean } = {}
268
+ ): DashboardServer {
269
+ const port = options.port ?? 4040;
270
+ const hostname = options.hostname ?? "127.0.0.1";
271
+
272
+ const sseManager = new SSEManager();
273
+ sseManager.start();
274
+
275
+ // Mutable active project state (swappable via project switcher)
276
+ let activeCwd = cwd;
277
+
278
+ function swapWatcher(newCwd: string) {
279
+ activeWatcher.close();
280
+ activeCwd = newCwd;
281
+ activeWatcher = createFileWatcher(newCwd, (fileId) => {
282
+ sseManager.broadcast({
283
+ fileId,
284
+ timestamp: new Date().toISOString(),
285
+ });
286
+ });
287
+ }
288
+
289
+ // Start file watcher
290
+ let activeWatcher = createFileWatcher(cwd, (fileId) => {
291
+ sseManager.broadcast({
292
+ fileId,
293
+ timestamp: new Date().toISOString(),
294
+ });
295
+ });
296
+
297
+ // Resolve the Next.js static build directory
298
+ const dashboardOutDir = join(
299
+ import.meta.dir,
300
+ "..",
301
+ "..",
302
+ "dashboard",
303
+ "out"
304
+ );
305
+ const dashboardBuilt = existsSync(join(dashboardOutDir, "index.html"));
306
+ let clientIdCounter = 0;
307
+
308
+ if (!dashboardBuilt) {
309
+ console.warn(
310
+ "[mink] dashboard not built. Run: cd dashboard && bun run build"
311
+ );
312
+ }
313
+
314
+ const server = Bun.serve({
315
+ port,
316
+ hostname,
317
+ idleTimeout: 0, // Disable idle timeout — SSE connections are long-lived
318
+ async fetch(req) {
319
+ const url = new URL(req.url);
320
+ const pathname = url.pathname;
321
+ const method = req.method;
322
+
323
+ // ── Static file serving (Next.js build) ─────────────────────
324
+ if (method === "GET" && !pathname.startsWith("/api/")) {
325
+ if (!dashboardBuilt) {
326
+ if (pathname === "/") {
327
+ return new Response(
328
+ "<html><body><h1>Mink Dashboard</h1><p>Dashboard not built. Run: <code>cd dashboard &amp;&amp; bun run build</code></p></body></html>",
329
+ { headers: { "Content-Type": "text/html; charset=utf-8" } }
330
+ );
331
+ }
332
+ } else {
333
+ // Serve from dashboard/out/
334
+ let filePath: string;
335
+ if (pathname === "/") {
336
+ filePath = join(dashboardOutDir, "index.html");
337
+ } else {
338
+ filePath = join(dashboardOutDir, pathname);
339
+ }
340
+
341
+ // Security: prevent directory traversal
342
+ if (!filePath.startsWith(dashboardOutDir)) {
343
+ return jsonResponse({ error: "Forbidden" }, 403);
344
+ }
345
+
346
+ const file = Bun.file(filePath);
347
+ if (await file.exists()) {
348
+ const ext = extname(filePath);
349
+ const contentType = MIME_TYPES[ext] || "application/octet-stream";
350
+ return new Response(file, {
351
+ headers: { "Content-Type": contentType },
352
+ });
353
+ }
354
+
355
+ // Client-side routing fallback: try {pathname}.html then index.html
356
+ const htmlFile = Bun.file(filePath + ".html");
357
+ if (await htmlFile.exists()) {
358
+ return new Response(htmlFile, {
359
+ headers: { "Content-Type": "text/html; charset=utf-8" },
360
+ });
361
+ }
362
+
363
+ // SPA fallback — serve index.html for unmatched routes
364
+ const indexFile = Bun.file(join(dashboardOutDir, "index.html"));
365
+ if (await indexFile.exists()) {
366
+ return new Response(indexFile, {
367
+ headers: { "Content-Type": "text/html; charset=utf-8" },
368
+ });
369
+ }
370
+ }
371
+ }
372
+
373
+ // ── SSE ──────────────────────────────────────────────────────
374
+ if (method === "GET" && pathname === "/api/events") {
375
+ const clientId = String(++clientIdCounter);
376
+ const stream = new ReadableStream<Uint8Array>({
377
+ start(controller) {
378
+ sseManager.addClient(clientId, controller);
379
+ // Send initial comment immediately to establish the stream
380
+ // and prevent Bun from treating it as idle/complete
381
+ controller.enqueue(encoder.encode(": connected\nretry: 3000\n\n"));
382
+ },
383
+ cancel() {
384
+ sseManager.removeClient(clientId);
385
+ },
386
+ });
387
+
388
+ return new Response(stream, {
389
+ headers: {
390
+ "Content-Type": "text/event-stream",
391
+ "Cache-Control": "no-cache",
392
+ Connection: "keep-alive",
393
+ "Access-Control-Allow-Origin": "*",
394
+ },
395
+ });
396
+ }
397
+
398
+ // ── REST API (GET) ───────────────────────────────────────────
399
+ if (method === "GET") {
400
+ // GET /api/projects — list all registered projects (no project param needed)
401
+ if (pathname === "/api/projects") {
402
+ return jsonResponse(getProjectsList(cwd, activeCwd));
403
+ }
404
+
405
+ // Resolve project cwd from ?project=<id> query param
406
+ const resolvedCwd = resolveProjectCwd(url, activeCwd);
407
+ if (resolvedCwd === null) {
408
+ return jsonResponse({ error: "Project not found" }, 404);
409
+ }
410
+
411
+ try {
412
+ switch (pathname) {
413
+ case "/api/overview":
414
+ return jsonResponse(loadOverview(resolvedCwd));
415
+ case "/api/token-ledger":
416
+ return jsonResponse(loadTokenLedgerPanel(resolvedCwd));
417
+ case "/api/file-index":
418
+ return jsonResponse(loadFileIndexPanel(resolvedCwd));
419
+ case "/api/scheduler":
420
+ return jsonResponse(loadSchedulerPanel(resolvedCwd));
421
+ case "/api/learning-memory":
422
+ return jsonResponse(loadLearningMemoryPanel(resolvedCwd));
423
+ case "/api/action-log":
424
+ return jsonResponse(loadActionLogPanel(resolvedCwd));
425
+ case "/api/bugs":
426
+ return jsonResponse(loadBugLogPanel(resolvedCwd));
427
+ case "/api/design":
428
+ return jsonResponse(loadDesignPanel(resolvedCwd));
429
+ }
430
+
431
+ // GET /api/design-images/:filename — serve captured screenshots
432
+ if (pathname.startsWith("/api/design-images/")) {
433
+ const filename = pathname.slice("/api/design-images/".length);
434
+ if (!filename || filename.includes("..") || filename.includes("/")) {
435
+ return jsonResponse({ error: "Invalid filename" }, 400);
436
+ }
437
+ const imgPath = join(designCapturesDir(resolvedCwd), filename);
438
+ const file = Bun.file(imgPath);
439
+ if (await file.exists()) {
440
+ return new Response(file, {
441
+ headers: {
442
+ "Content-Type": "image/jpeg",
443
+ "Cache-Control": "public, max-age=60",
444
+ "Access-Control-Allow-Origin": "*",
445
+ },
446
+ });
447
+ }
448
+ return jsonResponse({ error: "Image not found" }, 404);
449
+ }
450
+ } catch (err) {
451
+ return jsonResponse(
452
+ { error: err instanceof Error ? err.message : String(err) },
453
+ 500
454
+ );
455
+ }
456
+ }
457
+
458
+ // ── REST API (POST) ──────────────────────────────────────────
459
+ if (method === "POST") {
460
+ // POST /api/switch-project — swap the active project + file watcher
461
+ if (pathname === "/api/switch-project") {
462
+ try {
463
+ const body = await req.json() as { projectId?: string };
464
+ const projectId = body.projectId;
465
+ if (!projectId) {
466
+ return jsonResponse({ success: false, error: "Missing projectId" }, 400);
467
+ }
468
+
469
+ const projects = listRegisteredProjects();
470
+ const match = projects.find((p) => p.id === projectId);
471
+ if (!match) {
472
+ return jsonResponse({ success: false, error: "Project not found" }, 404);
473
+ }
474
+
475
+ swapWatcher(match.cwd);
476
+ sseManager.broadcast({
477
+ fileId: "project-switched" as StateFileId,
478
+ projectId,
479
+ timestamp: new Date().toISOString(),
480
+ });
481
+
482
+ return jsonResponse({ success: true });
483
+ } catch (err) {
484
+ return jsonResponse(
485
+ { success: false, error: err instanceof Error ? err.message : String(err) },
486
+ 500
487
+ );
488
+ }
489
+ }
490
+
491
+ // Resolve project cwd for POST actions
492
+ const resolvedCwd = resolveProjectCwd(url, activeCwd);
493
+ if (resolvedCwd === null) {
494
+ return jsonResponse({ error: "Project not found" }, 404);
495
+ }
496
+
497
+ // POST /api/tasks/:id/run
498
+ if (pathname.startsWith("/api/tasks/") && pathname.endsWith("/run")) {
499
+ const taskId = extractPathParam(pathname, "/api/tasks/");
500
+ const cleanId = taskId?.replace(/\/run$/, "") ?? "";
501
+ if (!cleanId) {
502
+ return jsonResponse({ success: false, error: "Missing task ID" }, 400);
503
+ }
504
+ return triggerTask(resolvedCwd, cleanId).then((result) =>
505
+ jsonResponse(result, result.success ? 200 : 500)
506
+ );
507
+ }
508
+
509
+ // POST /api/dead-letter/:id/retry
510
+ if (
511
+ pathname.startsWith("/api/dead-letter/") &&
512
+ pathname.endsWith("/retry")
513
+ ) {
514
+ const taskId = extractPathParam(pathname, "/api/dead-letter/");
515
+ const cleanId = taskId?.replace(/\/retry$/, "") ?? "";
516
+ if (!cleanId) {
517
+ return jsonResponse({ success: false, error: "Missing task ID" }, 400);
518
+ }
519
+ return triggerDeadLetterRetry(resolvedCwd, cleanId).then((result) =>
520
+ jsonResponse(result, result.success ? 200 : 500)
521
+ );
522
+ }
523
+
524
+ // POST /api/rescan
525
+ if (pathname === "/api/rescan") {
526
+ return triggerRescan(resolvedCwd).then((result) =>
527
+ jsonResponse(result, result.success ? 200 : 500)
528
+ );
529
+ }
530
+ }
531
+
532
+ // ── CORS Preflight ───────────────────────────────────────────
533
+ if (method === "OPTIONS") {
534
+ return new Response(null, {
535
+ status: 204,
536
+ headers: {
537
+ "Access-Control-Allow-Origin": "*",
538
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
539
+ "Access-Control-Allow-Headers": "Content-Type",
540
+ },
541
+ });
542
+ }
543
+
544
+ // ── 404 ──────────────────────────────────────────────────────
545
+ return jsonResponse({ error: "Not found" }, 404);
546
+ },
547
+ });
548
+
549
+ const serverUrl = `http://${hostname}:${server.port}`;
550
+
551
+ // Auto-open browser
552
+ if (options.open !== false) {
553
+ try {
554
+ const platform = process.platform;
555
+ const cmd =
556
+ platform === "darwin"
557
+ ? ["open", serverUrl]
558
+ : platform === "win32"
559
+ ? ["cmd", "/c", "start", serverUrl]
560
+ : ["xdg-open", serverUrl];
561
+ const proc = Bun.spawn(cmd, {
562
+ stdout: "ignore",
563
+ stderr: "ignore",
564
+ stdin: "ignore",
565
+ });
566
+ proc.unref();
567
+ } catch {
568
+ // Browser open is best-effort
569
+ }
570
+ }
571
+
572
+ return {
573
+ url: serverUrl,
574
+ close() {
575
+ sseManager.stop();
576
+ activeWatcher.close();
577
+ server.stop(true);
578
+ },
579
+ };
580
+ }