Einen hab ich noch an claude Code stört mich, dass man unen tippt und das Ergebnis oben angezeigt wirds du hattes mla gesagt, das könnte man lösen, wenn man claude im Hintergrund headless kaufen lässt…


TryMenü für Projektliste mit Start/Stop-Button


Aufwandseinschätzung — die Basis ist `claude -p --input-format stream-json --output-format stream-json`: ein dauerhafter Prozess pro Session, stdin bekommt User-Turns als JSON, stdout liefert einen Event-Strom (assistant-Text-Deltas, tool_use, tool_result, result). Statt PTY + xterm.js rendert die App die Events als Chat — Eingabe unten, Ausgabe chronologisch oben. Die bestehende Infrastruktur (Prozess-Spawning pro Projekt, Pool-/`CLAUDE_CONFIG_DIR`-Umgebung, Keychain) passt unverändert, weil dasselbe `claude`-Binary läuft.

Der Aufwand teilt sich klar:

- **Plumbing (überschaubar, 1–2 Tage):** Prozess statt PTY starten, JSONL parsen, Events per Tauri-Event ans Frontend, Chat-Liste + Eingabefeld, Markdown-Rendering. Strukturell nah an dem, was terminal.rs schon macht.
- **Interaktivität (der eigentliche Brocken):** Permission-Prompts kommen im stream-json-Modus als Control-Requests, die die App beantworten muss (oder man fährt fest mit `--permission-mode acceptEdits`/`bypassPermissions`). Dazu fehlen dann alle TUI-Features: Slash-Commands-UI, Plan-Mode-Dialoge, AskUserQuestion-Auswahl, @-Mentions, Ctrl-C-Interrupt, Diff-Anzeige. Jedes davon ist einzeln nachzubauen, und die Liste wächst mit jedem CLI-Release weiter.

Eine Minimalversion — Text rein, gestreamter Text plus Tool-Namen raus, Permissions fest auf acceptEdits, Resume über `--resume` — ist in wenigen Tagen drin und löst genau das Unten-tippen/oben-lesen-Problem. Ein vollwertiger Ersatz des interaktiven Claude Code ist Wochenarbeit mit dauerhafter Nachpflege; das Terminal bliebe als Fallback für alles, was die Chat-UI nicht kann.


ok -- dann erst mal nicht
bereiten wir realese auf gitHub vor
schareb' doch mal Dein ReadMe
GitHub fähig --> das arfst du jetzt schreiben, dass der Code komplett von cluade generiert wurde
Ansonten :: Purpose / Projeltaufbau / tooling / das Übliche


Bash

cd /home/marcuh/projects/ai-control && cat README.md 2>/dev/null; echo ---PKG---; cat package.json; echo ---SRC---; ls src src-tauri/src public; echo ---GIT---; git remote -v

Read existing README, package.json, source layout


The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.


[Request interrupted by user for tool use]


und?


Ich wollte vor dem Schreiben den Ist-Stand lesen — bestehendes README, package.json, Quelltext-Layout, Remote —, damit Purpose, Projektaufbau und Tooling den tatsächlichen Stand beschreiben statt aus dem Gedächtnis. Der Befehl wurde abgelehnt; ohne diese Infos schreibe ich das README nur aus dem, was aus dieser Session bekannt ist (Tauri 2, React/Vite-Frontend, Pools, eingebautes Terminal, Tray).


dann los


1	# Vue 3 + TypeScript + Vite
2
3	This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
4
5	Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
6

# Vue 3 + TypeScript + Vite

This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.

Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).


Write

# ai-control

Pool- und Session-Verwaltung für [Claude Code](https://claude.com/claude-code) auf macOS — als Tray-App mit eingebautem Terminal.

> Der gesamte Code dieses Projekts wurde von Claude (Claude Code) generiert.

## Zweck

Wer Claude Code mit mehreren Accounts bzw. Credential-Sets betreibt, muss pro Session das richtige `CLAUDE_CONFIG_DIR` samt Keychain-Eintrag treffen. ai-control macht daraus Klicks:

- **Pools** — benannte Credential-Sets (OAuth-Login oder API-Key) unter `~/.config/ai-control/pools/<pool>`. Jeder Pool ist ein eigenständiges Claude-Config-Verzeichnis mit eigenem Keychain-Eintrag.
- **Projekte** — Verzeichnisse unter `~/claude-projects/<name>` (erkennbar am `.claude`-Ordner). Jedem Projekt ist ein Pool zugeordnet; dazu Terminal-Einstellungen (Theme, Icon, Fenstertitel).
- **Sessions** — pro Projekt ein eingebautes Terminal (xterm.js + PTY), das Claude Code mit der Pool-Umgebung startet. Jedes Terminal läuft als eigener Prozess mit eigenem Dock-Icon und Cmd-Tab-Eintrag.
- **Tray** — die App selbst ist eine reine Menüleisten-App ohne Dock-Eintrag. Das Tray-Menü listet alle Projekte mit Icon und Status-Punkt (grün = läuft); Klick startet das Projekt oder holt das laufende Terminal nach vorn.
- **Session-Watcher** — erkennt das Ende einer Session über das Verschwinden des Terminal-Prozesses und kann dann (opt-in) den Projektkontext per git-sync committen und pushen.

## Projektaufbau

```
├── index.html            Haupt-UI (Projekt-/Pool-Verwaltung)
├── terminal.html         Terminal-Fenster (xterm.js)
├── src/                  Frontend: Vue 3 + TypeScript
├── src-tauri/
│   ├── src/lib.rs        Tauri-Commands: Projekte, Pools, Keychain,
│   │                     OAuth-Login, Tray-Menü, Session-Watcher
│   ├── src/terminal.rs   PTY-Sessions (portable-pty), Terminal-Fenster,
│   │                     Dock-Icon, Fokussieren laufender Terminals
│   └── icons/            App- und Tray-Icons
├── dev.sh                Entwicklungsmodus (tauri dev)
└── build.sh              Release-Build (.app-Bundle)
```

Zwei Prozessrollen aus einem Binary:

- **Haupt-App** (`ai-control`) — Tray, Hauptfenster, Verwaltung, Watcher.
- **Terminal-Prozess** (`ai-control --terminal <projekt>`) — ein Fenster mit eigener PTY; startet Claude Code im Projektverzeichnis mit `CLAUDE_CONFIG_DIR` des zugeordneten Pools.

Laufende Projekte werden über die Terminal-Prozesse erkannt (`pgrep` auf `--terminal <projekt>`), ohne Zustandsdatei.

## Tooling

| Bereich   | Stack |
|-----------|-------|
| Shell     | Tauri 2 (Rust) |
| Frontend  | Vue 3, TypeScript, Vite |
| Terminal  | xterm.js, portable-pty |
| macOS     | objc2 / objc2-app-kit (Dock-Icon, Fenster-Fokus, Tray) |
| Secrets   | keyring (macOS Keychain) |

## Entwicklung

```sh
./dev.sh      # tauri dev mit Hot-Reload
./build.sh    # Release-Build; Bundle landet in src-tauri/target/release/bundle/macos.noindex/
```

`build.sh` verschiebt das Bundle in ein `.noindex`-Verzeichnis und trägt es aus Launch Services aus, damit Spotlight nur die installierte Kopie findet. Installation: `ai-control.app` nach `~/Applications` kopieren.

Voraussetzungen: Rust (stable), Node.js/npm, macOS. `dev.sh`/`build.sh` erwarten `CARGO_HOME`/`RUSTUP_HOME` unter `~/tools/` — bei Standard-Installation die beiden Exporte in den Skripten anpassen.

## Konfigurationslayout

```
~/.config/ai-control/
├── settings.json                 App-Einstellungen (z. B. syncOnSessionEnd)
└── pools/<pool>/                 je Pool ein Claude-Config-Verzeichnis
    └── pool.json                 Name + Credential-Typ

~/claude-projects/<projekt>/
├── .claude/                      Claude-Code-Projektkonfiguration
└── ai-control.json               Pool-Zuordnung + Terminal-Einstellungen
```


The file /home/marcuh/projects/ai-control/README.md has been updated successfully. (file state is current in your context — no need to Read it back)

# ai-control

Pool- und Session-Verwaltung für [Claude Code](https://claude.com/claude-code) auf macOS — als Tray-App mit eingebautem Terminal.

> Der gesamte Code dieses Projekts wurde von Claude (Claude Code) generiert.

## Zweck

Wer Claude Code mit mehreren Accounts bzw. Credential-Sets betreibt, muss pro Session das richtige `CLAUDE_CONFIG_DIR` samt Keychain-Eintrag treffen. ai-control macht daraus Klicks:

- **Pools** — benannte Credential-Sets (OAuth-Login oder API-Key) unter `~/.config/ai-control/pools/<pool>`. Jeder Pool ist ein eigenständiges Claude-Config-Verzeichnis mit eigenem Keychain-Eintrag.
- **Projekte** — Verzeichnisse unter `~/claude-projects/<name>` (erkennbar am `.claude`-Ordner). Jedem Projekt ist ein Pool zugeordnet; dazu Terminal-Einstellungen (Theme, Icon, Fenstertitel).
- **Sessions** — pro Projekt ein eingebautes Terminal (xterm.js + PTY), das Claude Code mit der Pool-Umgebung startet. Jedes Terminal läuft als eigener Prozess mit eigenem Dock-Icon und Cmd-Tab-Eintrag.
- **Tray** — die App selbst ist eine reine Menüleisten-App ohne Dock-Eintrag. Das Tray-Menü listet alle Projekte mit Icon und Status-Punkt (grün = läuft); Klick startet das Projekt oder holt das laufende Terminal nach vorn.
- **Session-Watcher** — erkennt das Ende einer Session über das Verschwinden des Terminal-Prozesses und kann dann (opt-in) den Projektkontext per git-sync committen und pushen.

## Projektaufbau

```
├── index.html            Haupt-UI (Projekt-/Pool-Verwaltung)
├── terminal.html         Terminal-Fenster (xterm.js)
├── src/                  Frontend: Vue 3 + TypeScript
├── src-tauri/
│   ├── src/lib.rs        Tauri-Commands: Projekte, Pools, Keychain,
│   │                     OAuth-Login, Tray-Menü, Session-Watcher
│   ├── src/terminal.rs   PTY-Sessions (portable-pty), Terminal-Fenster,
│   │                     Dock-Icon, Fokussieren laufender Terminals
│   └── icons/            App- und Tray-Icons
├── dev.sh                Entwicklungsmodus (tauri dev)
└── build.sh              Release-Build (.app-Bundle)
```

Zwei Prozessrollen aus einem Binary:

- **Haupt-App** (`ai-control`) — Tray, Hauptfenster, Verwaltung, Watcher.
- **Terminal-Prozess** (`ai-control --terminal <projekt>`) — ein Fenster mit eigener PTY; startet Claude Code im Projektverzeichnis mit `CLAUDE_CONFIG_DIR` des zugeordneten Pools.

Laufende Projekte werden über die Terminal-Prozesse erkannt (`pgrep` auf `--terminal <projekt>`), ohne Zustandsdatei.

## Tooling

| Bereich   | Stack |
|-----------|-------|
| Shell     | Tauri 2 (Rust) |
| Frontend  | Vue 3, TypeScript, Vite |
| Terminal  | xterm.js, portable-pty |
| macOS     | objc2 / objc2-app-kit (Dock-Icon, Fenster-Fokus, Tray) |
| Secrets   | keyring (macOS Keychain) |

## Entwicklung

```sh
./dev.sh      # tauri dev mit Hot-Reload
./build.sh    # Release-Build; Bundle landet in src-tauri/target/release/bundle/macos.noindex/
```

`build.sh` verschiebt das Bundle in ein `.noindex`-Verzeichnis und trägt es aus Launch Services aus, damit Spotlight nur die installierte Kopie findet. Installation: `ai-control.app` nach `~/Applications` kopieren.

Voraussetzungen: Rust (stable), Node.js/npm, macOS. `dev.sh`/`build.sh` erwarten `CARGO_HOME`/`RUSTUP_HOME` unter `~/tools/` — bei Standard-Installation die beiden Exporte in den Skripten anpassen.

## Konfigurationslayout

```
~/.config/ai-control/
├── settings.json                 App-Einstellungen (z. B. syncOnSessionEnd)
└── pools/<pool>/                 je Pool ein Claude-Config-Verzeichnis
    └── pool.json                 Name + Credential-Typ

~/claude-projects/<projekt>/
├── .claude/                      Claude-Code-Projektkonfiguration
└── ai-control.json               Pool-Zuordnung + Terminal-Einstellungen
```

-This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.

+Pool- und Session-Verwaltung für [Claude Code](https://claude.com/claude-code) auf macOS — als Tray-App mit eingebautem Terminal.

-Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

+> Der gesamte Code dieses Projekts wurde von Claude (Claude Code) generiert.

+Wer Claude Code mit mehreren Accounts bzw. Credential-Sets betreibt, muss pro Session das richtige `CLAUDE_CONFIG_DIR` samt Keychain-Eintrag treffen. ai-control macht daraus Klicks:

+- **Pools** — benannte Credential-Sets (OAuth-Login oder API-Key) unter `~/.config/ai-control/pools/<pool>`. Jeder Pool ist ein eigenständiges Claude-Config-Verzeichnis mit eigenem Keychain-Eintrag.

+- **Projekte** — Verzeichnisse unter `~/claude-projects/<name>` (erkennbar am `.claude`-Ordner). Jedem Projekt ist ein Pool zugeordnet; dazu Terminal-Einstellungen (Theme, Icon, Fenstertitel).

+- **Sessions** — pro Projekt ein eingebautes Terminal (xterm.js + PTY), das Claude Code mit der Pool-Umgebung startet. Jedes Terminal läuft als eigener Prozess mit eigenem Dock-Icon und Cmd-Tab-Eintrag.

+- **Tray** — die App selbst ist eine reine Menüleisten-App ohne Dock-Eintrag. Das Tray-Menü listet alle Projekte mit Icon und Status-Punkt (grün = läuft); Klick startet das Projekt oder holt das laufende Terminal nach vorn.

+- **Session-Watcher** — erkennt das Ende einer Session über das Verschwinden des Terminal-Prozesses und kann dann (opt-in) den Projektkontext per git-sync committen und pushen.

+├── index.html            Haupt-UI (Projekt-/Pool-Verwaltung)

+├── terminal.html         Terminal-Fenster (xterm.js)

+├── src/                  Frontend: Vue 3 + TypeScript

+│   ├── src/lib.rs        Tauri-Commands: Projekte, Pools, Keychain,

+│   │                     OAuth-Login, Tray-Menü, Session-Watcher

+│   ├── src/terminal.rs   PTY-Sessions (portable-pty), Terminal-Fenster,

+│   │                     Dock-Icon, Fokussieren laufender Terminals

+│   └── icons/            App- und Tray-Icons

+├── dev.sh                Entwicklungsmodus (tauri dev)

+└── build.sh              Release-Build (.app-Bundle)

+- **Haupt-App** (`ai-control`) — Tray, Hauptfenster, Verwaltung, Watcher.

+- **Terminal-Prozess** (`ai-control --terminal <projekt>`) — ein Fenster mit eigener PTY; startet Claude Code im Projektverzeichnis mit `CLAUDE_CONFIG_DIR` des zugeordneten Pools.

+Laufende Projekte werden über die Terminal-Prozesse erkannt (`pgrep` auf `--terminal <projekt>`), ohne Zustandsdatei.

+| Frontend  | Vue 3, TypeScript, Vite |

+| macOS     | objc2 / objc2-app-kit (Dock-Icon, Fenster-Fokus, Tray) |

+| Secrets   | keyring (macOS Keychain) |

+./dev.sh      # tauri dev mit Hot-Reload

+./build.sh    # Release-Build; Bundle landet in src-tauri/target/release/bundle/macos.noindex/

+`build.sh` verschiebt das Bundle in ein `.noindex`-Verzeichnis und trägt es aus Launch Services aus, damit Spotlight nur die installierte Kopie findet. Installation: `ai-control.app` nach `~/Applications` kopieren.

+Voraussetzungen: Rust (stable), Node.js/npm, macOS. `dev.sh`/`build.sh` erwarten `CARGO_HOME`/`RUSTUP_HOME` unter `~/tools/` — bei Standard-Installation die beiden Exporte in den Skripten anpassen.

+├── settings.json                 App-Einstellungen (z. B. syncOnSessionEnd)

+└── pools/<pool>/                 je Pool ein Claude-Config-Verzeichnis

+    └── pool.json                 Name + Credential-Typ

+├── .claude/                      Claude-Code-Projektkonfiguration

+└── ai-control.json               Pool-Zuordnung + Terminal-Einstellungen

# Vue 3 + TypeScript + Vite

This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.

Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).


README ersetzt (`~/projects/ai-control/README.md`), auf Deutsch, mit dem Hinweis, dass der gesamte Code von Claude generiert wurde. Inhalt: Zweck (Pools/Projekte/Sessions/Tray/Watcher), Projektaufbau mit den zwei Prozessrollen aus einem Binary, Tooling-Tabelle, Entwicklung (`dev.sh`/`build.sh` inkl. `~/tools/`-Cargo-Pfad-Hinweis und `.noindex`-Erklärung), Konfigurationslayout unter `~/.config/ai-control` und `~/claude-projects`.

Aus Session-Wissen geschrieben, zwei Punkte darin sind nicht am Code verifiziert: Vue 3 (aus dem alten Template-README abgeleitet) und der Dateiname `ai-control.json` für die Projekt-Config.


ok -- nimmst du rein, dass Linux und Windows Integration folgen?


Edit

Pool- und Session-Verwaltung für [Claude Code](https://claude.com/claude-code) auf macOS — als Tray-App mit eingebautem Terminal.

> Der gesamte Code dieses Projekts wurde von Claude (Claude Code) generiert.

Pool- und Session-Verwaltung für [Claude Code](https://claude.com/claude-code) auf macOS — als Tray-App mit eingebautem Terminal. Linux- und Windows-Integration folgen.

> Der gesamte Code dieses Projekts wurde von Claude (Claude Code) generiert.


Pool- und Session-Verwaltung für [Claude Code](https://claude.com/claude-code) auf macOS — als Tray-App mit eingebautem Terminal. Linux- und Windows-Integration folgen.

> Der gesamte Code dieses Projekts wurde von Claude (Claude Code) generiert.

# ai-control

Pool- und Session-Verwaltung für [Claude Code](https://claude.com/claude-code) auf macOS — als Tray-App mit eingebautem Terminal.

> Der gesamte Code dieses Projekts wurde von Claude (Claude Code) generiert.

## Zweck

Wer Claude Code mit mehreren Accounts bzw. Credential-Sets betreibt, muss pro Session das richtige `CLAUDE_CONFIG_DIR` samt Keychain-Eintrag treffen. ai-control macht daraus Klicks:

- **Pools** — benannte Credential-Sets (OAuth-Login oder API-Key) unter `~/.config/ai-control/pools/<pool>`. Jeder Pool ist ein eigenständiges Claude-Config-Verzeichnis mit eigenem Keychain-Eintrag.
- **Projekte** — Verzeichnisse unter `~/claude-projects/<name>` (erkennbar am `.claude`-Ordner). Jedem Projekt ist ein Pool zugeordnet; dazu Terminal-Einstellungen (Theme, Icon, Fenstertitel).
- **Sessions** — pro Projekt ein eingebautes Terminal (xterm.js + PTY), das Claude Code mit der Pool-Umgebung startet. Jedes Terminal läuft als eigener Prozess mit eigenem Dock-Icon und Cmd-Tab-Eintrag.
- **Tray** — die App selbst ist eine reine Menüleisten-App ohne Dock-Eintrag. Das Tray-Menü listet alle Projekte mit Icon und Status-Punkt (grün = läuft); Klick startet das Projekt oder holt das laufende Terminal nach vorn.
- **Session-Watcher** — erkennt das Ende einer Session über das Verschwinden des Terminal-Prozesses und kann dann (opt-in) den Projektkontext per git-sync committen und pushen.

## Projektaufbau

```
├── index.html            Haupt-UI (Projekt-/Pool-Verwaltung)
├── terminal.html         Terminal-Fenster (xterm.js)
├── src/                  Frontend: Vue 3 + TypeScript
├── src-tauri/
│   ├── src/lib.rs        Tauri-Commands: Projekte, Pools, Keychain,
│   │                     OAuth-Login, Tray-Menü, Session-Watcher
│   ├── src/terminal.rs   PTY-Sessions (portable-pty), Terminal-Fenster,
│   │                     Dock-Icon, Fokussieren laufender Terminals
│   └── icons/            App- und Tray-Icons
├── dev.sh                Entwicklungsmodus (tauri dev)
└── build.sh              Release-Build (.app-Bundle)
```

Zwei Prozessrollen aus einem Binary:

- **Haupt-App** (`ai-control`) — Tray, Hauptfenster, Verwaltung, Watcher.
- **Terminal-Prozess** (`ai-control --terminal <projekt>`) — ein Fenster mit eigener PTY; startet Claude Code im Projektverzeichnis mit `CLAUDE_CONFIG_DIR` des zugeordneten Pools.

Laufende Projekte werden über die Terminal-Prozesse erkannt (`pgrep` auf `--terminal <projekt>`), ohne Zustandsdatei.

## Tooling

| Bereich   | Stack |
|-----------|-------|
| Shell     | Tauri 2 (Rust) |
| Frontend  | Vue 3, TypeScript, Vite |
| Terminal  | xterm.js, portable-pty |
| macOS     | objc2 / objc2-app-kit (Dock-Icon, Fenster-Fokus, Tray) |
| Secrets   | keyring (macOS Keychain) |

## Entwicklung

```sh
./dev.sh      # tauri dev mit Hot-Reload
./build.sh    # Release-Build; Bundle landet in src-tauri/target/release/bundle/macos.noindex/
```

`build.sh` verschiebt das Bundle in ein `.noindex`-Verzeichnis und trägt es aus Launch Services aus, damit Spotlight nur die installierte Kopie findet. Installation: `ai-control.app` nach `~/Applications` kopieren.

Voraussetzungen: Rust (stable), Node.js/npm, macOS. `dev.sh`/`build.sh` erwarten `CARGO_HOME`/`RUSTUP_HOME` unter `~/tools/` — bei Standard-Installation die beiden Exporte in den Skripten anpassen.

## Konfigurationslayout

```
~/.config/ai-control/
├── settings.json                 App-Einstellungen (z. B. syncOnSessionEnd)
└── pools/<pool>/                 je Pool ein Claude-Config-Verzeichnis
    └── pool.json                 Name + Credential-Typ

~/claude-projects/<projekt>/
├── .claude/                      Claude-Code-Projektkonfiguration
└── ai-control.json               Pool-Zuordnung + Terminal-Einstellungen
```

-Pool- und Session-Verwaltung für [Claude Code](https://claude.com/claude-code) auf macOS — als Tray-App mit eingebautem Terminal.

+Pool- und Session-Verwaltung für [Claude Code](https://claude.com/claude-code) auf macOS — als Tray-App mit eingebautem Terminal. Linux- und Windows-Integration folgen.


Drin — im Kopfsatz: „…auf macOS — als Tray-App mit eingebautem Terminal. Linux- und Windows-Integration folgen."


Ich weiß nicht
wie hart ist clude-projects verdrahtet
Wir hängen davon ab, dass wir alles in einem Ordner haben, oder betrifft das nur das optionlae sync?


Nicht nur das Sync — das Ein-Ordner-Modell ist die Grundannahme der App:

- **Projekt-Discovery:** die Projektliste ist `read_dir` über `projects_dir()` (Unterordner mit `.claude`). Ein Projekt existiert für die App nur als Unterordner dieses einen Roots.
- **Terminal-Start:** `open_terminal` baut den Pfad hart als `$HOME/claude-projects/<projekt>` (terminal.rs:24–25).
- **Projekt-Config:** `ai-control.json` liegt im Projektordner unter demselben Root.
- **Sync (optional):** setzt zusätzlich voraus, dass der Root ein einziges Git-Repo ist und `claude-projects/pool/bin/git-sync` existiert — das ist die engste, auf dein Setup zugeschnittene Stelle.

Der Ordnername selbst steckt in `Paths` (ein zentraler Ort) plus der Hartkodierung in terminal.rs. Konfigurierbar ist er derzeit nicht.


hmmm - nicht so schön
Wir könnten die Pool- Verwaltung in einen kondfiguerirbaren Ordner legen, und dann frei projektverzeichn isse dazu mappen lassen


Passt architektonisch gut, weil die Pools schon heute unabhängig sind (`~/.config/ai-control/pools`) — hart verdrahtet ist nur die Projekt-Discovery. Der Umbau wäre also: Verzeichnis-Scan durch eine explizite Projekt-Registry ersetzen.

Plan:

- **Registry** `projects.json` unter `~/.config/ai-control/`: Name → absoluter Pfad. Die App listet Projekte aus der Registry statt per `read_dir` über einen festen Root.
- **UI:** „Projekt hinzufügen" öffnet einen Ordner-Picker (dialog-Plugin ist schon eingebunden); entfernen löscht nur den Registry-Eintrag, nicht den Ordner. `create_project_full` bekommt einen Zielpfad statt des festen Roots.
- **Backend:** `Paths::projects_dir()`-Nutzungen und die Hartkodierung in `open_terminal` auf Registry-Lookup umstellen; `ai-control.json` bleibt im Projektordner und funktioniert damit an beliebigen Pfaden; Lauferkennung (pgrep auf Projektname) bleibt unverändert.
- **Sync:** verliert die Annahme „ein Root = ein Repo". Naheliegend: pro Projekt syncen, wenn der Projektordner ein Git-Repo ist; die Kopplung an `claude-projects/pool/bin/git-sync` fällt weg oder wird durch eine im Projekt konfigurierbare Sync-Quelle ersetzt — das ist der einzige Teil mit echter Verhaltensentscheidung.
- **Migration:** beim ersten Start ohne Registry die Unterordner von `~/claude-projects` mit `.claude` einmalig als Einträge übernehmen.

Warte auf Go.


ist - denke ich - besser
die additional Folder können ja bleiben -> dann passt das auch zu meinem setup
Ist aber doof für die portierbarkeit
ich gehe im Augenblick von <home> als basis aus
--> Syncbar ist derr Kram dann nur, wenn die Projekte im Homverzeichnis liegen


ist - denke ich - besser die additional Folder können ja bleiben -> dann passt das auch zu meinem setup Ist aber doof für die portierbarkeit ich gehe im Augenblick von <home> als basis aus --> Syncbar…


Der Sync selbst hängt nicht am Home: git-sync committet das Repo, in dem der Projektordner liegt — das funktioniert an jedem absoluten Pfad. Die Home-Abhängigkeit betrifft nur die **Portierbarkeit der Registry** zwischen Rechnern: absolute Pfade (`/home/marcuh/…`) stimmen auf dem nächsten Mac nur bei gleichem Usernamen.

Das lässt sich in der Registry lösen: Pfade unterhalb von `$HOME` als `~/…` speichern und beim Laden expandieren. Dann sind alle Home-Projekte maschinenübergreifend stabil; nur Einträge außerhalb von Home (z. B. `/Volumes/…`) bleiben absolut und damit maschinenspezifisch — was sachlich auch korrekt ist, weil so ein Pfad auf einem anderen Rechner ohnehin nicht garantiert existiert.

Dein Setup bleibt dabei unverändert: `~/claude-projects/…`-Einträge sind `~`-relativ und syncbar wie bisher.


dann machen wir das so
bau mal um


Bash

grep -n "projects_dir\|claude-projects\|PROJECT_FILE\|fn project_dir" /home/marcuh/projects/ai-control/src-tauri/src/*.rs

Find all uses of the hard-wired project root


/home/marcuh/projects/ai-control/src-tauri/src/terminal.rs:25:  let dir = PathBuf::from(home).join("claude-projects").join(&project);
/home/marcuh/projects/ai-control/src-tauri/src/terminal.rs:127:  let cwd = PathBuf::from(home).join("claude-projects").join(&project);
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:18:const PROJECT_FILE: &str = "ai-control.json";
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:32:  fn projects_dir(&self) -> PathBuf {
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:33:    self.home.join("claude-projects")
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:49:    self.projects_dir().join(project).join(PROJECT_FILE)
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:162:/// Committet/pusht das claude-projects-Repo nach einem Session-Ende über das
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:165:  let git_sync = home.join("claude-projects/pool/bin/git-sync");
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:166:  let repo = home.join("claude-projects");
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:207:        && is_git_repo(&paths.projects_dir())
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:248:  let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?;
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:279:    paths.projects_dir().join(name).join(TODO_FILE).display()
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:285:    .projects_dir()
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:353:    let todo_path = paths.projects_dir().join(name).join(TODO_FILE);
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:375:/// der Ordner ist Teil des einen claude-projects-Repos.
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:386:  let dir = paths.projects_dir().join(name);
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:410:  let mut allow = vec![format!("Edit(~/claude-projects/{name}/**)")];
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:417:    "autoMemoryDirectory": format!("~/claude-projects/{name}/memory"),
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:439:  let dir = paths.projects_dir().join(name);
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:450:    .projects_dir()
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:472:/// Löscht den Projektordner unter claude-projects; optional auch die
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:476:  let dir = paths.projects_dir().join(name);
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:545:      let dest = paths.projects_dir().join(project).join(&name);
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:562:    paths.projects_dir().join(project).join(icon)
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:664:  let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?;
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:667:    let cfg_path = entry.path().join(PROJECT_FILE);
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:937:/// Zielort der synced Runtime-Daten eines Pools im claude-projects-Repo.
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:939:  paths.projects_dir().join("pool").join(pool)
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:1222:  for entry in fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())? {
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:1719:  /// Frisches Pseudo-Home pro Test; claude-projects existiert wie auf dem
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:1727:    fs::create_dir_all(dir.join("claude-projects")).unwrap();
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:2055:    let dir = p.projects_dir().join("neu");
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:2072:    assert_eq!(settings["autoMemoryDirectory"], "~/claude-projects/neu/memory");
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:2076:      "Edit(~/claude-projects/neu/**)"
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:2098:    let todo_path = p.projects_dir().join("proj").join(TODO_FILE);
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:2130:    let dir = p.projects_dir().join("neu");
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:2138:    assert_eq!(settings["permissions"]["allow"][0], "Edit(~/claude-projects/neu/**)");
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:2167:    assert!(!p.projects_dir().join("neu").exists());
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:2197:    let proj_path = p.projects_dir().join("proj");
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:2314:    // Zieldaten liegen im claude-projects-Repo unter pool/<id>/
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:2315:    assert!(p.projects_dir().join("pool").join(&id).is_dir());
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:2418:    assert!(p.projects_dir().join("proj").join(".claude").is_dir());
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:2438:    assert!(!p.projects_dir().join("proj").exists());
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:2447:    assert!(!p.projects_dir().join("proj").exists());
/home/marcuh/projects/ai-control/src-tauri/src/lib.rs:2457:    assert!(!p.projects_dir().join("proj").exists());


1	mod terminal;
2
3	use serde::{Deserialize, Serialize};
4	use sha2::{Digest, Sha256};
5	use std::collections::{HashMap, HashSet};
6	use std::fs;
7	use std::io::{BufRead, BufReader};
8	use std::os::unix::fs::PermissionsExt;
9	use std::path::PathBuf;
10	use std::process::Command;
11	use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
12
13	/// Feste Dateinamen im Pool-Ordner. Der Pool-Ordner selbst ist das
14	/// CLAUDE_CONFIG_DIR. oauth-Credentials verwaltet claude selbst (Keychain);
15	/// die App speichert keine Tokens.
16	const APIKEY_FILE: &str = "apikey";
17	const POOL_FILE: &str = "pool.json";
18	const PROJECT_FILE: &str = "ai-control.json";
19
20	/// Wurzelpfade; in Tests mit temporärem home instanziierbar.
21	struct Paths {
22	  home: PathBuf,
23	}
24
25	impl Paths {
26	  fn real() -> Self {
27	    Paths {
28	      home: PathBuf::from(std::env::var("HOME").expect("HOME nicht gesetzt")),
29	    }
30	  }
31
32	  fn projects_dir(&self) -> PathBuf {
33	    self.home.join("claude-projects")
34	  }
35
36	  fn config_dir(&self) -> PathBuf {
37	    self.home.join(".config").join("ai-control")
38	  }
39
40	  fn pools_dir(&self) -> PathBuf {
41	    self.config_dir().join("pools")
42	  }
43
44	  fn pool_dir(&self, pool: &str) -> PathBuf {
45	    self.pools_dir().join(pool)
46	  }
47
48	  fn project_config(&self, project: &str) -> PathBuf {
49	    self.projects_dir().join(project).join(PROJECT_FILE)
50	  }
51	}
52

mod terminal;

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::{BufRead, BufReader};
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::process::Command;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

/// Feste Dateinamen im Pool-Ordner. Der Pool-Ordner selbst ist das
/// CLAUDE_CONFIG_DIR. oauth-Credentials verwaltet claude selbst (Keychain);
/// die App speichert keine Tokens.
const APIKEY_FILE: &str = "apikey";
const POOL_FILE: &str = "pool.json";
const PROJECT_FILE: &str = "ai-control.json";

/// Wurzelpfade; in Tests mit temporärem home instanziierbar.
struct Paths {
  home: PathBuf,
}

impl Paths {
  fn real() -> Self {
    Paths {
      home: PathBuf::from(std::env::var("HOME").expect("HOME nicht gesetzt")),
    }
  }

  fn projects_dir(&self) -> PathBuf {
    self.home.join("claude-projects")
  }

  fn config_dir(&self) -> PathBuf {
    self.home.join(".config").join("ai-control")
  }

  fn pools_dir(&self) -> PathBuf {
    self.config_dir().join("pools")
  }

  fn pool_dir(&self, pool: &str) -> PathBuf {
    self.pools_dir().join(pool)
  }

  fn project_config(&self, project: &str) -> PathBuf {
    self.projects_dir().join(project).join(PROJECT_FILE)
  }
}


240	    .map_err(|e| format!("{}: {e}", cfg_path.display()))?;
241	  serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))
242	}
243
244	// ---------- Projekte ----------
245
246	fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
247	  let mut projects = Vec::new();
248	  let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?;
249	  for entry in entries {
250	    let entry = entry.map_err(|e| e.to_string())?;
251	    let path = entry.path();
252	    if !path.join(".claude").is_dir() {
253	      continue;
254	    }
255	    let name = entry.file_name().to_string_lossy().into_owned();
256	    let cfg = read_project_config_in(paths, &name)?;
257	    projects.push(Project {
258	      running: is_running(&name),
259	      path: path.to_string_lossy().into_owned(),
260	      pool: cfg.pool,
261	      terminal: cfg.terminal,
262	      name,
263	    });
264	  }
265	  projects.sort_by(|a, b| a.name.cmp(&b.name));
266	  Ok(projects)
267	}
268
269	// ---------- Feature: Todoliste ----------
270
271	/// Muster robotunits: Datei im Projekt-Root, per SessionStart-Hook (jq)
272	/// als additionalContext in jede Session injiziert.
273	const TODO_FILE: &str = "OFFENE-PUNKTE.md";
274	const TODO_SKELETON: &str = "# Offene Punkte — bei jedem Start prüfen und abhaken\n\nKeine offenen Punkte.\n";
275
276	fn todo_hook_command(paths: &Paths, name: &str) -> String {
277	  format!(
278	    "jq -Rs '{{systemMessage: ., hookSpecificOutput:{{hookEventName:\"SessionStart\", additionalContext: .}}}}' {}",
279	    paths.projects_dir().join(name).join(TODO_FILE).display()
280	  )
281	}
282
283	fn settings_path(paths: &Paths, name: &str) -> PathBuf {
284	  paths
285	    .projects_dir()
286	    .join(name)
287	    .join(".claude")
288	    .join("settings.json")
289	}
290
291	fn hook_is_todo(group: &serde_json::Value) -> bool {
292	  group["hooks"]
293	    .as_array()
294	    .map(|hs| {
295	      hs.iter().any(|h| {
296	        h["command"]
297	          .as_str()
298	          .map_or(false, |c| c.contains(TODO_FILE))
299	      })
300	    })
301	    .unwrap_or(false)
302	}
303
304	fn todo_state_in(paths: &Paths, name: &str) -> Result<bool, String> {
305	  check_name(name)?;
306	  let sp = settings_path(paths, name);
307	  if !sp.is_file() {
308	    return Ok(false);
309	  }
310	  let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
311	  let v: serde_json::Value =
312	    serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
313	  Ok(
314	    v["hooks"]["SessionStart"]
315	      .as_array()
316	      .map(|a| a.iter().any(hook_is_todo))
317	      .unwrap_or(false),
318	  )
319	}
320
321	fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> {
322	  check_name(name)?;
323	  let sp = settings_path(paths, name);
324	  if !sp.is_file() {
325	    return Err(format!("settings.json fehlt: {}", sp.display()));
326	  }
327	  let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
328	  let mut v: serde_json::Value =
329	    serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
330
331	  let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
332	  let hooks = root
333	    .entry("hooks")
334	    .or_insert_with(|| serde_json::json!({}))
335	    .as_object_mut()
336	    .ok_or("hooks ist kein Objekt")?;
337	  let session_start = hooks
338	    .entry("SessionStart")
339	    .or_insert_with(|| serde_json::json!([]))
340	    .as_array_mut()
341	    .ok_or("SessionStart ist kein Array")?;
342
343	  session_start.retain(|g| !hook_is_todo(g));
344	  if enabled {
345	    session_start.insert(
346	      0,
347	      serde_json::json!({
348	        "hooks": [
349	          { "type": "command", "command": todo_hook_command(paths, name) }
350	        ]
351	      }),
352	    );
353	    let todo_path = paths.projects_dir().join(name).join(TODO_FILE);
354	    if !todo_path.is_file() {
355	      fs::write(&todo_path, TODO_SKELETON)
356	        .map_err(|e| format!("{}: {e}", todo_path.display()))?;
357	    }
358	  }
359
360	  let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
361	  fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
362	}
363
364	/// "~/x" relativ zum Home auflösen; alles andere unverändert.
365	fn expand_home(paths: &Paths, p: &str) -> PathBuf {
366	  match p.strip_prefix("~/") {
367	    Some(rest) => paths.home.join(rest),
368	    None => PathBuf::from(p),
369	  }
370	}
371
372	/// Legt ein Projekt nach dem Muster der bestehenden an: memory/, .gitignore
373	/// (Sentinel), .claude/settings.json (autoMemoryDirectory, Permissions,
374	/// Berechtigungen), ai-control.json mit Pool/Terminal-Config. Kein git init —
375	/// der Ordner ist Teil des einen claude-projects-Repos.
376	fn create_project_full_in(
377	  paths: &Paths,
378	  name: &str,
379	  pool: Option<&str>,
380	  work_dir: Option<&str>,
381	  create_work_dir: bool,
382	  terminal: TerminalConfig,
383	  todo: bool,
384	) -> Result<(), String> {
385	  check_name(name)?;
386	  let dir = paths.projects_dir().join(name);
387	  if dir.exists() {
388	    return Err(format!("Projekt existiert bereits: {name}"));
389	  }
390	  if let Some(pool) = pool {
391	    if !paths.pool_dir(pool).join(POOL_FILE).is_file() {
392	      return Err(format!("Pool existiert nicht: {pool}"));
393	    }
394	  }
395	  // Arbeitsverzeichnis zuerst prüfen/anlegen — scheitert das, entsteht kein
396	  // halbes Projekt.
397	  if let Some(wd) = work_dir {
398	    let wd_path = expand_home(paths, wd);
399	    if create_work_dir {
400	      fs::create_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?;
401	    } else if !wd_path.is_dir() {
402	      return Err(format!("Arbeitsverzeichnis fehlt: {}", wd_path.display()));
403	    }
404	  }
405
406	  fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string())?;
407	  fs::create_dir_all(dir.join("memory")).map_err(|e| e.to_string())?;
408	  fs::write(dir.join(".gitignore"), ".ai-control-running\n").map_err(|e| e.to_string())?;
409
410	  let mut allow = vec![format!("Edit(~/claude-projects/{name}/**)")];
411	  let mut additional: Vec<String> = Vec::new();
412	  if let Some(wd) = work_dir {
413	    allow.insert(0, format!("Edit({wd}/**)"));
414	    additional.push(wd.to_string());
415	  }
416	  let settings = serde_json::json!({
417	    "autoMemoryDirectory": format!("~/claude-projects/{name}/memory"),
418	    "permissions": {
419	      "allow": allow,
420	      "additionalDirectories": additional,
421	    },
422	  });
423	  let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
424	  fs::write(dir.join(".claude").join("settings.json"), raw + "\n").map_err(|e| e.to_string())?;
425
426	  let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal };
427	  if cfg.pool.is_some() || !cfg.terminal.is_empty() {
428	    write_project_config_in(paths, name, &cfg)?;
429	  }
430	  if todo {
431	    set_todo_in(paths, name, true)?;
432	  }
433	  Ok(())
434	}
435
436	#[cfg(test)]
437	fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
438	  check_name(name)?;
439	  let dir = paths.projects_dir().join(name);
440	  if dir.exists() {
441	    return Err(format!("Projekt existiert bereits: {name}"));
442	  }
443	  fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string())
444	}
445
446	/// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json.
447	fn project_work_dirs_in(paths: &Paths, name: &str) -> Result<Vec<String>, String> {
448	  check_name(name)?;
449	  let sp = paths
450	    .projects_dir()
451	    .join(name)
452	    .join(".claude")
453	    .join("settings.json");
454	  if !sp.is_file() {
455	    return Ok(Vec::new());
456	  }
457	  let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
458	  let v: serde_json::Value =
459	    serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
460	  Ok(
461	    v["permissions"]["additionalDirectories"]
462	      .as_array()
463	      .map(|a| {
464	        a.iter()
465	          .filter_map(|x| x.as_str().map(String::from))
466	          .collect()
467	      })
468	      .unwrap_or_default(),
469	  )
470	}
471
472	/// Löscht den Projektordner unter claude-projects; optional auch die
473	/// verknüpften Arbeitsordner. Bei laufender Session wird abgebrochen.
474	fn delete_project_in(paths: &Paths, name: &str, delete_work_dirs: bool) -> Result<(), String> {
475	  check_name(name)?;
476	  let dir = paths.projects_dir().join(name);
477	  if !dir.is_dir() {
478	    return Err(format!("Projekt nicht gefunden: {name}"));
479	  }
480	  if delete_work_dirs {
481	    for wd in project_work_dirs_in(paths, name)? {
482	      let wd_path = expand_home(paths, &wd);
483	      fs::remove_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?;
484	    }
485	  }
486	  fs::remove_dir_all(&dir).map_err(|e| e.to_string())
487	}
488
489	fn read_project_config_in(paths: &Paths, project: &str) -> Result<ProjectConfig, String> {
490	  let cfg_path = paths.project_config(project);
491	  if !cfg_path.is_file() {
492	    return Ok(ProjectConfig::default());
493	  }
494	  let raw =
495	    fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
496	  serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))
497	}
498
499	fn write_project_config_in(

    .map_err(|e| format!("{}: {e}", cfg_path.display()))?;
  serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))
}

// ---------- Projekte ----------

fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
  let mut projects = Vec::new();
  let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?;
  for entry in entries {
    let entry = entry.map_err(|e| e.to_string())?;
    let path = entry.path();
    if !path.join(".claude").is_dir() {
      continue;
    }
    let name = entry.file_name().to_string_lossy().into_owned();
    let cfg = read_project_config_in(paths, &name)?;
    projects.push(Project {
      running: is_running(&name),
      path: path.to_string_lossy().into_owned(),
      pool: cfg.pool,
      terminal: cfg.terminal,
      name,
    });
  }
  projects.sort_by(|a, b| a.name.cmp(&b.name));
  Ok(projects)
}

// ---------- Feature: Todoliste ----------

/// Muster robotunits: Datei im Projekt-Root, per SessionStart-Hook (jq)
/// als additionalContext in jede Session injiziert.
const TODO_FILE: &str = "OFFENE-PUNKTE.md";
const TODO_SKELETON: &str = "# Offene Punkte — bei jedem Start prüfen und abhaken\n\nKeine offenen Punkte.\n";

fn todo_hook_command(paths: &Paths, name: &str) -> String {
  format!(
    "jq -Rs '{{systemMessage: ., hookSpecificOutput:{{hookEventName:\"SessionStart\", additionalContext: .}}}}' {}",
    paths.projects_dir().join(name).join(TODO_FILE).display()
  )
}

fn settings_path(paths: &Paths, name: &str) -> PathBuf {
  paths
    .projects_dir()
    .join(name)
    .join(".claude")
    .join("settings.json")
}

fn hook_is_todo(group: &serde_json::Value) -> bool {
  group["hooks"]
    .as_array()
    .map(|hs| {
      hs.iter().any(|h| {
        h["command"]
          .as_str()
          .map_or(false, |c| c.contains(TODO_FILE))
      })
    })
    .unwrap_or(false)
}

fn todo_state_in(paths: &Paths, name: &str) -> Result<bool, String> {
  check_name(name)?;
  let sp = settings_path(paths, name);
  if !sp.is_file() {
    return Ok(false);
  }
  let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
  let v: serde_json::Value =
    serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
  Ok(
    v["hooks"]["SessionStart"]
      .as_array()
      .map(|a| a.iter().any(hook_is_todo))
      .unwrap_or(false),
  )
}

fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> {
  check_name(name)?;
  let sp = settings_path(paths, name);
  if !sp.is_file() {
    return Err(format!("settings.json fehlt: {}", sp.display()));
  }
  let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
  let mut v: serde_json::Value =
    serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;

  let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
  let hooks = root
    .entry("hooks")
    .or_insert_with(|| serde_json::json!({}))
    .as_object_mut()
    .ok_or("hooks ist kein Objekt")?;
  let session_start = hooks
    .entry("SessionStart")
    .or_insert_with(|| serde_json::json!([]))
    .as_array_mut()
    .ok_or("SessionStart ist kein Array")?;

  session_start.retain(|g| !hook_is_todo(g));
  if enabled {
    session_start.insert(
      0,
      serde_json::json!({
        "hooks": [
          { "type": "command", "command": todo_hook_command(paths, name) }
        ]
      }),
    );
    let todo_path = paths.projects_dir().join(name).join(TODO_FILE);
    if !todo_path.is_file() {
      fs::write(&todo_path, TODO_SKELETON)
        .map_err(|e| format!("{}: {e}", todo_path.display()))?;
    }
  }

  let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
  fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
}

/// "~/x" relativ zum Home auflösen; alles andere unverändert.
fn expand_home(paths: &Paths, p: &str) -> PathBuf {
  match p.strip_prefix("~/") {
    Some(rest) => paths.home.join(rest),
    None => PathBuf::from(p),
  }
}

/// Legt ein Projekt nach dem Muster der bestehenden an: memory/, .gitignore
/// (Sentinel), .claude/settings.json (autoMemoryDirectory, Permissions,
/// Berechtigungen), ai-control.json mit Pool/Terminal-Config. Kein git init —
/// der Ordner ist Teil des einen claude-projects-Repos.
fn create_project_full_in(
  paths: &Paths,
  name: &str,
  pool: Option<&str>,
  work_dir: Option<&str>,
  create_work_dir: bool,
  terminal: TerminalConfig,
  todo: bool,
) -> Result<(), String> {
  check_name(name)?;
  let dir = paths.projects_dir().join(name);
  if dir.exists() {
    return Err(format!("Projekt existiert bereits: {name}"));
  }
  if let Some(pool) = pool {
    if !paths.pool_dir(pool).join(POOL_FILE).is_file() {
      return Err(format!("Pool existiert nicht: {pool}"));
    }
  }
  // Arbeitsverzeichnis zuerst prüfen/anlegen — scheitert das, entsteht kein
  // halbes Projekt.
  if let Some(wd) = work_dir {
    let wd_path = expand_home(paths, wd);
    if create_work_dir {
      fs::create_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?;
    } else if !wd_path.is_dir() {
      return Err(format!("Arbeitsverzeichnis fehlt: {}", wd_path.display()));
    }
  }

  fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string())?;
  fs::create_dir_all(dir.join("memory")).map_err(|e| e.to_string())?;
  fs::write(dir.join(".gitignore"), ".ai-control-running\n").map_err(|e| e.to_string())?;

  let mut allow = vec![format!("Edit(~/claude-projects/{name}/**)")];
  let mut additional: Vec<String> = Vec::new();
  if let Some(wd) = work_dir {
    allow.insert(0, format!("Edit({wd}/**)"));
    additional.push(wd.to_string());
  }
  let settings = serde_json::json!({
    "autoMemoryDirectory": format!("~/claude-projects/{name}/memory"),
    "permissions": {
      "allow": allow,
      "additionalDirectories": additional,
    },
  });
  let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
  fs::write(dir.join(".claude").join("settings.json"), raw + "\n").map_err(|e| e.to_string())?;

  let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal };
  if cfg.pool.is_some() || !cfg.terminal.is_empty() {
    write_project_config_in(paths, name, &cfg)?;
  }
  if todo {
    set_todo_in(paths, name, true)?;
  }
  Ok(())
}

#[cfg(test)]
fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
  check_name(name)?;
  let dir = paths.projects_dir().join(name);
  if dir.exists() {
    return Err(format!("Projekt existiert bereits: {name}"));
  }
  fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string())
}

/// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json.
fn project_work_dirs_in(paths: &Paths, name: &str) -> Result<Vec<String>, String> {
  check_name(name)?;
  let sp = paths
    .projects_dir()
    .join(name)
    .join(".claude")
    .join("settings.json");
  if !sp.is_file() {
    return Ok(Vec::new());
  }
  let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
  let v: serde_json::Value =
    serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
  Ok(
    v["permissions"]["additionalDirectories"]
      .as_array()
      .map(|a| {
        a.iter()
          .filter_map(|x| x.as_str().map(String::from))
          .collect()
      })
      .unwrap_or_default(),
  )
}

/// Löscht den Projektordner unter claude-projects; optional auch die
/// verknüpften Arbeitsordner. Bei laufender Session wird abgebrochen.
fn delete_project_in(paths: &Paths, name: &str, delete_work_dirs: bool) -> Result<(), String> {
  check_name(name)?;
  let dir = paths.projects_dir().join(name);
  if !dir.is_dir() {
    return Err(format!("Projekt nicht gefunden: {name}"));
  }
  if delete_work_dirs {
    for wd in project_work_dirs_in(paths, name)? {
      let wd_path = expand_home(paths, &wd);
      fs::remove_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?;
    }
  }
  fs::remove_dir_all(&dir).map_err(|e| e.to_string())
}

fn read_project_config_in(paths: &Paths, project: &str) -> Result<ProjectConfig, String> {
  let cfg_path = paths.project_config(project);
  if !cfg_path.is_file() {
    return Ok(ProjectConfig::default());
  }
  let raw =
    fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
  serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))
}

fn write_project_config_in(


655
656	/// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess.
657	pub(crate) fn terminal_config(project: &str) -> Result<TerminalConfig, String> {
658	  Ok(read_project_config_in(&Paths::real(), project)?.terminal)
659	}
660
661	/// Namen aller Projekte, die `pool` zugeordnet haben.
662	fn projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
663	  let mut users = Vec::new();
664	  let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?;
665	  for entry in entries {
666	    let entry = entry.map_err(|e| e.to_string())?;
667	    let cfg_path = entry.path().join(PROJECT_FILE);
668	    if !cfg_path.is_file() {
669	      continue;
670	    }
671	    let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?;
672	    let cfg: ProjectConfig = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
673	    if cfg.pool.as_deref() == Some(pool) {
674	      users.push(entry.file_name().to_string_lossy().into_owned());
675	    }
676	  }
677	  users.sort();
678	  Ok(users)
679	}
680
681	// ---------- Pools ----------
682
683	/// Ablage der API-Keys: macOS-Keychain / Linux-Secret-Service (keyring-Crate).
684	/// Die Key-Datei im Pool-Ordner bleibt Fallback, wenn der Store beim Schreiben
685	/// nicht verfügbar ist.
686	trait ApikeyStore {
687	  fn set(&self, pool: &str, key: &str) -> Result<(), String>;
688	  fn has(&self, pool: &str) -> Result<bool, String>;
689	  fn delete(&self, pool: &str) -> Result<(), String>;
690	}
691
692	/// Service-Name der Einträge; Account ist die Pool-ID. Unter Linux legt die
693	/// keyring-Crate die Attribute service/username an — der apiKeyHelper liest
694	/// mit denselben Attributen über secret-tool.
695	const APIKEY_SERVICE: &str = "ai-control-apikey";
696
697	/// Fehler-Sentinel an die UI: Store nicht verfügbar und Datei-Ablage (noch)
698	/// nicht erlaubt — die UI fragt dann nach und wiederholt mit allow_file.
699	const KEYCHAIN_UNAVAILABLE: &str = "keychain-unavailable";
700
701	struct KeychainStore;
702
703	/// macOS über das security-CLI: dessen Einträge tragen /usr/bin/security in
704	/// der ACL, der apiKeyHelper (liest ebenfalls per security-CLI beim
705	/// claude-Start) kommt dadurch ohne Keychain-Prompt an den Key. Über das
706	/// Security-Framework angelegte Einträge (keyring-Crate) würden beim Lesen
707	/// durchs CLI prompten.
708	#[cfg(target_os = "macos")]
709	impl ApikeyStore for KeychainStore {
710	  fn set(&self, pool: &str, key: &str) -> Result<(), String> {
711	    let out = Command::new("security")
712	      .args(["add-generic-password", "-U", "-s", APIKEY_SERVICE, "-a", pool, "-w", key])
713	      .output()
714	      .map_err(|e| e.to_string())?;

/// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess.
pub(crate) fn terminal_config(project: &str) -> Result<TerminalConfig, String> {
  Ok(read_project_config_in(&Paths::real(), project)?.terminal)
}

/// Namen aller Projekte, die `pool` zugeordnet haben.
fn projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
  let mut users = Vec::new();
  let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?;
  for entry in entries {
    let entry = entry.map_err(|e| e.to_string())?;
    let cfg_path = entry.path().join(PROJECT_FILE);
    if !cfg_path.is_file() {
      continue;
    }
    let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?;
    let cfg: ProjectConfig = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
    if cfg.pool.as_deref() == Some(pool) {
      users.push(entry.file_name().to_string_lossy().into_owned());
    }
  }
  users.sort();
  Ok(users)
}

// ---------- Pools ----------

/// Ablage der API-Keys: macOS-Keychain / Linux-Secret-Service (keyring-Crate).
/// Die Key-Datei im Pool-Ordner bleibt Fallback, wenn der Store beim Schreiben
/// nicht verfügbar ist.
trait ApikeyStore {
  fn set(&self, pool: &str, key: &str) -> Result<(), String>;
  fn has(&self, pool: &str) -> Result<bool, String>;
  fn delete(&self, pool: &str) -> Result<(), String>;
}

/// Service-Name der Einträge; Account ist die Pool-ID. Unter Linux legt die
/// keyring-Crate die Attribute service/username an — der apiKeyHelper liest
/// mit denselben Attributen über secret-tool.
const APIKEY_SERVICE: &str = "ai-control-apikey";

/// Fehler-Sentinel an die UI: Store nicht verfügbar und Datei-Ablage (noch)
/// nicht erlaubt — die UI fragt dann nach und wiederholt mit allow_file.
const KEYCHAIN_UNAVAILABLE: &str = "keychain-unavailable";

struct KeychainStore;

/// macOS über das security-CLI: dessen Einträge tragen /usr/bin/security in
/// der ACL, der apiKeyHelper (liest ebenfalls per security-CLI beim
/// claude-Start) kommt dadurch ohne Keychain-Prompt an den Key. Über das
/// Security-Framework angelegte Einträge (keyring-Crate) würden beim Lesen
/// durchs CLI prompten.
#[cfg(target_os = "macos")]
impl ApikeyStore for KeychainStore {
  fn set(&self, pool: &str, key: &str) -> Result<(), String> {
    let out = Command::new("security")
      .args(["add-generic-password", "-U", "-s", APIKEY_SERVICE, "-a", pool, "-w", key])
      .output()
      .map_err(|e| e.to_string())?;


1210	/// Aggregiert message.usage aus den Transcript-JSONLs aller Pools:
1211	/// <pool>/projects/<kodierter-projektpfad>/*.jsonl → Pool × Projekt.
1212	/// Sichtfenster ist, was claude noch nicht weggeräumt hat (cleanupPeriodDays).
1213	fn usage_stats_in(paths: &Paths, days: u32) -> Result<Vec<UsageRow>, String> {
1214	  let now = SystemTime::now()
1215	    .duration_since(UNIX_EPOCH)
1216	    .map_err(|e| e.to_string())?
1217	    .as_secs() as i64;
1218	  let cutoff = now - days as i64 * 86400;
1219
1220	  // kodierter Projektpfad → Projektname
1221	  let mut names = HashMap::new();
1222	  for entry in fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())? {
1223	    let entry = entry.map_err(|e| e.to_string())?;
1224	    if !entry.path().join(".claude").is_dir() {
1225	      continue;
1226	    }
1227	    let name = entry.file_name().to_string_lossy().into_owned();
1228	    names.insert(encode_project_path(&entry.path().to_string_lossy()), name);
1229	  }
1230
1231	  let mut seen = HashSet::new();
1232	  let mut rows: HashMap<(String, String), UsageTotals> = HashMap::new();
1233
1234	  for pool_entry in fs::read_dir(paths.pools_dir()).map_err(|e| e.to_string())? {
1235	    let pool_entry = pool_entry.map_err(|e| e.to_string())?;
1236	    let id = pool_entry.file_name().to_string_lossy().into_owned();
1237	    // Anzeigename aus pool.json; Ordner ohne pool.json unter der ID ausweisen.
1238	    let pool = if pool_entry.path().join(POOL_FILE).is_file() {
1239	      read_pool(paths, &id)?.name
1240	    } else {
1241	      id
1242	    };
1243	    let projects_root = pool_entry.path().join("projects");
1244	    if !projects_root.is_dir() {
1245	      continue;
1246	    }
1247	    for proj_entry in fs::read_dir(&projects_root).map_err(|e| e.to_string())? {
1248	      let proj_entry = proj_entry.map_err(|e| e.to_string())?;
1249	      if !proj_entry.path().is_dir() {
1250	        continue;
1251	      }
1252	      let encoded = proj_entry.file_name().to_string_lossy().into_owned();
1253	      // Unbekannte Ordner (Sessions außerhalb der Projekte) unter dem
1254	      // kodierten Namen ausweisen statt verschlucken.
1255	      let project = names.get(&encoded).cloned().unwrap_or(encoded);
1256	      let totals = rows.entry((pool.clone(), project)).or_default();
1257	      for file in fs::read_dir(proj_entry.path()).map_err(|e| e.to_string())? {
1258	        let file = file.map_err(|e| e.to_string())?;
1259	        let path = file.path();
1260	        if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
1261	          continue;
1262	        }
1263	        let f = fs::File::open(&path).map_err(|e| format!("{}: {e}", path.display()))?;
1264	        for line in BufReader::new(f).lines() {
1265	          let line = line.map_err(|e| format!("{}: {e}", path.display()))?;
1266	          add_usage_line(&line, cutoff, &mut seen, totals);
1267	        }
1268	      }
1269	    }
1270	  }
1271
1272	  let mut out: Vec<UsageRow> = rows
1273	    .into_iter()
1274	    .map(|((pool, project), totals)| UsageRow { pool, project, totals })
1275	    .collect();
1276	  out.sort_by(|a, b| {
1277	    a.pool
1278	      .cmp(&b.pool)
1279	      .then(b.totals.cost_usd.total_cmp(&a.totals.cost_usd))
1280	  });
1281	  Ok(out)
1282	}
1283
1284	// ---------- Tauri-Commands ----------
1285
1286	#[tauri::command]
1287	fn list_projects() -> Result<Vec<Project>, String> {
1288	  list_projects_in(&Paths::real())
1289	}
1290
1291	#[tauri::command]
1292	fn create_project_full(
1293	  name: String,
1294	  pool: Option<String>,
1295	  work_dir: Option<String>,
1296	  create_work_dir: bool,
1297	  terminal: TerminalConfig,
1298	  todo: bool,
1299	) -> Result<(), String> {
1300	  create_project_full_in(
1301	    &Paths::real(),
1302	    &name,
1303	    pool.as_deref(),
1304	    work_dir.as_deref(),
1305	    create_work_dir,
1306	    terminal,
1307	    todo,
1308	  )
1309	}
1310
1311	#[tauri::command]
1312	fn todo_state(project: String) -> Result<bool, String> {
1313	  todo_state_in(&Paths::real(), &project)
1314	}
1315
1316	#[tauri::command]
1317	fn set_todo(project: String, enabled: bool) -> Result<(), String> {
1318	  set_todo_in(&Paths::real(), &project, enabled)
1319	}
1320
1321	#[tauri::command]
1322	fn delete_project(name: String, delete_work_dirs: bool) -> Result<(), String> {
1323	  if is_running(&name) {
1324	    return Err(format!("{name} läuft noch — erst beenden"));
1325	  }
1326	  delete_project_in(&Paths::real(), &name, delete_work_dirs)
1327	}
1328
1329	#[tauri::command]

/// Aggregiert message.usage aus den Transcript-JSONLs aller Pools:
/// <pool>/projects/<kodierter-projektpfad>/*.jsonl → Pool × Projekt.
/// Sichtfenster ist, was claude noch nicht weggeräumt hat (cleanupPeriodDays).
fn usage_stats_in(paths: &Paths, days: u32) -> Result<Vec<UsageRow>, String> {
  let now = SystemTime::now()
    .duration_since(UNIX_EPOCH)
    .map_err(|e| e.to_string())?
    .as_secs() as i64;
  let cutoff = now - days as i64 * 86400;

  // kodierter Projektpfad → Projektname
  let mut names = HashMap::new();
  for entry in fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())? {
    let entry = entry.map_err(|e| e.to_string())?;
    if !entry.path().join(".claude").is_dir() {
      continue;
    }
    let name = entry.file_name().to_string_lossy().into_owned();
    names.insert(encode_project_path(&entry.path().to_string_lossy()), name);
  }

  let mut seen = HashSet::new();
  let mut rows: HashMap<(String, String), UsageTotals> = HashMap::new();

  for pool_entry in fs::read_dir(paths.pools_dir()).map_err(|e| e.to_string())? {
    let pool_entry = pool_entry.map_err(|e| e.to_string())?;
    let id = pool_entry.file_name().to_string_lossy().into_owned();
    // Anzeigename aus pool.json; Ordner ohne pool.json unter der ID ausweisen.
    let pool = if pool_entry.path().join(POOL_FILE).is_file() {
      read_pool(paths, &id)?.name
    } else {
      id
    };
    let projects_root = pool_entry.path().join("projects");
    if !projects_root.is_dir() {
      continue;
    }
    for proj_entry in fs::read_dir(&projects_root).map_err(|e| e.to_string())? {
      let proj_entry = proj_entry.map_err(|e| e.to_string())?;
      if !proj_entry.path().is_dir() {
        continue;
      }
      let encoded = proj_entry.file_name().to_string_lossy().into_owned();
      // Unbekannte Ordner (Sessions außerhalb der Projekte) unter dem
      // kodierten Namen ausweisen statt verschlucken.
      let project = names.get(&encoded).cloned().unwrap_or(encoded);
      let totals = rows.entry((pool.clone(), project)).or_default();
      for file in fs::read_dir(proj_entry.path()).map_err(|e| e.to_string())? {
        let file = file.map_err(|e| e.to_string())?;
        let path = file.path();
        if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
          continue;
        }
        let f = fs::File::open(&path).map_err(|e| format!("{}: {e}", path.display()))?;
        for line in BufReader::new(f).lines() {
          let line = line.map_err(|e| format!("{}: {e}", path.display()))?;
          add_usage_line(&line, cutoff, &mut seen, totals);
        }
      }
    }
  }

  let mut out: Vec<UsageRow> = rows
    .into_iter()
    .map(|((pool, project), totals)| UsageRow { pool, project, totals })
    .collect();
  out.sort_by(|a, b| {
    a.pool
      .cmp(&b.pool)
      .then(b.totals.cost_usd.total_cmp(&a.totals.cost_usd))
  });
  Ok(out)
}

// ---------- Tauri-Commands ----------

#[tauri::command]
fn list_projects() -> Result<Vec<Project>, String> {
  list_projects_in(&Paths::real())
}

#[tauri::command]
fn create_project_full(
  name: String,
  pool: Option<String>,
  work_dir: Option<String>,
  create_work_dir: bool,
  terminal: TerminalConfig,
  todo: bool,
) -> Result<(), String> {
  create_project_full_in(
    &Paths::real(),
    &name,
    pool.as_deref(),
    work_dir.as_deref(),
    create_work_dir,
    terminal,
    todo,
  )
}

#[tauri::command]
fn todo_state(project: String) -> Result<bool, String> {
  todo_state_in(&Paths::real(), &project)
}

#[tauri::command]
fn set_todo(project: String, enabled: bool) -> Result<(), String> {
  set_todo_in(&Paths::real(), &project, enabled)
}

#[tauri::command]
fn delete_project(name: String, delete_work_dirs: bool) -> Result<(), String> {
  if is_running(&name) {
    return Err(format!("{name} läuft noch — erst beenden"));
  }
  delete_project_in(&Paths::real(), &name, delete_work_dirs)
}

#[tauri::command]


95	  let title = cfg.title.as_deref().unwrap_or(project);
96	  let builder = WebviewWindowBuilder::new(
97	    app,
98	    format!("term-{project}"),
99	    WebviewUrl::App(format!("terminal.html?project={project}").into()),
100	  )
101	  .title(format!("{title} — Session"))
102	  .inner_size(980.0, 640.0)
103	  .background_color(tauri::window::Color(r, g, b, 0xff));
104
105	  // Titelbar liegt über dem Inhalt; der Header in terminal.html ist Drag-Region.
106	  #[cfg(target_os = "macos")]
107	  let builder = builder
108	    .title_bar_style(tauri::TitleBarStyle::Overlay)
109	    .hidden_title(true);
110
111	  builder.build()?;
112	  Ok(())
113	}
114
115	/// Startet die PTY für das rufende Fenster: `claude-sync` im Projektordner.
116	/// zsh -i lädt die .zshrc (PATH, fnm); Pool, Sentinel und git-Sync macht
117	/// claude-sync selbst.
118	#[tauri::command]
119	pub fn term_start(
120	  window: tauri::WebviewWindow,
121	  terminals: State<Terminals>,
122	  project: String,
123	  rows: u16,
124	  cols: u16,
125	) -> Result<(), String> {
126	  let home = std::env::var("HOME").map_err(|e| e.to_string())?;
127	  let cwd = PathBuf::from(home).join("claude-projects").join(&project);
128
129	  let pty = native_pty_system()
130	    .openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
131	    .map_err(|e| e.to_string())?;
132
133	  let mut cmd = CommandBuilder::new("/bin/zsh");
134	  cmd.args(["-ic", "claude-sync"]);
135	  cmd.cwd(&cwd);
136	  cmd.env("TERM", "xterm-256color");
137
138	  let mut child = pty.slave.spawn_command(cmd).map_err(|e| e.to_string())?;
139	  drop(pty.slave);
140
141	  let killer = child.clone_killer();
142	  let writer = pty.master.take_writer().map_err(|e| e.to_string())?;
143	  let mut reader = pty.master.try_clone_reader().map_err(|e| e.to_string())?;
144
145	  let label = window.label().to_string();
146	  let app = window.app_handle().clone();
147
148	  // Output gebündelt emittieren statt pro read(): der Reader schiebt Chunks
149	  // in einen Channel, der Emitter sammelt alles, was innerhalb von 8 ms
150	  // ankommt, zu einem Event zusammen. Bei Output-Fluten (Scrollen, TUI-
151	  // Redraws) sinkt die Zahl der IPC-Events um Größenordnungen; die 8 ms
152	  // Zusatzlatenz beim Tasten-Echo liegen unter einem Frame.
153	  let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
154	  std::thread::spawn(move || {
155	    let mut buf = [0u8; 8192];
156	    loop {
157	      match reader.read(&mut buf) {
158	        Ok(0) | Err(_) => break,
159	        Ok(n) => {
160	          if tx.send(buf[..n].to_vec()).is_err() {
161	            break;
162	          }
163	        }
164	      }

  let title = cfg.title.as_deref().unwrap_or(project);
  let builder = WebviewWindowBuilder::new(
    app,
    format!("term-{project}"),
    WebviewUrl::App(format!("terminal.html?project={project}").into()),
  )
  .title(format!("{title} — Session"))
  .inner_size(980.0, 640.0)
  .background_color(tauri::window::Color(r, g, b, 0xff));

  // Titelbar liegt über dem Inhalt; der Header in terminal.html ist Drag-Region.
  #[cfg(target_os = "macos")]
  let builder = builder
    .title_bar_style(tauri::TitleBarStyle::Overlay)
    .hidden_title(true);

  builder.build()?;
  Ok(())
}

/// Startet die PTY für das rufende Fenster: `claude-sync` im Projektordner.
/// zsh -i lädt die .zshrc (PATH, fnm); Pool, Sentinel und git-Sync macht
/// claude-sync selbst.
#[tauri::command]
pub fn term_start(
  window: tauri::WebviewWindow,
  terminals: State<Terminals>,
  project: String,
  rows: u16,
  cols: u16,
) -> Result<(), String> {
  let home = std::env::var("HOME").map_err(|e| e.to_string())?;
  let cwd = PathBuf::from(home).join("claude-projects").join(&project);

  let pty = native_pty_system()
    .openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
    .map_err(|e| e.to_string())?;

  let mut cmd = CommandBuilder::new("/bin/zsh");
  cmd.args(["-ic", "claude-sync"]);
  cmd.cwd(&cwd);
  cmd.env("TERM", "xterm-256color");

  let mut child = pty.slave.spawn_command(cmd).map_err(|e| e.to_string())?;
  drop(pty.slave);

  let killer = child.clone_killer();
  let writer = pty.master.take_writer().map_err(|e| e.to_string())?;
  let mut reader = pty.master.try_clone_reader().map_err(|e| e.to_string())?;

  let label = window.label().to_string();
  let app = window.app_handle().clone();

  // Output gebündelt emittieren statt pro read(): der Reader schiebt Chunks
  // in einen Channel, der Emitter sammelt alles, was innerhalb von 8 ms
  // ankommt, zu einem Event zusammen. Bei Output-Fluten (Scrollen, TUI-
  // Redraws) sinkt die Zahl der IPC-Events um Größenordnungen; die 8 ms
  // Zusatzlatenz beim Tasten-Echo liegen unter einem Frame.
  let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
  std::thread::spawn(move || {
    let mut buf = [0u8; 8192];
    loop {
      match reader.read(&mut buf) {
        Ok(0) | Err(_) => break,
        Ok(n) => {
          if tx.send(buf[..n].to_vec()).is_err() {
            break;
          }
        }
      }


Backend-Design steht. Jetzt das Frontend ansehen:


ls -R /home/marcuh/projects/ai-control/src | head -40; grep -rn "create_project_full\|delete_project\|list_projects" /home/marcuh/projects/ai-control/src --include=*.ts --include=*.vue -l

List frontend sources and find project-command call sites


App.vue
assets
components
i18n.ts
main.ts
style.css
terminal.ts

/home/marcuh/projects/ai-control/src/assets:

/home/marcuh/projects/ai-control/src/components:
PoolList.vue
ProjectList.vue
UsageList.vue
(eval):1: no matches found: --include=*.ts


grep -rn "create_project_full\|delete_project\|list_projects\|invoke(" /home/marcuh/projects/ai-control/src/components/ProjectList.vue | head -30; wc -l /home/marcuh/projects/ai-control/src/components/ProjectList.vue

Find project command usage in ProjectList.vue


/home/marcuh/projects/ai-control/src/components/ProjectList.vue:73:    projects.value = await invoke<Project[]>("list_projects");
/home/marcuh/projects/ai-control/src/components/ProjectList.vue:84:    await invoke("stop_project", { project: project.name });
/home/marcuh/projects/ai-control/src/components/ProjectList.vue:93:    await invoke("open_terminal", { project: project.name });
/home/marcuh/projects/ai-control/src/components/ProjectList.vue:113:    await invoke("assign_pool", { project: project.name, pool });
/home/marcuh/projects/ai-control/src/components/ProjectList.vue:127:    await invoke("restart_project", { project: p.name });
/home/marcuh/projects/ai-control/src/components/ProjectList.vue:191:    await invoke("set_terminal_config", {
/home/marcuh/projects/ai-control/src/components/ProjectList.vue:197:    await invoke("set_todo", { project: s.name, enabled: s.todo });
/home/marcuh/projects/ai-control/src/components/ProjectList.vue:249:    await invoke("create_project_full", {
/home/marcuh/projects/ai-control/src/components/ProjectList.vue:291:    await invoke("delete_project", {
     557 /home/marcuh/projects/ai-control/src/components/ProjectList.vue


1
310
311	<template>
312

313	    <button class="primary" @click="openWizard">
314	      {{ $t("projects.newProject") }}
315	    </button>
316

317
318
{{ error }}

319

320	    <colgroup>
321	      <col class="col-dot" />
322	      <col />
323	      <col class="col-pool" />
324	      <col class="col-actions" />
325	    </colgroup>
326

327

328
329	        {{ $t("projects.project") }}
330	        {{ $t("projects.pool") }}
331
332

333

334

335

336
337
338
339
340
341
342	            {{ p.name }}
343
344	          {{ p.path }}
345
346
347	          <select :value="p.pool ?? ''" @change="assign(p, $event)">
348	            <option value="" disabled>{{ $t("projects.noPool") }}</option>
349	            <option v-for="pool in pools" :key="pool.id" :value="pool.id">
350	              {{ pool.name }}
351	            </option>
352	          </select>
353
354
355
356	            <button
357	              class="gear"
358	              :title="$t('projects.terminalSettings')"
359	              @click="openSettings(p)"
360	            >
361	              ⚙
362	            </button>
363	            <button
364	              class="gear danger"
365	              :disabled="p.running"
366	              :title="p.running ? $t('projects.deleteRunning') : $t('projects.delete')"
367	              @click="askDelete(p)"
368	            >
369	              🗑
370	            </button>
371	            <button v-if="p.running" class="stop" @click="stop(p)">
372	              {{ $t("projects.stop") }}
373	            </button>
374	            <button
375	              v-else
376	              class="start"
377	              :disabled="!poolReady(p)"
378	              :title="startBlockedReason(p) && $t(startBlockedReason(p)!)"
379	              @click="openTerminal(p)"
380	            >
381	              {{ $t("projects.start") }}
382	            </button>
383
384
385

386

387

388
389

390	    <form class="dialog" @submit.prevent="createProject">
391
{{ $t("projects.wizardTitle") }}

392
393	        {{ $t("projects.name") }}
394	        <input v-model="wizard.name" autofocus required />
395
396
397	        {{ $t("projects.pool") }}
398	        <select v-model="wizard.pool">
399	          <option value="">{{ $t("projects.noPool") }}</option>
400	          <option v-for="pool in pools" :key="pool.id" :value="pool.id">
401	            {{ pool.name }}
402	          </option>
403	        </select>
404
405
406	        {{ $t("projects.workDir") }}
407
408	          <select v-model="wizard.workDirMode">
409	            <option value="none">{{ $t("projects.workDirNone") }}</option>
410	            <option value="default">
411	              ~/projects/{{ wizard.name.trim() || "…" }}
412	            </option>
413	            <option value="custom">{{ $t("projects.workDirCustom") }}</option>
414	          </select>
415	          <button
416	            v-if="wizard.workDirMode === 'custom'"
417	            type="button"
418	            @click="pickWorkDir"
419	          >
420	            {{ $t("projects.chooseFolder") }}
421	          </button>
422
423
424
425
426	        {{ wizard.workDir }}
427
428
429	        {{ $t("projects.title") }}
430	        <input v-model="wizard.title" :placeholder="wizard.name.trim()" />
431
432
433	        {{ $t("projects.theme") }}
434	        <select v-model="wizard.theme">
435	          <option v-for="[value, label] in THEME_NAMES" :key="value" :value="value">
436	            {{ label }}
437	          </option>
438	        </select>
439
440
441	        {{ $t("projects.todo") }}
442
443	          <input v-model="wizard.todo" type="checkbox" />
444	          {{ $t("projects.todoDesc") }}
445
446
447
{{ $t("projects.wizardHint") }}

448

449	        <button type="button" @click="wizard = null">
450	          {{ $t("projects.cancel") }}
451	        </button>
452	        <button
453	          type="submit"
454	          class="primary"
455	          :disabled="
456	            wizard.name.trim() === '' ||
457	            (wizard.workDirMode === 'custom' && !wizard.workDir)
458	          "
459	        >
460	          {{ $t("projects.create") }}
461	        </button>
462

463	    </form>
464

465
466

467

468
{{ $t("projects.deleteTitle", { name: pendingDelete.name }) }}

469
{{ $t("projects.deleteWarning", { name: pendingDelete.name }) }}

470	      <template v-if="pendingDelete.workDirs.length">
471
472	          <input v-model="pendingDelete.deleteWorkDirs" type="checkbox" />
473	          {{ $t("projects.deleteWorkDirs") }}
474
475

476
{{ wd }}

477

478

479	          {{ $t("projects.workDirsStay") }}
480

481	      </template>
482

483	        <button type="button" @click="pendingDelete = null">
484	          {{ $t("projects.cancel") }}
485	        </button>
486	        <button class="danger" @click="confirmDelete">
487	          {{ $t("projects.delete") }}
488	        </button>
489

490

491

492
493

494

495
{{ $t("projects.terminal", { name: settings.name }) }}

496
497	        {{ $t("projects.title") }}
498	        <input v-model="settings.title" :placeholder="settings.name" />
499
500
501	        {{ $t("projects.theme") }}
502	        <select v-model="settings.theme">
503	          <option v-for="[value, label] in THEME_NAMES" :key="value" :value="value">
504	            {{ label }}
505	          </option>
506	        </select>
507
508
509	        {{ $t("projects.dockIcon") }}
510
511	          <button @click="pickIcon">{{ $t("projects.chooseFile") }}</button>
512	          <button v-if="settings.icon" @click="settings.icon = null">
513	            {{ $t("projects.remove") }}
514	          </button>
515
516	            {{ settings.icon.split("/").pop() }}
517	            {{ settings.icon }}
518
519	          {{ $t("projects.defaultIcon") }}
520
521
522
523	        {{ $t("projects.todo") }}
524
525	          <input v-model="settings.todo" type="checkbox" />
526	          {{ $t("projects.todoDesc") }}
527
528
529
{{ $t("projects.appliesNextStart") }}

530

531	        <button @click="settings = null">{{ $t("projects.cancel") }}</button>
532	        <button class="primary" @click="saveSettings">
533	          {{ $t("projects.save") }}
534	        </button>
535

536

537

538
539

540

541
{{ $t("projects.stillRunning", { name: pending.name }) }}

542
{{ $t("projects.poolChangeSaved") }}

543

544	        {{ poolName(pending.from) ?? $t("projects.noPoolAssigned") }}
545	        →
546	        {{ poolName(pending.to) }}
547

548
{{ $t("projects.restartHint") }}

549

550	        <button @click="pending = null">{{ $t("projects.keepRunning") }}</button>
551	        <button class="primary" :disabled="restarting" @click="restartNow">
552	          {{ restarting ? $t("projects.restarting") : $t("projects.restartNow") }}
553	        </button>
554

555

556

557	</template>
558

<template>

    <button class="primary" @click="openWizard">
      {{ $t("projects.newProject") }}
    </button>

{{ error }}

    <colgroup>
      <col class="col-dot" />
      <col />
      <col class="col-pool" />
      <col class="col-actions" />
    </colgroup>

        {{ $t("projects.project") }}
        {{ $t("projects.pool") }}

            {{ p.name }}

          {{ p.path }}

          <select :value="p.pool ?? ''" @change="assign(p, $event)">
            <option value="" disabled>{{ $t("projects.noPool") }}</option>
            <option v-for="pool in pools" :key="pool.id" :value="pool.id">
              {{ pool.name }}
            </option>
          </select>

            <button
              class="gear"
              :title="$t('projects.terminalSettings')"
              @click="openSettings(p)"
            >
              ⚙
            </button>
            <button
              class="gear danger"
              :disabled="p.running"
              :title="p.running ? $t('projects.deleteRunning') : $t('projects.delete')"
              @click="askDelete(p)"
            >
              🗑
            </button>
            <button v-if="p.running" class="stop" @click="stop(p)">
              {{ $t("projects.stop") }}
            </button>
            <button
              v-else
              class="start"
              :disabled="!poolReady(p)"
              :title="startBlockedReason(p) && $t(startBlockedReason(p)!)"
              @click="openTerminal(p)"
            >
              {{ $t("projects.start") }}
            </button>

    <form class="dialog" @submit.prevent="createProject">

{{ $t("projects.wizardTitle") }}

        {{ $t("projects.name") }}
        <input v-model="wizard.name" autofocus required />

        {{ $t("projects.pool") }}
        <select v-model="wizard.pool">
          <option value="">{{ $t("projects.noPool") }}</option>
          <option v-for="pool in pools" :key="pool.id" :value="pool.id">
            {{ pool.name }}
          </option>
        </select>

        {{ $t("projects.workDir") }}

          <select v-model="wizard.workDirMode">
            <option value="none">{{ $t("projects.workDirNone") }}</option>
            <option value="default">
              ~/projects/{{ wizard.name.trim() || "…" }}
            </option>
            <option value="custom">{{ $t("projects.workDirCustom") }}</option>
          </select>
          <button
            v-if="wizard.workDirMode === 'custom'"
            type="button"
            @click="pickWorkDir"
          >
            {{ $t("projects.chooseFolder") }}
          </button>

        {{ wizard.workDir }}

        {{ $t("projects.title") }}
        <input v-model="wizard.title" :placeholder="wizard.name.trim()" />

        {{ $t("projects.theme") }}
        <select v-model="wizard.theme">
          <option v-for="[value, label] in THEME_NAMES" :key="value" :value="value">
            {{ label }}
          </option>
        </select>

        {{ $t("projects.todo") }}

          <input v-model="wizard.todo" type="checkbox" />
          {{ $t("projects.todoDesc") }}

{{ $t("projects.wizardHint") }}

        <button type="button" @click="wizard = null">
          {{ $t("projects.cancel") }}
        </button>
        <button
          type="submit"
          class="primary"
          :disabled="
            wizard.name.trim() === '' ||
            (wizard.workDirMode === 'custom' && !wizard.workDir)
          "
        >
          {{ $t("projects.create") }}
        </button>

    </form>

{{ $t("projects.deleteTitle", { name: pendingDelete.name }) }}

{{ $t("projects.deleteWarning", { name: pendingDelete.name }) }}

      <template v-if="pendingDelete.workDirs.length">

          <input v-model="pendingDelete.deleteWorkDirs" type="checkbox" />
          {{ $t("projects.deleteWorkDirs") }}

{{ wd }}

          {{ $t("projects.workDirsStay") }}

      </template>

        <button type="button" @click="pendingDelete = null">
          {{ $t("projects.cancel") }}
        </button>
        <button class="danger" @click="confirmDelete">
          {{ $t("projects.delete") }}
        </button>

{{ $t("projects.terminal", { name: settings.name }) }}

        {{ $t("projects.title") }}
        <input v-model="settings.title" :placeholder="settings.name" />

        {{ $t("projects.theme") }}
        <select v-model="settings.theme">
          <option v-for="[value, label] in THEME_NAMES" :key="value" :value="value">
            {{ label }}
          </option>
        </select>

        {{ $t("projects.dockIcon") }}

          <button @click="pickIcon">{{ $t("projects.chooseFile") }}</button>
          <button v-if="settings.icon" @click="settings.icon = null">
            {{ $t("projects.remove") }}
          </button>

            {{ settings.icon.split("/").pop() }}
            {{ settings.icon }}

          {{ $t("projects.defaultIcon") }}

        {{ $t("projects.todo") }}

          <input v-model="settings.todo" type="checkbox" />
          {{ $t("projects.todoDesc") }}

{{ $t("projects.appliesNextStart") }}

        <button @click="settings = null">{{ $t("projects.cancel") }}</button>
        <button class="primary" @click="saveSettings">
          {{ $t("projects.save") }}
        </button>

{{ $t("projects.stillRunning", { name: pending.name }) }}

{{ $t("projects.poolChangeSaved") }}

        {{ poolName(pending.from) ?? $t("projects.noPoolAssigned") }}
        →
        {{ poolName(pending.to) }}

{{ $t("projects.restartHint") }}

        <button @click="pending = null">{{ $t("projects.keepRunning") }}</button>
        <button class="primary" :disabled="restarting" @click="restartNow">
          {{ restarting ? $t("projects.restarting") : $t("projects.restartNow") }}
        </button>

</template>


499	fn write_project_config_in(
500	  paths: &Paths,
501	  project: &str,
502	  cfg: &ProjectConfig,
503	) -> Result<(), String> {
504	  let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
505	  fs::write(paths.project_config(project), raw + "\n").map_err(|e| e.to_string())
506	}
507
508	fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result<(), String> {
509	  if !paths.pool_dir(pool).join(POOL_FILE).is_file() {
510	    return Err(format!("Pool existiert nicht: {pool}"));
511	  }
512	  let mut cfg = read_project_config_in(paths, project)?;
513	  cfg.pool = Some(pool.to_string());
514	  write_project_config_in(paths, project, &cfg)
515	}
516
517	/// Nimmt den Pool raus; die Config-Datei bleibt nur, wenn sie noch
518	/// Terminal-Einstellungen trägt.
519	fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> {
520	  let mut cfg = read_project_config_in(paths, project)?;
521	  cfg.pool = None;
522	  if cfg.terminal.is_empty() {
523	    let cfg_path = paths.project_config(project);
524	    return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()));
525	  }
526	  write_project_config_in(paths, project, &cfg)
527	}
528
529	fn set_terminal_config_in(
530	  paths: &Paths,
531	  project: &str,
532	  mut terminal: TerminalConfig,
533	) -> Result<(), String> {
534	  // Absolut gewählte Icons in den Projektordner kopieren und relativ
535	  // speichern — so wird das Icon mit dem Projekt-Repo gesynct.
536	  if let Some(icon) = terminal.icon.as_deref() {
537	    if icon.starts_with('/') {
538	      let src = PathBuf::from(icon);
539	      let ext = src
540	        .extension()
541	        .and_then(|e| e.to_str())
542	        .unwrap_or("png")
543	        .to_lowercase();
544	      let name = format!("icon.{ext}");
545	      let dest = paths.projects_dir().join(project).join(&name);
546	      if src != dest {
547	        fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?;
548	      }
549	      terminal.icon = Some(name);
550	    }
551	  }
552	  let mut cfg = read_project_config_in(paths, project)?;
553	  cfg.terminal = terminal;
554	  write_project_config_in(paths, project, &cfg)
555	}
556
557	/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im Projektordner.
558	fn resolve_icon_path(paths: &Paths, project: &str, icon: &str) -> PathBuf {
559	  if icon.starts_with('/') {
560	    PathBuf::from(icon)
561	  } else {
562	    paths.projects_dir().join(project).join(icon)
563	  }
564	}
565
566	/// Icon eines Projekts als PNG-data-URL für die Übersicht; ICNS wird per
567	/// sips nach PNG konvertiert, weil der Browser ICNS nicht rendert.
568	#[tauri::command]
569	fn project_icon(project: String) -> Result<Option<String>, String> {
570	  use base64::{engine::general_purpose::STANDARD, Engine};
571	  let paths = Paths::real();
572	  let Some(icon) = read_project_config_in(&paths, &project)?.terminal.icon else {
573	    return Ok(None);

fn write_project_config_in(
  paths: &Paths,
  project: &str,
  cfg: &ProjectConfig,
) -> Result<(), String> {
  let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
  fs::write(paths.project_config(project), raw + "\n").map_err(|e| e.to_string())
}

fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result<(), String> {
  if !paths.pool_dir(pool).join(POOL_FILE).is_file() {
    return Err(format!("Pool existiert nicht: {pool}"));
  }
  let mut cfg = read_project_config_in(paths, project)?;
  cfg.pool = Some(pool.to_string());
  write_project_config_in(paths, project, &cfg)
}

/// Nimmt den Pool raus; die Config-Datei bleibt nur, wenn sie noch
/// Terminal-Einstellungen trägt.
fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> {
  let mut cfg = read_project_config_in(paths, project)?;
  cfg.pool = None;
  if cfg.terminal.is_empty() {
    let cfg_path = paths.project_config(project);
    return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()));
  }
  write_project_config_in(paths, project, &cfg)
}

fn set_terminal_config_in(
  paths: &Paths,
  project: &str,
  mut terminal: TerminalConfig,
) -> Result<(), String> {
  // Absolut gewählte Icons in den Projektordner kopieren und relativ
  // speichern — so wird das Icon mit dem Projekt-Repo gesynct.
  if let Some(icon) = terminal.icon.as_deref() {
    if icon.starts_with('/') {
      let src = PathBuf::from(icon);
      let ext = src
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or("png")
        .to_lowercase();
      let name = format!("icon.{ext}");
      let dest = paths.projects_dir().join(project).join(&name);
      if src != dest {
        fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?;
      }
      terminal.icon = Some(name);
    }
  }
  let mut cfg = read_project_config_in(paths, project)?;
  cfg.terminal = terminal;
  write_project_config_in(paths, project, &cfg)
}

/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im Projektordner.
fn resolve_icon_path(paths: &Paths, project: &str, icon: &str) -> PathBuf {
  if icon.starts_with('/') {
    PathBuf::from(icon)
  } else {
    paths.projects_dir().join(project).join(icon)
  }
}

/// Icon eines Projekts als PNG-data-URL für die Übersicht; ICNS wird per
/// sips nach PNG konvertiert, weil der Browser ICNS nicht rendert.
#[tauri::command]
fn project_icon(project: String) -> Result<Option<String>, String> {
  use base64::{engine::general_purpose::STANDARD, Engine};
  let paths = Paths::real();
  let Some(icon) = read_project_config_in(&paths, &project)?.terminal.icon else {
    return Ok(None);


Alle Stellen bekannt. Jetzt die Backend-Edits — zuerst Paths + Registry:


Edit

const APIKEY_FILE: &str = "apikey";
const POOL_FILE: &str = "pool.json";
const PROJECT_FILE: &str = "ai-control.json";

/// Wurzelpfade; in Tests mit temporärem home instanziierbar.
struct Paths {
  home: PathBuf,
}

impl Paths {
  fn real() -> Self {
    Paths {
      home: PathBuf::from(std::env::var("HOME").expect("HOME nicht gesetzt")),
    }
  }

  fn projects_dir(&self) -> PathBuf {
    self.home.join("claude-projects")
  }

  fn config_dir(&self) -> PathBuf {
    self.home.join(".config").join("ai-control")
  }

  fn pools_dir(&self) -> PathBuf {
    self.config_dir().join("pools")
  }

  fn pool_dir(&self, pool: &str) -> PathBuf {
    self.pools_dir().join(pool)
  }

  fn project_config(&self, project: &str) -> PathBuf {
    self.projects_dir().join(project).join(PROJECT_FILE)
  }
}

const APIKEY_FILE: &str = "apikey";
const POOL_FILE: &str = "pool.json";
const PROJECT_FILE: &str = "ai-control.json";
const PROJECTS_FILE: &str = "projects.json";

/// Wurzelpfade; in Tests mit temporärem home instanziierbar.
pub(crate) struct Paths {
  home: PathBuf,
}

impl Paths {
  pub(crate) fn real() -> Self {
    Paths {
      home: PathBuf::from(std::env::var("HOME").expect("HOME nicht gesetzt")),
    }
  }

  /// Default-Root: Alt-Layout (Discovery ohne Registry) und Ablageort neuer
  /// Projekte ohne gewählten Zielordner.
  fn projects_dir(&self) -> PathBuf {
    self.home.join("claude-projects")
  }

  fn config_dir(&self) -> PathBuf {
    self.home.join(".config").join("ai-control")
  }

  fn projects_file(&self) -> PathBuf {
    self.config_dir().join(PROJECTS_FILE)
  }

  fn pools_dir(&self) -> PathBuf {
    self.config_dir().join("pools")
  }

  fn pool_dir(&self, pool: &str) -> PathBuf {
    self.pools_dir().join(pool)
  }
}

// ---------- Projekt-Registry ----------

/// Pfad unterhalb von Home als "~/…" schreiben — Registry-Einträge im
/// Home-Bereich bleiben damit maschinenübergreifend stabil.
fn contract_home(paths: &Paths, p: &std::path::Path) -> String {
  match p.strip_prefix(&paths.home) {
    Ok(rest) => format!("~/{}", rest.display()),
    Err(_) => p.display().to_string(),
  }
}

/// Registry Name → Projektordner. Ohne projects.json gilt das Alt-Layout:
/// Unterordner von ~/claude-projects mit .claude-Ordner.
fn load_registry(paths: &Paths) -> Result<std::collections::BTreeMap<String, PathBuf>, String> {
  let file = paths.projects_file();
  if file.is_file() {
    let raw = fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
    let map: std::collections::BTreeMap<String, String> =
      serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", file.display()))?;
    return Ok(
      map
        .into_iter()
        .map(|(name, p)| {
          let dir = expand_home(paths, &p);
          (name, dir)
        })
        .collect(),
    );
  }
  let mut map = std::collections::BTreeMap::new();
  let Ok(entries) = fs::read_dir(paths.projects_dir()) else {
    return Ok(map); // kein Alt-Layout → keine Projekte
  };
  for entry in entries {
    let entry = entry.map_err(|e| e.to_string())?;
    if entry.path().join(".claude").is_dir() {
      map.insert(entry.file_name().to_string_lossy().into_owned(), entry.path());
    }
  }
  Ok(map)
}

fn save_registry(
  paths: &Paths,
  reg: &std::collections::BTreeMap<String, PathBuf>,
) -> Result<(), String> {
  let contracted: std::collections::BTreeMap<&String, String> =
    reg.iter().map(|(name, dir)| (name, contract_home(paths, dir))).collect();
  let raw = serde_json::to_string_pretty(&contracted).map_err(|e| e.to_string())?;
  fs::create_dir_all(paths.config_dir()).map_err(|e| e.to_string())?;
  let file = paths.projects_file();
  fs::write(&file, raw + "\n").map_err(|e| format!("{}: {e}", file.display()))
}

/// Ordner eines registrierten Projekts.
pub(crate) fn project_dir(paths: &Paths, name: &str) -> Result<PathBuf, String> {
  load_registry(paths)?
    .remove(name)
    .ok_or_else(|| format!("Projekt nicht registriert: {name}"))
}

/// Nimmt ein Projekt in die Registry auf; materialisiert dabei auch die
/// per Alt-Layout gefundenen Einträge in projects.json.
fn register_project(paths: &Paths, name: &str, dir: &std::path::Path) -> Result<(), String> {
  let mut reg = load_registry(paths)?;
  if reg.contains_key(name) {
    return Err(format!("Projekt existiert bereits: {name}"));
  }
  reg.insert(name.to_string(), dir.to_path_buf());
  save_registry(paths, &reg)
}

/// Entfernt nur den Registry-Eintrag; der Projektordner bleibt.
fn unregister_project(paths: &Paths, name: &str) -> Result<(), String> {
  let mut reg = load_registry(paths)?;
  if reg.remove(name).is_none() {
    return Err(format!("Projekt nicht registriert: {name}"));
  }
  save_registry(paths, &reg)
}

fn project_config_path(paths: &Paths, project: &str) -> Result<PathBuf, String> {
  Ok(project_dir(paths, project)?.join(PROJECT_FILE))
}