1210:    "permissions": { "allow": [PANEL_WRITE_PERMISSION] },
1225:  install_panel_skill(dir);
1232:const PANEL_SKILL: &str = include_str!("../resources/panel-skill.md");
1236:const PANEL_WRITE_PERMISSION: &str = "mcp__aicontrol__write_panel";
1240:fn install_panel_skill(pool_dir: &std::path::Path) {
1243:    let _ = fs::write(skill_dir.join("SKILL.md"), PANEL_SKILL);
1249:const STALE_PANEL_PERMISSIONS: [&str; 2] = ["Bash(tee:*)", "Bash(cat:*)"];
1277:  allow.retain(|e| !e.as_str().is_some_and(|s| STALE_PANEL_PERMISSIONS.contains(&s)));
1278:  let has = allow.iter().any(|e| e.as_str() == Some(PANEL_WRITE_PERMISSION));
1280:    allow.push(serde_json::json!(PANEL_WRITE_PERMISSION));


1230	/// Skill, der das MCP-Tool `write_panel` für Entwürfe anweist. Liegt im
1231	/// Pool-Ordner (= CLAUDE_CONFIG_DIR), damit claude ihn als User-Skill findet.
1232	const PANEL_SKILL: &str = include_str!("../resources/panel-skill.md");
1233
1234	/// Freigabe für das MCP-Tool `write_panel` — damit der Aufruf ohne Rückfrage
1235	/// läuft. Namensschema: `mcp__<server>__<tool>`.
1236	const PANEL_WRITE_PERMISSION: &str = "mcp__aicontrol__write_panel";
1237
1238	/// Schreibt/aktualisiert die Panel-Skill-Datei in einem Pool (überschreibt eine
1239	/// evtl. ältere, tee-basierte Fassung).
1240	fn install_panel_skill(pool_dir: &std::path::Path) {
1241	  let skill_dir = pool_dir.join("skills").join("panel");
1242	  if fs::create_dir_all(&skill_dir).is_ok() {
1243	    let _ = fs::write(skill_dir.join("SKILL.md"), PANEL_SKILL);
1244	  }
1245	}
1246
1247	/// Frühere Panel-Freigaben (Bash-basiert), die beim MCP-Umstieg aus den
1248	/// Pool-Settings entfernt werden.
1249	const STALE_PANEL_PERMISSIONS: [&str; 2] = ["Bash(tee:*)", "Bash(cat:*)"];

/// Skill, der das MCP-Tool `write_panel` für Entwürfe anweist. Liegt im
/// Pool-Ordner (= CLAUDE_CONFIG_DIR), damit claude ihn als User-Skill findet.
const PANEL_SKILL: &str = include_str!("../resources/panel-skill.md");

/// Freigabe für das MCP-Tool `write_panel` — damit der Aufruf ohne Rückfrage
/// läuft. Namensschema: `mcp__<server>__<tool>`.
const PANEL_WRITE_PERMISSION: &str = "mcp__aicontrol__write_panel";

/// Schreibt/aktualisiert die Panel-Skill-Datei in einem Pool (überschreibt eine
/// evtl. ältere, tee-basierte Fassung).
fn install_panel_skill(pool_dir: &std::path::Path) {
  let skill_dir = pool_dir.join("skills").join("panel");
  if fs::create_dir_all(&skill_dir).is_ok() {
    let _ = fs::write(skill_dir.join("SKILL.md"), PANEL_SKILL);
  }
}

/// Frühere Panel-Freigaben (Bash-basiert), die beim MCP-Umstieg aus den
/// Pool-Settings entfernt werden.
const STALE_PANEL_PERMISSIONS: [&str; 2] = ["Bash(tee:*)", "Bash(cat:*)"];


Edit

/// Freigabe für das MCP-Tool `write_panel` — damit der Aufruf ohne Rückfrage
/// läuft. Namensschema: `mcp__<server>__<tool>`.
const PANEL_WRITE_PERMISSION: &str = "mcp__aicontrol__write_panel";

/// MCP-Server-Key — zugleich das Label, das claude im Tool-Call anzeigt
/// („text panel"). Bestimmt den Tool-Namespace `mcp__<key>__<tool>`.
const PANEL_MCP_SERVER: &str = "text-panel";

/// Freigabe für das MCP-Tool `write_panel` — damit der Aufruf ohne Rückfrage
/// läuft. Namensschema: `mcp__<server>__<tool>`.
const PANEL_WRITE_PERMISSION: &str = "mcp__text-panel__write_panel";


The file /Users/marcus.hinz/projects/ai-control/src-tauri/src/lib.rs has been updated successfully. (file state is current in your context — no need to Read it back)

/// Freigabe für das MCP-Tool `write_panel` — damit der Aufruf ohne Rückfrage
/// läuft. Namensschema: `mcp__<server>__<tool>`.
const PANEL_WRITE_PERMISSION: &str = "mcp__aicontrol__write_panel";

/// MCP-Server-Key — zugleich das Label, das claude im Tool-Call anzeigt
/// („text panel"). Bestimmt den Tool-Namespace `mcp__<key>__<tool>`.
const PANEL_MCP_SERVER: &str = "text-panel";

/// Freigabe für das MCP-Tool `write_panel` — damit der Aufruf ohne Rückfrage
/// läuft. Namensschema: `mcp__<server>__<tool>`.
const PANEL_WRITE_PERMISSION: &str = "mcp__text-panel__write_panel";

/// Pool-Ordner (= CLAUDE_CONFIG_DIR), damit claude ihn als User-Skill findet.

const PANEL_SKILL: &str = include_str!("../resources/panel-skill.md");

+/// MCP-Server-Key — zugleich das Label, das claude im Tool-Call anzeigt

+/// („text panel"). Bestimmt den Tool-Namespace `mcp__<key>__<tool>`.

+const PANEL_MCP_SERVER: &str = "text-panel";

-const PANEL_WRITE_PERMISSION: &str = "mcp__aicontrol__write_panel";

+const PANEL_WRITE_PERMISSION: &str = "mcp__text-panel__write_panel";

/// Schreibt/aktualisiert die Panel-Skill-Datei in einem Pool (überschreibt eine

/// evtl. ältere, tee-basierte Fassung).


ok -- das scheint jetzt zu gehen schreibe bite satt calling ai control calling text panel ins prompt


Add dockable text panel with copy functionality to terminal


/// Frühere Panel-Freigaben (Bash-basiert), die beim MCP-Umstieg aus den
/// Pool-Settings entfernt werden.
const STALE_PANEL_PERMISSIONS: [&str; 2] = ["Bash(tee:*)", "Bash(cat:*)"];

/// Frühere Panel-Freigaben (Bash- und alter MCP-Key), die aus den Pool-Settings
/// entfernt werden.
const STALE_PANEL_PERMISSIONS: [&str; 3] =
  ["Bash(tee:*)", "Bash(cat:*)", "mcp__aicontrol__write_panel"];


/// Frühere Panel-Freigaben (Bash-basiert), die beim MCP-Umstieg aus den
/// Pool-Settings entfernt werden.
const STALE_PANEL_PERMISSIONS: [&str; 2] = ["Bash(tee:*)", "Bash(cat:*)"];

/// Frühere Panel-Freigaben (Bash- und alter MCP-Key), die aus den Pool-Settings
/// entfernt werden.
const STALE_PANEL_PERMISSIONS: [&str; 3] =
  ["Bash(tee:*)", "Bash(cat:*)", "mcp__aicontrol__write_panel"];

-/// Frühere Panel-Freigaben (Bash-basiert), die beim MCP-Umstieg aus den

-const STALE_PANEL_PERMISSIONS: [&str; 2] = ["Bash(tee:*)", "Bash(cat:*)"];

+/// Frühere Panel-Freigaben (Bash- und alter MCP-Key), die aus den Pool-Settings

+const STALE_PANEL_PERMISSIONS: [&str; 3] =

+  ["Bash(tee:*)", "Bash(cat:*)", "mcp__aicontrol__write_panel"];

/// Trägt die MCP-Freigabe in die settings.json eines Pools ein und entfernt

/// die alten Bash-Freigaben — idempotent, ohne sonstige Einträge zu verändern.


Jetzt `register_mcp_server`: alten `aicontrol`-Eintrag entfernen und unter `text-panel` registrieren:


  servers.insert(
    "aicontrol".into(),
    serde_json::json!({
      "type": "stdio",
      "command": exe.to_string_lossy(),
      "args": ["--mcp-panel"],
      // Tool nicht deferren, sonst sieht das Modell write_panel nicht und
      // schreibt den Entwurf in den Chat statt ins Panel.
      "alwaysLoad": true,
    }),
  );

  servers.remove("aicontrol"); // alter Key vor Umbenennung
  servers.insert(
    PANEL_MCP_SERVER.into(),
    serde_json::json!({
      "type": "stdio",
      "command": exe.to_string_lossy(),
      "args": ["--mcp-panel"],
      // Tool nicht deferren, sonst sieht das Modell write_panel nicht und
      // schreibt den Entwurf in den Chat statt ins Panel.
      "alwaysLoad": true,
    }),
  );


  servers.insert(
    "aicontrol".into(),
    serde_json::json!({
      "type": "stdio",
      "command": exe.to_string_lossy(),
      "args": ["--mcp-panel"],
      // Tool nicht deferren, sonst sieht das Modell write_panel nicht und
      // schreibt den Entwurf in den Chat statt ins Panel.
      "alwaysLoad": true,
    }),
  );

  servers.remove("aicontrol"); // alter Key vor Umbenennung
  servers.insert(
    PANEL_MCP_SERVER.into(),
    serde_json::json!({
      "type": "stdio",
      "command": exe.to_string_lossy(),
      "args": ["--mcp-panel"],
      // Tool nicht deferren, sonst sieht das Modell write_panel nicht und
      // schreibt den Entwurf in den Chat statt ins Panel.
      "alwaysLoad": true,
    }),
  );

+  servers.remove("aicontrol"); // alter Key vor Umbenennung


Bash

export CARGO_HOME="$HOME/tools/.cargo" RUSTUP_HOME="$HOME/tools/.rustup"; export PATH="$CARGO_HOME/bin:$PATH"; cd /Users/marcus.hinz/projects/ai-control/src-tauri && cargo build 2>&1 | tail -4

Build nach Umbenennung


Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.76s


Build grün. Ich verifiziere kurz den neuen Server-Namen im Binary und wende die Umbenennung direkt auf alle Pools an (alten `aicontrol`-Server + alte Freigabe raus, `text-panel` rein):


BIN="/Users/marcus.hinz/projects/ai-control/src-tauri/target/debug/ai-control"
echo "=== serverInfo im Binary ==="
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}' | "$BIN" --mcp-panel | python3 -c "import json,sys;print(json.loads(sys.stdin.readline())['result']['serverInfo'])"
echo "=== Pools umschreiben ==="
CMD="$BIN"
cd "$HOME/.config/ai-control/pools"
for d in */; do
  pool="${d%/}"
  python3 - "$pool" "$CMD" <<'PY'
import json,sys,os
pool,cmd=sys.argv[1],sys.argv[2]
# .claude.json: aicontrol -> text-panel
cj=f"{pool}/.claude.json"
if os.path.exists(cj):
    d=json.load(open(cj)); srv=d.get("mcpServers",{})
    srv.pop("aicontrol",None)
    srv["text-panel"]={"type":"stdio","command":cmd,"args":["--mcp-panel"],"alwaysLoad":True}
    d["mcpServers"]=srv
    json.dump(d,open(cj,"w"),ensure_ascii=False,indent=2)
# settings.json: Freigabe umbenennen
sj=f"{pool}/settings.json"
d=json.load(open(sj)); a=d.setdefault("permissions",{}).setdefault("allow",[])
a=[x for x in a if x!="mcp__aicontrol__write_panel"]
if "mcp__text-panel__write_panel" not in a: a.append("mcp__text-panel__write_panel")
d["permissions"]["allow"]=a
json.dump(d,open(sj,"w"),ensure_ascii=False,indent=2)
print(f"{pool}: text-panel gesetzt")
PY
done

serverInfo prüfen und Pools auf text-panel umschreiben


=== serverInfo im Binary ===
{'name': 'text-panel', 'version': '0.2.0'}
=== Pools umschreiben ===
24d5f60d-19d9-477e-957c-a68728e79424: text-panel gesetzt
apiDefault: text-panel gesetzt
private: text-panel gesetzt
Shell cwd was reset to /Users/marcus.hinz/claude-projects/ai-control


`serverInfo` = `text-panel`, alle Pools umgeschrieben. Headless-Gegenprobe mit dem neuen Key — natürliche Bitte, Tool muss ohne Denial laufen:


SCRATCH="/private/tmp/claude-502/-Users-marcus-hinz-claude-projects-ai-control/f7704328-4eb4-4f0f-bf42-da68abab401d/scratchpad"
PANEL="$SCRATCH/panel-rename.md"; rm -f "$PANEL"
cd "$SCRATCH"
CLAUDE_CONFIG_DIR="$HOME/.config/ai-control/pools/private" AI_CONTROL_PANEL="$PANEL" \
  zsh -ic 'claude -p "Entwirf einen kurzen ADR zum Thema Logging, zwei Absaetze." --output-format json' 2>&1 | python3 -c "import json,sys;d=json.load(sys.stdin);print('result:',repr(d.get('result'))[:160]);print('denials:',[x['tool_name'] for x in d.get('permission_denials',[])])" 2>&1
[ -f "$PANEL" ] && { echo ">>> Panel JA (Auszug):"; head -4 "$PANEL"; } || echo ">>> Panel NEIN"

Headless-Test mit umbenanntem Server text-panel


result: 'ADR liegt im Panel.'
denials: []
>>> Panel JA (Auszug):
# ADR-00X: Strukturiertes Logging

**Status:** Angenommen
**Datum:** 2026-07-11
Shell cwd was reset to /Users/marcus.hinz/claude-projects/ai-control


Umbenannt und verifiziert: Der MCP-Server-Key ist jetzt `text-panel` — das ist der Name, den claude im Tool-Call zeigt. Headless-Gegenprobe (natürliche Bitte „Entwirf einen kurzen ADR…") lief sauber: Tool `mcp__text-panel__write_panel` aufgerufen, keine Denials, Panel geschrieben, Chat nur „ADR liegt im Panel."

Direkt in alle Pools geschrieben (alter `aicontrol`-Server + alte Freigabe raus, `text-panel` rein), plus Code-Fix für die Migration. **Kein Rebuild nötig** — nur ein **frisches** Terminal öffnen, damit claude den umbenannten Server lädt.

Zwei Anmerkungen:
- Das genaue TUI-Label sehe ich headless nicht (steht nicht im JSON) — bestätige bitte, dass der Call jetzt „text-panel" zeigt.
- Ein echtes Leerzeichen („text panel") im Server-Key bricht das Permission-Mangling, deshalb `text-panel`. Falls claude den Bindestrich anzeigt und dich das stört, sag Bescheid — dann probiere ich, ob ein Key mit Leerzeichen bei 2.1.207 doch trägt.


Alles gut
wie ist es jetzt genau geregelt?
Jeder Pool hat automatisch den MCP Server eingrtragen?


Ja — jeder Pool bekommt den MCP-Server automatisch. Geregelt an zwei Stellen:

- **Bei jedem App-Start:** `provision_pools_for_panel` läuft über alle vorhandenen Pools.
- **Bei Pool-Anlage:** `init_pool_config` macht dasselbe für den neuen Pool.

Pro Pool (= ein `CLAUDE_CONFIG_DIR`) wird dabei geschrieben:

1. **`.claude.json`** → `mcpServers."text-panel"`:
   ```json
   { "type": "stdio", "command": "<App-Binary>", "args": ["--mcp-panel"], "alwaysLoad": true }
   ```
   `command` ist der Pfad der gerade laufenden App (`current_exe`), wird bei jedem Start neu gesetzt. `alwaysLoad` hält das Tool geladen (nicht deferred).

2. **`settings.json`** → `permissions.allow` bekommt `mcp__text-panel__write_panel`; alte Einträge (`Bash(tee:*)`, `Bash(cat:*)`, `mcp__aicontrol__write_panel`) werden entfernt.

3. **`skills/panel/SKILL.md`** → weist das Modell an, für Entwürfe `write_panel` zu nutzen statt in den Chat zu schreiben.

**Zur Laufzeit** setzt die App beim Terminalstart (`term_start`) `AI_CONTROL_PANEL` auf die projektspezifische Panel-Datei. claude startet den MCP-Server als Kind und vererbt die Env; `write_panel` schreibt in genau diese Datei, der Watcher zieht sie ins Panel.

Ein Merkpunkt: Weil `command` auf `current_exe` zeigt, unterscheidet sich der Eintrag zwischen Dev-Build und installierter App — es gilt der Pfad der zuletzt gestarteten App. Und `.claude.json` ist claudes Live-State-Datei; unsere Migration schreibt sie bei jedem Start neu (gemergt) — der noch offene Clobber-Punkt.


ok --
heißt:
Wenn ich ein Projekt aus dem aIC panel satrte, alles gut --> der MCP läuft
wenn ich es - wie hier - direkt satrte, kann der MCP fehlen, oder ist er dann gar nicht registriert?


Zwei getrennte Dinge — Registrierung und Ziel:

**Registrierung** steht persistent auf Platte in `<pool>/.claude.json`, geschrieben von der App. Sie hängt nur davon ab, welches `CLAUDE_CONFIG_DIR` claude nutzt, nicht davon, wer das Terminal startet:

- Direkt gestartet mit `CLAUDE_CONFIG_DIR` = ein von der App provisionierter Pool → **MCP ist registriert und läuft**. Deine `.zshrc` setzt `CLAUDE_CONFIG_DIR` per Default auf `pools/private`, wenn nichts gesetzt ist — deshalb liefen meine Direkt-Tests im `private`-Pool durch.
- Direkt gestartet ohne diesen Default / mit Default-Config `~/.claude` → dort ist der Server **nicht** eingetragen → Tool fehlt.
- Ein Pool, für den die App noch **nie** lief → noch nicht provisioniert → kein Eintrag.

**Ziel (`AI_CONTROL_PANEL`)** setzt ausschließlich die App beim Terminalstart. Ein direkt gestartetes Terminal hat die Variable **nicht**. Folge: Selbst wenn der Server läuft und `write_panel` aufgerufen wird, findet er kein Ziel und gibt einen Fehler zurück („AI_CONTROL_PANEL nicht gesetzt") — es landet nichts, und ein Panel-Fenster gibt es außerhalb der App ohnehin nicht.

Kurz:
- **Aus dem AIC-Panel gestartet:** Registrierung + Ziel + Panel-UI da → funktioniert.
- **Direkt gestartet:** Registrierung meist da (via `private`-Default), aber **kein Ziel und kein Panel** → `write_panel` läuft ins Leere. Nicht „fehlt zufällig", sondern das Ziel fehlt systematisch, weil nur die App es setzt.


das meine ich nicht
<home>.claude ist für uns OHNE BELANG
ich meine den Fall, dass mit der richtigen config_dir aus dem Terminal gesatrtet wird und aIC NICHT laäuft


Klar. Der Fall: richtiges `CLAUDE_CONFIG_DIR`, AIC-App läuft nicht.

**Registrierung:** steht in `<pool>/.claude.json` auf Platte — von einem früheren App-Lauf geschrieben. Sie ist da, unabhängig davon, ob AIC gerade läuft.

**Server-Start:** hängt **nicht** an der laufenden AIC-App. claude startet den MCP-Server selbst als Kindprozess — es ruft einfach das Binary im `--mcp-panel`-Modus auf (der beendet vor jeder Tauri/GTK-Init, kein Fenster, kein Tray). Voraussetzung ist nur, dass die Binary-Datei am eingetragenen Pfad existiert. Es gibt **keine** Live-IPC zur AIC-App — der Kanal ist die Datei, nicht ein Socket zur App.

Also: **MCP ist registriert und `write_panel` ist aufrufbar, auch wenn AIC nicht läuft.** Was fehlt, ist nicht der MCP, sondern:

1. **`AI_CONTROL_PANEL`** — die Zielvariable setzt nur die App beim Terminalstart. Ohne sie schreibt `write_panel` ins Leere (Fehler „nicht gesetzt"), außer du exportierst sie selbst.
2. **Panel-Anzeige** — Watcher und Panel-Fenster leben im App-Terminalprozess. Ohne AIC wird die Datei zwar geschrieben (falls Ziel gesetzt), aber nichts zeigt sie an.

Kurz: Der MCP-Server braucht AIC nicht — der schreibt selbstständig in die Datei. AIC liefert nur das Ziel (Env) und die Darstellung (Watcher + Panel). Einziger echter Bruch wäre ein toter Binary-Pfad: zeigt `command` auf einen Dev-Build, der später weg ist, scheitert der Server-Start.


das meine ich nicht <home>.claude ist für uns OHNE BELANG ich meine den Fall, dass mit der richtigen config_dir aus dem Terminal gesatrtet wird und aIC NICHT laäuft


ok
gut
Wieveile Instanzen xdes Servers laufen?


Einer pro claude-Prozess, also pro Terminal.

- claude startet beim Session-Start für jeden konfigurierten MCP-Server **genau ein** stdio-Kind. Bei uns eins: `text-panel`.
- Der Prozess ist an die Session gekoppelt und endet mit ihr (stdin schließt).
- Drei offene Projekt-Terminals → drei claude-Prozesse → **drei** unabhängige Server-Instanzen, jede schreibt in ihr eigenes `AI_CONTROL_PANEL`.
- Kein geteilter Singleton, keine mehreren pro Session. Jede Instanz ist eine schlanke stdio-Schleife, die idle ist, bis ein `write_panel` kommt.

Auch jeder headless-`claude -p`-Aufruf (wie meine Tests) startet kurz eine Instanz und beendet sie danach wieder.


Ist das normal?


Ja. Das ist das Standardmodell der MCP-**stdio**-Transportart: Der Client (die claude-Session) startet den Server als eigenen Subprozess über stdin/stdout. Ein stdio-Server ist damit prinzipbedingt pro Client — nicht geteilt. Jede Session hat ihre eigene Instanz.

Ein einzelner, geteilter Serverprozess wäre der andere Transport (HTTP/SSE), wo mehrere Clients denselben laufenden Server ansprechen. Für ein lokales Tool wie hier ist stdio der übliche, leichtgewichtige Weg — eine idle stdio-Schleife pro Session kostet praktisch nichts.


ok
gut - commit und push
dann weitere Features


Launching skill: git-commit


Base directory for this skill: /Users/marcus.hinz/claude-projects/ai-control/.claude/skills/git-commit

# git-commit — ai-control veröffentlichen

Zwei Ebenen, strikt getrennt:

1. **Immer**: Commit + Push auf **lintus** (`origin`) — die volle Dev-Historie.
2. **Optional, nur wenn ausdrücklich verlangt**: Publish auf **github** (Squash-Mirror) und ein GitHub-Release mit Linux-Binaries.

Nie ohne Ansage die github-Ebene mitlaufen lassen. Commit-Messages ohne jeden Claude-Hinweis (kein Co-Authored-By, kein „Generated with").

## Repo & Remotes

- App-Repo: `~/projects/ai-control`
- `origin` → `ssh://git@git.lintus.art:2222/marcus.hinz/ai-control.git` — volle Historie, hierher geht jeder Commit.
- `github` → `https://github.com/mHinz-hub/ai-control.git` — **kuratierter Squash-Mirror**: pro Release genau ein Commit `ai-control X.Y.Z`, dessen Tree dem Dev-Tree entspricht. Die volle Dev-Historie gehört **nicht** dorthin.

`~/claude-projects/ai-control` (Control-Repo) ist ein anderes Repo (nur lintus) — nicht gemeint.

## 1. Normaler Commit + Push (immer)

```bash
cd ~/projects/ai-control
git add -A
git status -s                 # prüfen, was reingeht
git commit -m "<sachliche Nachricht>"
git push origin main
```

## 2. github-Publish (optional)

Voraussetzung: der Release-Commit liegt auf lokalem `main` und ist auf lintus gepusht; bei einem Versionssprung ist die Version in **allen vier** Dateien gehoben (siehe Abschnitt 4).

`git push github main` ist verboten (divergiert gegen den Squash-Mirror → Reject). Stattdessen einen einzelnen Squash-Commit mit dem aktuellen Tree auf `github/main` setzen:

```bash
cd ~/projects/ai-control
git fetch github
tree=$(git rev-parse HEAD^{tree})
new=$(git commit-tree "$tree" -p github/main -m "ai-control X.Y.Z")
git push github "$new:main"          # Squash-Commit als neues github/main
git tag -f vX.Y.Z "$new"             # Tag auf den Squash-Commit (nicht den Dev-Commit)
git push github -f vX.Y.Z            # bestehenden Tag ggf. ersetzen
```

Das hält die lokale Dev-Historie unangetastet und reiht sich in `ai-control 0.1.0 / 0.1.1 / …` ein. Der Tag zeigt bewusst auf den Squash-Commit, nicht auf den Dev-Commit.

## 3. Release mit Linux-Binaries (optional, auf der Linux-Box)

Baudeps auf der Box: `webkit2gtk-4.1`, `libgtk-3`, `libayatana-appindicator3`, `librsvg2`, `patchelf`.

```bash
cd ~/projects/ai-control
npm run tauri build
# Artefakte:
#   src-tauri/target/release/bundle/appimage/ai-control_*.AppImage
#   src-tauri/target/release/bundle/deb/ai-control_*.deb

# Release anlegen (falls noch keins zum Tag existiert):
gh release create vX.Y.Z --repo mHinz-hub/ai-control --title "vX.Y.Z" --notes "<Änderungen>"

# Binaries anhängen (jederzeit nachträglich möglich):
gh release upload vX.Y.Z --repo mHinz-hub/ai-control \
  src-tauri/target/release/bundle/appimage/ai-control_*.AppImage \
  src-tauri/target/release/bundle/deb/ai-control_*.deb
```

Der macOS-Build (`./build.sh`, DMG) hängt nicht am Linux-Build; `macos-private-api` u. Ä. sind per `cfg` aus. Die GNOME-Popup-Extension ist separat, kein Teil des Binaries.

## 4. Version heben (bei Release)

Vor Abschnitt 2/3 die Version in vier Dateien gleichziehen:

- `package.json` → `"version"`
- `src-tauri/Cargo.toml` → `[package] version`
- `src-tauri/Cargo.lock` → Eintrag unter `name = "ai-control"`
- `src-tauri/tauri.conf.json` → `"version"`

Dann normal committen (Abschnitt 1), danach optional 2/3.


cd ~/projects/ai-control && git add -A && echo "=== status ===" && git status -s && echo "=== diffstat ===" && git diff --cached --stat

Änderungen stagen und prüfen


=== status ===
M  package-lock.json
M  package.json
A  panel.html
M  src-tauri/capabilities/default.json
A  src-tauri/resources/panel-skill.md
M  src-tauri/src/lib.rs
A  src-tauri/src/mcp.rs
M  src-tauri/src/terminal.rs
A  src/panel-view.ts
A  src/panel.ts
M  src/terminal.ts
M  terminal.html
M  vite.config.ts
=== diffstat ===
 package-lock.json                   |  17 +++-
 package.json                        |   1 +
 panel.html                          | 143 +++++++++++++++++++++++++++++++
 src-tauri/capabilities/default.json |   3 +-
 src-tauri/resources/panel-skill.md  |  21 +++++
 src-tauri/src/lib.rs                | 165 +++++++++++++++++++++++++++++++++++-
 src-tauri/src/mcp.rs                | 109 ++++++++++++++++++++++++
 src-tauri/src/terminal.rs           |  66 +++++++++++++++
 src/panel-view.ts                   |  53 ++++++++++++
 src/panel.ts                        |  27 ++++++
 src/terminal.ts                     |  72 ++++++++++++++++
 terminal.html                       | 153 ++++++++++++++++++++++++++++++++-
 vite.config.ts                      |   1 +
 13 files changed, 823 insertions(+), 8 deletions(-)


ok gut - commit und push dann weitere Features


cd ~/projects/ai-control && git commit -q -m "Entwurfs-Panel mit MCP-Tool write_panel

- Andockbares Panel im Terminal-Fenster: Markdown-Rendering, Copy-Button,
  normale Selektion; in eigenes Fenster ablösbar und wieder andockbar
  (Splitter, panel-detached/panel-attached).
- MCP-stdio-Server (app --mcp-panel) mit Tool write_panel; schreibt in die
  Datei aus AI_CONTROL_PANEL, Watcher zieht sie ins Panel.
- Provisionierung je Pool: Server-Registrierung in .claude.json (alwaysLoad),
  Freigabe mcp__text-panel__write_panel in settings.json, Panel-Skill; bei
  App-Start (alle Pools) und Pool-Anlage. Alte tee-Freigaben werden entfernt.
- Capability deckt panel-*-Fenster ab." && git log --oneline -1 && echo "=== push ===" && git push origin main 2>&1 | tail -5

Commit und Push auf origin


8aa758a Entwurfs-Panel mit MCP-Tool write_panel
=== push ===
remote: . Processing 1 references
remote: Processed 1 references in total
To ssh://git.lintus.art:2222/marcus.hinz/ai-control.git
   c39330b..8aa758a  main -> main


Committet und gepusht auf `origin` (lintus):

```
8aa758a  Entwurfs-Panel mit MCP-Tool write_panel
c39330b..8aa758a  main -> main
```

github-Publish blieb außen vor (nicht verlangt). Offen aus der Session: der `.claude.json`-Clobber-Punkt (Migration schreibt claudes Live-State bei jedem App-Start neu).

Bereit für die nächsten Features.


gut --
erst noch: kannst Du lokal bei mir neu installieren?


Ja. Bauen und die `.app` nach `~/Applications` kopieren geht ohne die App zu starten — meine Session (ein Terminal der laufenden App) bleibt unberührt, ich starte nichts und öffne kein Terminal. Kurz die Ausgangslage prüfen: Installationsort und ob die App läuft.


echo "=== ~/Applications ==="; ls -la ~/Applications 2>/dev/null | grep -i "ai-control\|aic" || echo "(nichts unter ~/Applications)"
echo "=== /Applications ==="; ls -la /Applications 2>/dev/null | grep -i "ai-control\|aic" || echo "(nichts unter /Applications)"
echo "=== läuft die App? ==="; pgrep -fl "ai-control" | grep -v "mcp-panel\|claude\|Claude" | head
echo "=== node/npm ==="; command -v npm node 2>&1

Installationsort und Laufzustand prüfen


=== ~/Applications ===
drwxr-xr-x   3 marcus.hinz  staff     96 Jul  7 09:25 ai-control.app
=== /Applications ===
(nichts unter /Applications)
=== läuft die App? ===
4746 target/debug/ai-control
17926 /Users/marcus.hinz/projects/ai-control/src-tauri/target/debug/ai-control --terminal limbach
28949 /bin/zsh -c source /Users/marcus.hinz/.config/ai-control/pools/private/shell-snapshots/snapshot-zsh-1783764967577-btc6lj.sh 2>/dev/null || true && setopt NO_EXTENDED_GLOB NO_BARE_GLOB_QUAL 2>/dev/null || true && eval 'echo "=== ~/Applications ==="; ls -la ~/Applications 2>/dev/null | grep -i "ai-control\|aic" || echo "(nichts unter ~/Applications)"
echo "=== /Applications ==="; ls -la /Applications 2>/dev/null | grep -i "ai-control\|aic" || echo "(nichts unter /Applications)"
81652 bash ./projects/ai-control/dev.sh
81672 node /Users/marcus.hinz/projects/ai-control/node_modules/.bin/tauri dev
81784 node /Users/marcus.hinz/projects/ai-control/node_modules/.bin/vite
82699 /Users/marcus.hinz/projects/ai-control/src-tauri/target/debug/ai-control --terminal misc
=== node/npm ===
/Users/marcus.hinz/.local/state/fnm_multishells/64035_1783764497877/bin/npm
/Users/marcus.hinz/.local/state/fnm_multishells/64035_1783764497877/bin/node


Ausgangslage klar:
- Installiert: `~/Applications/ai-control.app` (läuft **nicht**).
- Was läuft, ist der **Dev-Build** (`tauri dev`, PID 4746) samt Dev-Terminals — das ist getrennt.

Also sicher: Ich baue einen Release und ersetze `~/Applications/ai-control.app` (nicht in Benutzung). Nichts wird gestartet, meine Session bleibt. Der Release-Build dauert einige Minuten — ich starte ihn im Hintergrund über `zsh -ic` (fnm/node-Env) und beobachte:


cd ~/projects/ai-control && rm -f build.log && zsh -ic './build.sh' > build.log 2>&1

Release-Build via build.sh (Hintergrund)


Command running in background with ID: bmdatj7p7. Output is being written to: /private/tmp/claude-502/-Users-marcus-hinz-claude-projects-ai-control/5725422a-e742-4ebc-a17d-a054c85ed8b5/tasks/bmdatj7p7.output. You will be notified when it completes. To check interim output, use Read on that file path.


gut --  erst noch: kannst Du lokal bei mir neu installieren?


Build läuft im Hintergrund. Kurzer Zwischenblick, ob er sauber angelaufen ist:


sleep 20; tail -15 ~/projects/ai-control/build.log

Build-Log Zwischenstand


dist/assets/rolldown-runtime-CEoa-xHZ.js                          0.54 kB │ gzip:   0.36 kB
dist/assets/popup-BFuN42Uq.js                                     1.66 kB │ gzip:   0.95 kB
dist/assets/webviewWindow-DNd13Ths.js                            20.20 kB │ gzip:   4.65 kB
dist/assets/panel-view-D51IQyrs.js                               39.85 kB │ gzip:  12.11 kB
dist/assets/main-MedUwDan.js                                     92.55 kB │ gzip:  28.87 kB
dist/assets/vue.runtime.esm-bundler-D56zvlpP.js                 110.14 kB │ gzip:  42.24 kB
dist/assets/terminal-tA6f4ePE.js                                662.39 kB │ gzip: 183.19 kB

✓ built in 823ms
[plugin builtin:vite-reporter]
(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application
- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
   Compiling ai-control v0.2.0 (/Users/marcus.hinz/projects/ai-control/src-tauri)


Vite-Build fertig, jetzt kompiliert der Rust-Release (`ai-control`) — das ist der lange Teil. Ich melde mich, sobald der Build durch ist, und installiere dann nach `~/Applications`.


<task-notification>
<task-id>bmdatj7p7</task-id>
<tool-use-id>toolu_01P6N88fkE777UyPZtd7bGkk</tool-use-id>
<output-file>/private/tmp/claude-502/-Users-marcus-hinz-claude-projects-ai-control/5725422a-e742-4ebc-a17d-a054c85ed8b5/tasks/bmdatj7p7.output</output-file>
<status>completed</status>
<summary>Background command "Release-Build via build.sh (Hintergrund)" completed (exit code 0)</summary>
</task-notification>


Build fertig (exit 0). Log-Ende und Artefakte prüfen, dann installieren:


cd ~/projects/ai-control && tail -8 build.log
echo "=== gebaute .app finden ==="
ls -la src-tauri/target/release/bundle/macos.noindex/ 2>&1
ls -la src-tauri/target/release/bundle/macos/ 2>&1
ls -la src-tauri/target/release/bundle/dmg/*.dmg 2>&1

Build-Ergebnis und .app-Ort prüfen


    Bundling ai-control_0.2.0_aarch64.dmg (/Users/marcus.hinz/projects/ai-control/src-tauri/target/release/bundle/dmg/ai-control_0.2.0_aarch64.dmg)
     Running bundle_dmg.sh
    Finished 2 bundles at:
        /Users/marcus.hinz/projects/ai-control/src-tauri/target/release/bundle/macos/ai-control.app
        /Users/marcus.hinz/projects/ai-control/src-tauri/target/release/bundle/dmg/ai-control_0.2.0_aarch64.dmg

failed to scan /Users/marcus.hinz/projects/ai-control/src-tauri/target/release/bundle/macos.noindex/ai-control.app: -10814
 from spotlightDMG: /Users/marcus.hinz/projects/ai-control/src-tauri/target/release/bundle/dmg/ai-control_0.2.0_aarch64.dmg
=== gebaute .app finden ===
total 0
drwxr-xr-x  3 marcus.hinz  staff   96 Jul 11 12:48 .
drwxr-xr-x  7 marcus.hinz  staff  224 Jul 11 12:48 ..
drwxr-xr-x  3 marcus.hinz  staff   96 Jul 11 12:48 ai-control.app
total 115600
drwxr-xr-x  7 marcus.hinz  staff       224 Jul 11 12:48 .
drwxr-xr-x  7 marcus.hinz  staff       224 Jul 11 12:48 ..
-rw-r--r--@ 1 marcus.hinz  staff      6148 Jul  4 21:39 .DS_Store
-rw-r--r--@ 1 marcus.hinz  staff  36738560 Jul  4 21:37 rw.23928.ai-control_0.1.0_aarch64.dmg
-rw-r--r--@ 1 marcus.hinz  staff  36738560 Jul  4 21:19 rw.72530.ai-control_0.1.0_aarch64.dmg
-rw-r--r--@ 1 marcus.hinz  staff  36739072 Jul  5 15:59 rw.72605.ai-control_0.1.0_aarch64.dmg
-rw-r--r--@ 1 marcus.hinz  staff  36739072 Jul  5 16:00 rw.73090.ai-control_0.1.0_aarch64.dmg
-rw-r--r--@ 1 marcus.hinz  staff  4553389 Jul 11 12:48 src-tauri/target/release/bundle/dmg/ai-control_0.2.0_aarch64.dmg


Build sauber (der `-10814` ist der erwartete lsregister-Hinweis aus build.sh). Die neue `.app` liegt in `macos.noindex/`. Ich prüfe kurz, dass das Release-Binary den neuen Stand hat (`text-panel`), und installiere dann nach `~/Applications`:


NEW="/Users/marcus.hinz/projects/ai-control/src-tauri/target/release/bundle/macos.noindex/ai-control.app"
BINIC="$NEW/Contents/MacOS/ai-control"
echo "=== Release-Binary serverInfo ==="
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}' | "$BINIC" --mcp-panel | python3 -c "import json,sys;print(json.loads(sys.stdin.readline())['result']['serverInfo'])" 2>&1
echo "=== installieren nach ~/Applications ==="
rm -rf ~/Applications/ai-control.app
cp -R "$NEW" ~/Applications/ai-control.app
echo "kopiert:"; ls -la ~/Applications/ai-control.app/Contents/MacOS/
LSREGISTER=/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister
"$LSREGISTER" -f ~/Applications/ai-control.app && echo "bei Launch Services registriert"
echo "=== Quarantäne? ==="; xattr ~/Applications/ai-control.app 2>&1 || echo "(keine xattr)"

Release-Binary verifizieren und nach ~/Applications installieren


=== Release-Binary serverInfo ===
{'name': 'text-panel', 'version': '0.2.0'}
=== installieren nach ~/Applications ===
kopiert:
total 21176
drwxr-xr-x  3 marcus.hinz  staff        96 Jul 11 12:49 .
drwxr-xr-x  5 marcus.hinz  staff       160 Jul 11 12:49 ..
-rwxr-xr-x  1 marcus.hinz  staff  10840944 Jul 11 12:49 ai-control
bei Launch Services registriert
=== Quarantäne? ===


Installiert. Release-Binary hat den neuen Stand (`serverInfo: text-panel`, v0.2.0), nach `~/Applications/ai-control.app` kopiert, bei Launch Services registriert, keine Quarantäne (lokal gebaut).


rm -f ~/projects/ai-control/build.log && echo "build.log entfernt"

Temporäres Build-Log entfernen


build.log entfernt


Fertig.

- Release gebaut (`build.sh`), verifiziert (`text-panel`, 0.2.0), nach `~/Applications/ai-control.app` installiert und bei Launch Services registriert.
- Ich habe **nichts gestartet** — meine Session bleibt. Der laufende **Dev-Build** (`tauri dev`) läuft weiter; um die installierte Version zu nutzen, Dev beenden und `~/Applications/ai-control.app` starten.
- Beim ersten Start der installierten App setzt die Migration den MCP-`command`-Pfad auf das **installierte** Binary (`~/Applications/ai-control.app/Contents/MacOS/ai-control`) statt des Dev-Pfads — künftige Terminals nutzen dann das installierte Binary.
- DMG liegt zusätzlich unter `src-tauri/target/release/bundle/dmg/ai-control_0.2.0_aarch64.dmg`.

Bereit für die nächsten Features.


Man könnte die Dokumnte auch in einem Ordner persistieren / auf ausdrücklichen wunsch sozusagen


Ja. Sauberster Weg: ein **„Speichern"-Button im Panel** neben „Kopieren". Das ist der „ausdrückliche Wunsch" als UI-Aktion — kein Modell, keine Permission.

Ablauf:
- Klick → Tauri-Save-Dialog (`tauri-plugin-dialog` ist schon drin), vorgeschlagener Dateiname aus der ersten Überschrift/Zeile des Entwurfs, Endung `.md`.
- Schreibt den **Rohtext** (nicht das gerenderte HTML) an den gewählten Ort.
- Optional ein gemerkter Default-Ordner (z. B. `~/Documents/ai-control` oder pro Projekt `<projekt>/docs`), damit man nicht jedes Mal navigiert — Dialog nur zum Bestätigen/Umbenennen.

Damit bleibt das Panel-Grunddesign ephemer (überschrieben/geleert), und Persistieren ist ein bewusster Extra-Schritt.

Falls du willst, dass stattdessen (oder zusätzlich) **das Modell** auf Zuruf speichert („schreib den ADR und leg ihn unter docs/ ab"), ginge ein zweites MCP-Tool `save_panel(text, name)` oder ein `persist`-Parameter an `write_panel` — dann mit Zielordner-Logik im Server. Der UI-Button ist aber der schlankere „ausdrückliche Wunsch".

Sag, welche Variante (Button, Modell-Tool, oder beides) und welchen Default-Ordner, dann baue ich es.


Eher ein einstelbaere Archiv ordner, der auch gescanned / indexiert werden kann


Verstanden — kein Save-Dialog, sondern ein fester, einstellbarer Archiv-Ordner, in dem die Entwürfe als Dateien landen und von außen scan-/indexierbar sind.

Vorschlag:

- **Einstellung** `archiveDir` in `~/.config/ai-control/settings.json` (wie `poolSyncDir`: leer = Feature aus). Default z. B. `~/Documents/ai-control-archiv`.
- **Ablage:** `<archiveDir>/<projekt>/<YYYY-MM-DD_HHMM>-<slug>.md`. Slug aus der ersten Überschrift. Jede Datei mit YAML-Frontmatter (`title`, `project`, `created`, `source: ai-control`) — dadurch sauber von Spotlight/grep/RAG scanbar, plain Markdown.
- **Auslöser (ausdrücklicher Wunsch):** „Archivieren"-Button im Panel neben „Kopieren". Nimmt den aktuell angezeigten Rohtext, schreibt die Datei, kurzer Bestätigungs-Hinweis.
- Panel-Grunddesign bleibt ephemer; Archivieren ist der bewusste Extra-Schritt.

Zwei Dinge lege bitte fest, dann baue ich:
1. **Auslöser:** nur Panel-Button, oder zusätzlich ein Modell-Tool `archive_panel` (dann kann ich auf Zuruf „archivier das" ablegen)?
2. **Default-Ordner** und ob **Unterordner pro Projekt** (wie oben) oder flach.


es wäre wichtig, den Ordner pro Projekt zu setzen - der könnte ja in cxder Settings unter die Permissions genommen werden
-> Archiv button klick / aufruf archieviere das -> keon Ordner kmonfiguriert :: Tauri Dialog zur auswahl
Eintragen und archivieren
passt das


Passt — mit einer Einschränkung, die ich vorher klarstellen will.

**Design, wie ich es verstehe:**
- Archiv-Ordner **pro Projekt**, gespeichert in der Projekt-Config (`ai-control.json`) und zusätzlich in `.claude/settings.json` unter `permissions.additionalDirectories` eingetragen — damit claude den Archiv-Ordner später **lesen/scannen/indexieren** darf. (Das Schreiben selbst braucht keine Permission — das macht die App bzw. der MCP-Server direkt.)
- Auslöser: Panel-Button **oder** „archiviere das".
- Kein Ordner konfiguriert → Ordner wählen → eintragen (Config + Permission) → archivieren. Danach ohne Dialog.

**Die Einschränkung — Dialog nur aus der UI:**
Der Tauri-Ordner-Dialog geht nur aus der App (Panel-Button). Das Modell-Tool `archive_panel` läuft im MCP-Subprozess **ohne UI** und kann keinen Dialog öffnen. Heißt:
- Ist ein Ordner konfiguriert: Button **und** „archiviere das" archivieren beide direkt.
- Ist **keiner** konfiguriert: nur der **Button** kann den Dialog zeigen und den Ordner einmalig setzen. „archiviere das" gibt dann zurück „erst im Panel einen Archiv-Ordner wählen".

Also: die einmalige Einrichtung läuft über den Button, danach funktioniert auch der Zuruf. Kein Live-Socket vom MCP-Server zur App, deshalb kann der Zuruf den Dialog nicht auslösen.

Wenn das so passt, baue ich es: Setting-Feld pro Projekt, Button + `archive_panel`-Tool, Ablage `<archiv>/<YYYY-MM-DD_HHMM>-<slug>.md` mit Frontmatter.


alternativen zum UI Dialog / fallback aus dem Terminal?


Ja, es gibt einen sauberen Terminal-Fallback — ganz ohne Dialog.

**Empfehlung: `archive_panel` bekommt ein optionales Argument `dir`.**
- Ordner konfiguriert → Button und Zuruf archivieren direkt.
- Kein Ordner, Zuruf „archiviere das" → das Tool meldet „kein Archiv-Ordner gesetzt", das Modell fragt im Chat nach dem Pfad, du tippst ihn. Das Modell ruft `archive_panel(text, dir)`. Der Server schreibt die Datei **und** merkt sich `dir` als Projekt-Archiv (Config + `additionalDirectories`).

Damit sind es zwei gleichwertige Einrichtungswege für dasselbe „setzen + archivieren":
- **UI:** nativer Ordner-Dialog (Button).
- **Terminal:** Pfad tippen (Zuruf). Kein Dialog, keine laufende App nötig.

Danach in beiden Fällen ohne Nachfrage.

**Zwei weitere Optionen, die ich nicht empfehle:**
- *Indirektes IPC:* Server schreibt einen „Ordner-wählen"-Marker in eine Datei, die App-Watcher sieht ihn und öffnet den Tauri-Dialog, schreibt das Ergebnis zurück. Funktioniert nur bei laufender App, der Server müsste blockierend warten — mehr Kopplung für wenig Gewinn.
- *Modell schreibt selbst:* Ist der Archiv-Ordner in den Permissions, könnte das Modell die Datei mit seinem normalen Write-Tool ablegen — ganz ohne MCP-Tool. Konsistenter ist aber, dass derselbe Server schreibt, der auch `write_panel` bedient.

Der Pfad-Zuruf ist der beste Fallback: terminal-nativ, kein Dialog, persistiert genauso. Passt das als Lösung?


ok -- bau mal


Go. Zuerst lese ich die relevanten Strukturen (ProjectConfig, Schreiben von `ai-control.json`, `additionalDirectories`), um sauber anzudocken.


cd ~/projects/ai-control/src-tauri/src
grep -n "struct ProjectConfig\|fn write_project_config\|fn terminal_config\|fn project_pool_dir\|struct TerminalConfig\|fn read_project_config\|ProjectConfig {" lib.rs | head

Anker für ProjectConfig und Config-IO finden


165:struct ProjectConfig {
173:pub struct TerminalConfig {
241:pub(crate) fn project_pool_dir(project: &str) -> Result<Option<PathBuf>, String> {
553:  let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal };
759:fn read_project_config_in(paths: &Paths, project: &str) -> Result<ProjectConfig, String> {
769:fn write_project_config_in(
955:pub(crate) fn terminal_config(project: &str) -> Result<TerminalConfig, String> {


160	  #[serde(rename = "credentialType")]
161	  credential_type: String,
162	}
163
164	#[derive(Serialize, Deserialize, Default)]
165	struct ProjectConfig {
166	  #[serde(default, skip_serializing_if = "Option::is_none")]
167	  pool: Option<String>,
168	  #[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
169	  terminal: TerminalConfig,
170	}
171
172	#[derive(Serialize, Deserialize, Default, Clone)]
173	pub struct TerminalConfig {
174	  #[serde(default, skip_serializing_if = "Option::is_none")]
175	  pub theme: Option<String>,
176	  #[serde(default, skip_serializing_if = "Option::is_none")]
177	  pub icon: Option<String>,
178	  #[serde(default, skip_serializing_if = "Option::is_none")]
179	  pub title: Option<String>,
180	}
181
182	impl TerminalConfig {
183	  fn is_empty(&self) -> bool {
184	    self.theme.is_none() && self.icon.is_none() && self.title.is_none()
185	  }
186	}
187
188	fn is_running(project: &str) -> bool {
189	  !terminal_pids(project).is_empty()
190	}
191
192	/// Projekte, die diesem Pool zugeordnet sind und gerade laufen — dieselbe
193	/// Lauf-Erkennung wie die Projektliste (`is_running`).
194	fn running_projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
195	  Ok(projects_using_pool(paths, pool)?
196	    .into_iter()
197	    .filter(|p| is_running(p))
198	    .collect())
199	}
200
201	/// PIDs der eingebauten Terminal-Prozesse (`app --terminal <projekt>`).
202	fn terminal_pids(project: &str) -> Vec<u32> {
203	  let out = Command::new("pgrep")
204	    .args(["-f", "--", &format!("--terminal {project}$")])

  #[serde(rename = "credentialType")]
  credential_type: String,
}

#[derive(Serialize, Deserialize, Default)]
struct ProjectConfig {
  #[serde(default, skip_serializing_if = "Option::is_none")]
  pool: Option<String>,
  #[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
  terminal: TerminalConfig,
}

#[derive(Serialize, Deserialize, Default, Clone)]
pub struct TerminalConfig {
  #[serde(default, skip_serializing_if = "Option::is_none")]
  pub theme: Option<String>,
  #[serde(default, skip_serializing_if = "Option::is_none")]
  pub icon: Option<String>,
  #[serde(default, skip_serializing_if = "Option::is_none")]
  pub title: Option<String>,
}

impl TerminalConfig {
  fn is_empty(&self) -> bool {
    self.theme.is_none() && self.icon.is_none() && self.title.is_none()
  }
}

fn is_running(project: &str) -> bool {
  !terminal_pids(project).is_empty()
}

/// Projekte, die diesem Pool zugeordnet sind und gerade laufen — dieselbe
/// Lauf-Erkennung wie die Projektliste (`is_running`).
fn running_projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
  Ok(projects_using_pool(paths, pool)?
    .into_iter()
    .filter(|p| is_running(p))
    .collect())
}

/// PIDs der eingebauten Terminal-Prozesse (`app --terminal <projekt>`).
fn terminal_pids(project: &str) -> Vec<u32> {
  let out = Command::new("pgrep")
    .args(["-f", "--", &format!("--terminal {project}$")])


759	fn read_project_config_in(paths: &Paths, project: &str) -> Result<ProjectConfig, String> {
760	  let cfg_path = project_config_path(paths, project)?;
761	  if !cfg_path.is_file() {
762	    return Ok(ProjectConfig::default());
763	  }
764	  let raw =
765	    fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
766	  serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))
767	}
768
769	fn write_project_config_in(
770	  paths: &Paths,
771	  project: &str,
772	  cfg: &ProjectConfig,
773	) -> Result<(), String> {
774	  let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
775	  fs::write(project_config_path(paths, project)?, raw + "\n").map_err(|e| e.to_string())
776	}
777
778	fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result<(), String> {
779	  if !paths.pool_dir(pool).join(POOL_FILE).is_file() {
780	    return Err(format!("Pool existiert nicht: {pool}"));
781	  }
782	  let mut cfg = read_project_config_in(paths, project)?;
783	  cfg.pool = Some(pool.to_string());
784	  write_project_config_in(paths, project, &cfg)
785	}
786
787	/// Nimmt den Pool raus; die Config-Datei bleibt nur, wenn sie noch
788	/// Terminal-Einstellungen trägt.
789	fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> {
790	  let mut cfg = read_project_config_in(paths, project)?;
791	  cfg.pool = None;
792	  if cfg.terminal.is_empty() {
793	    let cfg_path = project_config_path(paths, project)?;
794	    return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()));
795	  }
796	  write_project_config_in(paths, project, &cfg)
797	}
798
799	fn set_terminal_config_in(
800	  paths: &Paths,
801	  project: &str,
802	  mut terminal: TerminalConfig,
803	) -> Result<(), String> {
804	  let mut cfg = read_project_config_in(paths, project)?;
805	  // Absolut gewählte Icons ins gemeinsame Icons-Verzeichnis kopieren und als
806	  // Dateiname speichern — Icons gehören zur App-Config, nicht ins Quell-Repo.
807	  if let Some(icon) = terminal.icon.as_deref() {
808	    if icon.starts_with('/') {

fn read_project_config_in(paths: &Paths, project: &str) -> Result<ProjectConfig, String> {
  let cfg_path = project_config_path(paths, 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(
  paths: &Paths,
  project: &str,
  cfg: &ProjectConfig,
) -> Result<(), String> {
  let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
  fs::write(project_config_path(paths, 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 = project_config_path(paths, 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> {
  let mut cfg = read_project_config_in(paths, project)?;
  // Absolut gewählte Icons ins gemeinsame Icons-Verzeichnis kopieren und als
  // Dateiname speichern — Icons gehören zur App-Config, nicht ins Quell-Repo.
  if let Some(icon) = terminal.icon.as_deref() {
    if icon.starts_with('/') {


646	/// Trägt einen Arbeitsordner in die Projekt-settings.json ein — wie im
647	/// Wizard: permissions.additionalDirectories + Edit-Permission. Legt die
648	/// Datei an, wenn sie fehlt (importierte Projekte).
649	fn add_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> {
650	  check_name(name)?;
651	  let wd_path = expand_home(paths, dir);
652	  if !wd_path.is_dir() {
653	    return Err(format!("Arbeitsverzeichnis fehlt: {}", wd_path.display()));
654	  }
655	  let dir = contract_home(paths, &wd_path);
656	  let sp = settings_path(&project_dir(paths, name)?);
657	  let mut v: serde_json::Value = if sp.is_file() {
658	    let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
659	    serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?
660	  } else {
661	    serde_json::json!({})
662	  };
663	  let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
664	  let perms = root
665	    .entry("permissions")
666	    .or_insert_with(|| serde_json::json!({}))
667	    .as_object_mut()
668	    .ok_or("permissions ist kein Objekt")?;
669	  let dirs = perms
670	    .entry("additionalDirectories")
671	    .or_insert_with(|| serde_json::json!([]))
672	    .as_array_mut()
673	    .ok_or("additionalDirectories ist kein Array")?;
674	  if dirs.iter().any(|d| d.as_str() == Some(&dir)) {
675	    return Err(format!("schon eingetragen: {dir}"));
676	  }
677	  dirs.push(serde_json::json!(dir));
678	  let allow = perms
679	    .entry("allow")
680	    .or_insert_with(|| serde_json::json!([]))
681	    .as_array_mut()
682	    .ok_or("allow ist kein Array")?;
683	  allow.insert(0, serde_json::json!(format!("Edit({dir}/**)")));
684	  let parent = sp.parent().ok_or("settings.json ohne Elternordner")?;
685	  fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
686	  let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
687	  fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
688	}
689
690	/// Nimmt einen Arbeitsordner wieder raus: additionalDirectories-Eintrag und
691	/// zugehörige Edit-Permission. Der Ordner selbst bleibt.
692	fn remove_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> {
693	  check_name(name)?;
694	  let sp = settings_path(&project_dir(paths, name)?);
695	  let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
696	  let mut v: serde_json::Value =
697	    serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
698	  let perms = v["permissions"]
699	    .as_object_mut()
700	    .ok_or("permissions ist kein Objekt")?;
701	  let dirs = perms["additionalDirectories"]
702	    .as_array_mut()
703	    .ok_or("additionalDirectories ist kein Array")?;
704	  let before = dirs.len();
705	  dirs.retain(|d| d.as_str() != Some(dir));
706	  if dirs.len() == before {
707	    return Err(format!("nicht eingetragen: {dir}"));
708	  }
709	  if let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) {
710	    allow.retain(|p| p.as_str() != Some(&format!("Edit({dir}/**)")));
711	  }
712	  let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
713	  fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
714	}
715
716	/// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json.
717	fn project_work_dirs_in(paths: &Paths, name: &str) -> Result<Vec<String>, String> {
718	  check_name(name)?;
719	  let sp = settings_path(&project_dir(paths, name)?);
720	  if !sp.is_file() {

/// Trägt einen Arbeitsordner in die Projekt-settings.json ein — wie im
/// Wizard: permissions.additionalDirectories + Edit-Permission. Legt die
/// Datei an, wenn sie fehlt (importierte Projekte).
fn add_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> {
  check_name(name)?;
  let wd_path = expand_home(paths, dir);
  if !wd_path.is_dir() {
    return Err(format!("Arbeitsverzeichnis fehlt: {}", wd_path.display()));
  }
  let dir = contract_home(paths, &wd_path);
  let sp = settings_path(&project_dir(paths, name)?);
  let mut v: serde_json::Value = if sp.is_file() {
    let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
    serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?
  } else {
    serde_json::json!({})
  };
  let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
  let perms = root
    .entry("permissions")
    .or_insert_with(|| serde_json::json!({}))
    .as_object_mut()
    .ok_or("permissions ist kein Objekt")?;
  let dirs = perms
    .entry("additionalDirectories")
    .or_insert_with(|| serde_json::json!([]))
    .as_array_mut()
    .ok_or("additionalDirectories ist kein Array")?;
  if dirs.iter().any(|d| d.as_str() == Some(&dir)) {
    return Err(format!("schon eingetragen: {dir}"));
  }
  dirs.push(serde_json::json!(dir));
  let allow = perms
    .entry("allow")
    .or_insert_with(|| serde_json::json!([]))
    .as_array_mut()
    .ok_or("allow ist kein Array")?;
  allow.insert(0, serde_json::json!(format!("Edit({dir}/**)")));
  let parent = sp.parent().ok_or("settings.json ohne Elternordner")?;
  fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.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()))
}

/// Nimmt einen Arbeitsordner wieder raus: additionalDirectories-Eintrag und
/// zugehörige Edit-Permission. Der Ordner selbst bleibt.
fn remove_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> {
  check_name(name)?;
  let sp = settings_path(&project_dir(paths, name)?);
  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 perms = v["permissions"]
    .as_object_mut()
    .ok_or("permissions ist kein Objekt")?;
  let dirs = perms["additionalDirectories"]
    .as_array_mut()
    .ok_or("additionalDirectories ist kein Array")?;
  let before = dirs.len();
  dirs.retain(|d| d.as_str() != Some(dir));
  if dirs.len() == before {
    return Err(format!("nicht eingetragen: {dir}"));
  }
  if let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) {
    allow.retain(|p| p.as_str() != Some(&format!("Edit({dir}/**)")));
  }
  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()))
}

/// 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 = settings_path(&project_dir(paths, name)?);
  if !sp.is_file() {


cd ~/projects/ai-control/src-tauri/src; grep -n "fn project_config_path\|fn settings_path\|fn project_dir\b" lib.rs

project_config_path/settings_path finden


119:pub(crate) fn project_dir(paths: &Paths, name: &str) -> Result<PathBuf, String> {
144:fn project_config_path(paths: &Paths, project: &str) -> Result<PathBuf, String> {
402:fn settings_path(dir: &std::path::Path) -> PathBuf {


Alles klar zum Andocken — es gibt schon ein Muster für `additionalDirectories` (`add_work_dir_in`). Ich baue jetzt:

1. `ProjectConfig.archiveDir` + Helfer (lesen/setzen, Permission-Eintrag),
2. Archiv-Logik (Frontmatter, Slug, UTC-Zeitstempel),
3. MCP-Tool `archive_panel` + `AI_CONTROL_PROJECT`-Env,
4. Tauri-Commands + Panel-Button mit Dialog-Fallback.

Los, ProjectConfig + Helfer in lib.rs:


Edit

#[derive(Serialize, Deserialize, Default)]
struct ProjectConfig {
  #[serde(default, skip_serializing_if = "Option::is_none")]
  pool: Option<String>,
  #[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
  terminal: TerminalConfig,
}

#[derive(Serialize, Deserialize, Default)]
struct ProjectConfig {
  #[serde(default, skip_serializing_if = "Option::is_none")]
  pool: Option<String>,
  #[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
  terminal: TerminalConfig,
  /// Zielordner fürs Archivieren von Panel-Entwürfen (~-relativ gespeichert).
  #[serde(
    default,
    rename = "archiveDir",
    skip_serializing_if = "Option::is_none"
  )]
  archive_dir: Option<String>,
}


The file /Users/marcus.hinz/projects/ai-control/src-tauri/src/lib.rs has been updated successfully. (file state is current in your context — no need to Read it back)

#[derive(Serialize, Deserialize, Default)]
struct ProjectConfig {
  #[serde(default, skip_serializing_if = "Option::is_none")]
  pool: Option<String>,
  #[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
  terminal: TerminalConfig,
}

#[derive(Serialize, Deserialize, Default)]
struct ProjectConfig {
  #[serde(default, skip_serializing_if = "Option::is_none")]
  pool: Option<String>,
  #[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
  terminal: TerminalConfig,
  /// Zielordner fürs Archivieren von Panel-Entwürfen (~-relativ gespeichert).
  #[serde(
    default,
    rename = "archiveDir",
    skip_serializing_if = "Option::is_none"
  )]
  archive_dir: Option<String>,
}

+  /// Zielordner fürs Archivieren von Panel-Entwürfen (~-relativ gespeichert).

+    skip_serializing_if = "Option::is_none"

#[derive(Serialize, Deserialize, Default, Clone)]


Bash

cd ~/projects/ai-control/src-tauri/src; grep -n "ProjectConfig {" lib.rs

ProjectConfig-Literale finden


165:struct ProjectConfig {
560:  let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal };


Edit

let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal };

let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal, archive_dir: None };


-  let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal };

+  let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal, archive_dir: None };

if cfg.pool.is_some() || !cfg.terminal.is_empty() {


  let mut cfg = read_project_config_in(paths, project)?;
  cfg.pool = None;
  if cfg.terminal.is_empty() {
    let cfg_path = project_config_path(paths, project)?;
    return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()));
  }
  write_project_config_in(paths, project, &cfg)

  let mut cfg = read_project_config_in(paths, project)?;
  cfg.pool = None;
  if cfg.terminal.is_empty() && cfg.archive_dir.is_none() {
    let cfg_path = project_config_path(paths, project)?;
    return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()));
  }
  write_project_config_in(paths, project, &cfg)


  let mut cfg = read_project_config_in(paths, project)?;
  cfg.pool = None;
  if cfg.terminal.is_empty() {
    let cfg_path = project_config_path(paths, project)?;
    return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()));
  }
  write_project_config_in(paths, project, &cfg)

  let mut cfg = read_project_config_in(paths, project)?;
  cfg.pool = None;
  if cfg.terminal.is_empty() && cfg.archive_dir.is_none() {
    let cfg_path = project_config_path(paths, project)?;
    return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()));
  }
  write_project_config_in(paths, project, &cfg)

fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> {

+  if cfg.terminal.is_empty() && cfg.archive_dir.is_none() {


Jetzt die Archiv-Helfer und -Logik. Ich platziere sie bei `panel_file`:


Bash

cd ~/projects/ai-control/src-tauri/src; grep -n "pub(crate) fn panel_file" lib.rs

panel_file-Stelle finden


71:pub(crate) fn panel_file(project: &str) -> PathBuf {


62	  /// Panel-Dateien pro Projekt: der Skill schreibt seinen Entwurf hier hinein,
63	  /// der Terminal-Prozess beobachtet die Datei und zeigt sie im Panel.
64	  fn panels_dir(&self) -> PathBuf {
65	    self.config_dir().join("panels")
66	  }
67	}
68
69	/// Panel-Datei eines Projekts (Kanal Skill -> Panel). Der Pfad landet als
70	/// AI_CONTROL_PANEL in der PTY-Umgebung.
71	pub(crate) fn panel_file(project: &str) -> PathBuf {
72	  Paths::real().panels_dir().join(format!("{project}.md"))
73	}
74
75	// ---------- Projekt-Registry ----------

  /// Panel-Dateien pro Projekt: der Skill schreibt seinen Entwurf hier hinein,
  /// der Terminal-Prozess beobachtet die Datei und zeigt sie im Panel.
  fn panels_dir(&self) -> PathBuf {
    self.config_dir().join("panels")
  }
}

/// Panel-Datei eines Projekts (Kanal Skill -> Panel). Der Pfad landet als
/// AI_CONTROL_PANEL in der PTY-Umgebung.
pub(crate) fn panel_file(project: &str) -> PathBuf {
  Paths::real().panels_dir().join(format!("{project}.md"))
}

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


Edit

/// Panel-Datei eines Projekts (Kanal Skill -> Panel). Der Pfad landet als
/// AI_CONTROL_PANEL in der PTY-Umgebung.
pub(crate) fn panel_file(project: &str) -> PathBuf {
  Paths::real().panels_dir().join(format!("{project}.md"))
}

/// Panel-Datei eines Projekts (Kanal Skill -> Panel). Der Pfad landet als
/// AI_CONTROL_PANEL in der PTY-Umgebung.
pub(crate) fn panel_file(project: &str) -> PathBuf {
  Paths::real().panels_dir().join(format!("{project}.md"))
}

// ---------- Archiv (Panel-Entwürfe persistieren) ----------

/// Konfigurierter Archiv-Ordner des Projekts (ai-control.json: archiveDir),
/// Home-expandiert; None, wenn nicht gesetzt.
pub(crate) fn project_archive_dir(project: &str) -> Option<PathBuf> {
  let paths = Paths::real();
  let dir = read_project_config_in(&paths, project).ok()?.archive_dir?;
  Some(expand_home(&paths, &dir))
}

/// Setzt den Archiv-Ordner: ai-control.json (~-relativ) und ein Eintrag in
/// permissions.additionalDirectories + Edit der Projekt-settings.json, damit
/// claude das Archiv später lesen/scannen darf.
pub(crate) fn set_project_archive_dir(project: &str, dir: &str) -> Result<(), String> {
  let paths = Paths::real();
  let expanded = expand_home(&paths, dir);
  fs::create_dir_all(&expanded).map_err(|e| format!("{}: {e}", expanded.display()))?;
  let contracted = contract_home(&paths, &expanded);
  let mut cfg = read_project_config_in(&paths, project)?;
  cfg.archive_dir = Some(contracted.clone());
  write_project_config_in(&paths, project, &cfg)?;
  add_archive_permission(&paths, project, &contracted)
}

/// Trägt den Archiv-Ordner idempotent in additionalDirectories + Edit-Allow ein.
fn add_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<(), String> {
  let sp = settings_path(&project_dir(paths, project)?);
  let mut v: serde_json::Value = if sp.is_file() {
    serde_json::from_str(&fs::read_to_string(&sp).map_err(|e| e.to_string())?)
      .map_err(|e| format!("{}: {e}", sp.display()))?
  } else {
    serde_json::json!({})
  };
  let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
  let perms = root
    .entry("permissions")
    .or_insert_with(|| serde_json::json!({}))
    .as_object_mut()
    .ok_or("permissions ist kein Objekt")?;
  let dirs = perms
    .entry("additionalDirectories")
    .or_insert_with(|| serde_json::json!([]))
    .as_array_mut()
    .ok_or("additionalDirectories ist kein Array")?;
  if !dirs.iter().any(|d| d.as_str() == Some(dir)) {
    dirs.push(serde_json::json!(dir));
  }
  let edit = format!("Edit({dir}/**)");
  let allow = perms
    .entry("allow")
    .or_insert_with(|| serde_json::json!([]))
    .as_array_mut()
    .ok_or("allow ist kein Array")?;
  if !allow.iter().any(|p| p.as_str() == Some(&edit)) {
    allow.push(serde_json::json!(edit));
  }
  let parent = sp.parent().ok_or("settings.json ohne Elternordner")?;
  fs::create_dir_all(parent).map_err(|e| e.to_string())?;
  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()))
}

/// Archiviert den aktuellen Panel-Inhalt des Projekts als Markdown-Datei mit
/// Frontmatter im Archiv-Ordner. `dir_override` setzt den Ordner zugleich
/// (Terminal-Fallback ohne Dialog). Liefert den geschriebenen Pfad.
pub(crate) fn archive_panel_content(
  project: &str,
  dir_override: Option<&str>,
) -> Result<PathBuf, String> {
  if let Some(d) = dir_override {
    set_project_archive_dir(project, d)?;
  }
  let dir = project_archive_dir(project).ok_or("kein Archiv-Ordner gesetzt")?;
  let text = fs::read_to_string(panel_file(project)).unwrap_or_default();
  if text.trim().is_empty() {
    return Err("Panel ist leer — nichts zu archivieren".into());
  }
  fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;

  let secs = SystemTime::now()
    .duration_since(UNIX_EPOCH)
    .map_err(|e| e.to_string())?
    .as_secs();
  let (stamp, iso) = utc_stamp(secs);
  let title = first_line(&text);
  let path = dir.join(format!("{stamp}-{}.md", slugify(&title)));
  let doc = format!(
    "---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n---\n\n{}\n",
    title.replace('"', "'"),
    text.trim_end(),
  );
  fs::write(&path, doc).map_err(|e| format!("{}: {e}", path.display()))?;
  Ok(path)
}

/// Titelzeile: erste Überschrift (## …) oder sonst erste nicht-leere Zeile.
fn first_line(text: &str) -> String {
  let mut fallback: Option<&str> = None;
  for line in text.lines() {
    let t = line.trim();
    if t.is_empty() {
      continue;
    }
    let h = t.trim_start_matches('#').trim();
    if t.starts_with('#') && !h.is_empty() {
      return h.to_string();
    }
    fallback.get_or_insert(t);
  }
  fallback.unwrap_or("entwurf").to_string()
}

/// Dateinamen-tauglicher Slug (ascii, klein, Bindestriche, max. 60 Zeichen).
fn slugify(s: &str) -> String {
  let mut out = String::new();
  for c in s.chars() {
    if c.is_ascii_alphanumeric() {
      out.push(c.to_ascii_lowercase());
    } else if !out.ends_with('-') {
      out.push('-');
    }
  }
  let out: String = out.trim_matches('-').chars().take(60).collect();
  if out.is_empty() {
    "entwurf".into()
  } else {
    out
  }
}

/// UTC aus Epoch-Sekunden: (Dateistempel `YYYY-MM-DD_HHMM`, ISO `…Z`).
fn utc_stamp(secs: u64) -> (String, String) {
  let (h, mi, s) = (secs / 3600 % 24, secs / 60 % 60, secs % 60);
  let (y, m, d) = civil_from_days((secs / 86400) as i64);
  (
    format!("{y:04}-{m:02}-{d:02}_{h:02}{mi:02}"),
    format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z"),
  )
}

/// Kalenderdatum aus Tagen seit 1970-01-01 (Howard-Hinnant-Algorithmus).
fn civil_from_days(z: i64) -> (i64, u32, u32) {
  let z = z + 719468;
  let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
  let doe = z - era * 146097;
  let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
  let y = yoe + era * 400;
  let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
  let mp = (5 * doy + 2) / 153;
  let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
  let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
  (if m <= 2 { y + 1 } else { y }, m, d)
}


/// Panel-Datei eines Projekts (Kanal Skill -> Panel). Der Pfad landet als
/// AI_CONTROL_PANEL in der PTY-Umgebung.
pub(crate) fn panel_file(project: &str) -> PathBuf {
  Paths::real().panels_dir().join(format!("{project}.md"))
}

// ---------- Archiv (Panel-Entwürfe persistieren) ----------

/// Konfigurierter Archiv-Ordner des Projekts (ai-control.json: archiveDir),
/// Home-expandiert; None, wenn nicht gesetzt.
pub(crate) fn project_archive_dir(project: &str) -> Option<PathBuf> {
  let paths = Paths::real();
  let dir = read_project_config_in(&paths, project).ok()?.archive_dir?;
  Some(expand_home(&paths, &dir))
}

/// Setzt den Archiv-Ordner: ai-control.json (~-relativ) und ein Eintrag in
/// permissions.additionalDirectories + Edit der Projekt-settings.json, damit
/// claude das Archiv später lesen/scannen darf.
pub(crate) fn set_project_archive_dir(project: &str, dir: &str) -> Result<(), String> {
  let paths = Paths::real();
  let expanded = expand_home(&paths, dir);
  fs::create_dir_all(&expanded).map_err(|e| format!("{}: {e}", expanded.display()))?;
  let contracted = contract_home(&paths, &expanded);
  let mut cfg = read_project_config_in(&paths, project)?;
  cfg.archive_dir = Some(contracted.clone());
  write_project_config_in(&paths, project, &cfg)?;
  add_archive_permission(&paths, project, &contracted)
}

/// Trägt den Archiv-Ordner idempotent in additionalDirectories + Edit-Allow ein.
fn add_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<(), String> {
  let sp = settings_path(&project_dir(paths, project)?);
  let mut v: serde_json::Value = if sp.is_file() {
    serde_json::from_str(&fs::read_to_string(&sp).map_err(|e| e.to_string())?)
      .map_err(|e| format!("{}: {e}", sp.display()))?
  } else {
    serde_json::json!({})
  };
  let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
  let perms = root
    .entry("permissions")
    .or_insert_with(|| serde_json::json!({}))
    .as_object_mut()
    .ok_or("permissions ist kein Objekt")?;
  let dirs = perms
    .entry("additionalDirectories")
    .or_insert_with(|| serde_json::json!([]))
    .as_array_mut()
    .ok_or("additionalDirectories ist kein Array")?;
  if !dirs.iter().any(|d| d.as_str() == Some(dir)) {
    dirs.push(serde_json::json!(dir));
  }
  let edit = format!("Edit({dir}/**)");
  let allow = perms
    .entry("allow")
    .or_insert_with(|| serde_json::json!([]))
    .as_array_mut()
    .ok_or("allow ist kein Array")?;
  if !allow.iter().any(|p| p.as_str() == Some(&edit)) {
    allow.push(serde_json::json!(edit));
  }
  let parent = sp.parent().ok_or("settings.json ohne Elternordner")?;
  fs::create_dir_all(parent).map_err(|e| e.to_string())?;
  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()))
}

/// Archiviert den aktuellen Panel-Inhalt des Projekts als Markdown-Datei mit
/// Frontmatter im Archiv-Ordner. `dir_override` setzt den Ordner zugleich
/// (Terminal-Fallback ohne Dialog). Liefert den geschriebenen Pfad.
pub(crate) fn archive_panel_content(
  project: &str,
  dir_override: Option<&str>,
) -> Result<PathBuf, String> {
  if let Some(d) = dir_override {
    set_project_archive_dir(project, d)?;
  }
  let dir = project_archive_dir(project).ok_or("kein Archiv-Ordner gesetzt")?;
  let text = fs::read_to_string(panel_file(project)).unwrap_or_default();
  if text.trim().is_empty() {
    return Err("Panel ist leer — nichts zu archivieren".into());
  }
  fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;

  let secs = SystemTime::now()
    .duration_since(UNIX_EPOCH)
    .map_err(|e| e.to_string())?
    .as_secs();
  let (stamp, iso) = utc_stamp(secs);
  let title = first_line(&text);
  let path = dir.join(format!("{stamp}-{}.md", slugify(&title)));
  let doc = format!(
    "---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n---\n\n{}\n",
    title.replace('"', "'"),
    text.trim_end(),
  );
  fs::write(&path, doc).map_err(|e| format!("{}: {e}", path.display()))?;
  Ok(path)
}

/// Titelzeile: erste Überschrift (## …) oder sonst erste nicht-leere Zeile.
fn first_line(text: &str) -> String {
  let mut fallback: Option<&str> = None;
  for line in text.lines() {
    let t = line.trim();
    if t.is_empty() {
      continue;
    }
    let h = t.trim_start_matches('#').trim();
    if t.starts_with('#') && !h.is_empty() {
      return h.to_string();
    }
    fallback.get_or_insert(t);
  }
  fallback.unwrap_or("entwurf").to_string()
}

/// Dateinamen-tauglicher Slug (ascii, klein, Bindestriche, max. 60 Zeichen).
fn slugify(s: &str) -> String {
  let mut out = String::new();
  for c in s.chars() {
    if c.is_ascii_alphanumeric() {
      out.push(c.to_ascii_lowercase());
    } else if !out.ends_with('-') {
      out.push('-');
    }
  }
  let out: String = out.trim_matches('-').chars().take(60).collect();
  if out.is_empty() {
    "entwurf".into()
  } else {
    out
  }
}

/// UTC aus Epoch-Sekunden: (Dateistempel `YYYY-MM-DD_HHMM`, ISO `…Z`).
fn utc_stamp(secs: u64) -> (String, String) {
  let (h, mi, s) = (secs / 3600 % 24, secs / 60 % 60, secs % 60);
  let (y, m, d) = civil_from_days((secs / 86400) as i64);
  (
    format!("{y:04}-{m:02}-{d:02}_{h:02}{mi:02}"),
    format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z"),
  )
}

/// Kalenderdatum aus Tagen seit 1970-01-01 (Howard-Hinnant-Algorithmus).
fn civil_from_days(z: i64) -> (i64, u32, u32) {
  let z = z + 719468;
  let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
  let doe = z - era * 146097;
  let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
  let y = yoe + era * 400;
  let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
  let mp = (5 * doy + 2) / 153;
  let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
  let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
  (if m <= 2 { y + 1 } else { y }, m, d)
}

+// ---------- Archiv (Panel-Entwürfe persistieren) ----------

+/// Konfigurierter Archiv-Ordner des Projekts (ai-control.json: archiveDir),

+/// Home-expandiert; None, wenn nicht gesetzt.

+pub(crate) fn project_archive_dir(project: &str) -> Option<PathBuf> {

+  let dir = read_project_config_in(&paths, project).ok()?.archive_dir?;

+/// Setzt den Archiv-Ordner: ai-control.json (~-relativ) und ein Eintrag in

+/// permissions.additionalDirectories + Edit der Projekt-settings.json, damit

+/// claude das Archiv später lesen/scannen darf.

+pub(crate) fn set_project_archive_dir(project: &str, dir: &str) -> Result<(), String> {

+  let expanded = expand_home(&paths, dir);

+  fs::create_dir_all(&expanded).map_err(|e| format!("{}: {e}", expanded.display()))?;

+  let contracted = contract_home(&paths, &expanded);

+  let mut cfg = read_project_config_in(&paths, project)?;

+  cfg.archive_dir = Some(contracted.clone());

+  write_project_config_in(&paths, project, &cfg)?;

+  add_archive_permission(&paths, project, &contracted)

+/// Trägt den Archiv-Ordner idempotent in additionalDirectories + Edit-Allow ein.

+fn add_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<(), String> {

+  let sp = settings_path(&project_dir(paths, project)?);

+  let mut v: serde_json::Value = if sp.is_file() {

+    serde_json::from_str(&fs::read_to_string(&sp).map_err(|e| e.to_string())?)

+      .map_err(|e| format!("{}: {e}", sp.display()))?

+  let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;

+    .or_insert_with(|| serde_json::json!({}))

+    .ok_or("permissions ist kein Objekt")?;

+    .or_insert_with(|| serde_json::json!([]))

+    .ok_or("additionalDirectories ist kein Array")?;

+  if !dirs.iter().any(|d| d.as_str() == Some(dir)) {

+  let edit = format!("Edit({dir}/**)");

+  if !allow.iter().any(|p| p.as_str() == Some(&edit)) {

+    allow.push(serde_json::json!(edit));

+  let parent = sp.parent().ok_or("settings.json ohne Elternordner")?;

+  fs::create_dir_all(parent).map_err(|e| e.to_string())?;

+  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()))

+/// Archiviert den aktuellen Panel-Inhalt des Projekts als Markdown-Datei mit

+/// Frontmatter im Archiv-Ordner. `dir_override` setzt den Ordner zugleich

+/// (Terminal-Fallback ohne Dialog). Liefert den geschriebenen Pfad.

+    set_project_archive_dir(project, d)?;

+  let dir = project_archive_dir(project).ok_or("kein Archiv-Ordner gesetzt")?;

+  let text = fs::read_to_string(panel_file(project)).unwrap_or_default();

+    return Err("Panel ist leer — nichts zu archivieren".into());

+  fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;

+  let path = dir.join(format!("{stamp}-{}.md", slugify(&title)));

+    "---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n---\n\n{}\n",

+  fs::write(&path, doc).map_err(|e| format!("{}: {e}", path.display()))?;

+/// Titelzeile: erste Überschrift (## …) oder sonst erste nicht-leere Zeile.

+  let mut fallback: Option<&str> = None;

+    let h = t.trim_start_matches('#').trim();

+    if t.starts_with('#') && !h.is_empty() {

+/// Dateinamen-tauglicher Slug (ascii, klein, Bindestriche, max. 60 Zeichen).

+      out.push(c.to_ascii_lowercase());

+  let out: String = out.trim_matches('-').chars().take(60).collect();

+/// UTC aus Epoch-Sekunden: (Dateistempel `YYYY-MM-DD_HHMM`, ISO `…Z`).

+fn utc_stamp(secs: u64) -> (String, String) {

+  let (y, m, d) = civil_from_days((secs / 86400) as i64);

+/// Kalenderdatum aus Tagen seit 1970-01-01 (Howard-Hinnant-Algorithmus).

+  let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);

+  let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;

/// Pfad unterhalb von Home als "~/…" schreiben — Registry-Einträge im


let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);

-  let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;

+  let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;


Jetzt die Tauri-Commands (Button) und Registrierung im terminal_builder:


/// Popup-Fußzeile „Beenden": App verlassen.
#[tauri::command]
fn quit_app(app: tauri::AppHandle) {
  app.exit(0);
}

/// Popup-Fußzeile „Beenden": App verlassen.
#[tauri::command]
fn quit_app(app: tauri::AppHandle) {
  app.exit(0);
}

/// Konfigurierter Archiv-Ordner des Projekts (für den Panel-Button: entscheidet,
/// ob der Ordner-Dialog nötig ist).
#[tauri::command]
fn panel_archive_dir_cmd(project: String) -> Option<String> {
  project_archive_dir(&project).map(|p| p.display().to_string())
}

/// Archiviert den aktuellen Panel-Entwurf. `dir` (aus dem Ordner-Dialog) setzt
/// den Archiv-Ordner zugleich; ohne `dir` muss er konfiguriert sein.
#[tauri::command]
fn panel_archive_cmd(project: String, dir: Option<String>) -> Result<String, String> {
  archive_panel_content(&project, dir.as_deref()).map(|p| p.display().to_string())
}


/// Popup-Fußzeile „Beenden": App verlassen.
#[tauri::command]
fn quit_app(app: tauri::AppHandle) {
  app.exit(0);
}

/// Konfigurierter Archiv-Ordner des Projekts (für den Panel-Button: entscheidet,
/// ob der Ordner-Dialog nötig ist).
#[tauri::command]
fn panel_archive_dir_cmd(project: String) -> Option<String> {
  project_archive_dir(&project).map(|p| p.display().to_string())
}

/// Archiviert den aktuellen Panel-Entwurf. `dir` (aus dem Ordner-Dialog) setzt
/// den Archiv-Ordner zugleich; ohne `dir` muss er konfiguriert sein.
#[tauri::command]
fn panel_archive_cmd(project: String, dir: Option<String>) -> Result<String, String> {
  archive_panel_content(&project, dir.as_deref()).map(|p| p.display().to_string())
}

+/// Konfigurierter Archiv-Ordner des Projekts (für den Panel-Button: entscheidet,

+fn panel_archive_dir_cmd(project: String) -> Option<String> {

+  project_archive_dir(&project).map(|p| p.display().to_string())

+/// Archiviert den aktuellen Panel-Entwurf. `dir` (aus dem Ordner-Dialog) setzt

+/// den Archiv-Ordner zugleich; ohne `dir` muss er konfiguriert sein.

+fn panel_archive_cmd(project: String, dir: Option<String>) -> Result<String, String> {

+  archive_panel_content(&project, dir.as_deref()).map(|p| p.display().to_string())

/// D-Bus-Relay für die GNOME-Extension: Show()/Hide() steuern das Popup-Fenster.

/// Die Extension ruft Show() beim Panel-Klick und positioniert das Fenster selbst.


      terminal::term_start,
      terminal::term_log,
      terminal::term_write,
      terminal::term_resize,
      terminal::panel_read,
      terminal::open_panel_window,

      terminal::term_start,
      terminal::term_log,
      terminal::term_write,
      terminal::term_resize,
      terminal::panel_read,
      terminal::open_panel_window,
      panel_archive_dir_cmd,
      panel_archive_cmd,


      terminal::term_start,
      terminal::term_log,
      terminal::term_write,
      terminal::term_resize,
      terminal::panel_read,
      terminal::open_panel_window,
      panel_archive_dir_cmd,
      panel_archive_cmd,

// Header im Terminal-Prozess: Projektliste, Icon und Pool-Name.


Der Terminal-Prozess braucht das Dialog-Plugin (Frontend-`open`). Es ist bisher nur im Haupt-Builder. In `terminal_builder` ergänzen:


fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
  tauri::Builder::default()
    .manage(terminal::Terminals::default())

fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
  tauri::Builder::default()
    .plugin(tauri_plugin_dialog::init())
    .manage(terminal::Terminals::default())


fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
  tauri::Builder::default()
    .manage(terminal::Terminals::default())

fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
  tauri::Builder::default()
    .plugin(tauri_plugin_dialog::init())
    .manage(terminal::Terminals::default())

/// Regular, dadurch Dock-Icon und Cmd-Tab-Eintrag pro Terminal.

+    .plugin(tauri_plugin_dialog::init())


Jetzt `terminal.rs`: `AI_CONTROL_PROJECT`-Env für den MCP-Server:


  cmd.env("TERM", "xterm-256color");
  cmd.env("AI_CONTROL_PANEL", &panel_path);

  cmd.env("TERM", "xterm-256color");
  cmd.env("AI_CONTROL_PANEL", &panel_path);
  cmd.env("AI_CONTROL_PROJECT", &project);


The file /Users/marcus.hinz/projects/ai-control/src-tauri/src/terminal.rs has been updated successfully. (file state is current in your context — no need to Read it back)

  cmd.env("TERM", "xterm-256color");
  cmd.env("AI_CONTROL_PANEL", &panel_path);
  cmd.env("AI_CONTROL_PROJECT", &project);

+  cmd.env("AI_CONTROL_PROJECT", &project);

if let Some(pool_dir) = crate::project_pool_dir(&project)? {


Jetzt `mcp.rs`: Tool `archive_panel` in `tools/list` und `tools/call`:


    "tools/list" => Some(json!({
      "tools": [{
        "name": "write_panel",
        "description":
          "Legt einen längeren Entwurf (ADR, E-Mail, Dokument, Spezifikation, \
           Commit-Message, Textbaustein) im ai-control-Panel neben dem Terminal \
           ab, wo er mit der Maus selektierbar und über einen Button kopierbar \
           ist. Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \
           aufrufen und im Chat nur kurz bestätigen.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "text": {
              "type": "string",
              "description": "Vollständiger Entwurf als Markdown-Rohtext.",
            }
          },
          "required": ["text"],
        },
      }],
    })),

    "tools/list" => Some(json!({
      "tools": [
        {
          "name": "write_panel",
          "description":
            "Legt einen längeren Entwurf (ADR, E-Mail, Dokument, Spezifikation, \
             Commit-Message, Textbaustein) im ai-control-Panel neben dem Terminal \
             ab, wo er mit der Maus selektierbar und über einen Button kopierbar \
             ist. Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \
             aufrufen und im Chat nur kurz bestätigen.",
          "inputSchema": {
            "type": "object",
            "properties": {
              "text": {
                "type": "string",
                "description": "Vollständiger Entwurf als Markdown-Rohtext.",
              }
            },
            "required": ["text"],
          },
        },
        {
          "name": "archive_panel",
          "description":
            "Archiviert den aktuell im Panel liegenden Entwurf dauerhaft als \
             Markdown-Datei im Archiv-Ordner des Projekts. Auf Wunsch nutzen \
             („archiviere das"). Ist kein Ordner konfiguriert, den Zielpfad im \
             Argument `dir` mitgeben (der Nutzer nennt ihn); ohne `dir` und ohne \
             konfigurierten Ordner meldet das Tool das zurück.",
          "inputSchema": {
            "type": "object",
            "properties": {
              "dir": {
                "type": "string",
                "description":
                  "Optionaler Archiv-Ordner. Wird gesetzt und für künftige \
                   Archivierungen gemerkt.",
              }
            },
          },
        },
      ],
    })),


The file /Users/marcus.hinz/projects/ai-control/src-tauri/src/mcp.rs has been updated successfully. (file state is current in your context — no need to Read it back)

    "tools/list" => Some(json!({
      "tools": [
        {
          "name": "write_panel",
          "description":
            "Legt einen längeren Entwurf (ADR, E-Mail, Dokument, Spezifikation, \
             Commit-Message, Textbaustein) im ai-control-Panel neben dem Terminal \
             ab, wo er mit der Maus selektierbar und über einen Button kopierbar \
             ist. Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \
             aufrufen und im Chat nur kurz bestätigen.",
          "inputSchema": {
            "type": "object",
            "properties": {
              "text": {
                "type": "string",
                "description": "Vollständiger Entwurf als Markdown-Rohtext.",
              }
            },
            "required": ["text"],
          },
        },
        {
          "name": "archive_panel",
          "description":
            "Archiviert den aktuell im Panel liegenden Entwurf dauerhaft als \
             Markdown-Datei im Archiv-Ordner des Projekts. Auf Wunsch nutzen \
             („archiviere das"). Ist kein Ordner konfiguriert, den Zielpfad im \
             Argument `dir` mitgeben (der Nutzer nennt ihn); ohne `dir` und ohne \
             konfigurierten Ordner meldet das Tool das zurück.",
          "inputSchema": {
            "type": "object",
            "properties": {
              "dir": {
                "type": "string",
                "description":
                  "Optionaler Archiv-Ordner. Wird gesetzt und für künftige \
                   Archivierungen gemerkt.",
              }
            },
          },
        },
      ],
    })),

//! Minimaler MCP-stdio-Server (`app --mcp-panel`), den claude als Tool-Server
//! startet. Er stellt genau ein Tool bereit: `write_panel(text)` schreibt den
//! Text in die Panel-Datei aus `AI_CONTROL_PANEL` (dieselbe Env, die der
//! Terminal-Prozess der PTY mitgibt und die claude an seine MCP-Kinder vererbt).
//! Der bestehende Datei-Watcher im Terminal-Prozess zieht den Inhalt ins Panel.
//!
//! Protokoll: JSON-RPC 2.0, newline-getrennt (ein Objekt pro Zeile).

use serde_json::{json, Value};
use std::io::{BufRead, Write};

pub fn run_mcp_panel() {
  let stdin = std::io::stdin();
  let mut stdout = std::io::stdout();
  let mut reader = stdin.lock();
  let mut line = String::new();

  loop {
    line.clear();
    if reader.read_line(&mut line).unwrap_or(0) == 0 {
      break; // stdin geschlossen -> claude hat den Server beendet
    }
    let trimmed = line.trim();
    if trimmed.is_empty() {
      continue;
    }
    let Ok(req) = serde_json::from_str::<Value>(trimmed) else {
      continue;
    };

    let id = req.get("id").cloned();
    let method = req.get("method").and_then(Value::as_str).unwrap_or("");
    let result = handle(method, &req);

    // Nur Requests (mit id) bekommen eine Antwort; Notifications nicht.
    let (Some(id), Some(result)) = (id, result) else {
      continue;
    };
    let resp = json!({ "jsonrpc": "2.0", "id": id, "result": result });
    if writeln!(stdout, "{resp}").is_err() || stdout.flush().is_err() {
      break;
    }
  }
}

/// Liefert das `result` für einen Request, oder None für Notifications/Unbekanntes.
fn handle(method: &str, req: &Value) -> Option<Value> {
  match method {
    "initialize" => {
      // Vom Client vorgeschlagene Protokollversion übernehmen.
      let protocol = req["params"]["protocolVersion"]
        .as_str()
        .unwrap_or("2024-11-05");
      Some(json!({
        "protocolVersion": protocol,
        "capabilities": { "tools": {} },
        "serverInfo": { "name": "text-panel", "version": env!("CARGO_PKG_VERSION") },
      }))
    }
    "tools/list" => Some(json!({
      "tools": [{
        "name": "write_panel",
        "description":
          "Legt einen längeren Entwurf (ADR, E-Mail, Dokument, Spezifikation, \
           Commit-Message, Textbaustein) im ai-control-Panel neben dem Terminal \
           ab, wo er mit der Maus selektierbar und über einen Button kopierbar \
           ist. Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \
           aufrufen und im Chat nur kurz bestätigen.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "text": {
              "type": "string",
              "description": "Vollständiger Entwurf als Markdown-Rohtext.",
            }
          },
          "required": ["text"],
        },
      }],
    })),
    "tools/call" => Some(call_tool(req)),
    "ping" => Some(json!({})),
    _ => None,
  }
}

fn call_tool(req: &Value) -> Value {
  let name = req["params"]["name"].as_str().unwrap_or("");
  if name != "write_panel" {
    return json!({
      "content": [{ "type": "text", "text": format!("Unbekanntes Tool: {name}") }],
      "isError": true,
    });
  }
  let text = req["params"]["arguments"]["text"].as_str().unwrap_or("");
  match std::env::var("AI_CONTROL_PANEL") {
    Ok(path) => match std::fs::write(&path, text) {
      Ok(()) => json!({ "content": [{ "type": "text", "text": "Entwurf ins Panel geschrieben." }] }),
      Err(e) => json!({
        "content": [{ "type": "text", "text": format!("Panel-Datei nicht schreibbar: {e}") }],
        "isError": true,
      }),
    },
    Err(_) => json!({
      "content": [{ "type": "text", "text": "AI_CONTROL_PANEL nicht gesetzt (Terminal außerhalb ai-control)." }],
      "isError": true,
    }),
  }
}

-          "Legt einen längeren Entwurf (ADR, E-Mail, Dokument, Spezifikation, \

-           Commit-Message, Textbaustein) im ai-control-Panel neben dem Terminal \

-           ab, wo er mit der Maus selektierbar und über einen Button kopierbar \

-           ist. Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \

-           aufrufen und im Chat nur kurz bestätigen.",

-              "description": "Vollständiger Entwurf als Markdown-Rohtext.",

+            "Legt einen längeren Entwurf (ADR, E-Mail, Dokument, Spezifikation, \

+             Commit-Message, Textbaustein) im ai-control-Panel neben dem Terminal \

+             ab, wo er mit der Maus selektierbar und über einen Button kopierbar \

+             ist. Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \

+             aufrufen und im Chat nur kurz bestätigen.",

+                "description": "Vollständiger Entwurf als Markdown-Rohtext.",

+            "Archiviert den aktuell im Panel liegenden Entwurf dauerhaft als \

+             Markdown-Datei im Archiv-Ordner des Projekts. Auf Wunsch nutzen \

+             („archiviere das"). Ist kein Ordner konfiguriert, den Zielpfad im \

+             Argument `dir` mitgeben (der Nutzer nennt ihn); ohne `dir` und ohne \

+             konfigurierten Ordner meldet das Tool das zurück.",

+                  "Optionaler Archiv-Ordner. Wird gesetzt und für künftige \

+                   Archivierungen gemerkt.",


fn call_tool(req: &Value) -> Value {
  let name = req["params"]["name"].as_str().unwrap_or("");
  if name != "write_panel" {
    return json!({
      "content": [{ "type": "text", "text": format!("Unbekanntes Tool: {name}") }],
      "isError": true,
    });
  }
  let text = req["params"]["arguments"]["text"].as_str().unwrap_or("");
  match std::env::var("AI_CONTROL_PANEL") {
    Ok(path) => match std::fs::write(&path, text) {
      Ok(()) => json!({ "content": [{ "type": "text", "text": "Entwurf ins Panel geschrieben." }] }),
      Err(e) => json!({
        "content": [{ "type": "text", "text": format!("Panel-Datei nicht schreibbar: {e}") }],
        "isError": true,
      }),
    },
    Err(_) => json!({
      "content": [{ "type": "text", "text": "AI_CONTROL_PANEL nicht gesetzt (Terminal außerhalb ai-control)." }],
      "isError": true,
    }),
  }
}

fn call_tool(req: &Value) -> Value {
  match req["params"]["name"].as_str().unwrap_or("") {
    "write_panel" => call_write(req),
    "archive_panel" => call_archive(req),
    other => json!({
      "content": [{ "type": "text", "text": format!("Unbekanntes Tool: {other}") }],
      "isError": true,
    }),
  }
}

fn call_write(req: &Value) -> Value {
  let text = req["params"]["arguments"]["text"].as_str().unwrap_or("");
  match std::env::var("AI_CONTROL_PANEL") {
    Ok(path) => match std::fs::write(&path, text) {
      Ok(()) => json!({ "content": [{ "type": "text", "text": "Entwurf ins Panel geschrieben." }] }),
      Err(e) => json!({
        "content": [{ "type": "text", "text": format!("Panel-Datei nicht schreibbar: {e}") }],
        "isError": true,
      }),
    },
    Err(_) => json!({
      "content": [{ "type": "text", "text": "AI_CONTROL_PANEL nicht gesetzt (Terminal außerhalb ai-control)." }],
      "isError": true,
    }),
  }
}

fn call_archive(req: &Value) -> Value {
  let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
  let dir = req["params"]["arguments"]["dir"].as_str();
  match crate::archive_panel_content(&project, dir) {
    Ok(path) => json!({
      "content": [{ "type": "text", "text": format!("Archiviert: {}", path.display()) }],
    }),
    Err(e) => json!({
      "content": [{
        "type": "text",
        "text": format!("Nicht archiviert: {e}. Zielordner im Argument `dir` angeben oder im Panel wählen."),
      }],
      "isError": true,
    }),
  }
}


fn call_tool(req: &Value) -> Value {
  match req["params"]["name"].as_str().unwrap_or("") {
    "write_panel" => call_write(req),
    "archive_panel" => call_archive(req),
    other => json!({
      "content": [{ "type": "text", "text": format!("Unbekanntes Tool: {other}") }],
      "isError": true,
    }),
  }
}

fn call_write(req: &Value) -> Value {
  let text = req["params"]["arguments"]["text"].as_str().unwrap_or("");
  match std::env::var("AI_CONTROL_PANEL") {
    Ok(path) => match std::fs::write(&path, text) {
      Ok(()) => json!({ "content": [{ "type": "text", "text": "Entwurf ins Panel geschrieben." }] }),
      Err(e) => json!({
        "content": [{ "type": "text", "text": format!("Panel-Datei nicht schreibbar: {e}") }],
        "isError": true,
      }),
    },
    Err(_) => json!({
      "content": [{ "type": "text", "text": "AI_CONTROL_PANEL nicht gesetzt (Terminal außerhalb ai-control)." }],
      "isError": true,
    }),
  }
}

fn call_archive(req: &Value) -> Value {
  let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
  let dir = req["params"]["arguments"]["dir"].as_str();
  match crate::archive_panel_content(&project, dir) {
    Ok(path) => json!({
      "content": [{ "type": "text", "text": format!("Archiviert: {}", path.display()) }],
    }),
    Err(e) => json!({
      "content": [{
        "type": "text",
        "text": format!("Nicht archiviert: {e}. Zielordner im Argument `dir` angeben oder im Panel wählen."),
      }],
      "isError": true,
    }),
  }
}

//! Minimaler MCP-stdio-Server (`app --mcp-panel`), den claude als Tool-Server
//! startet. Er stellt genau ein Tool bereit: `write_panel(text)` schreibt den
//! Text in die Panel-Datei aus `AI_CONTROL_PANEL` (dieselbe Env, die der
//! Terminal-Prozess der PTY mitgibt und die claude an seine MCP-Kinder vererbt).
//! Der bestehende Datei-Watcher im Terminal-Prozess zieht den Inhalt ins Panel.
//!
//! Protokoll: JSON-RPC 2.0, newline-getrennt (ein Objekt pro Zeile).

use serde_json::{json, Value};
use std::io::{BufRead, Write};

pub fn run_mcp_panel() {
  let stdin = std::io::stdin();
  let mut stdout = std::io::stdout();
  let mut reader = stdin.lock();
  let mut line = String::new();

  loop {
    line.clear();
    if reader.read_line(&mut line).unwrap_or(0) == 0 {
      break; // stdin geschlossen -> claude hat den Server beendet
    }
    let trimmed = line.trim();
    if trimmed.is_empty() {
      continue;
    }
    let Ok(req) = serde_json::from_str::<Value>(trimmed) else {
      continue;
    };

    let id = req.get("id").cloned();
    let method = req.get("method").and_then(Value::as_str).unwrap_or("");
    let result = handle(method, &req);

    // Nur Requests (mit id) bekommen eine Antwort; Notifications nicht.
    let (Some(id), Some(result)) = (id, result) else {
      continue;
    };
    let resp = json!({ "jsonrpc": "2.0", "id": id, "result": result });
    if writeln!(stdout, "{resp}").is_err() || stdout.flush().is_err() {
      break;
    }
  }
}

/// Liefert das `result` für einen Request, oder None für Notifications/Unbekanntes.
fn handle(method: &str, req: &Value) -> Option<Value> {
  match method {
    "initialize" => {
      // Vom Client vorgeschlagene Protokollversion übernehmen.
      let protocol = req["params"]["protocolVersion"]
        .as_str()
        .unwrap_or("2024-11-05");
      Some(json!({
        "protocolVersion": protocol,
        "capabilities": { "tools": {} },
        "serverInfo": { "name": "text-panel", "version": env!("CARGO_PKG_VERSION") },
      }))
    }
    "tools/list" => Some(json!({
      "tools": [
        {
          "name": "write_panel",
          "description":
            "Legt einen längeren Entwurf (ADR, E-Mail, Dokument, Spezifikation, \
             Commit-Message, Textbaustein) im ai-control-Panel neben dem Terminal \
             ab, wo er mit der Maus selektierbar und über einen Button kopierbar \
             ist. Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \
             aufrufen und im Chat nur kurz bestätigen.",
          "inputSchema": {
            "type": "object",
            "properties": {
              "text": {
                "type": "string",
                "description": "Vollständiger Entwurf als Markdown-Rohtext.",
              }
            },
            "required": ["text"],
          },
        },
        {
          "name": "archive_panel",
          "description":
            "Archiviert den aktuell im Panel liegenden Entwurf dauerhaft als \
             Markdown-Datei im Archiv-Ordner des Projekts. Auf Wunsch nutzen \
             („archiviere das"). Ist kein Ordner konfiguriert, den Zielpfad im \
             Argument `dir` mitgeben (der Nutzer nennt ihn); ohne `dir` und ohne \
             konfigurierten Ordner meldet das Tool das zurück.",
          "inputSchema": {
            "type": "object",
            "properties": {
              "dir": {
                "type": "string",
                "description":
                  "Optionaler Archiv-Ordner. Wird gesetzt und für künftige \
                   Archivierungen gemerkt.",
              }
            },
          },
        },
      ],
    })),
    "tools/call" => Some(call_tool(req)),
    "ping" => Some(json!({})),
    _ => None,
  }
}

fn call_tool(req: &Value) -> Value {
  let name = req["params"]["name"].as_str().unwrap_or("");
  if name != "write_panel" {
    return json!({
      "content": [{ "type": "text", "text": format!("Unbekanntes Tool: {name}") }],
      "isError": true,
    });
  }
  let text = req["params"]["arguments"]["text"].as_str().unwrap_or("");
  match std::env::var("AI_CONTROL_PANEL") {
    Ok(path) => match std::fs::write(&path, text) {
      Ok(()) => json!({ "content": [{ "type": "text", "text": "Entwurf ins Panel geschrieben." }] }),
      Err(e) => json!({
        "content": [{ "type": "text", "text": format!("Panel-Datei nicht schreibbar: {e}") }],
        "isError": true,
      }),
    },
    Err(_) => json!({
      "content": [{ "type": "text", "text": "AI_CONTROL_PANEL nicht gesetzt (Terminal außerhalb ai-control)." }],
      "isError": true,
    }),
  }
}

-  let name = req["params"]["name"].as_str().unwrap_or("");

-      "content": [{ "type": "text", "text": format!("Unbekanntes Tool: {name}") }],

+  match req["params"]["name"].as_str().unwrap_or("") {

+    "archive_panel" => call_archive(req),

+      "content": [{ "type": "text", "text": format!("Unbekanntes Tool: {other}") }],

+fn call_archive(req: &Value) -> Value {

+  let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();

+  let dir = req["params"]["arguments"]["dir"].as_str();

+  match crate::archive_panel_content(&project, dir) {

+      "content": [{ "type": "text", "text": format!("Archiviert: {}", path.display()) }],

+        "text": format!("Nicht archiviert: {e}. Zielordner im Argument `dir` angeben oder im Panel wählen."),


Rust-Seite bauen, bevor Frontend: