hmgr 0.0.4 → 0.0.6

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 (41) hide show
  1. package/README.md +10 -0
  2. package/dist/adapter/checkout-pfad.js +10 -2
  3. package/dist/adapter/claude-code.js +251 -23
  4. package/dist/adapter/composer.js +4 -3
  5. package/dist/adapter/datei-text.js +18 -0
  6. package/dist/adapter/dateisystem.js +146 -8
  7. package/dist/adapter/git.js +129 -17
  8. package/dist/adapter/hermes.js +235 -10
  9. package/dist/adapter/marketplace-json.js +2 -1
  10. package/dist/adapter/mcp-katalog.js +124 -0
  11. package/dist/adapter/quelle-text.js +10 -1
  12. package/dist/adapter/skill-md.js +96 -19
  13. package/dist/adapter/terminal.js +1 -1
  14. package/dist/adapter/umgebung.js +43 -0
  15. package/dist/adapter/vergleich.js +20 -0
  16. package/dist/adapter/zustand-json.js +6 -1
  17. package/dist/cli/hilfe.js +227 -26
  18. package/dist/cli/main.js +362 -20
  19. package/dist/gui/beobachter.js +108 -0
  20. package/dist/gui/icons.js +114 -0
  21. package/dist/gui/login-pfad.js +120 -0
  22. package/dist/gui/main.js +823 -0
  23. package/dist/gui/mcp-ansicht.js +57 -0
  24. package/dist/gui/oeffnen.js +33 -0
  25. package/dist/gui/preload.cjs +70 -0
  26. package/dist/gui/renderer/fonts/Inter-LICENSE.txt +92 -0
  27. package/dist/gui/renderer/fonts/InterVariable.woff2 +0 -0
  28. package/dist/gui/renderer/fonts/JetBrainsMono-Medium.woff2 +0 -0
  29. package/dist/gui/renderer/fonts/JetBrainsMono-OFL.txt +93 -0
  30. package/dist/gui/renderer/fonts/JetBrainsMono-Regular.woff2 +0 -0
  31. package/dist/gui/renderer/index.html +581 -0
  32. package/dist/gui/renderer/renderer.js +3227 -0
  33. package/dist/gui/renderer/styles.css +701 -0
  34. package/dist/kern/anwendungsfaelle.js +859 -65
  35. package/dist/kern/modell.js +297 -0
  36. package/dist/kern/quelle-ort.js +14 -1
  37. package/dist/ports/harness.js +16 -0
  38. package/dist/ports/mcp-katalog.js +1 -0
  39. package/dist/ports/umgebung.js +1 -0
  40. package/dist/ports/zustand.js +1 -1
  41. package/package.json +47 -2
@@ -0,0 +1,823 @@
1
+ // hmgr-GUI, Electron-Main (E40). Baut dieselben Anwendungsfälle wie die CLI (src/cli/main.ts),
2
+ // registriert IPC-Handler für den Renderer und implementiert den Rückfrage-Port als Dialog im
3
+ // Fenster. Kein zweiter Prozess: der Kern läuft direkt im Main-Prozess, die GUI ist ein zweiter
4
+ // Kompositions-Einstieg neben der CLI (ARCHITEKTUR.md „Eingang").
5
+ import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from 'electron';
6
+ import path from 'node:path';
7
+ import { randomUUID } from 'node:crypto';
8
+ import { stat } from 'node:fs/promises';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { bauen, umgebungLesen } from "../cli/main.js";
11
+ import { quelleAlsText, quelleAusText } from "../adapter/quelle-text.js";
12
+ import { quelleName, quelleSchluessel } from "../kern/modell.js";
13
+ import { kannProfile } from "../ports/harness.js";
14
+ import { iconFuerQuelle } from "./icons.js";
15
+ import { entscheideOeffnen } from "./oeffnen.js";
16
+ import { mcpUebernahmeSerialisieren, mcpUebersichtSerialisieren, mcpWirkungSerialisieren } from "./mcp-ansicht.js";
17
+ import { beobachten } from "./beobachter.js";
18
+ import { loginPathUebernehmen } from "./login-pfad.js";
19
+ const hierVerzeichnis = path.dirname(fileURLToPath(import.meta.url));
20
+ const smoketest = process.env.HMGR_GUI_SMOKETEST === '1';
21
+ /**
22
+ * Nur eine hmgr-Instanz je Nutzer (E67). Grund: zwei laufende Fenster schrieben nacheinander auf
23
+ * dieselbe `state.json` und überschrieben sich gegenseitig — der Zustand ist einer, nicht einer je
24
+ * Fenster. Die zweite Instanz bekommt den Lock nicht und beendet sich; die erste holt in
25
+ * `second-instance` ihr Fenster nach vorn, damit der zweite Start nicht wirkungslos aussieht.
26
+ * Gilt auf allen Plattformen: unter macOS ist der Doppelstart selten (das Dock startet nicht neu),
27
+ * unter Windows ist ein zweiter Klick im Startmenü der Normalfall.
28
+ *
29
+ * Der Lock wird ganz früh geholt, noch vor `bauen()` und der PATH-Ermittlung: eine Instanz, die
30
+ * ohnehin gleich wieder geht, soll nichts mehr anfassen und keine Shell mehr starten.
31
+ *
32
+ * Im Smoke-Test ist er aus. Er hängt am Electron-userData-Verzeichnis, und das leitet macOS nicht
33
+ * über das gefälschte HOME um (gemessen, siehe E67) — eine offene hmgr-GUI auf dem Rechner würde
34
+ * den Smoke-Lauf sonst stumm abwürgen. Dieselbe Zurückhaltung wie bei Watcher und
35
+ * Auto-Aktualisieren: was einen automatisierten Lauf von außen stören kann, bleibt im Smoke aus.
36
+ */
37
+ const einzelinstanz = smoketest || app.requestSingleInstanceLock();
38
+ if (!einzelinstanz)
39
+ app.quit();
40
+ const umgebung = umgebungLesen();
41
+ /** Cache der Quellen-Icons (E60), unterhalb von HMGR_HOME wie der Zustand: Tests/Smoke fassen so
42
+ nie ein echtes Verzeichnis an. */
43
+ const iconCacheVerzeichnis = path.join(umgebung.hmgrHome, 'icon-cache');
44
+ /** Intervall des selbsttätigen Aktualisierens (E44): fest, keine Einstellung. */
45
+ const SECHS_STUNDEN_MS = 6 * 60 * 60 * 1000;
46
+ /**
47
+ * Fake der claude-CLI für den Smoke-Test (E40 R4), nach dem Muster von `fakeClaude` in
48
+ * tests/helfer.ts. Kein Kindprozess, keine echte CLI: ein automatisierter Lauf fasst die echte
49
+ * claude-CLI formal nie an, auch wenn sie auf dem PATH liegt.
50
+ *
51
+ * `--version` läuft durch, damit Claude Code im Smoke-Test seine Fähigkeiten bekommt (E61: die
52
+ * MCP-Fähigkeit hängt an der vorhandenen CLI). Sonst blieben die Claude-Code-Zeilen der MCP-Ansicht
53
+ * ungetestet. Jeder andere Aufruf scheitert mit Code 1: die lesenden Pfade tragen das (Plugin-Liste
54
+ * leer, `eingehaengte` liest ohnehin nur ~/.claude.json), und die schreibenden bleiben so
55
+ * verschlossen — der Smoke-Test hängt nichts ein und aus, er öffnet die Dialoge nur.
56
+ */
57
+ const smoketestClaudeAusfuehrer = async (args) => args[0] === '--version' ? { stdout: 'claude 0.0.0-smoke\n', code: 0 } : { stdout: '', code: 1 };
58
+ let hauptfenster = null;
59
+ /**
60
+ * Rückfrage-Port als Dialog im Renderer (E28, E34): der Main-Prozess schickt die Frage per
61
+ * `webContents.send`, der Renderer zeigt einen <dialog> im Stil des Entwurfs und schickt die
62
+ * Antwort über einen Kanal mit Korrelations-ID zurück. Ohne offenes Fenster kann niemand antworten,
63
+ * dort gilt wie am Terminal ohne TTY: Nein bzw. keine Antwort (E34).
64
+ */
65
+ class GuiRueckfrage {
66
+ ausstehend = new Map();
67
+ constructor() {
68
+ ipcMain.on('hmgr:rueckfrage:antwort', (_event, nachricht) => {
69
+ const aufloesen = this.ausstehend.get(nachricht.id);
70
+ if (aufloesen === undefined)
71
+ return;
72
+ this.ausstehend.delete(nachricht.id);
73
+ aufloesen(nachricht.antwort);
74
+ });
75
+ }
76
+ stellen(payload) {
77
+ if (hauptfenster === null)
78
+ return Promise.resolve(payload.art === 'bestaetigen' ? false : undefined);
79
+ const id = randomUUID();
80
+ return new Promise((aufloesen) => {
81
+ this.ausstehend.set(id, aufloesen);
82
+ hauptfenster?.webContents.send('hmgr:rueckfrage', { id, ...payload });
83
+ });
84
+ }
85
+ async bestaetigen(frage) {
86
+ const antwort = await this.stellen({ art: 'bestaetigen', frage });
87
+ return antwort === true;
88
+ }
89
+ async auswaehlen(frage, optionen) {
90
+ const antwort = await this.stellen({ art: 'auswaehlen', frage, optionen });
91
+ return typeof antwort === 'number' ? antwort : undefined;
92
+ }
93
+ }
94
+ /**
95
+ * PATH aus der Login-Shell (F47=O2, E63): aus dem Finder gestartet bekommt die App nur den
96
+ * macOS-Minimal-PATH, ohne z. B. ~/.local/bin mit der claude-CLI. Die Ermittlung läuft ab hier
97
+ * parallel zum Electron-Start; gewartet wird zentral in `auffangen`, und damit vor jedem
98
+ * Adapter-Aufruf, denn jeder IPC-Handler geht durch `auffangen` und Spawns gibt es nur dort
99
+ * (claude-code.ts, git.ts — Watcher, Fokus-Reload und Auto-Aktualisieren stoßen den Renderer an,
100
+ * der wieder über IPC hereinkommt).
101
+ *
102
+ * Bewusst kein Top-Level-`await`: im ESM-Main-Entry läuft vor `ready` nur, was die Imports als
103
+ * Nebeneffekt tun; ein Await dort verschiebt die Registrierung aller App-Handler und verzögert
104
+ * das Fenster um die Laufzeit der Shell (~2 s). So erscheint das Fenster sofort, und höchstens
105
+ * der erste Datenabruf wartet. Im Smoke-Test unterbleibt die Ermittlung ganz (nie eine echte
106
+ * Shell, Muster: Watcher/Auto-Aktualisieren sind im Smoke auch aus). Der `catch` ist die letzte
107
+ * Leine: dieser Aufruf darf die App unter keinen Umständen stören.
108
+ *
109
+ * Unter Windows steigt `loginPathUebernehmen` selbst sofort aus (E67): dort gibt es keine
110
+ * Login-Shell im POSIX-Sinn und keine PATH-Lücke zu füllen.
111
+ */
112
+ const pathBereit = smoketest || !einzelinstanz
113
+ ? Promise.resolve()
114
+ : loginPathUebernehmen().catch(() => undefined);
115
+ const rueckfrage = new GuiRueckfrage();
116
+ const { anwendungsfaelle, ports } = bauen(umgebung, {
117
+ rueckfrage,
118
+ ...(smoketest ? { claudeAusfuehrer: smoketestClaudeAusfuehrer } : {}),
119
+ });
120
+ async function auffangen(fn) {
121
+ await pathBereit; // erst der Login-PATH (F47=O2), dann der Adapter — sonst findet er die CLI nicht
122
+ try {
123
+ return { ok: true, daten: await fn() };
124
+ }
125
+ catch (fehler) {
126
+ return { ok: false, fehler: fehler instanceof Error ? fehler.message : String(fehler) };
127
+ }
128
+ }
129
+ /** ZielZustand über IPC: Maps werden zu einfachen Objekten, der Rest ist schon JSON-tauglich. */
130
+ function zielZustandSerialisieren(z) {
131
+ return {
132
+ ziel: z.ziel,
133
+ verzeichnis: z.verzeichnis,
134
+ eintraege: z.eintraege,
135
+ einschraenkungen: Object.fromEntries(z.einschraenkungen),
136
+ gruende: Object.fromEntries(z.gruende),
137
+ ignoriert: z.ignoriert,
138
+ uebernahme: Object.fromEntries(z.uebernahme),
139
+ istRegistriert: z.istRegistriert,
140
+ kannSkillOrdner: z.kannSkillOrdner,
141
+ ...(z.plugins === undefined ? {} : { plugins: z.plugins }),
142
+ ...(z.verwalteterSkillOrdner === undefined ? {} : { verwalteterSkillOrdner: z.verwalteterSkillOrdner }),
143
+ };
144
+ }
145
+ function zielAusIpc(harness, ort) {
146
+ return { harness, ort };
147
+ }
148
+ function registriereIpc() {
149
+ ipcMain.handle('hmgr:version', async () => auffangen(async () => app.getVersion()));
150
+ // Für pfadKurz im Renderer (Anzeige-Kürzung auf ~): nur lesend, nie in einen anderen Aufruf
151
+ // zurückgespeist. Dasselbe Home wie der Kern (`umgebungLesen`: `HOME` vor `os.homedir()`, E68) —
152
+ // sonst kürzt die Anzeige dort nicht, wo HOME abweichend gesetzt ist (MSYS/Git-Bash).
153
+ ipcMain.handle('hmgr:home', async () => auffangen(async () => umgebung.home));
154
+ // Ebenfalls nur für die Anzeige (E68): der Renderer leitet daraus den Pfadtrenner ab, mit dem er
155
+ // eigene Anzeigepfade zusammensetzt („~/.claude.json" bzw. „~\.claude.json").
156
+ ipcMain.handle('hmgr:plattform', async () => auffangen(async () => process.platform));
157
+ ipcMain.handle('hmgr:istZustand', async () => auffangen(async () => (await anwendungsfaelle.istZustand()).map(zielZustandSerialisieren)));
158
+ ipcMain.handle('hmgr:marketplaces', async () => auffangen(async () => (await anwendungsfaelle.marketplaces()).map((m) => ({
159
+ quelleText: quelleAlsText(m.quelle),
160
+ name: m.name,
161
+ plugins: m.plugins,
162
+ }))));
163
+ // Skill-Katalog über alle bekannten Quellen (E40 R3): über den Anwendungsfall `katalog`, nicht
164
+ // mehr direkt über die Ports. Eine nicht erreichbare Quelle trägt einen Fehlervermerk (E12-Muster
165
+ // wie istZustand/marketplaces), ihre Skills fehlen einfach in der flachen Liste.
166
+ ipcMain.handle('hmgr:skills', async () => auffangen(async () => {
167
+ const ergebnis = [];
168
+ for (const eintrag of await anwendungsfaelle.katalog()) {
169
+ if (eintrag.ref === undefined)
170
+ continue; // nicht erreichbar, keine Skills
171
+ for (const skill of eintrag.skills)
172
+ ergebnis.push({
173
+ name: skill.name,
174
+ pfad: skill.pfad,
175
+ ...(skill.beschreibung === undefined ? {} : { beschreibung: skill.beschreibung }),
176
+ ...(skill.nurPerBefehl === undefined ? {} : { nurPerBefehl: skill.nurPerBefehl }),
177
+ ...(skill.nichtPerBefehl === undefined ? {} : { nichtPerBefehl: skill.nichtPerBefehl }),
178
+ ...(skill.registriertHooks === undefined ? {} : { registriertHooks: skill.registriertHooks }),
179
+ ...(skill.werkzeugeErlaubt === undefined ? {} : { werkzeugeErlaubt: skill.werkzeugeErlaubt }),
180
+ ...(skill.kompatibilitaet === undefined ? {} : { kompatibilitaet: skill.kompatibilitaet }),
181
+ ...(skill.modell === undefined ? {} : { modell: skill.modell }),
182
+ ...(skill.eigenerKontext === undefined ? {} : { eigenerKontext: skill.eigenerKontext }),
183
+ ...(skill.argumentHinweis === undefined ? {} : { argumentHinweis: skill.argumentHinweis }),
184
+ quelleText: quelleAlsText(eintrag.quelle),
185
+ quelleName: quelleName(eintrag.quelle),
186
+ quelleSchluessel: quelleSchluessel(eintrag.quelle),
187
+ ref: eintrag.ref,
188
+ });
189
+ }
190
+ return ergebnis;
191
+ }));
192
+ // Nur die Herkunft, ohne die Quellen zu lesen: der Katalog daneben tut das schon.
193
+ ipcMain.handle('hmgr:quellen', async () => auffangen(async () => (await anwendungsfaelle.quellen()).map((quelle) => ({
194
+ art: quelle.art,
195
+ text: quelleAlsText(quelle),
196
+ name: quelleName(quelle),
197
+ }))));
198
+ // Quelle-Verwaltung in den Einstellungen (E54, E59): Text-zu-Quelle passiert im Main-Prozess, der
199
+ // Renderer kennt nur Zeichenketten. Der Anzeigename kommt serverseitig mit, damit dem Renderer nie
200
+ // ein weggefiltertes Feld fehlt (E59: „name" ist reine Anzeige, geht nicht in quelleAusText ein).
201
+ ipcMain.handle('hmgr:quelleHinzufuegen', async (_event, text, name) => auffangen(async () => {
202
+ const ergebnis = await anwendungsfaelle.quelleHinzufuegen(quelleAusText(text), name);
203
+ return {
204
+ quelle: {
205
+ art: ergebnis.quelle.art,
206
+ text: quelleAlsText(ergebnis.quelle),
207
+ name: quelleName(ergebnis.quelle),
208
+ },
209
+ skills: ergebnis.skills,
210
+ marketplace: ergebnis.marketplace,
211
+ bekannt: ergebnis.bekannt,
212
+ };
213
+ }));
214
+ ipcMain.handle('hmgr:quelleEntfernen', async (_event, text) => auffangen(async () => anwendungsfaelle.quelleEntfernen(quelleAusText(text))));
215
+ ipcMain.handle('hmgr:quelleUmbenennen', async (_event, text, name) => auffangen(async () => {
216
+ const umbenannt = await anwendungsfaelle.quelleUmbenennen(quelleAusText(text), name);
217
+ return { art: umbenannt.art, text: quelleAlsText(umbenannt), name: quelleName(umbenannt) };
218
+ }));
219
+ // Aufräum-Angebot nach dem Entfernen einer Quelle (E58): Inventar vor dem eigentlichen Entfernen
220
+ // erheben (der Renderer ruft das, bevor er quelleEntfernen aufruft), dann bei Zustimmung abräumen.
221
+ // Beide Ergebnisse reichen unverändert durch: schon reine Daten, keine Maps oder Funktionen darin.
222
+ ipcMain.handle('hmgr:quelleAufraeumInventar', async (_event, text) => auffangen(async () => anwendungsfaelle.quelleAufraeumInventar(quelleAusText(text))));
223
+ ipcMain.handle('hmgr:quelleAufraeumen', async (_event, inventar) => auffangen(async () => anwendungsfaelle.quelleAufraeumen(inventar)));
224
+ // Die GUI arbeitet an einem Arbeitsverzeichnis wie die CLI mit --local (E57, F32): Links werden
225
+ // lokal gelegt und entfernt, der Zeiger bleibt unangetastet, der Kern hängt bei einer bestehenden
226
+ // Bindung von sich aus einen Hinweis an. Für Home und Profil ist `lokal` folgenlos (E26).
227
+ ipcMain.handle('hmgr:linkLegen', async (_event, args) => auffangen(async () => anwendungsfaelle.linkLegen({
228
+ skill: args.skill,
229
+ ziel: zielAusIpc(args.harness, args.ort),
230
+ ...(args.quelle === undefined ? {} : { quelle: args.quelle }),
231
+ lokal: args.ort.art === 'arbeitsverzeichnis',
232
+ })));
233
+ ipcMain.handle('hmgr:linkEntfernen', async (_event, args) => auffangen(async () => anwendungsfaelle.linkEntfernen({
234
+ skill: args.skill,
235
+ ziel: zielAusIpc(args.harness, args.ort),
236
+ lokal: args.ort.art === 'arbeitsverzeichnis',
237
+ })));
238
+ ipcMain.handle('hmgr:eintragIgnorieren', async (_event, args) => auffangen(async () => anwendungsfaelle.eintragIgnorieren({ ziel: zielAusIpc(args.harness, args.ort), name: args.name })));
239
+ ipcMain.handle('hmgr:eintragBeachten', async (_event, args) => auffangen(async () => anwendungsfaelle.eintragBeachten({ ziel: zielAusIpc(args.harness, args.ort), name: args.name })));
240
+ // Auch die Übernahme legt Links und schriebe ohne `lokal` den Zeiger (E31 über linkAusFund), sie
241
+ // trifft also dieselbe Sperre wie link (E57). In der GUI arbeitet sie darum wie die CLI mit
242
+ // --local (F32), sonst bräche der Knopf an jedem gebundenen Arbeitsverzeichnis ab.
243
+ ipcMain.handle('hmgr:uebernehmen', async (_event, args) => auffangen(async () => anwendungsfaelle.uebernehmen({
244
+ ziel: zielAusIpc(args.harness, args.ort),
245
+ ...(args.name === undefined ? {} : { name: args.name }),
246
+ probelauf: args.probelauf,
247
+ lokal: args.ort.art === 'arbeitsverzeichnis',
248
+ })));
249
+ ipcMain.handle('hmgr:eintragLoeschen', async (_event, args) => auffangen(async () => anwendungsfaelle.eintragLoeschen({ ziel: zielAusIpc(args.harness, args.ort), name: args.name })));
250
+ ipcMain.handle('hmgr:aktualisieren', async (_event, args) => auffangen(async () => anwendungsfaelle.aktualisieren(args)));
251
+ ipcMain.handle('hmgr:arbeitsverzeichnisHinzufuegen', async (_event, pfad) => auffangen(async () => anwendungsfaelle.arbeitsverzeichnisHinzufuegen(pfad)));
252
+ ipcMain.handle('hmgr:arbeitsverzeichnisVergessen', async (_event, pfad) => auffangen(async () => anwendungsfaelle.arbeitsverzeichnisVergessen(pfad)));
253
+ // Nativer Verzeichnis-Dialog fürs Pfadfeld in „Verzeichnis hinzufügen" (A1). Im Smoke-Test darf
254
+ // nie ein echter Systemdialog aufgehen: sofort null, wie bei Abbruch.
255
+ ipcMain.handle('hmgr:verzeichnisWaehlen', async () => auffangen(async () => {
256
+ if (smoketest || hauptfenster === null)
257
+ return null;
258
+ const ergebnis = await dialog.showOpenDialog(hauptfenster, {
259
+ properties: ['openDirectory', 'createDirectory'],
260
+ });
261
+ if (ergebnis.canceled || ergebnis.filePaths.length === 0)
262
+ return null;
263
+ return ergebnis.filePaths[0] ?? null;
264
+ }));
265
+ // Harness-Schalter im Einstellungen-Modal (E38, GRILL.md Entwurfskritik 4).
266
+ ipcMain.handle('hmgr:harnessTypen', async () => auffangen(async () => anwendungsfaelle.harnessTypen()));
267
+ ipcMain.handle('hmgr:aktiveHarnesses', async () => auffangen(async () => anwendungsfaelle.aktiveHarnesses()));
268
+ // Für den Vermerk „nicht installiert" an den Schaltern (E42).
269
+ ipcMain.handle('hmgr:nichtVorhandeneHarnesses', async () => auffangen(async () => anwendungsfaelle.nichtVorhandeneHarnesses()));
270
+ ipcMain.handle('hmgr:harnessAktivieren', async (_event, name) => auffangen(async () => anwendungsfaelle.harnessAktivieren(name)));
271
+ ipcMain.handle('hmgr:harnessDeaktivieren', async (_event, name) => auffangen(async () => anwendungsfaelle.harnessDeaktivieren(name)));
272
+ ipcMain.handle('hmgr:saeubern', async (_event, name) => auffangen(async () => anwendungsfaelle.saeubern(name)));
273
+ // Verwalteter Hermes-Skill-Ordner (E56): Zeigen kommt schon über hmgr:istZustand, hier nur Ändern.
274
+ ipcMain.handle('hmgr:skillOrdnerFestlegen', async (_event, args) => auffangen(async () => anwendungsfaelle.skillOrdnerFestlegen(zielAusIpc(args.harness, args.ort), args.pfad)));
275
+ ipcMain.handle('hmgr:skillOrdnerEntfernen', async (_event, args) => auffangen(async () => anwendungsfaelle.skillOrdnerEntfernen(zielAusIpc(args.harness, args.ort))));
276
+ ipcMain.handle('hmgr:skillOrdnerLoeschen', async (_event, args) => auffangen(async () => anwendungsfaelle.skillOrdnerLoeschen(zielAusIpc(args.harness, args.ort), args.pfad)));
277
+ // Quellen-Icon (E60): nur im Main-Prozess geladen, als data-URL geliefert (CSP erlaubt img-src
278
+ // data:). Im Smoke-Test nie ein Netzzugriff: sofort null, der Renderer zeigt dann das generische
279
+ // Symbol. Ein Fehlschlag wirft nie (auffangen fängt ihn, iconFuerQuelle selbst wirft ohnehin nicht).
280
+ ipcMain.handle('hmgr:icon', async (_event, quelleText) => auffangen(async () => {
281
+ if (smoketest)
282
+ return null;
283
+ return iconFuerQuelle(quelleAusText(quelleText), { cacheVerzeichnis: iconCacheVerzeichnis });
284
+ }));
285
+ // Quelle öffnen (E60): die Protokoll-Weiche steht in oeffnen.ts (rein, testbar), hier nur die
286
+ // Wirkung. `shell.openExternal` bekommt ausschließlich http(s)-URLs, `shell.openPath` nur einen
287
+ // Ordner, den es wirklich gibt — nichts anderes wird zugelassen. Alles, was es gibt, aber kein
288
+ // Ordner ist, wird im Dateimanager nur gezeigt, nicht geöffnet: `openPath` startet sonst eine Datei
289
+ // mit ihrem Standardprogramm, und ein Pfad aus dem Renderer soll nichts starten können.
290
+ ipcMain.handle('hmgr:quelleOeffnen', async (_event, quelleText) => auffangen(async () => {
291
+ const entscheidung = entscheideOeffnen(quelleAusText(quelleText));
292
+ if (entscheidung.art === 'browser')
293
+ await shell.openExternal(entscheidung.url);
294
+ else if (entscheidung.art === 'ordner') {
295
+ const info = await stat(entscheidung.pfad).catch(() => undefined);
296
+ if (info === undefined)
297
+ throw new Error(`Es gibt „${entscheidung.pfad}" nicht (mehr).`);
298
+ if (info.isDirectory())
299
+ await shell.openPath(entscheidung.pfad);
300
+ else
301
+ shell.showItemInFolder(entscheidung.pfad);
302
+ }
303
+ return entscheidung;
304
+ }));
305
+ // MCP-Ansicht (E61-Folgerunde): Whitelisting und der F39-Vermerk je Karte stehen in mcp-ansicht.ts
306
+ // (rein, ohne Electron, wie oeffnen.ts und icons.ts), damit sie testbar sind. Der Main-Prozess
307
+ // ruft nur noch auf und liefert die eigene Prozess-Umgebung für die F39-Prüfung.
308
+ ipcMain.handle('hmgr:mcpUebersicht', async () => auffangen(async () => mcpUebersichtSerialisieren(await anwendungsfaelle.mcpUebersicht(), process.env)));
309
+ // Plätze für den Einhängen-Dialog (F42): die Fähigkeit hängt am Adapter, nicht am Namen (E16),
310
+ // deshalb kommt die Liste unverändert aus dem Kern statt aus einer Harness-Fallunterscheidung hier.
311
+ ipcMain.handle('hmgr:mcpPlaetze', async () => auffangen(async () => anwendungsfaelle.mcpPlaetze()));
312
+ ipcMain.handle('hmgr:mcpEinhaengen', async (_event, args) => auffangen(async () => mcpWirkungSerialisieren(await anwendungsfaelle.mcpEinhaengen({ name: args.name, ziel: zielAusIpc(args.harness, args.ort) }))));
313
+ ipcMain.handle('hmgr:mcpAushaengen', async (_event, args) => auffangen(async () => mcpWirkungSerialisieren(await anwendungsfaelle.mcpAushaengen({ name: args.name, ziel: zielAusIpc(args.harness, args.ort) }))));
314
+ // Übernehmen aus der geteilten .mcp.json (E64): die Bestätigung holt der Renderer ein, hier läuft
315
+ // nur die Ausführung. Der Name muss nicht im Katalog stehen.
316
+ ipcMain.handle('hmgr:mcpUebernehmen', async (_event, args) => auffangen(async () => mcpUebernahmeSerialisieren(await anwendungsfaelle.mcpUebernehmen({ name: args.name, ziel: zielAusIpc(args.harness, args.ort) }))));
317
+ }
318
+ /** Mindestabstand zwischen zwei Fokus-Reloads (E62): schnelles Fenster-Wechseln löst keine Lawine
319
+ von Anzeige-Neuladungen aus. Getrennt vom Entprellen im Datei-Watcher (das sammelt Events vor dem
320
+ ersten Aufruf), hier wird zusätzlich zwischen aufeinanderfolgenden Aufrufen gedrosselt (trailing:
321
+ ein Aufruf innerhalb der Sperrzeit wird nicht verworfen, sondern ans Ende der Sperrzeit verschoben). */
322
+ const FOKUS_RELOAD_MIN_ABSTAND_MS = 3000;
323
+ /** Sammelzeit des Datei-Watchers (E62): mehrere Events in kurzer Folge lösen einen Reload aus. */
324
+ const BEOBACHTER_ENTPRELLEN_MS = 400;
325
+ /**
326
+ * Pfade, die die GUI kennt und darum beobachtet (E62): hmgr-Zustand samt MCP-Katalog, die
327
+ * Harness-MCP-Konfigurationen (`~/.claude.json`, Hermes' `config.yaml`) und die `.env`-Dateien, die
328
+ * die F39-Prüfung beim MCP-Einhängen liest — bei Hermes liegt die `.env` je Ort neben der
329
+ * `config.yaml` (so liest sie der Adapter in `mcp.envDatei`), darum auch je Profil. Die Profile
330
+ * werden einmal beim Start am Harness selbst erfragt (`profile()`, ein Verzeichnis-Blick, kein
331
+ * ganzer Erhebungslauf); ein zur Laufzeit neu angelegtes Profil beobachtet der Watcher nicht
332
+ * dynamisch, das deckt der Fokus-Reload ab (E62).
333
+ *
334
+ * Nicht dabei: die geteilten `.mcp.json` der Arbeitsverzeichnisse, die die MCP-Übersicht seit dem
335
+ * E61-Nachtrag mitliest. Ihre Liste ist zur Laufzeit veränderlich, wie die der Hermes-Profile, und
336
+ * damit gilt für sie dasselbe: der Fokus-Reload deckt sie ab.
337
+ */
338
+ async function beobachtetePfade() {
339
+ const hermesHome = path.join(umgebung.home, '.hermes');
340
+ const pfade = [
341
+ path.join(umgebung.hmgrHome, 'state.json'),
342
+ path.join(umgebung.hmgrHome, 'mcp.yaml'),
343
+ path.join(umgebung.home, '.claude.json'),
344
+ path.join(hermesHome, 'config.yaml'),
345
+ path.join(hermesHome, '.env'),
346
+ ];
347
+ const hermes = ports.harnesses.find((h) => h.name === 'hermes');
348
+ if (hermes !== undefined && kannProfile(hermes))
349
+ for (const name of await hermes.profile().catch(() => [])) {
350
+ const profilHome = path.join(hermesHome, 'profiles', name);
351
+ pfade.push(path.join(profilHome, 'config.yaml'), path.join(profilHome, '.env'));
352
+ }
353
+ return pfade;
354
+ }
355
+ /**
356
+ * Stößt beim Renderer ein stilles Neuladen der Anzeige an (E62), nie einen Aktualisieren-Lauf mit
357
+ * Netzzugriff. Gedrosselt (trailing throttle): unmittelbar, wenn seit dem letzten Anstoß genug Zeit
358
+ * verging, sonst genau ein nachgeholter Anstoß am Ende der Sperrzeit.
359
+ */
360
+ function neuLadenAnstossenFabrik(minAbstandMs) {
361
+ let letzter = 0;
362
+ let timer;
363
+ return function anstossen() {
364
+ const jetzt = Date.now();
365
+ const seitLetztem = jetzt - letzter;
366
+ if (seitLetztem >= minAbstandMs) {
367
+ letzter = jetzt;
368
+ hauptfenster?.webContents.send('hmgr:neuLadenAnstossen');
369
+ return;
370
+ }
371
+ if (timer !== undefined)
372
+ return; // schon ein Anstoß fürs Ende der Sperrzeit vorgemerkt
373
+ timer = setTimeout(() => {
374
+ timer = undefined;
375
+ letzter = Date.now();
376
+ hauptfenster?.webContents.send('hmgr:neuLadenAnstossen');
377
+ }, minAbstandMs - seitLetztem);
378
+ };
379
+ }
380
+ const neuLadenAnstossen = neuLadenAnstossenFabrik(FOKUS_RELOAD_MIN_ABSTAND_MS);
381
+ let beobachter;
382
+ function fensterOeffnen() {
383
+ hauptfenster = new BrowserWindow({
384
+ width: 1280,
385
+ height: 860,
386
+ minWidth: 860,
387
+ minHeight: 560,
388
+ backgroundColor: '#E3E7E0', // --platte, verhindert einen weißen Blitz beim Start
389
+ // Im Smoke-Test bleibt das Fenster unsichtbar: der Renderer lädt trotzdem vollständig, der
390
+ // DOM-Auszug kommt per executeJavaScript. So klaut ein automatisierter Lauf niemandem den
391
+ // Eingabefokus und öffnet kein sichtbares Fenster.
392
+ show: !smoketest,
393
+ webPreferences: {
394
+ preload: path.join(hierVerzeichnis, 'preload.cjs'),
395
+ contextIsolation: true,
396
+ nodeIntegration: false,
397
+ sandbox: true,
398
+ },
399
+ });
400
+ hauptfenster.on('closed', () => {
401
+ hauptfenster = null;
402
+ });
403
+ // Renderer-Konsole im Main-Log, für Fehlersuche und den Smoke-Test.
404
+ hauptfenster.webContents.on('console-message', (event) => {
405
+ console.log(`[renderer] ${event.message}`);
406
+ });
407
+ // Nur für den Smoke-Test (scripts/gui-smoke-test.ts): ein DOM-Auszug im Log, danach Ende.
408
+ if (smoketest) {
409
+ const fenster = hauptfenster;
410
+ fenster.webContents.once('did-finish-load', () => {
411
+ setTimeout(() => {
412
+ void fenster.webContents
413
+ .executeJavaScript(`(async () => {
414
+ // Einstellungen-Modal (E40 R2): öffnen, die Harness-Schalter ablesen, wieder schließen.
415
+ document.getElementById('einstellungen')?.click();
416
+ var einstellungenOffen = document.getElementById('d-einstellungen')?.open === true;
417
+ var harnessSchalter = Array.from(document.querySelectorAll('#harnessliste input[type=checkbox]'))
418
+ .map((i) => i.dataset.harness + ':' + i.checked)
419
+ .join(',');
420
+ // Quellen-Abschnitt der Einstellungen (E54): Knopf und Dialoge vorhanden, der
421
+ // Hinzufügen-Dialog lässt sich öffnen und per Abbrechen ohne Fehler wieder schließen.
422
+ // Kein echter Quellen-Lauf: der Abbrechen-Knopf löst keinen IPC-Aufruf aus.
423
+ var quelleneuVorhanden = document.getElementById('quelleneu') !== null;
424
+ var dialogeQuelleVorhanden =
425
+ document.getElementById('d-quelle') !== null && document.getElementById('d-quelle-vergessen') !== null;
426
+ var quelleFehler = null;
427
+ var dQuelleOffenNachOeffnen = false;
428
+ var dQuelleOffenNachAbbrechen = true;
429
+ try {
430
+ document.getElementById('quelleneu')?.click();
431
+ dQuelleOffenNachOeffnen = document.getElementById('d-quelle')?.open === true;
432
+ document.querySelector('#d-quelle button[value="abbruch"]')?.click();
433
+ dQuelleOffenNachAbbrechen = document.getElementById('d-quelle')?.open === true;
434
+ } catch (fehler) {
435
+ quelleFehler = String(fehler);
436
+ }
437
+ // Aufräum-Angebot nach Quelle entfernen (E58): kein echter Lauf (der würde Checkouts
438
+ // und Links wirklich löschen), nur ob der Dialog existiert und sich öffnen und über
439
+ // Behalten schließen lässt, ohne Fehler.
440
+ var dialogQuelleAufraeumenVorhanden = document.getElementById('d-quelle-aufraeumen') !== null;
441
+ var quelleAufraeumenFehler = null;
442
+ var dQuelleAufraeumenOffenNachOeffnen = false;
443
+ var dQuelleAufraeumenOffenNachBehalten = true;
444
+ try {
445
+ var dQuelleAufraeumen = document.getElementById('d-quelle-aufraeumen');
446
+ dQuelleAufraeumen?.showModal();
447
+ dQuelleAufraeumenOffenNachOeffnen = dQuelleAufraeumen?.open === true;
448
+ document.querySelector('#d-quelle-aufraeumen button[value="behalten"]')?.click();
449
+ dQuelleAufraeumenOffenNachBehalten = dQuelleAufraeumen?.open === true;
450
+ } catch (fehler) {
451
+ quelleAufraeumenFehler = String(fehler);
452
+ }
453
+ // Anzeigename der Quelle (E59): die Einstellungen-Liste zeigt den abgeleiteten Namen
454
+ // „skills-repo" statt der vollen Fixture-URL, das Namensfeld im Hinzufügen-Dialog gibt
455
+ // es zusätzlich zum URL/Pfad-Feld.
456
+ var quellenlisteNamen = Array.from(document.querySelectorAll('#quellenliste b'))
457
+ .map((b) => b.textContent)
458
+ .join(',');
459
+ var dqNameVorhanden = document.getElementById('dq-name') !== null;
460
+ // Umbenennen (E59): der Knopf je Quelle öffnet den Dialog mit dem aktuellen Namen im
461
+ // Feld, Abbrechen schließt ihn wieder. Beide Knöpfe der Zeile liegen zusammen in
462
+ // einem Kasten, sonst lägen sie im Grid übereinander.
463
+ var quellenknoepfeZahl = document.querySelectorAll('#quellenliste .quellenknoepfe').length;
464
+ var quelleUmbenennenFehler = null;
465
+ var dQuUOffenNachOeffnen = false;
466
+ var dQuUName = null;
467
+ var dQuUOffenNachAbbrechen = true;
468
+ try {
469
+ var dQuU = document.getElementById('d-quelle-umbenennen');
470
+ // E60: der Umbenennen-Knopf ist jetzt ein Stift-Icon-Knopf ohne Text (Klasse
471
+ // „umbenennen", aria-label statt Beschriftung).
472
+ document.querySelector('#quellenliste .umbenennen')?.click();
473
+ dQuUOffenNachOeffnen = dQuU?.open === true;
474
+ dQuUName = document.getElementById('dqu-name')?.value ?? null;
475
+ document.querySelector('#d-quelle-umbenennen button[value="abbruch"]')?.click();
476
+ dQuUOffenNachAbbrechen = dQuU?.open === true;
477
+ } catch (fehler) {
478
+ quelleUmbenennenFehler = String(fehler);
479
+ }
480
+ document.getElementById('d-einstellungen')?.close();
481
+ // Konfigzeile am Hermes-Ziel (E56): der verwaltete Pfad steht in der Karte, auch
482
+ // ungeklappt (hidden ändert textContent nicht). Dazu der Festlegen-Weg am unmanaged
483
+ // Profil, Öffnen und Abbrechen ohne Fehler, nach dem Muster der d-quelle-Prüfungen.
484
+ var konfigzeileEl = document.querySelector('.konfigzeile');
485
+ var konfigzeileText = konfigzeileEl ? konfigzeileEl.textContent.replace(/\\s+/g, ' ').trim() : null;
486
+ var skillOrdnerFehler = null;
487
+ var dSkillordnerOffenNachOeffnen = false;
488
+ var dSkillordnerOffenNachAbbrechen = true;
489
+ try {
490
+ var festlegenKnopf = Array.from(document.querySelectorAll('button'))
491
+ .find((b) => b.textContent === 'Skill-Ordner festlegen …');
492
+ festlegenKnopf?.click();
493
+ dSkillordnerOffenNachOeffnen = document.getElementById('d-skillordner')?.open === true;
494
+ document.querySelector('#d-skillordner button[value="abbruch"]')?.click();
495
+ dSkillordnerOffenNachAbbrechen = document.getElementById('d-skillordner')?.open === true;
496
+ } catch (fehler) {
497
+ skillOrdnerFehler = String(fehler);
498
+ }
499
+ // E60: Beschreibung in der Ziel-Tabelle (nicht nur im Katalog), „manuell" entfällt,
500
+ // Zahnrad statt Sonne am Einstellungen-Knopf.
501
+ var zielBeschreibung = document.querySelector('#zielliste .linkliste .was')?.textContent ?? null;
502
+ // E55-Marken auch in der Ziel-Tabelle, unter der Beschreibung (2026-09-05).
503
+ var zielMarken = document.querySelector('#zielliste .linkliste .wasfeld .marken')?.textContent
504
+ ?.replace(/\\s+/g, ' ').trim() ?? null;
505
+ var zielManuellVorhanden = (document.getElementById('zielliste')?.textContent ?? '').includes('manuell');
506
+ var zahnradVorhanden = (document.querySelector('#einstellungen svg')?.innerHTML ?? '').includes('6.47 3.56');
507
+ // Katalog (E40 R3, R5): Beschreibung aus dem Fixture muss in der Skill-Zeile stehen.
508
+ document.querySelector('[data-ansicht="katalog"]')?.click();
509
+ var katalogBeschreibung = document.querySelector('#katalog .zeile .beschreibung')?.textContent ?? null;
510
+ // Besonderheiten aus dem Frontmatter (E55): Marken an der Zeile, Details erst nach
511
+ // einem Klick auf den Aufklapp-Knopf.
512
+ var katalogMarken = document.querySelector('#katalog .zeile .marken')?.textContent
513
+ ?.replace(/\\s+/g, ' ').trim() ?? null;
514
+ var aufklapp = document.querySelector('#katalog .zeile .aufklappknopf');
515
+ var katalogDetailsZu = document.querySelector('#katalog .zeile .skillkoerper')?.hidden ?? null;
516
+ aufklapp?.click();
517
+ var katalogAufgeklappt = aufklapp?.getAttribute('aria-expanded') ?? null;
518
+ var katalogDetails = document.querySelector('#katalog .zeile .skillkoerper:not([hidden])')?.textContent
519
+ ?.replace(/\\s+/g, ' ').trim() ?? null;
520
+ // Sicherheitsrelevante Marken (F11): Hooks und Werkzeuge tragen die warnende Klasse.
521
+ var katalogMarkenWarnend = Array.from(document.querySelectorAll('#katalog .zeile .merker.warnend'))
522
+ .map((m) => m.textContent).join(',');
523
+ // Skills-hinzufügen-Dialog (F13): dieselben Marken an der Wahlzeile.
524
+ var dskKnopf = Array.from(document.querySelectorAll('button'))
525
+ .find((b) => b.textContent === 'Skills hinzufügen');
526
+ var dskMarken = null;
527
+ if (dskKnopf) {
528
+ dskKnopf.click();
529
+ dskMarken = document.querySelector('#dsk-liste .wahlzeile .marken')?.textContent
530
+ ?.replace(/\\s+/g, ' ').trim() ?? null;
531
+ document.getElementById('d-skills')?.close();
532
+ }
533
+ // MCP-Ansicht (E61-Folgerunde, F46: abgespeckt zum reinen Katalog): Navigationsreihenfolge
534
+ // Ziele, MCP, Plugins, Skills, Karten mit Name/Beschreibung/Homepage-Link, F39-Warnzeile,
535
+ // Einhängen-Dialog öffnen/abbrechen ohne IPC-Schreibaktion. Orte-Liste und Fremdkasten
536
+ // gehören seit F46 nicht mehr in diese Ansicht (die Prüfung darauf ist unten invertiert),
537
+ // sie leben nur noch am MCP-Abschnitt der Ziel-Karten (weiter unten unverändert geprüft).
538
+ var navReihenfolge = Array.from(document.querySelectorAll('.nav [data-ansicht]'))
539
+ .map((b) => b.dataset.ansicht).join(',');
540
+ document.querySelector('[data-ansicht="mcp"]')?.click();
541
+ var mcpKartenText = document.getElementById('mcpliste')?.textContent
542
+ ?.replace(/\\s+/g, ' ').trim() ?? null;
543
+ var mcpHeimLinkVorhanden = document.querySelector('#mcpliste .mcpkopf .qlink') !== null;
544
+ var mcpWarnzeileVorhanden = document.querySelector('#mcpliste .mcp .ruhezeile') !== null;
545
+ // F46: die Orte-Liste je Karte und der Fremdkasten der Ansicht sind ganz weg, nicht nur
546
+ // leer — beide Selektoren dürfen im DOM überhaupt nicht mehr auftauchen.
547
+ var mcpOrteVorhanden = document.querySelector('#mcpliste .mcporte') !== null;
548
+ var mcpFremdkastenVorhanden = document.getElementById('mcpfremd') !== null;
549
+ var mcpFehler = null;
550
+ var dMcpOffenNachOeffnen = false;
551
+ var dMcpOffenNachAbbrechen = true;
552
+ // Die Wahlzeilen des Einhängen-Dialogs (F42): sie kommen aus der Adapter-Fähigkeit,
553
+ // hier steht, welche Orte zur Wahl stehen und wohin der Eintrag jeweils ginge. Zwei
554
+ // Karten, weil die Zeilen verschieden aussehen: „mail" hängt an beiden Home-Orten
555
+ // schon (gesperrte Zeile), „teamwork" nirgends (die Notizen je Ort).
556
+ var mcpWahlText = null;
557
+ var mcpWahlTextFrei = null;
558
+ try {
559
+ var mcpEinKnoepfe = Array.from(document.querySelectorAll('#mcpliste .mcpkopf button'))
560
+ .filter((b) => (b.textContent || '').indexOf('Einhängen') === 0);
561
+ var mcpWahlOeffnen = async (knopf) => {
562
+ knopf?.click();
563
+ // window.hmgr.mcpPlaetze() ist asynchron: kurz nachfassen, bis die Wahlzeilen stehen.
564
+ await new Promise((r) => setTimeout(r, 200));
565
+ return document.getElementById('dm-orte')?.textContent?.replace(/\\s+/g, ' ').trim() ?? null;
566
+ };
567
+ mcpWahlText = await mcpWahlOeffnen(mcpEinKnoepfe[0]);
568
+ dMcpOffenNachOeffnen = document.getElementById('d-mcp')?.open === true;
569
+ document.querySelector('#d-mcp button[value="abbruch"]')?.click();
570
+ dMcpOffenNachAbbrechen = document.getElementById('d-mcp')?.open === true;
571
+ mcpWahlTextFrei = await mcpWahlOeffnen(mcpEinKnoepfe[1]);
572
+ document.querySelector('#d-mcp button[value="abbruch"]')?.click();
573
+ } catch (fehler) {
574
+ mcpFehler = String(fehler);
575
+ }
576
+ // Aushängen-Rückfrage (F46: nur noch über den Entfernen-Knopf am MCP-Abschnitt einer
577
+ // Ziel-Karte auslösbar, die MCP-Ansicht selbst hat dafür keinen Knopf mehr).
578
+ var mcpAusFehler = null;
579
+ var dMcpAusOffenNachOeffnen = false;
580
+ var dMcpAusOffenNachAbbrechen = true;
581
+ // Seit E64 stehen im MCP-Abschnitt zwei Knopfarten nebeneinander (Entfernen und
582
+ // Übernehmen), deshalb wird hier nicht mehr der erste Knopf genommen, sondern der
583
+ // Entfernen-Knopf an seinem aria-label erkannt.
584
+ var mcpZeilenKnoepfe = Array.from(document.querySelectorAll('#zielliste .mcpkartezeilen button'));
585
+ try {
586
+ var mcpAusKnopf = mcpZeilenKnoepfe.find(function (b) {
587
+ return (b.getAttribute('aria-label') || '').indexOf('aushängen') > -1;
588
+ });
589
+ mcpAusKnopf?.click();
590
+ dMcpAusOffenNachOeffnen = document.getElementById('d-mcpaus')?.open === true;
591
+ document.querySelector('#d-mcpaus button[value="nein"]')?.click();
592
+ dMcpAusOffenNachAbbrechen = document.getElementById('d-mcpaus')?.open === true;
593
+ } catch (fehler) {
594
+ mcpAusFehler = String(fehler);
595
+ }
596
+ // Übernehmen aus der geteilten .mcp.json (E64): Knopf an einer geteilten Zeile,
597
+ // Rückfrage öffnen und über „Nein" wieder schließen — ohne IPC-Schreibaktion, wie bei
598
+ // den anderen MCP-Dialogen. Der Text der Rückfrage läuft mit, er ist die Warnung an
599
+ // den Nutzer und darf nicht stillschweigend verschwinden.
600
+ var mcpUebFehler = null;
601
+ var dMcpUebOffenNachOeffnen = false;
602
+ var dMcpUebOffenNachAbbrechen = true;
603
+ var mcpUebText = null;
604
+ try {
605
+ var mcpUebKnopf = mcpZeilenKnoepfe.find(function (b) {
606
+ return (b.textContent || '').indexOf('Übernehmen') === 0;
607
+ });
608
+ mcpUebKnopf?.click();
609
+ dMcpUebOffenNachOeffnen = document.getElementById('d-mcpueb')?.open === true;
610
+ mcpUebText = document.getElementById('dmu-text')?.textContent?.replace(/\\s+/g, ' ').trim() ?? null;
611
+ document.querySelector('#d-mcpueb button[value="nein"]')?.click();
612
+ dMcpUebOffenNachAbbrechen = document.getElementById('d-mcpueb')?.open === true;
613
+ } catch (fehler) {
614
+ mcpUebFehler = String(fehler);
615
+ }
616
+ // MCP an der Ziel-Karte (E61-GUI-Nachtrag), Gegenrichtung zur MCP-Ansicht: Abschnitt und
617
+ // Knopf an der Hermes-Home-Karte (Katalog-Zeile mit Entfernen, Fremd-Zeile ohne Knopf)
618
+ // und an der kunde-x-Verzeichnis-Karte (zwei Zeilen aus der geteilten .mcp.json, seit
619
+ // E64 beide mit Übernehmen-Knopf), dazu die Knopf-Sichtbarkeit: an der agents-Karte
620
+ // darf weder Abschnitt noch Knopf stehen, agents implementiert die MCP-Fähigkeit
621
+ // nicht (E16).
622
+ var zielKarten = Array.from(document.querySelectorAll('#zielliste .ziel'));
623
+ var karteMitKopftext = function (t) {
624
+ return zielKarten.find(function (k) {
625
+ return (k.querySelector('.zielkopf')?.textContent || '').indexOf(t) > -1;
626
+ });
627
+ };
628
+ var mcpHinzuKnopfAn = function (karte) {
629
+ return karte
630
+ ? Array.from(karte.querySelectorAll('button')).find((b) => (b.textContent || '').indexOf('MCP hinzufügen') === 0)
631
+ : undefined;
632
+ };
633
+ var hermesKarte = karteMitKopftext('Hermes · Home');
634
+ var hermesMcpText = hermesKarte?.querySelector('.mcpkarteabschnitt')?.textContent
635
+ ?.replace(/\\s+/g, ' ').trim() ?? null;
636
+ var hermesMcpZeilenKnopfZahl = hermesKarte?.querySelectorAll('.mcpkartezeilen button').length ?? 0;
637
+ var hermesMcpKnopfVorhanden = mcpHinzuKnopfAn(hermesKarte) !== undefined;
638
+ var kundeXKarte = karteMitKopftext('kunde-x');
639
+ var kundeXMcpText = kundeXKarte?.querySelector('.mcpkarteabschnitt')?.textContent
640
+ ?.replace(/\\s+/g, ' ').trim() ?? null;
641
+ var kundeXMcpZeilenKnopfZahl = kundeXKarte?.querySelectorAll('.mcpkartezeilen button').length ?? 0;
642
+ var kundeXMcpKnopfVorhanden = mcpHinzuKnopfAn(kundeXKarte) !== undefined;
643
+ var agentsKarte = karteMitKopftext('Agents · Home');
644
+ // Ohne diese Zusicherung wären die beiden Abwesenheits-Prüfungen darunter leer erfüllt,
645
+ // sobald die Karte selbst aus der Liste fiele: „kein Abschnitt" hieße dann nur „keine Karte".
646
+ var agentsKarteVorhanden = agentsKarte !== undefined;
647
+ var agentsMcpAbschnittVorhanden = (agentsKarte?.querySelector('.mcpkarteabschnitt') ?? null) !== null;
648
+ var agentsMcpKnopfVorhanden = mcpHinzuKnopfAn(agentsKarte) !== undefined;
649
+ // Dialog „MCP hinzufügen" (E47-Muster): öffnen an der Hermes-Karte und über Abbrechen
650
+ // wieder schließen, ohne IPC-Schreibaktion (mcpEinhaengen liefe erst nach „Einhängen").
651
+ var dmhFehler = null;
652
+ var dMcpHinzuOffenNachOeffnen = false;
653
+ var dMcpHinzuOffenNachAbbrechen = true;
654
+ var dmhListeText = null;
655
+ try {
656
+ mcpHinzuKnopfAn(hermesKarte)?.click();
657
+ dMcpHinzuOffenNachOeffnen = document.getElementById('d-mcp-hinzufuegen')?.open === true;
658
+ dmhListeText = document.getElementById('dmh-liste')?.textContent?.replace(/\\s+/g, ' ').trim() ?? null;
659
+ document.querySelector('#d-mcp-hinzufuegen button[value="abbruch"]')?.click();
660
+ dMcpHinzuOffenNachAbbrechen = document.getElementById('d-mcp-hinzufuegen')?.open === true;
661
+ } catch (fehler) {
662
+ dmhFehler = String(fehler);
663
+ }
664
+ // Schriften aus fonts/ (Zusatzpunkt 6): erzwingt das Laden und belegt es danach.
665
+ await Promise.all([
666
+ document.fonts.load('400 16px Inter'),
667
+ document.fonts.load('500 16px "JetBrains Mono"'),
668
+ ]);
669
+ return JSON.stringify({
670
+ titel: document.title,
671
+ kopfzahlen: document.getElementById('kopfzahlen')?.textContent,
672
+ zielliste: document.getElementById('zielliste')?.textContent?.replace(/\\s+/g, ' ').trim().slice(0, 600),
673
+ meldung: document.getElementById('meldungtext')?.textContent,
674
+ einstellungenOffen,
675
+ harnessSchalter,
676
+ zielBeschreibung,
677
+ zielMarken,
678
+ zielManuellVorhanden,
679
+ zahnradVorhanden,
680
+ katalogBeschreibung,
681
+ katalogMarken,
682
+ katalogDetailsZu,
683
+ katalogAufgeklappt,
684
+ katalogDetails,
685
+ katalogMarkenWarnend,
686
+ dskMarken,
687
+ interGeladen: document.fonts.check('400 16px Inter'),
688
+ monoGeladen: document.fonts.check('500 16px "JetBrains Mono"'),
689
+ quelleneuVorhanden,
690
+ dialogeQuelleVorhanden,
691
+ quellenlisteNamen,
692
+ dqNameVorhanden,
693
+ quellenknoepfeZahl,
694
+ dQuUOffenNachOeffnen,
695
+ dQuUName,
696
+ dQuUOffenNachAbbrechen,
697
+ quelleUmbenennenFehler,
698
+ dQuelleOffenNachOeffnen,
699
+ dQuelleOffenNachAbbrechen,
700
+ quelleFehler,
701
+ dialogQuelleAufraeumenVorhanden,
702
+ dQuelleAufraeumenOffenNachOeffnen,
703
+ dQuelleAufraeumenOffenNachBehalten,
704
+ quelleAufraeumenFehler,
705
+ // Hinweiszeile im Erfolgsdialog (E26, F32): vorhanden und im Ruhezustand leer.
706
+ deHinweisVorhanden: document.getElementById('de-hinweis') !== null,
707
+ deHinweisVerborgen: document.getElementById('de-hinweis')?.hidden === true,
708
+ konfigzeileText,
709
+ dSkillordnerOffenNachOeffnen,
710
+ dSkillordnerOffenNachAbbrechen,
711
+ skillOrdnerFehler,
712
+ navReihenfolge,
713
+ mcpKartenText,
714
+ mcpHeimLinkVorhanden,
715
+ mcpWarnzeileVorhanden,
716
+ mcpOrteVorhanden,
717
+ mcpFremdkastenVorhanden,
718
+ dMcpOffenNachOeffnen,
719
+ dMcpOffenNachAbbrechen,
720
+ mcpWahlText,
721
+ mcpWahlTextFrei,
722
+ mcpFehler,
723
+ dMcpAusOffenNachOeffnen,
724
+ dMcpAusOffenNachAbbrechen,
725
+ mcpAusFehler,
726
+ dMcpUebOffenNachOeffnen,
727
+ dMcpUebOffenNachAbbrechen,
728
+ mcpUebText,
729
+ mcpUebFehler,
730
+ hermesMcpText,
731
+ hermesMcpZeilenKnopfZahl,
732
+ hermesMcpKnopfVorhanden,
733
+ kundeXMcpText,
734
+ kundeXMcpZeilenKnopfZahl,
735
+ kundeXMcpKnopfVorhanden,
736
+ agentsKarteVorhanden,
737
+ agentsMcpAbschnittVorhanden,
738
+ agentsMcpKnopfVorhanden,
739
+ dMcpHinzuOffenNachOeffnen,
740
+ dMcpHinzuOffenNachAbbrechen,
741
+ dmhListeText,
742
+ dmhFehler,
743
+ });
744
+ })()`)
745
+ .then((auszug) => console.log(`[smoke] DOM-Auszug: ${auszug}`))
746
+ .finally(() => app.quit());
747
+ }, 3000);
748
+ });
749
+ }
750
+ // Selbsttätiges Aktualisieren (E44): einmal, sobald der Renderer bereit ist, danach alle sechs
751
+ // Stunden, solange das Fenster offen bleibt. Im Smoke-Test nie, sonst würde ein automatisierter
752
+ // Lauf einen echten Git-Fetch anstoßen.
753
+ if (!smoketest) {
754
+ const fenster = hauptfenster;
755
+ fenster.webContents.once('did-finish-load', () => {
756
+ fenster.webContents.send('hmgr:aktualisierenAnstossen');
757
+ });
758
+ const intervall = setInterval(() => fenster.webContents.send('hmgr:aktualisierenAnstossen'), SECHS_STUNDEN_MS);
759
+ fenster.on('closed', () => clearInterval(intervall));
760
+ }
761
+ // Die GUI erfährt von Änderungen außerhalb ihrer selbst (E62): Fokus-Reload und Datei-Watcher.
762
+ // Beide stoßen nur ein stilles Neuladen der Anzeige an, nie einen Aktualisieren-Lauf mit
763
+ // Netzzugriff. Im Smoke-Test keins von beiden, dieselbe Zurückhaltung wie beim automatischen
764
+ // Aktualisieren (E44): ein Event dürfte sonst in den DOM-Auszug pfuschen.
765
+ if (!smoketest) {
766
+ const fenster = hauptfenster;
767
+ fenster.on('focus', () => neuLadenAnstossen());
768
+ void beobachtetePfade().then((pfade) => {
769
+ if (fenster.isDestroyed())
770
+ return; // Fenster schon zu, während die Pfade ermittelt wurden
771
+ beobachter = beobachten(pfade, () => neuLadenAnstossen(), { entprellenMs: BEOBACHTER_ENTPRELLEN_MS });
772
+ });
773
+ fenster.on('closed', () => {
774
+ beobachter?.close();
775
+ beobachter = undefined;
776
+ });
777
+ }
778
+ void hauptfenster.loadFile(path.join(hierVerzeichnis, 'renderer', 'index.html'));
779
+ }
780
+ /**
781
+ * Holt das vorhandene Fenster nach vorn — die Antwort der laufenden Instanz auf einen zweiten
782
+ * Start (E67). Minimiert kommt es erst wieder hoch, dann bekommt es den Fokus.
783
+ */
784
+ function fensterNachVornHolen() {
785
+ if (hauptfenster === null || hauptfenster.isDestroyed())
786
+ return;
787
+ if (hauptfenster.isMinimized())
788
+ hauptfenster.restore();
789
+ hauptfenster.show();
790
+ hauptfenster.focus();
791
+ }
792
+ registriereIpc();
793
+ if (einzelinstanz) {
794
+ app.on('second-instance', () => fensterNachVornHolen());
795
+ app.whenReady().then(() => {
796
+ // Im Smoke-Test kein Dock-Icon und kein Fokuswechsel: das Fenster bleibt unsichtbar (show:false
797
+ // oben), macOS würde aber ohne diese Zeile trotzdem ein Dock-Icon zeigen und aktivieren.
798
+ if (smoketest && process.platform === 'darwin')
799
+ app.dock?.hide();
800
+ // Windows-Fensterkonventionen (E67), beide nur dort:
801
+ // — Die Taskleiste gruppiert Fenster und Verknüpfungen über die AppUserModelID. Ohne sie steht
802
+ // der Prozess unter einer von Windows geratenen ID, und das angeheftete Startmenü-Symbol
803
+ // gehört dann nicht zum laufenden Fenster. Dieselbe ID wie die `appId` im build-Block, denn
804
+ // die schreibt der NSIS-Installer in die Verknüpfung.
805
+ // — Electron blendet unter Windows sonst sein Standardmenü ein (Datei/Bearbeiten/Ansicht/…),
806
+ // eine Menüleiste, die hmgr nicht bedient und die nur Platz über der Ansicht kostet. macOS
807
+ // bleibt unangetastet: dort ist die Menüleiste des Systems Pflicht (Beenden, Kopieren,
808
+ // Einfügen hängen daran).
809
+ if (process.platform === 'win32') {
810
+ app.setAppUserModelId('de.timkaufmann.hmgr');
811
+ Menu.setApplicationMenu(null);
812
+ }
813
+ fensterOeffnen();
814
+ app.on('activate', () => {
815
+ if (BrowserWindow.getAllWindows().length === 0)
816
+ fensterOeffnen();
817
+ });
818
+ });
819
+ }
820
+ app.on('window-all-closed', () => {
821
+ if (process.platform !== 'darwin')
822
+ app.quit();
823
+ });