hmgr 0.0.5 → 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.
@@ -0,0 +1,120 @@
1
+ // Ermittelt den PATH einer Login-Shell des Nutzers (F47=O2). Grund: die gepackte hmgr.app startet
2
+ // aus dem Finder mit dem macOS-Minimal-PATH (/usr/bin:/bin:/usr/sbin:/sbin), darin fehlt z. B.
3
+ // ~/.local/bin mit der claude-CLI (E63). Reine Funktion, ohne Electron, nach dem Muster von
4
+ // oeffnen.ts: main.ts ruft sie beim Start und übernimmt nur process.env.PATH.
5
+ //
6
+ // Unter Windows unterbleibt die Ermittlung ganz (E67): dort erbt ein aus dem Startmenü gestarteter
7
+ // Prozess den vollen Benutzer- und System-PATH aus der Registry, es gibt keine Lücke zu schließen,
8
+ // und eine Login-Shell im POSIX-Sinn gibt es nicht.
9
+ import { spawn } from 'node:child_process';
10
+ import path from 'node:path';
11
+ /** Zeitgrenze für die Shell. Gemessen auf picasso: `zsh -i -l -c` braucht ~2,0 s, das Doppelte
12
+ als Puffer reicht; wird sie überschritten, bleibt der PATH eben unverändert. */
13
+ export const ZEITGRENZE_MS = 5_000;
14
+ /**
15
+ * Standard-Ausführer: die angegebene Shell, stdin zu (sonst kann sie an einem Prompt hängen),
16
+ * stderr verworfen. Ein Fehlschlag wird zum Code, nicht zur Ausnahme — die PATH-Ermittlung darf
17
+ * die App nie zum Absturz bringen.
18
+ *
19
+ * Eigener Wecker statt der `timeout`-Option von `spawn`: die killt zwar die Shell, aber `close`
20
+ * feuert erst, wenn auch alle Erben die stdout-Pipe losgelassen haben — eine interaktive Shell
21
+ * kann Hintergrundprozesse hinterlassen. Der Wecker löst unabhängig davon auf, damit dieser Aufruf
22
+ * unter keinen Umständen länger als `ZEITGRENZE_MS` offen bleibt. `unref`, damit ein laufender
23
+ * Wecker das Beenden des Prozesses nicht aufhält.
24
+ */
25
+ export const shellAusfuehrer = (shell, args) => new Promise((aufloesen) => {
26
+ let erledigt = false;
27
+ const beenden = (ergebnis) => {
28
+ if (erledigt)
29
+ return;
30
+ erledigt = true;
31
+ clearTimeout(wecker);
32
+ aufloesen(ergebnis);
33
+ };
34
+ const kind = spawn(shell, args, { stdio: ['ignore', 'pipe', 'ignore'] });
35
+ const wecker = setTimeout(() => {
36
+ kind.kill('SIGKILL');
37
+ beenden({ stdout: '', code: 1 });
38
+ }, ZEITGRENZE_MS);
39
+ wecker.unref();
40
+ let stdout = '';
41
+ kind.stdout.on('data', (d) => (stdout += d));
42
+ kind.on('error', () => beenden({ stdout: '', code: 1 }));
43
+ kind.on('close', (code) => beenden({ stdout, code: code ?? 1 }));
44
+ });
45
+ /**
46
+ * Liefert den PATH einer interaktiven Login-Shell des Nutzers, oder `undefined` bei Fehler,
47
+ * Zeitüberschreitung oder unplausibler Ausgabe. Shell aus `process.env.SHELL`, Fallback `/bin/zsh`
48
+ * (macOS-Default).
49
+ *
50
+ * `-i` ist nötig, nicht bequem: zsh liest `~/.zshrc` nur interaktiv, `-l` allein nur
51
+ * `~/.zshenv`/`~/.zprofile`/`~/.zlogin`. Wer wie auf picasso gar kein `~/.zprofile` hat und alle
52
+ * PATH-Erweiterungen in `~/.zshrc` stehen hat, bekäme mit `-l` allein nur das Ergebnis von
53
+ * `path_helper` zurück — also genau nicht `~/.local/bin` mit der claude-CLI, um die es hier geht
54
+ * (gemessen 2026-09-05: `-l` ohne `-i` liefert dort kein `~/.local/bin`, `-i -l` liefert es).
55
+ * Das Hänge-Risiko einer interaktiven Shell fängt der Ausführer ab: stdin zu und harte Zeitgrenze.
56
+ *
57
+ * Der Marker trennt den PATH von allem, was eine gesprächige `~/.zshrc` vorher auf stdout schreibt
58
+ * (Begrüßung, Versionshinweis, Prompt-Framework) — ohne ihn landete das Geschwätz im ersten
59
+ * PATH-Eintrag. Alles vor dem letzten Marker wird verworfen; fehlt er, gilt die Ausgabe als
60
+ * unbrauchbar.
61
+ */
62
+ export const MARKER = '__hmgr_path__';
63
+ export async function pathAusLoginShellErmitteln(ausfuehrer = shellAusfuehrer) {
64
+ const shell = process.env.SHELL && process.env.SHELL.trim() !== '' ? process.env.SHELL : '/bin/zsh';
65
+ const { stdout, code } = await ausfuehrer(shell, ['-i', '-l', '-c', `printf %s%s '${MARKER}' "$PATH"`]);
66
+ if (code !== 0)
67
+ return undefined;
68
+ const ab = stdout.lastIndexOf(MARKER);
69
+ if (ab === -1)
70
+ return undefined;
71
+ const pfad = stdout.slice(ab + MARKER.length).trim();
72
+ if (pfad === '' || !pfad.includes('/'))
73
+ return undefined;
74
+ return pfad;
75
+ }
76
+ /**
77
+ * Führt den Login-PATH vor den bestehenden PATH: neue Einträge zuerst, danach der bisherige PATH,
78
+ * Duplikate raus (erstes Vorkommen zählt). So verliert die App nie einen Eintrag aus dem
79
+ * bisherigen PATH, gewinnt aber die zusätzlichen Pfade der Login-Shell. Kein Voll-Env-Import
80
+ * anderer Variablen — nur der PATH wird übernommen, um Überraschungen zu vermeiden.
81
+ *
82
+ * Getrennt wird an `path.delimiter`, nicht fest an ':' (E67): unter Windows ist der Trenner ';',
83
+ * und ein hart verdrahteter Doppelpunkt zerlegte dort `C:\Windows;C:\Git\cmd` in „C", „\Windows;C"
84
+ * und „\Git\cmd" — der PATH wäre zerstört, sobald ein Laufwerksbuchstabe doppelt vorkommt und die
85
+ * Duplikatsprüfung zuschlägt. Aufgerufen wird die Funktion unter Windows zwar nicht mehr
86
+ * (`loginPathUebernehmen` steigt dort vorher aus), sie soll aber für sich genommen richtig sein.
87
+ * Der Trenner ist ein Parameter, damit das auf jedem Rechner prüfbar ist.
88
+ */
89
+ export function pathZusammenfuehren(bisherigerPath, loginPath, trenner = path.delimiter) {
90
+ const eintraege = [...loginPath.split(trenner), ...(bisherigerPath ?? '').split(trenner)].filter((eintrag) => eintrag !== '');
91
+ const gesehen = new Set();
92
+ const ergebnis = [];
93
+ for (const eintrag of eintraege) {
94
+ if (gesehen.has(eintrag))
95
+ continue;
96
+ gesehen.add(eintrag);
97
+ ergebnis.push(eintrag);
98
+ }
99
+ return ergebnis.join(trenner);
100
+ }
101
+ /**
102
+ * Ermittelt den Login-PATH und setzt ihn (vorangestellt) auf `process.env.PATH`. Bei Fehler,
103
+ * Timeout oder leerer/unplausibler Ausgabe bleibt `process.env.PATH` unverändert — dieser Aufruf
104
+ * darf die App nie am Start hindern.
105
+ *
106
+ * Unter Windows passiert nichts (E67). Zwei Gründe, jeder für sich ausreichend: dort gibt es keine
107
+ * Login-Shell im POSIX-Sinn, `$SHELL` ist normalerweise gar nicht gesetzt und der Aufruf liefe
108
+ * gegen das nicht vorhandene `/bin/zsh` ins Leere; wo `$SHELL` doch gesetzt ist (Git-Bash- und
109
+ * MSYS-Umgebungen setzen es), käme ein POSIX-PATH mit ':' als Trenner zurück und würde dem echten
110
+ * Windows-PATH vorangestellt — kein Gewinn, aber ein Risiko. Die Plattform ist ein Parameter, damit
111
+ * der Ausstieg auf jedem Rechner prüfbar ist.
112
+ */
113
+ export async function loginPathUebernehmen(ausfuehrer = shellAusfuehrer, plattform = process.platform) {
114
+ if (plattform === 'win32')
115
+ return;
116
+ const loginPath = await pathAusLoginShellErmitteln(ausfuehrer);
117
+ if (loginPath === undefined)
118
+ return;
119
+ process.env.PATH = pathZusammenfuehren(process.env.PATH, loginPath);
120
+ }
package/dist/gui/main.js CHANGED
@@ -2,19 +2,41 @@
2
2
  // registriert IPC-Handler für den Renderer und implementiert den Rückfrage-Port als Dialog im
3
3
  // Fenster. Kein zweiter Prozess: der Kern läuft direkt im Main-Prozess, die GUI ist ein zweiter
4
4
  // Kompositions-Einstieg neben der CLI (ARCHITEKTUR.md „Eingang").
5
- import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron';
5
+ import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from 'electron';
6
6
  import path from 'node:path';
7
- import os from 'node:os';
8
7
  import { randomUUID } from 'node:crypto';
9
8
  import { stat } from 'node:fs/promises';
10
9
  import { fileURLToPath } from 'node:url';
11
10
  import { bauen, umgebungLesen } from "../cli/main.js";
12
11
  import { quelleAlsText, quelleAusText } from "../adapter/quelle-text.js";
13
12
  import { quelleName, quelleSchluessel } from "../kern/modell.js";
13
+ import { kannProfile } from "../ports/harness.js";
14
14
  import { iconFuerQuelle } from "./icons.js";
15
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";
16
19
  const hierVerzeichnis = path.dirname(fileURLToPath(import.meta.url));
17
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();
18
40
  const umgebung = umgebungLesen();
19
41
  /** Cache der Quellen-Icons (E60), unterhalb von HMGR_HOME wie der Zustand: Tests/Smoke fassen so
20
42
  nie ein echtes Verzeichnis an. */
@@ -23,10 +45,16 @@ const iconCacheVerzeichnis = path.join(umgebung.hmgrHome, 'icon-cache');
23
45
  const SECHS_STUNDEN_MS = 6 * 60 * 60 * 1000;
24
46
  /**
25
47
  * Fake der claude-CLI für den Smoke-Test (E40 R4), nach dem Muster von `fakeClaude` in
26
- * tests/helfer.ts: nie `code: 0`, also ohne Plugin-Fähigkeit, wie ohne installierte CLI. So fasst
27
- * ein automatisierter Lauf formal nie die echte claude-CLI an, auch wenn sie auf dem PATH liegt.
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.
28
56
  */
29
- const smoketestClaudeAusfuehrer = async () => ({ stdout: '', code: 1 });
57
+ const smoketestClaudeAusfuehrer = async (args) => args[0] === '--version' ? { stdout: 'claude 0.0.0-smoke\n', code: 0 } : { stdout: '', code: 1 };
30
58
  let hauptfenster = null;
31
59
  /**
32
60
  * Rückfrage-Port als Dialog im Renderer (E28, E34): der Main-Prozess schickt die Frage per
@@ -63,12 +91,34 @@ class GuiRueckfrage {
63
91
  return typeof antwort === 'number' ? antwort : undefined;
64
92
  }
65
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);
66
115
  const rueckfrage = new GuiRueckfrage();
67
- const { anwendungsfaelle } = bauen(umgebung, {
116
+ const { anwendungsfaelle, ports } = bauen(umgebung, {
68
117
  rueckfrage,
69
118
  ...(smoketest ? { claudeAusfuehrer: smoketestClaudeAusfuehrer } : {}),
70
119
  });
71
120
  async function auffangen(fn) {
121
+ await pathBereit; // erst der Login-PATH (F47=O2), dann der Adapter — sonst findet er die CLI nicht
72
122
  try {
73
123
  return { ok: true, daten: await fn() };
74
124
  }
@@ -97,8 +147,13 @@ function zielAusIpc(harness, ort) {
97
147
  }
98
148
  function registriereIpc() {
99
149
  ipcMain.handle('hmgr:version', async () => auffangen(async () => app.getVersion()));
100
- // Für pfadKurz im Renderer (Anzeige-Kürzung auf ~): nur lesend, nie in einen anderen Aufruf zurückgespeist.
101
- ipcMain.handle('hmgr:home', async () => auffangen(async () => os.homedir()));
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));
102
157
  ipcMain.handle('hmgr:istZustand', async () => auffangen(async () => (await anwendungsfaelle.istZustand()).map(zielZustandSerialisieren)));
103
158
  ipcMain.handle('hmgr:marketplaces', async () => auffangen(async () => (await anwendungsfaelle.marketplaces()).map((m) => ({
104
159
  quelleText: quelleAlsText(m.quelle),
@@ -230,7 +285,7 @@ function registriereIpc() {
230
285
  // Quelle öffnen (E60): die Protokoll-Weiche steht in oeffnen.ts (rein, testbar), hier nur die
231
286
  // Wirkung. `shell.openExternal` bekommt ausschließlich http(s)-URLs, `shell.openPath` nur einen
232
287
  // Ordner, den es wirklich gibt — nichts anderes wird zugelassen. Alles, was es gibt, aber kein
233
- // Ordner ist, wird im Finder nur gezeigt, nicht geöffnet: `openPath` startet sonst eine Datei
288
+ // Ordner ist, wird im Dateimanager nur gezeigt, nicht geöffnet: `openPath` startet sonst eine Datei
234
289
  // mit ihrem Standardprogramm, und ein Pfad aus dem Renderer soll nichts starten können.
235
290
  ipcMain.handle('hmgr:quelleOeffnen', async (_event, quelleText) => auffangen(async () => {
236
291
  const entscheidung = entscheideOeffnen(quelleAusText(quelleText));
@@ -247,7 +302,83 @@ function registriereIpc() {
247
302
  }
248
303
  return entscheidung;
249
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
+ };
250
379
  }
380
+ const neuLadenAnstossen = neuLadenAnstossenFabrik(FOKUS_RELOAD_MIN_ABSTAND_MS);
381
+ let beobachter;
251
382
  function fensterOeffnen() {
252
383
  hauptfenster = new BrowserWindow({
253
384
  width: 1280,
@@ -368,6 +499,9 @@ function fensterOeffnen() {
368
499
  // E60: Beschreibung in der Ziel-Tabelle (nicht nur im Katalog), „manuell" entfällt,
369
500
  // Zahnrad statt Sonne am Einstellungen-Knopf.
370
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;
371
505
  var zielManuellVorhanden = (document.getElementById('zielliste')?.textContent ?? '').includes('manuell');
372
506
  var zahnradVorhanden = (document.querySelector('#einstellungen svg')?.innerHTML ?? '').includes('6.47 3.56');
373
507
  // Katalog (E40 R3, R5): Beschreibung aus dem Fixture muss in der Skill-Zeile stehen.
@@ -396,6 +530,137 @@ function fensterOeffnen() {
396
530
  ?.replace(/\\s+/g, ' ').trim() ?? null;
397
531
  document.getElementById('d-skills')?.close();
398
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
+ }
399
664
  // Schriften aus fonts/ (Zusatzpunkt 6): erzwingt das Laden und belegt es danach.
400
665
  await Promise.all([
401
666
  document.fonts.load('400 16px Inter'),
@@ -409,6 +674,7 @@ function fensterOeffnen() {
409
674
  einstellungenOffen,
410
675
  harnessSchalter,
411
676
  zielBeschreibung,
677
+ zielMarken,
412
678
  zielManuellVorhanden,
413
679
  zahnradVorhanden,
414
680
  katalogBeschreibung,
@@ -443,6 +709,37 @@ function fensterOeffnen() {
443
709
  dSkillordnerOffenNachOeffnen,
444
710
  dSkillordnerOffenNachAbbrechen,
445
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,
446
743
  });
447
744
  })()`)
448
745
  .then((auszug) => console.log(`[smoke] DOM-Auszug: ${auszug}`))
@@ -461,20 +758,65 @@ function fensterOeffnen() {
461
758
  const intervall = setInterval(() => fenster.webContents.send('hmgr:aktualisierenAnstossen'), SECHS_STUNDEN_MS);
462
759
  fenster.on('closed', () => clearInterval(intervall));
463
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
+ }
464
778
  void hauptfenster.loadFile(path.join(hierVerzeichnis, 'renderer', 'index.html'));
465
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
+ }
466
792
  registriereIpc();
467
- app.whenReady().then(() => {
468
- // Im Smoke-Test kein Dock-Icon und kein Fokuswechsel: das Fenster bleibt unsichtbar (show:false
469
- // oben), macOS würde aber ohne diese Zeile trotzdem ein Dock-Icon zeigen und aktivieren.
470
- if (smoketest && process.platform === 'darwin')
471
- app.dock?.hide();
472
- fensterOeffnen();
473
- app.on('activate', () => {
474
- if (BrowserWindow.getAllWindows().length === 0)
475
- fensterOeffnen();
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
+ });
476
818
  });
477
- });
819
+ }
478
820
  app.on('window-all-closed', () => {
479
821
  if (process.platform !== 'darwin')
480
822
  app.quit();
@@ -0,0 +1,57 @@
1
+ // MCP-Ansicht der GUI (E61-Folgerunde): reine Umformung von Kern-Daten in IPC-taugliche, gewhiteliste
2
+ // Objekte, ohne Electron — testbar wie oeffnen.ts und icons.ts. Der eigentliche IPC-Handler in
3
+ // main.ts ruft diese Funktionen nur noch auf.
4
+ import { variablenVerweise, } from "../kern/anwendungsfaelle.js";
5
+ /** Katalog-Eintrag über IPC: die bekannten Felder, nichts ungefiltert durchgereicht (E40 R3). */
6
+ export function mcpEintragSerialisieren(eintrag) {
7
+ return {
8
+ name: eintrag.name,
9
+ beschreibung: eintrag.beschreibung,
10
+ ...(eintrag.homepage === undefined ? {} : { homepage: eintrag.homepage }),
11
+ ...(eintrag.notizen === undefined ? {} : { notizen: eintrag.notizen }),
12
+ konfig: eintrag.konfig,
13
+ };
14
+ }
15
+ /**
16
+ * F39 an der Katalog-Karte: dieselbe Prüf-Funktion wie beim tatsächlichen Einhängen
17
+ * (`variablenVerweise`), hier aber nur gegen die übergebene Umgebung geprüft — ohne den
18
+ * ortsbezogenen `.env`-Abgleich, der ist ortsspezifisch und bleibt dem eigentlichen Einhängen
19
+ * vorbehalten (kommt von dort als `warnung` zurück).
20
+ */
21
+ export function mcpFehlendeVariablen(eintrag, umgebung) {
22
+ return variablenVerweise(eintrag.konfig).filter((name) => !(name in umgebung) || umgebung[name] === undefined);
23
+ }
24
+ /** Übersicht über IPC (E61-Folgerunde): Katalog-Einträge whitelisted, dazu der F39-Vermerk je Karte. */
25
+ export function mcpUebersichtSerialisieren(uebersicht, umgebung) {
26
+ return {
27
+ katalog: uebersicht.katalog.map((k) => ({
28
+ eintrag: mcpEintragSerialisieren(k.eintrag),
29
+ eingehaengt: k.eingehaengt,
30
+ geteilteFundorte: k.geteilteFundorte,
31
+ fehlendeVariablen: mcpFehlendeVariablen(k.eintrag, umgebung),
32
+ })),
33
+ unbekannt: uebersicht.unbekannt,
34
+ unbekannteGeteilteFundorte: uebersicht.unbekannteGeteilteFundorte,
35
+ };
36
+ }
37
+ /** Ergebnis von `mcpEinhaengen`/`mcpAushaengen` über IPC, whitelisted wie der Katalog-Eintrag. */
38
+ export function mcpWirkungSerialisieren(ergebnis) {
39
+ return {
40
+ eintrag: mcpEintragSerialisieren(ergebnis.eintrag),
41
+ ziel: ergebnis.ziel,
42
+ ...(ergebnis.hinweis === undefined ? {} : { hinweis: ergebnis.hinweis }),
43
+ ...(ergebnis.warnung === undefined ? {} : { warnung: ergebnis.warnung }),
44
+ };
45
+ }
46
+ /**
47
+ * Ergebnis von `mcpUebernehmen` über IPC (E64). Kein Katalog-Eintrag darin: übernommen wird auch,
48
+ * was der Katalog nicht kennt, deshalb reicht der Name.
49
+ */
50
+ export function mcpUebernahmeSerialisieren(ergebnis) {
51
+ return {
52
+ name: ergebnis.name,
53
+ ziel: ergebnis.ziel,
54
+ datei: ergebnis.datei,
55
+ ...(ergebnis.hinweis === undefined ? {} : { hinweis: ergebnis.hinweis }),
56
+ };
57
+ }