File size: 21,440 Bytes
a1b6014
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
/**
 * Runtime plugin loader β€” plugins as CODE, not registry edits, loaded after
 * build time. The pipeline every non-bundled plugin takes:
 *
 *   source (plain ESM js) -> [integrity check] -> bare-specifier rewrite
 *   (`@hermes/plugin-sdk` / `react*` -> live shim blobs, see sdk/runtime.ts)
 *   -> blob `import()` -> validate default HermesPlugin -> register(ctx)
 *
 * Loading the same plugin id again disposes the previous registrations first
 * (agent rewrites a plugin file -> clean reload). Failures toast + log; a
 * broken plugin can never take the app down.
 *
 * Sources today: the in-repo runtime example (`?raw`, proves the pipeline)
 * and the two on-disk doors β€” `<hermes home>/desktop-plugins/<name>/plugin.js`
 * and the unified agent-plugin half `<hermes home>/plugins/<name>/desktop/
 * plugin.js` β€” the doors the agent writes through.
 *
 * SECURITY β€” this is NOT a capability boundary. A loaded plugin is evaluated
 * as ESM in the renderer realm with FULL app authority: the React singleton,
 * the whole SDK (`host.request` gateway RPC, `ctx.rest`, storage, `navigate`).
 * The isolation here is *error* isolation only (ContribBoundary, isolated
 * listeners) β€” a plugin can't crash the app, but it can do anything the app
 * can. That's acceptable for local sources (disk files can already run code),
 * and `integrity` only proves the bytes match a hash β€” it does NOT sandbox.
 * A remote source (https + allowlist) must NOT reuse this pipeline as-is:
 * it needs a real boundary (iframe/worker + CSP + capability gating) before
 * it can land. The `{ integrity }` option is the transport seam, not the
 * trust seam.
 */

import { installPluginSdk, sdkImportMap } from '@/sdk/runtime'
import { notifyError } from '@/store/notifications'

import { createPluginContext, type HermesPlugin } from './plugin'
import { $pluginRecords, dropPlugin, pluginActive, type PluginKind, publishPlugin } from './plugins-store'

interface LoadOptions {
  /** Root-level default-enable CAP: `false` ships the plugin opt-in (inventory
   *  row, off until the user toggles) even if the plugin says otherwise. The
   *  unified agent-plugin root sets this so `~/.hermes/plugins` keeps its
   *  installed-but-inert posture (GHSA-mcfc-hp25-cjv7) on the desktop side too. */
  defaultEnabled?: boolean
  /** Absolute plugin.js path (disk plugins) β€” recorded for reveal/inventory. */
  file?: string
  /** `sha256-<base64>` β€” verified against the source before evaluation. */
  integrity?: string
  /** Inventory bucket; the disk door is the default runtime source. */
  kind?: PluginKind
  /** Agent package whose desktop half this is (unified packages). */
  packageName?: string
  packageOrigin?: PackageMarker['origin']
}

/** Live runtime plugins: id -> disposers (unload/reload support). */
const loaded = new Map<string, (() => void)[]>()

// Matches the specifier of a static `from '…'`, a side-effect `import '…'`, or
// a dynamic `import('…')` β€” anchored to import/export syntax so a bare string
// literal or comment (e.g. `notify('react')`) is never touched.
const importSpecifierRe = () => /(from\s*|import\s*\(\s*|import\s+)(['"])([^'"]+)\2/g

/** Rewrite ONLY mapped import specifiers (@hermes/plugin-sdk, react*) to their
 *  live shim blob URLs β€” never occurrences inside strings/comments. */
function rewriteSpecifiers(source: string): string {
  const map = sdkImportMap()

  return source.replace(importSpecifierRe(), (whole, pre, quote, spec) =>
    map[spec] ? `${pre}${quote}${map[spec]}${quote}` : whole
  )
}

/** Bare import specifiers the loader can't resolve (not relative/URL, not in
 *  the SDK map). Surfaced up-front so they don't fail as a cryptic native
 *  "Failed to resolve module specifier" from the blob import. */
function unsupportedImports(source: string): string[] {
  const map = sdkImportMap()
  const bare = new Set<string>()

  for (const m of source.matchAll(importSpecifierRe())) {
    const spec = m[3]

    // Skip relative/absolute (./ ../ /) and any URL scheme (blob: http(s):).
    if (spec && !/^[./]/.test(spec) && !/^[a-z][a-z0-9+.-]*:/i.test(spec) && !map[spec]) {
      bare.add(spec)
    }
  }

  return [...bare]
}

async function verifyIntegrity(source: string, integrity: string): Promise<boolean> {
  const [algo, expected] = integrity.split('-', 2)

  if (algo !== 'sha256' || !expected) {
    return false
  }

  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(source))
  // Standard SRI base64 (`sha256-<base64>`) β€” a base64url-encoded hash won't match.
  const actual = btoa(String.fromCharCode(...new Uint8Array(digest)))

  return actual === expected
}

export function unloadRuntimePlugin(id: string): void {
  loaded.get(id)?.forEach(dispose => dispose())
  loaded.delete(id)
}

/** Evaluate + register one runtime plugin. Returns its id, or null on failure. */
export async function loadRuntimePlugin(
  source: string,
  origin: string,
  options: LoadOptions = {}
): Promise<null | string> {
  installPluginSdk()

  try {
    if (options.integrity && !(await verifyIntegrity(source, options.integrity))) {
      throw new Error(`integrity check failed for ${origin}`)
    }

    const unsupported = unsupportedImports(source)

    if (unsupported.length > 0) {
      throw new Error(
        `unsupported import${unsupported.length > 1 ? 's' : ''}: ${unsupported.join(', ')} β€” ` +
          `runtime plugins may only import @hermes/plugin-sdk and react`
      )
    }

    const url = URL.createObjectURL(new Blob([rewriteSpecifiers(source)], { type: 'text/javascript' }))

    let mod: { default?: HermesPlugin }

    try {
      mod = await import(/* @vite-ignore */ url)
    } finally {
      URL.revokeObjectURL(url)
    }

    const plugin = mod.default

    if (!plugin?.id || typeof plugin.register !== 'function') {
      throw new Error(`${origin} has no valid default HermesPlugin export`)
    }

    // A disk/runtime copy of a plugin that now ships BUNDLED (e.g. a
    // standalone install of hermes-bots predating its adoption in-tree) must
    // not register a second time: contributions would double up and the two
    // copies would fight over storage. The bundled copy wins; the disk copy
    // is skipped β€” but VISIBLY: a silent skip left the stale folder
    // undiscoverable while (on shells without the bundled twin) the same
    // folder actively breaks the feature it shadows. The inventory row
    // carries the file path so Capabilities β†’ Plugins can reveal it for deletion.
    if ($pluginRecords.get()[plugin.id]?.kind === 'bundled') {
      console.info(`[plugins] ${origin} skipped β€” "${plugin.id}" already ships bundled with the app`)
      publishPlugin({
        id: `${plugin.id}:disk-shadowed`,
        name: `${plugin.name ?? plugin.id} (stale disk copy)`,
        description: `Shadowed by the bundled "${plugin.id}" plugin β€” this folder is no longer used and can be deleted.`,
        kind: options.kind ?? 'disk',
        file: options.file,
        status: 'disabled'
      })

      return null
    }

    const record = {
      id: plugin.id,
      name: plugin.name ?? plugin.id,
      description: plugin.description,
      kind: options.kind ?? 'disk',
      file: options.file,
      packageName: options.packageName,
      packageOrigin: options.packageOrigin
    }

    const activate = () => {
      // Reload = dispose the previous incarnation, then register fresh.
      unloadRuntimePlugin(plugin.id)
      const disposers: (() => void)[] = []
      plugin.register(createPluginContext(plugin.id, dispose => disposers.push(dispose)))
      loaded.set(plugin.id, disposers)
      publishPlugin({ ...record, status: 'loaded' })
    }

    publishPlugin({ ...record, status: 'disabled' }, { activate, deactivate: () => unloadRuntimePlugin(plugin.id) })

    // A disabled plugin still inventories (settings shows it, toggle
    // reactivates via the handle above) β€” it just never registers. A root-level
    // `defaultEnabled: false` caps the plugin's own default: the user's explicit
    // enable still wins, a plugin can't self-enable past its root's posture.
    if (pluginActive(plugin.id, (plugin.defaultEnabled ?? true) && (options.defaultEnabled ?? true))) {
      activate()
    }

    return plugin.id
  } catch (error) {
    console.error(`[plugins] runtime load failed (${origin})`, error)
    notifyError(error, `Plugin "${origin}" failed to load`)
    publishPlugin({
      id: origin,
      name: origin,
      kind: options.kind ?? 'disk',
      file: options.file,
      packageName: options.packageName,
      packageOrigin: options.packageOrigin,
      status: 'error',
      error: error instanceof Error ? error.message : String(error)
    })

    return null
  }
}

// ---------------------------------------------------------------------------
// The on-disk plugin door β€” ONE app-level root, `<hermes home>/desktop-plugins/`:
//  - `<id>/plugin.js` β€” a standalone desktop plugin (agent- or user-written);
//  - `<package>/plugin.js` + `.hermes-package.json` β€” the desktop HALF of a
//    unified agent+desktop package, COPIED here by Electron from the package's
//    `plugins/<package>/desktop/` folder (electron/desktop-plugins-root.ts).
//    The agent half stays in its profile; the desktop half lives with the app,
//    so it neither appears nor disappears when the active profile changes.
// SELF-MAINTAINING β€” no reload ceremony:
//  - each plugin.js is fs-watched (the preview watcher IPC, debounced in
//    main): saving the file hot-reloads the plugin in place;
//  - each root is fs-watched too (watchDirectory IPC), so new folders load +
//    removed ones unload on the change tick; older Electron shells without
//    watchDirectory fall back to the slow visible-tab poll.
// Panes land via placement adoption and STAY where the user drags them β€”
// the tree treats not-yet-loaded pane ids as hidden, so boot and reload are
// collapse -> appear, never a placeholder flash.
// ---------------------------------------------------------------------------

const DISK_POLL_MS = 5_000

interface DiskRoot {
  dir: string
  /** Path segments below each scanned folder to the entry file. */
  entrySegments: readonly string[]
}

/** The app-level root, resolved fresh each pass (Electron-local, never the
 *  backend's hermes_home β€” #66899). Resolving it also runs Electron's
 *  reconcile, so unified packages' desktop halves are current before we scan. */
async function diskRoots(): Promise<DiskRoot[]> {
  const root = await window.hermesDesktop?.desktopPluginsRoot?.()

  return root ? [{ dir: root, entrySegments: ['plugin.js'] }] : []
}

/** Marker Electron writes beside a materialized unified-package half. Its
 *  presence means: opt-in posture (the Python half is installed-but-inert
 *  until allowlisted β€” GHSA-mcfc-hp25-cjv7 β€” so the desktop half matches), and
 *  the record carries the package name so the Plugins page pairs it with the
 *  agent row. */
const PACKAGE_MARKER = '.hermes-package.json'

interface PackageMarker {
  origin?: { catalogName?: string; repo?: string; sha?: string }
  package: string
}

async function readPackageMarker(desktop: Window['hermesDesktop'], folder: string): Promise<null | PackageMarker> {
  try {
    const { entries } = await desktop.readDir(folder)
    const marker = entries.find(entry => entry.name === PACKAGE_MARKER && !entry.isDirectory)

    if (!marker) {
      return null
    }

    const parsed = JSON.parse((await desktop.readFileText(marker.path)).text) as {
      catalogName?: string
      package?: string
      repo?: string
      sha?: string
    }

    if (!parsed.package) {
      return null
    }

    return {
      origin: parsed.repo ? { catalogName: parsed.catalogName, repo: parsed.repo, sha: parsed.sha } : undefined,
      package: parsed.package
    }
  } catch {
    return null
  }
}

interface DiskPlugin {
  /** Root posture, forwarded on every (re)load of this entry. */
  defaultEnabled?: boolean
  file: string
  /** Agent package this folder is the desktop half of (unified packages). */
  packageName?: string
  packageOrigin?: PackageMarker['origin']
  /** Loaded plugin id (null while broken β€” kept so a fixing save reloads). */
  id: null | string
  /** Origin label (folder name) β€” the toast/inventory name for load errors. */
  origin: string
  watchId: null | string
}

/** Live disk plugins keyed by ENTRY FILE path β€” unique across both roots
 *  (folder names alone can collide between them). */
const disk = new Map<string, DiskPlugin>()
let watching = false
let scanning = false

/** Drop a folder-named error record β€” unless that name is the live plugin id
 *  of ANOTHER disk entry (two roots can carry same-named folders; a broken one
 *  must not clobber its healthy namesake's inventory row). */
function dropOriginRecord(origin: string, except: DiskPlugin): void {
  for (const other of disk.values()) {
    if (other !== except && other.id === origin) {
      return
    }
  }

  dropPlugin(origin)
}

/** A plugin source that could not be read in FULL. Evaluating a truncated
 *  file is never acceptable β€” half a module can still parse. */
class PluginSourceOversizeError extends Error {}

/** Read a plugin entry file in full. Prefers the dedicated readPluginSource
 *  IPC (16 MiB cap, no truncation). Older shells predate it and only offer
 *  the preview read, which silently truncates at 512 KiB β€” there the read
 *  fails loudly instead of handing a partial file to the evaluator. */
async function readPluginSourceText(file: string): Promise<string> {
  const desktop = window.hermesDesktop!

  if (desktop.readPluginSource) {
    return (await desktop.readPluginSource(file)).text
  }

  const result = await desktop.readFileText(file)

  if (result.truncated) {
    throw new PluginSourceOversizeError(
      "plugin.js exceeds this shell's 512 KiB read limit β€” update Hermes Desktop to load larger plugins"
    )
  }

  return result.text
}

/** Returns false when the entry file could not be read (vanished mid-read) so
 *  the caller can reconcile/unload the registration instead of retaining a
 *  live ghost for a missing entry. */
async function loadDiskPlugin(entry: DiskPlugin): Promise<boolean> {
  const prevId = entry.id

  try {
    const text = await readPluginSourceText(entry.file)

    const id = await loadRuntimePlugin(text, entry.origin, {
      defaultEnabled: entry.defaultEnabled,
      file: entry.file,
      packageName: entry.packageName,
      packageOrigin: entry.packageOrigin
    })

    // A hot-edit that changes `plugin.id`: loadRuntimePlugin only disposes the
    // NEW id, so unload the previous incarnation here or its contributions +
    // inventory row orphan.
    if (id && prevId && prevId !== id) {
      unloadRuntimePlugin(prevId)
      dropPlugin(prevId)
    }

    entry.id = id ?? entry.id

    // A fixing save under a different plugin id β€” drop the folder-named
    // error record so the inventory shows one row, not a ghost.
    if (id && id !== entry.origin) {
      dropOriginRecord(entry.origin, entry)
    }

    return true
  } catch (error) {
    // An oversize source is a REAL failure the user must see (the silent
    // shape was the bug: a truncated file evaluated as a syntax error, or
    // worse, as half a plugin). It is a completed read of an existing file,
    // so report it and keep the registration (true) β€” everything else is a
    // file vanishing mid-read, where false lets the caller reconcile/unload.
    if (error instanceof PluginSourceOversizeError) {
      console.error(`[plugins] ${entry.origin}: ${error.message}`)
      notifyError(error, `Plugin "${entry.origin}" failed to load`)
      publishPlugin({
        id: entry.origin,
        name: entry.origin,
        kind: 'disk',
        file: entry.file,
        status: 'error',
        error: error.message
      })

      return true
    }

    return false
  }
}

async function resolveDiskPluginEntry(
  desktop: Window['hermesDesktop'],
  folderPath: string,
  segments: readonly string[]
): Promise<string | null> {
  let currentDir = folderPath

  for (let index = 0; index < segments.length; index += 1) {
    const { entries } = await desktop.readDir(currentDir)
    const entry = entries.find(candidate => candidate.name === segments[index])

    if (!entry) {
      return null
    }

    const last = index === segments.length - 1

    if (last) {
      return entry.isDirectory ? null : entry.path
    }

    if (!entry.isDirectory) {
      return null
    }

    currentDir = entry.path
  }

  return null
}

async function scanDiskPlugins(): Promise<void> {
  const desktop = window.hermesDesktop

  // Re-entrancy guard: the 5s poll must not overlap a slow in-flight scan
  // (reads/loads can exceed the interval).
  if (!desktop || scanning) {
    return
  }

  scanning = true

  try {
    const roots = await diskRoots()

    if (roots.length === 0) {
      return
    }

    const seen = new Set<string>()

    for (const root of roots) {
      let entries

      try {
        ;({ entries } = await desktop.readDir(root.dir))
      } catch {
        continue // Root missing (no plugins yet) β€” the poll/watch reconciles.
      }

      for (const dir of entries.filter(e => e.isDirectory)) {
        let file: string | null

        try {
          file = await resolveDiskPluginEntry(desktop, dir.path, root.entrySegments)
        } catch {
          continue // Folder changed during the metadata walk; the next tick reconciles.
        }

        if (!file) {
          continue // Ordinary agent package with no Desktop half β€” not an error.
        }

        seen.add(file)

        if (disk.has(file)) {
          continue
        }

        const marker = await readPackageMarker(desktop, dir.path)

        const record: DiskPlugin = {
          // A unified package's desktop half ships opt-in, like its agent half.
          defaultEnabled: marker ? false : undefined,
          file,
          id: null,
          origin: dir.name,
          packageName: marker?.package,
          packageOrigin: marker?.origin,
          watchId: null
        }

        disk.set(file, record)

        if (!(await loadDiskPlugin(record))) {
          disk.delete(file)

          continue
        }

        try {
          record.watchId = (await desktop.watchPreviewFile(file)).id
        } catch {
          // Unwatchable β€” the poll still reconciles new folders; edits need a
          // manual "Reload desktop plugins".
        }
      }
    }

    // Folder deleted -> plugin gone, cleanly (inventory row included).
    for (const [file, record] of disk) {
      if (seen.has(file)) {
        continue
      }

      if (record.id) {
        unloadRuntimePlugin(record.id)
        dropPlugin(record.id)
      }

      dropOriginRecord(record.origin, record)

      if (record.watchId) {
        void desktop.stopPreviewFileWatch(record.watchId)
      }

      disk.delete(file)
    }
  } catch {
    // No plugin roots (or no gateway yet) β€” nothing to reconcile.
  } finally {
    scanning = false
  }
}

/** Manual rescan (the ⌘K "Reload desktop plugins" fallback). */
export const discoverRuntimePlugins = scanDiskPlugins

/** Start the self-maintaining disk door: initial scan, per-file hot reload,
 *  fs-watched folder reconciliation (poll fallback on older shells). Idempotent. */
export function watchRuntimePlugins(): void {
  const desktop = window.hermesDesktop

  if (watching || !desktop) {
    return
  }

  watching = true

  const dirWatchIds = new Set<string>()
  const watchedDirs = new Set<string>()

  desktop.onPreviewFileChanged(({ id }) => {
    // Directory tick: a plugin folder appeared or vanished β€” reconcile.
    if (dirWatchIds.has(id)) {
      void scanDiskPlugins()

      return
    }

    for (const record of disk.values()) {
      if (record.watchId === id) {
        void loadDiskPlugin(record).then(readable => {
          if (!readable) {
            void scanDiskPlugins()
          }
        })

        return
      }
    }
  })

  // True only when EVERY root is fs-watched β€” a partially watched set keeps
  // the poll alive so unwatched roots still reconcile new/removed folders.
  const startDirWatches = async (): Promise<boolean> => {
    if (!desktop.watchDirectory) {
      return false
    }

    const roots = await diskRoots()

    if (roots.length === 0) {
      return false
    }

    let all = true

    for (const root of roots) {
      if (watchedDirs.has(root.dir)) {
        continue
      }

      try {
        dirWatchIds.add((await desktop.watchDirectory(root.dir)).id)
        watchedDirs.add(root.dir)
      } catch {
        // Dir missing or unwatchable β€” the poll covers it and retries here.
        all = false
      }
    }

    return all
  }

  void scanDiskPlugins()
  void startDirWatches().then(watched => {
    if (watched) {
      return
    }

    const timer = window.setInterval(() => {
      if (document.visibilityState !== 'visible') {
        return
      }

      void scanDiskPlugins()

      // A root may have appeared since β€” upgrade to the watches and retire
      // this poll once every root is covered.
      void startDirWatches().then(upgraded => {
        if (upgraded) {
          window.clearInterval(timer)
        }
      })
    }, DISK_POLL_MS)
  })
}