fix(desktop): a linked plugin checkout drives its desktop half; doctor flags a stale copy

The Desktop loads a unified package's desktop half from the copy in
desktop-plugins/<name>/, not from the package. Editing the source shipped
nothing and nothing said so:

- a package symlinked into plugins/ (the in-place dev layout the Python
  loader already follows) was never copied at all — Dirent.isDirectory()
  is false for a symlink;
- an installed package edited in place stayed stale until Rescan/restart.

Electron now follows symlinked package dirs, marks their copy linked, and
re-syncs it on any byte difference. The renderer watches a linked copy's
source plugin.js and, on save, runs the reconcile and hot-reloads. Catalog
and git installs keep the mtime rule. `hermes plugins doctor` warns when
the copy differs from the source and says how to refresh it.
This commit is contained in:
teknium1
2026-10-08 02:14:23 -07:00
committed by Teknium
parent 6cb6aad026
commit cc1500a071
8 changed files with 369 additions and 5 deletions

View File

@@ -102,6 +102,54 @@ describe('reconcileUnifiedDesktopHalves', () => {
expect(fs.existsSync(path.join(appRoot, 'media'))).toBe(false)
})
it.skipIf(process.platform === 'win32')(
'materializes a SYMLINKED dev package and re-syncs on any byte change, even with an older mtime',
async () => {
// `ln -s ~/src/my-plugin ~/.hermes/plugins/` is how a plugin is developed in
// place; the Python loader follows it, but the reconcile skipped the link
// (Dirent.isDirectory() is false for a symlink) — the desktop half never loaded.
const home = makeHome()
const appRoot = path.join(home, 'desktop-plugins')
const checkout = path.join(home, 'src', 'media')
const source = path.join(checkout, 'desktop', 'plugin.js')
write(source, 'v1')
fs.mkdirSync(path.join(home, 'plugins'), { recursive: true })
fs.symlinkSync(checkout, path.join(home, 'plugins', 'media'))
expect(await reconcileUnifiedDesktopHalves(home, appRoot)).toEqual([path.join(appRoot, 'media')])
expect(fs.readFileSync(path.join(appRoot, 'media', 'plugin.js'), 'utf8')).toBe('v1')
const marker = JSON.parse(fs.readFileSync(path.join(appRoot, 'media', PACKAGE_MARKER), 'utf8'))
expect(marker).toMatchObject({
linked: true,
package: 'media',
source: path.join(home, 'plugins', 'media', 'desktop')
})
expect(await reconcileUnifiedDesktopHalves(home, appRoot)).toEqual([])
// An edit that lands an OLDER mtime (git stash pop, cp -p) still re-syncs.
write(source, 'v2')
const past = new Date(Date.now() - 3_600_000)
fs.utimesSync(source, past, past)
expect(await reconcileUnifiedDesktopHalves(home, appRoot)).toEqual([path.join(appRoot, 'media')])
expect(fs.readFileSync(path.join(appRoot, 'media', 'plugin.js'), 'utf8')).toBe('v2')
}
)
it('keeps the mtime gate for an installed (non-linked) package', async () => {
const home = makeHome()
const appRoot = path.join(home, 'desktop-plugins')
const source = path.join(home, 'plugins', 'media', 'desktop', 'plugin.js')
write(source, 'v1')
await reconcileUnifiedDesktopHalves(home, appRoot)
const marker = JSON.parse(fs.readFileSync(path.join(appRoot, 'media', PACKAGE_MARKER), 'utf8'))
expect(marker.linked).toBeUndefined()
write(source, 'v2')
const past = new Date(Date.now() - 3_600_000)
fs.utimesSync(source, past, past)
expect(await reconcileUnifiedDesktopHalves(home, appRoot)).toEqual([])
})
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
'skips a package the app cannot read and still materializes its siblings',
async () => {

View File

@@ -29,6 +29,10 @@ export interface DesktopHalfMarker {
source: string
/** mtimeMs of the source `plugin.js` at copy time; a newer source re-copies. */
sourceMtimeMs: number
/** The package folder is a symlink (a dev checkout linked into `plugins/`):
* the copy re-syncs on ANY byte difference and the renderer watches the
* source, so saving the checkout hot-reloads the pane. */
linked?: boolean
/** Where the PACKAGE came from, so "Install here" can install its agent half
* into another profile: the catalog sidecar's repo/sha, else the git remote. */
repo?: string
@@ -78,11 +82,30 @@ export async function ensureDir(dir: string): Promise<string> {
return dir
}
async function listDirs(dir: string): Promise<string[]> {
async function listDirs(dir: string, followLinks = false): Promise<string[]> {
try {
const entries = await fs.promises.readdir(dir, { withFileTypes: true })
return entries.filter(entry => entry.isDirectory()).map(entry => entry.name)
// `Dirent.isDirectory()` is false for a symlink. A package root follows
// links to directories: `ln -s ~/src/my-plugin ~/.hermes/plugins/` is how a
// plugin is developed in place, and the Python loader already follows it.
const linkedDir = async (name: string) => {
try {
return (await fs.promises.stat(path.join(dir, name))).isDirectory()
} catch {
return false
}
}
const names: string[] = []
for (const entry of entries) {
if (entry.isDirectory() || (followLinks && entry.isSymbolicLink() && (await linkedDir(entry.name)))) {
names.push(entry.name)
}
}
return names
} catch {
return []
}
@@ -194,11 +217,13 @@ export async function materializeDesktopHalf(
const target = path.join(appRoot, packageName)
const existing = await readMarker(target)
const linked = (await fs.promises.lstat(packageDir)).isSymbolicLink()
const marker: DesktopHalfMarker = {
package: packageName,
source: sourceDir,
sourceMtimeMs: stat.mtimeMs,
...(linked ? { linked } : {}),
...(await packageOrigin(packageDir))
}
@@ -229,7 +254,15 @@ export async function materializeDesktopHalf(
}
}
if (existing && existing.source === sourceDir && existing.sourceMtimeMs >= stat.mtimeMs) {
// An installed package re-copies when its source is newer. A linked dev
// checkout compares bytes: mtime alone misses a `cp -p` / `git stash pop`
// that lands an older timestamp, and the copy would stay stale silently.
if (
existing &&
existing.source === sourceDir &&
Boolean(existing.linked) === linked &&
(linked ? await sameFile(path.join(target, 'plugin.js'), entry) : existing.sourceMtimeMs >= stat.mtimeMs)
) {
return null
}
}
@@ -305,7 +338,7 @@ export async function reconcileUnifiedDesktopHalves(hermesHome: string, appRoot:
for (const home of await localHomes(hermesHome)) {
const pluginsRoot = path.join(home, 'plugins')
for (const name of await listDirs(pluginsRoot)) {
for (const name of await listDirs(pluginsRoot, true)) {
if (seen.has(name)) {
continue
}

View File

@@ -1187,3 +1187,95 @@ describe('manual "Reload desktop plugins" (#91503)', () => {
}
})
})
describe('linked dev package: the source checkout drives the desktop half', () => {
it('watches the SOURCE plugin.js and a save there re-syncs the copy and reloads it', async () => {
const root = '/local/.hermes/desktop-plugins'
const sourceDir = '/local/.hermes/plugins/devpkg/desktop'
desktopPluginsRoot.mockResolvedValue(root)
readDir.mockImplementation(async dir => {
if (dir === root) {
return { entries: [{ isDirectory: true, name: 'devpkg', path: `${root}/devpkg` }] }
}
if (dir === `${root}/devpkg`) {
return {
entries: [
{ isDirectory: false, name: '.hermes-package.json', path: `${root}/devpkg/.hermes-package.json` },
{ isDirectory: false, name: 'plugin.js', path: `${root}/devpkg/plugin.js` }
]
}
}
return { entries: [] }
})
const registerV1 = vi.fn()
const registerV2 = vi.fn()
Object.assign(globalThis, { __devV1: registerV1, __devV2: registerV2 })
let copy = 'export default { id: "devpkg", register: globalThis.__devV1 }'
readFileText.mockImplementation(async file =>
file.endsWith('.hermes-package.json')
? { text: JSON.stringify({ linked: true, package: 'devpkg', source: sourceDir, sourceMtimeMs: 1 }) }
: { text: copy }
)
watchPreviewFile.mockImplementation(async file => ({ id: `w:${file}` }))
// Electron's reconcile is the one writer of the copy: it lands the new bytes.
const reconcileDesktopPlugins = vi.fn(async () => {
copy = 'export default { id: "devpkg", register: globalThis.__devV2 }'
return [`${root}/devpkg`]
})
Object.assign((window as unknown as { hermesDesktop: object }).hermesDesktop, { reconcileDesktopPlugins })
const createObjectURL = vi
.spyOn(URL, 'createObjectURL')
.mockImplementation(
blob =>
`data:text/javascript;base64,${Buffer.from((blob as unknown as { parts: string[] }).parts.join('')).toString('base64')}`
)
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined)
const RealBlob = globalThis.Blob
vi.stubGlobal(
'Blob',
class {
parts: string[]
constructor(parts: string[]) {
this.parts = parts
}
}
)
// watchRuntimePlugins is once-per-module and an earlier test already ran it;
// a fresh module graph gives this test its own change listener.
vi.resetModules()
const loader = await import('./runtime-loader')
const store = await import('./plugins-store')
try {
loader.watchRuntimePlugins()
await vi.waitFor(() => expect(watchPreviewFile).toHaveBeenCalledWith(`${sourceDir}/plugin.js`))
await store.setPluginEnabled('devpkg', true)
expect(registerV1).toHaveBeenCalledTimes(1)
// The developer saves the checkout, not the copy.
const onChange = onPreviewFileChanged.mock.calls[0][0] as (event: { id: string }) => void
onChange({ id: `w:${sourceDir}/plugin.js` })
await vi.waitFor(() => expect(registerV2).toHaveBeenCalledTimes(1))
expect(reconcileDesktopPlugins).toHaveBeenCalledTimes(1)
// The replaced copy's watch is re-bound to the new inode.
expect(stopPreviewFileWatch).toHaveBeenCalledWith(`w:${root}/devpkg/plugin.js`)
} finally {
createObjectURL.mockRestore()
revokeObjectURL.mockRestore()
vi.stubGlobal('Blob', RealBlob)
loader.unloadRuntimePlugin('devpkg')
delete (globalThis as unknown as { __devV1?: unknown }).__devV1
delete (globalThis as unknown as { __devV2?: unknown }).__devV2
}
})
})

View File

@@ -587,6 +587,8 @@ async function diskRoots(): Promise<DiskRoot[]> {
const PACKAGE_MARKER = '.hermes-package.json'
interface PackageMarker {
/** The package is a symlinked dev checkout; `source` is its `desktop/` dir. */
linkedSource?: string
origin?: { catalogName?: string; repo?: string; sha?: string }
package: string
}
@@ -608,9 +610,11 @@ async function readPackageMarker(desktop: Window['hermesDesktop'], folder: strin
const parsed = JSON.parse(read.text) as {
catalogName?: string
linked?: boolean
package?: string
repo?: string
sha?: string
source?: string
}
if (!parsed.package) {
@@ -618,6 +622,7 @@ async function readPackageMarker(desktop: Window['hermesDesktop'], folder: strin
}
return {
linkedSource: parsed.linked && parsed.source ? parsed.source : undefined,
origin: parsed.repo ? { catalogName: parsed.catalogName, repo: parsed.repo, sha: parsed.sha } : undefined,
package: parsed.package
}
@@ -637,6 +642,10 @@ interface DiskPlugin {
id: null | string
/** Origin label (folder name) — the toast/inventory name for load errors. */
origin: string
/** Linked dev package only: the SOURCE `desktop/plugin.js` this copy came from. */
sourceFile?: string
/** Watch on `sourceFile` — a save re-syncs the copy, then reloads it. */
sourceWatchId?: null | string
watchId: null | string
}
@@ -802,6 +811,49 @@ async function watchDiskPluginFile(desktop: NonNullable<Window['hermesDesktop']>
}
}
/** Watch a linked dev package's SOURCE `plugin.js`. Without it the developer
* edits their checkout while the app keeps running the copy in
* `desktop-plugins/` — the copy is only refreshed on Rescan or restart. */
async function watchDiskPluginSource(desktop: NonNullable<Window['hermesDesktop']>, record: DiskPlugin): Promise<void> {
if (!record.sourceFile || record.sourceWatchId) {
return
}
try {
const watch = await desktop.watchPreviewFile(record.sourceFile)
record.sourceWatchId = isReadFileErrorResult(watch) ? null : watch.id
} catch {
record.sourceWatchId = null
}
}
/** A linked source changed: copy it over the app-root half (Electron's
* reconcile — the one writer of that folder), then reload every copy that
* pass replaced and re-bind its watch, which the folder swap orphaned. */
async function resyncLinkedDiskPlugins(): Promise<void> {
const desktop = window.hermesDesktop
if (!desktop?.reconcileDesktopPlugins) {
return
}
const touched = new Set((await desktop.reconcileDesktopPlugins().catch(() => [])) ?? [])
for (const record of disk.values()) {
// The folder of `<root>/<name>/plugin.js`, either separator (Windows paths).
if (!touched.has(record.file.replace(/[\\/][^\\/]*$/, ''))) {
continue
}
if (await loadDiskPlugin(record)) {
await watchDiskPluginFile(desktop, record)
} else {
void scanDiskPlugins()
}
}
}
/** Reconcile the disk root with the inventory. `reloadKnown` (the manual
* "Reload desktop plugins" command) also re-reads every already-known entry
* file: an installer that atomically replaces a plugin folder keeps the
@@ -875,6 +927,8 @@ async function scanDiskPlugins(reloadKnown = false): Promise<void> {
origin: dir.name,
packageName: marker?.package,
packageOrigin: marker?.origin,
sourceFile: marker?.linkedSource ? `${marker.linkedSource}/plugin.js` : undefined,
sourceWatchId: null,
watchId: null
}
@@ -887,6 +941,7 @@ async function scanDiskPlugins(reloadKnown = false): Promise<void> {
}
await watchDiskPluginFile(desktop, record)
await watchDiskPluginSource(desktop, record)
}
}
@@ -919,6 +974,10 @@ function retireDiskPlugin(file: string, record: DiskPlugin): void {
void window.hermesDesktop?.stopPreviewFileWatch(record.watchId)
}
if (record.sourceWatchId) {
void window.hermesDesktop?.stopPreviewFileWatch(record.sourceWatchId)
}
disk.delete(file)
}
@@ -985,6 +1044,12 @@ export function watchRuntimePlugins(): void {
}
for (const record of disk.values()) {
if (record.sourceWatchId === id) {
void resyncLinkedDiskPlugins()
return
}
if (record.watchId === id) {
void loadDiskPlugin(record).then(readable => {
if (!readable) {

View File

@@ -4,6 +4,7 @@ check routes through the real runtime contracts instead of a parallel scanner.""
from __future__ import annotations
import inspect
import json
import os
import shutil
import socket
@@ -332,6 +333,40 @@ def _check_manifest_v2(report: "DoctorReport", manifest: Any) -> None:
report.warning(f"config_schema key {skey!r} declares unknown type {stype!r}")
def _check_desktop_half_copy(report: DoctorReport, path: Path) -> None:
"""Warn when the Desktop app runs a stale copy of this package's ``desktop/plugin.js``.
Electron copies a unified package's desktop half to ``<root>/desktop-plugins/<name>/`` and loads
THAT copy, so editing the package in place changes nothing on screen until the copy is refreshed.
The copy carries a ``.hermes-package.json`` marker whose ``source`` names the folder it came from.
"""
source = path / "desktop" / "plugin.js"
if not source.is_file():
return
from hermes_constants import get_default_hermes_root
app_root = get_default_hermes_root() / "desktop-plugins"
try:
copies = sorted(app_root.glob("*/.hermes-package.json"))
except OSError:
return
for marker_file in copies:
try:
marker = json.loads(marker_file.read_text(encoding="utf-8-sig"))
if Path(marker["source"]).resolve() != source.parent.resolve():
continue
if (marker_file.parent / "plugin.js").read_bytes() == source.read_bytes():
return
except (OSError, ValueError, KeyError, TypeError):
continue
report.warning(
f"Desktop runs a stale copy of desktop/plugin.js ({marker_file.parent}); your edits are not "
"loaded. Refresh it with Capabilities → Plugins → Rescan in the Desktop app (or restart it). "
"To develop in place, symlink the checkout into plugins/ instead: the Desktop then re-syncs "
"the copy on every save.")
return
def doctor_plugin(target: str | os.PathLike[str] | None = None) -> DoctorReport:
"""Validate one plugin through Hermes' real scanner and registration path."""
try:
@@ -392,6 +427,8 @@ def doctor_plugin(target: str | os.PathLike[str] | None = None) -> DoctorReport:
report.error(str(exc))
except Exception as exc:
report.error(f"unexpected validation failure: {type(exc).__name__}: {exc}")
# Outside the runtime sandbox: it swaps HERMES_HOME for a temp dir, and the copy lives in the real one.
_check_desktop_half_copy(report, path)
return report

View File

@@ -0,0 +1,60 @@
"""``hermes plugins doctor`` warns when Desktop runs a stale copy of a unified package's desktop half."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
def _plugin(root: Path) -> Path:
plugin = root / "plugins" / "uni"
(plugin / "desktop").mkdir(parents=True)
(plugin / "plugin.yaml").write_text("name: uni\nversion: 0.1.0\n", encoding="utf-8")
(plugin / "__init__.py").write_text("def register(ctx):\n pass\n", encoding="utf-8")
(plugin / "desktop" / "plugin.js").write_text("export default { id: 'uni' } // v1\n", encoding="utf-8")
return plugin
def _copy(home: Path, plugin: Path) -> Path:
copy = home / "desktop-plugins" / "uni"
copy.mkdir(parents=True)
(copy / "plugin.js").write_bytes((plugin / "desktop" / "plugin.js").read_bytes())
(copy / ".hermes-package.json").write_text(
json.dumps({"package": "uni", "source": str(plugin / "desktop"), "sourceMtimeMs": 1}), encoding="utf-8")
return copy
def _stale_warnings(report) -> list[str]:
return [f.message for f in report.findings if "stale copy" in f.message]
@pytest.fixture
def home(tmp_path, monkeypatch) -> Path:
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
return home
def test_doctor_warns_when_the_desktop_copy_is_stale(home: Path) -> None:
from hermes_cli.plugin_dev import doctor_plugin
plugin = _plugin(home)
copy = _copy(home, plugin)
assert _stale_warnings(doctor_plugin(plugin)) == []
(plugin / "desktop" / "plugin.js").write_text("export default { id: 'uni' } // v2\n", encoding="utf-8")
report = doctor_plugin(plugin)
[warning] = _stale_warnings(report)
assert str(copy) in warning
assert "Rescan" in warning
assert report.ok # a warning, never a CI failure
def test_doctor_is_silent_without_a_desktop_copy(home: Path) -> None:
from hermes_cli.plugin_dev import doctor_plugin
assert _stale_warnings(doctor_plugin(_plugin(home))) == []

View File

@@ -1543,6 +1543,32 @@ such damage and replaced on the next **Rescan**, while a marker-less folder
that *does* hold a `plugin.js` is a standalone plugin you installed by hand and
is never overwritten.
#### Developing a unified package
The app loads the **copy** in `desktop-plugins/<id>/`, not your package. An
installed package (catalog, Git URL, `file://` path) is refreshed only when its
source `plugin.js` is newer than the copy and something asks — **Rescan** in
**Capabilities → Plugins**, an install/update through the app, or a restart —
so editing `~/.hermes/plugins/<id>/desktop/plugin.js` in place does nothing
on screen until then. Develop from a checkout linked into `plugins/` instead:
```bash
ln -s ~/src/my-plugin ~/.hermes/plugins/my-plugin
```
A linked package's copy is marked `"linked": true`. The app watches the
checkout's `desktop/plugin.js`; every save re-syncs the copy (by content, not
mtime, so `git stash pop` counts) and hot-reloads the plugin. Catalog and Git
installs keep the mtime rule.
`hermes plugins doctor <path-or-id>` warns when the copy differs from your
source and names the copy it found:
```text
WARN: Desktop runs a stale copy of desktop/plugin.js (~/.hermes/desktop-plugins/my-plugin);
your edits are not loaded. Refresh it with Capabilities → Plugins → Rescan …
```
Two enable switches still apply, on purpose, and both default to **off**: the
desktop half ships opt-in — it inventories in **Capabilities → Plugins** but stays
disabled until the user toggles it — matching the Python half's

View File

@@ -251,7 +251,10 @@ cd ~/.hermes/plugins/calculator
manifest parser, namespaced import, `register(ctx)`, hook registry, and tool
registry used by Hermes itself. It reports invalid hook names, callbacks that do
not accept `**kwargs`, registration failures, and drift between declared and
registered tools/hooks. Pass `--ci` to exit non-zero on an error:
registered tools/hooks. For a package with a `desktop/plugin.js`, it also warns
when the Desktop app is running a stale copy of it (see
[Developing a unified package](../desktop-plugin-sdk.md#developing-a-unified-package)).
Pass `--ci` to exit non-zero on an error:
```bash
hermes plugins doctor . --ci