Laufende Hintergrundarbeit sichtbar machen, /fertig prueft den Abschluss, /uebergabe reicht an einen frischen Agenten weiter

A security-hardened, declarative NixOS configuration focused on privacy, anonymity, and full reproducibility.
Sicherheitsscore: 9.5/10 | Security Hardening Details | Secret Rotation Policy
| Component | Configuration |
|---|---|
| NixOS Version | 26.05 (Yarara) |
| Desktop | GNOME 49.3 (Wayland, GDM) |
| Shell | Nushell + Starship + Modern Unix Tools |
| Editor | Neovim (Rust IDE), VSCodium (externe Terminal) |
| VPN | ProtonVPN GUI (WireGuard, Auto-Connect, Kill-Switch) |
| Encryption | LUKS2 Full-Disk + FIDO2 + TPM 2.0 + Secure Boot |
| Secrets | sops-nix (Age-encrypted) |
| Hardware Key | Nitrokey 3C NFC (FIDO2, SSH, OpenPGP, TOTP) |
| Kernel | 6.12 LTS (nixpkgs) + Memory Hardening + sysctl-Härtung (kein Lockdown-LSM im Kernel) |
| Anonymity | IPv6 disabled, Hostname randomized, No mDNS Broadcasting |
flake.nix # Flake Entry Point (gepinnte Inputs)
├── configuration.nix # System Configuration
├── home.nix # User Configuration (Home Manager)
├── hardware-configuration.nix
├── secrets/
│ └── secrets.yaml # Encrypted Secrets (Age)
├── pkgs/
│ └── default.nix # Custom packages overlay
└── modules/
├── network.nix # NetworkManager, DNS-over-TLS, DNSSEC, Anonymity, Firejail Sandbox
├── firewall.nix # VPN Kill Switch + Port-Scan + DHCP Snooping + mDNS Limits + rp_filter (loose)
├── firewall-zones.nix # Network Segmentation Zones
├── protonvpn.nix # WireGuard Auto-Connect
├── desktop.nix # GNOME Desktop (Wayland)
├── audio.nix # PipeWire
├── power.nix # TLP, Thermald
├── sops.nix # Secret Management (Age)
├── security.nix # Kernel Hardening, Base AppArmor, ClamAV, USBGuard, AIDE
├── apparmor-profiles.nix # Custom AppArmor MAC for LibreWolf, Thunderbird, etc.
├── email-alerts.nix # Critical Security Event Notifications (msmtp)
├── cve-monitoring.nix # CVE-Monitoring für installierte Pakete
├── secureboot.nix # Lanzaboote + TPM2 + Secure Boot Monitoring
├── suricata.nix # Intrusion Detection System (WiFi + VPN)
├── logwatch.nix # Automated Security Monitoring & Daily Reports
├── ssh-hardening.nix # SSH Server Hardening (prepared, disabled)
├── vr-arbeit.nix # Virtuelle Monitore in der Quest 3 (WiVRn, WayVR, GNOME)
└── home/
├── gnome-settings.nix # GNOME Dconf (Privacy, Screen Lock)
├── neovim.nix # Neovim IDE
└── sway.nix # Sway Window Manager
📚 Detaillierte Dokumentation: SECURITY-HARDENING.md
heim_in/heim_out; firewall-heimnetz.service füllt sie nur, solange das NM-Profil Greenside4 aktiv ist (PSK-gebunden) — ein fremdes Fritz!Box-WLAN mit 192.168.178.0/24 bekommt nichts davonnftables.service startet vor network-pre.target, kein Boot-Fenster ohne Kill-Switchssh-tpm-agent, ~/.ssh/id_ecdsa.tpm für root/admin/GitHub/GitLab/sftp/Signatur, id_ecdsa_colmena.tpm für den Deploy, id_rsa_router.tpm für den Router — dessen dropbear kann kein ECDSA): benutzbar ohne Rückfrage, damit Colmena und Claude selbstständig arbeiten, aber nicht kopierbar. Die früheren Ed25519-Dateien ohne Passphrase sind gelöscht und überall ausgetragen. TPM gelöscht (BIOS-Reset, Mainboard) = Schlüssel weg; Notweg auf den Server ist der Nitrokey-Schlüssel admin@nitrokey (root und admin). Plan: docs/superpowers/plans/2026-09-23-ssh-schluessel-tpm.mdverify-secureboot liest nach jedem Boot die Firmware-Variable SecureBoot und meldet ins Journal (journalctl -t secureboot); der Nutzerdienst secureboot-warnung zeigt dieselbe Warnung auf dem Desktop. Seit 2026-09-23 in der Firmware AN (user mode, eigene PK, Microsoft-Schlüssel in KEK/db), BIOS mit Supervisor-Passwort~/.ssh/id_ecdsa signiert (ECDSA, über ssh-tpm-agent); ältere Commits tragen den früheren Ed25519-Schlüssel und bleiben über allowed_signers verifizierbar/etc/firejail/globals.local sperrt ssh-tpm-agent, ssh-agent, gcr und den WezTerm-Agent für alle Firejail-Profileobsidian-custom.profile, logseq-custom.profile): Das mitgelieferte gibt ganz ~/Dokumente frei, hier sieht die App nur ~/Dokumente/Obsidian; ~/.ssh, ~/.gnupg und /var/lib/sops-nix sind zusaetzlich ausgeblendet. Die Reihenfolge ist die ganze Wirkung: nowhitelist muss VOR dem include stehen, und ein zusaetzliches --whitelist= verengt gar nichts — Firejail-Whitelists sind additivkillUnconfinedConfinables = truelinuxPackages_6_12 (hardened wurde aus nixpkgs entfernt) mit sysctl-Härtung, u. a. kernel.io_uring_disabled=2, dev.tty.ldisc_autoload=0, kernel.sysrq=0presentDevicePolicy = "apply-policy"); Root-Hubs, Kamera, WWAN-Modem und Bluetooth haben eigene Regeln, gepinnt auf Schnittstellen und internen Portfaillock --user <nutzer>). Fail2Ban ist aus (keine Netz-Dienste)/var/log/security-reports/idle-delay = 300; bis 2026-09-23 stand 0 = nie)Erweiterte Boot-Parameter:
- IOMMU aktiviert (intel_iommu=on, DMA-Schutz)
- init_on_alloc=1 (Speicher bei Allokation nullen)
- init_on_free=1 (Speicher bei Freigabe nullen)
- page_alloc.shuffle=1 (Page-Allocator randomisieren)
- randomize_kstack_offset=on (Kernel-Stack ASLR)
- slab_nomerge (Anti-Exploit)
- vsyscall=none (Legacy-Syscalls deaktiviert)
- mitigations=auto,nosmt (CPU-Mitigations + SMT aus)
Sysctl Hardening:
- ASLR maximiert (randomize_va_space=2)
- Kernel Pointer versteckt (kptr_restrict=2)
- dmesg nur für root (dmesg_restrict=1)
- Kexec deaktiviert
- BPF JIT gehärtet
- Ptrace eingeschränkt (yama.ptrace_scope=1)
- Core Dumps komplett deaktiviert (kernel.core_pattern = /bin/false)
- Unprivilegierte BPF deaktiviert
- TCP Timestamps deaktiviert (OS-Fingerprinting-Schutz)
- SYN Cookies aktiviert
- Source Routing deaktiviert
- ICMP Redirects ignoriert
- Reverse Path Filtering überall loose (`rp_filter=2`); strict ginge wegen WireGuard nicht
- Kernel Module Locking aktiviert (lockKernelModules = true)
- Swappiness minimiert (vm.swappiness=1)
Ungenutzte und potenziell unsichere Module sind blockiert:
SSH-Hardening-Modul vorbereitet (ssh-hardening.nix):
# Clone repository
git clone https://github.com/<nutzer>/nixos-config.git
cd nixos-config
# Generate Age key (if not present)
mkdir -p /var/lib/sops-nix
age-keygen -o /var/lib/sops-nix/key.txt
# Add public key to .sops.yaml and re-encrypt secrets
# (see Secrets Management)
# Build and activate system
sudo nixos-rebuild switch --flake .#nixos
# Create Secure Boot keys
sudo sbctl create-keys
# Enroll keys in firmware
sudo sbctl enroll-keys --microsoft
# Rebuild system (signs automatically)
sudo nixos-rebuild switch --flake .#nixos
VPN Kill Switch mit nftables:
network-pre.target; Heimnetz-Freigaben dynamisch über firewall-heimnetz.service (NM-Dispatcher)rp_filter=2); strict ginge wegen WireGuard nichtProtonVPN GUI mit WireGuard:
Umfassende Sicherheitskonfiguration:
security.audit, Regeln prüfen mit sudo auditctl -l)MCP-Server mit echten NixOS-Daten für Claude Code (github.com/utensils/mcp-nixos):
environment.systemPackages), damit er in jedem Projekt greift und nicht an einer geladenen devShell hängtmcpServers.nixos in ~/.claude.json über ein Home-Manager-Aktivierungsskript — Claude Code liest MCP-Server weder aus settings.json noch aus einer .mcp.json im Konfigurationsverzeichnis (nachgemessen mit claude 2.1.278)~/.claude.json ist Claude Codes veränderliche Zustandsdatei; das Skript setzt idempotent genau einen Schlüsselv3.1.0 gepinnt, deshalb nicht Teil von notify-updatesMCP-Server für das ELSTER-Portal in Claude Code (github.com/lukasschwarz/elster-mcp-server, Paket pkgs/elster-mcp-server): UStVA, EÜR, ESt, Verlauf und Posteingang per Puppeteer.
elster_ustva_confirm gibt eine Voranmeldung ab — nicht rückholbar. EÜR und ESt füllen nur bis „Prüfen", Verlauf und Posteingang lesen nurPUPPETEER_EXECUTABLE_PATH) — mit Sandkasten, upstreams Vorgabe --no-sandbox ist gestrichenelster-zertifikat, elster-passwort, elster-steuerpflichtiger). Der Wrapper elster-mcp-sops baut daraus bei jedem Start die Konfiguration in $XDG_RUNTIME_DIR/elster-mcp (tmpfs) — nichts davon steht in ~/.claude.json oder in der Umgebungsecrets/secrets.yaml stehen, bevor das Modul geschaltet wird — sonst bricht sops-nix den Bau ab~/.local/share/elster-mcp (0700), nicht im Arbeitsverzeichnis der Sitzungelster_config_show verdeckt nur das Passwort; Steuernummer und Anschrift gehen an das ModellClaude Code legt Gelesenes als verlinkte Markdown-Notizen im Obsidian-Vault ab (github.com/AgriciDaniel/claude-obsidian):
extraKnownMarketplaces und enabledPlugins in ~/.claude/settings.json — dieselbe Stelle wie bei thedotmack. Claude Code klont den Marktplatz daraufhin selbst nach ~/.claude/plugins/marketplaces/CLAUDE_OBSIDIAN_VAULT ist das, was „global" hier bedeutet: Ohne die Variable loest das Plugin seinen Vault aus dem aktuellen Verzeichnis auf und bricht bei Unklarheit ab — eine Sitzung in ~/nixos-config faende nichts. Gesetzt als environment.sessionVariables und nicht als home.sessionVariables, weil letzteres aus GNOME gestartete Anwendungen nicht zuverlaessig erreicht (dieselbe Lehre wie bei GSK_RENDERER)settings.json schreibt Claude Code selbst; das Skript setzt idempotent genau zwei Schluesseladopt). Sie ist zweistufig gebaut (Plan mit SHA-256, dann --apply --approved-plan-sha256), damit ein Mensch dazwischen liest — in einem Aktivierungsskript liefe sie bei jedem Rebuild gegen einen Vault, der per Git auf obsi-01 synchronisiert wirdsuperpowers und claude-mem auchArbeiten an virtuellen Monitoren in der Meta Quest 3 — in der GNOME-Sitzung.
RecordVirtual), solange vr-monitore läuft — sie stehen wie angesteckte Bildschirme in Einstellungen → Bildschirmeadb reverse, Loopback): kein offener Port, Avahi bleibt aus, der Kill-Switch ist nicht berührtpkgs/wivrn/), dieselbe Version wie auf dem Homeserver — ein Client auf der Quest für beidevr-arbeit an [anzahl] [BREITExHOEHE] (Vorgabe 2 × 2560x1440), vr-arbeit aus, vr-arbeit status; beim ersten Mal vorher vr-arbeit koppelnNeovim als Rust IDE:
Obsidian mit ObsidiSync — Notizen ueber den eigenen Server (obsi-01, https://obsidian.rusty-vault.de) auf allen Geraeten:
~/Dokumente/Obsidianios-git-sync, Release-Tag v0.15.0 — dieselbe Version, die der Server faehrt) als einzelne Symlinks unter .obsidian/plugins/<id>/. Einzeln und nicht als Verzeichnis, damit das Plugin sein data.json daneben schreiben kanndata.json, aktive Plugins in community-plugins.json, Vault-Eintrag in ~/.config/obsidian/obsidian.json. Diese Dateien schreibt die Anwendung im Betrieb selbst — ein Symlink darauf wuerde die Anmeldung bei jedem Rebuild wegwerfen/v1/auth/config nennt Issuer und client_id). Die Anmeldung als achim passiert einmal je Geraet im Plugin-Dialognetwork.nix, Menueeintrag ueber xdg.desktopEntries.obsidianshouldIgnoreVaultPath in ObsidiSync ist eine fest eingebaute Liste und liest keine .gitignore. Ausgenommen sind nur .git/, .obsidian-git-sync/, ObsidiSync History/, .trash/, .obsidian/workspace*.json, .obsidian/cache/ und .obsidian/plugins/ios-git-sync/. Daraus folgt zweierlei:ios-git-sync/data.json bleibt lokal — gut.main.js). Unangenehm wird das erst, wenn ein zweites Geraet sie aendert und der Server sie gegen einen schreibgeschuetzten Store-Symlink zurueckschreiben will. Bis dahin: beobachten.vault-meta/ von claude-obsidian mit, obwohl dessen eigene .gitignore es ausschliessen will (gemessen am 2026-09-20: nach adopt lagen 15 statt der geplanten 12 Dateien im Vault)xdg-open den Browser DRINNEN — und weil Chrome und LibreWolf hier selbst Firejail-Wrapper sind, scheitert das an nonewprivs. Der Wrapper legt deshalb ein eigenes xdg-open in den PATH, das org.freedesktop.portal.OpenURI ruft; geoeffnet wird damit ausserhalb des Sandkastens. Das Profil erlaubt dafuer genau einen D-Bus-Namen (org.freedesktop.portal.*) — und zwar VOR dem include, sonst weist Firejail die Lockerung ab| Secret | Path | Usage |
|---|---|---|
| WiFi Password | wifi/home | NetworkManager |
| Email Password | email/posteo | Thunderbird, GNOME Keyring |
| Anthropic API Key | anthropic-api-key | avante.nvim, crush, claude-code |
| GitHub Token | github-token | gh CLI, octo.nvim |
| WireGuard Key | wireguard-private-key | ProtonVPN |
| VPN Endpoint | protonvpn/endpoint | WireGuard Config |
| VPN Public Key | protonvpn/publickey | WireGuard Config |
| ProtonVPN IP Ranges | protonvpn/ip-ranges | Firewall Zones |
| Admin Email | system/admin-email | Logwatch Security Reports |
| SSH Key (Hetzner) | ssh/hetzner-vps | SSH |
| Miniflux Credentials | miniflux/* | Newsflash RSS-Reader |
| ELSTER-Zertifikat | elster-zertifikat | elster-mcp-server (.pfx, base64 in einer Zeile) |
| ELSTER-Zertifikatspasswort | elster-passwort | elster-mcp-server |
| ELSTER-Steuerdaten | elster-steuerpflichtiger | elster-mcp-server (JSON: taxNumber, stateCode, Name, Anschrift) |
# Edit secrets file (decrypts automatically)
sops secrets/secrets.yaml
# Set a single secret
sops --set '["secret-name"] "secret-value"' secrets/secrets.yaml
# Display a secret
sops -d --extract '["secret-name"]' secrets/secrets.yaml
# Generate host Age key from SSH key
ssh-to-age -i /etc/ssh/ssh_host_ed25519_key.pub
# Add key to .sops.yaml and re-encrypt secrets
sops updatekeys secrets/secrets.yaml
# Toolchain aus nixpkgs-unstable (deklarativ verwaltet):
# cargo, rustc, rust-analyzer, clippy, rustfmt
# cargo-nextest (Test Runner), cargo-depgraph (Dependency Graph)
# In Neovim:
# - Automatische Completion
# - Clippy on save
# - Debugging mit F5
# - Code Actions mit <leader>ca
# In VSCodium:
# - rust-analyzer + clippy
# - LLDB Debugging
# - TangleGuard (Dependency Graph Visualisierung)
# - ⚠️ Integriertes Terminal: Nicht verfügbar (hardened Kernel + Electron PTYhooks/register.tsx 567 lines1import { atom, read, update } from 'claude-code'
2import type {
3 BackgroundTaskSummary,
4 CommandRunInput,
5 CommandRunResult,
6 EngineInterface as Engine,
7 Register,
8 ToolCallInput,
9 Timer,
10 ToolCallResult,
11 RenderElement,
12 RenderInput,
13} from 'claude-code'
14
15import type { Lauf } from '../types'
16import { dauer, kurz, sichtbar } from './zustand'
17
18// $ darf nicht ueber einen Import wandern (Regel des Validators), deshalb
19// steht alles, was $ benutzt, in dieser einen Datei.
20
21// ---- Zustand der Sitzung ----
22
23const hintergrund = atom({ plugin: 'werkbank', key: 'hintergrund' } as const, [])
24const vordergrund = atom({ plugin: 'werkbank', key: 'vordergrund' } as const, [])
25const starts = atom({ plugin: 'werkbank', key: 'starts' } as const, [])
26const lotse = atom({ plugin: 'werkbank', key: 'lotse' } as const, '')
27const lotseStand = atom({ plugin: 'werkbank', key: 'lotseStand' } as const, 0)
28// Takt fuer die Anzeige: der Tick schreibt die Uhrzeit, das zeichnet die
29// Laufzeiten neu, ohne dass der Render-Hook selbst schreibt.
30const jetzt = atom({ plugin: 'werkbank', key: 'jetzt' } as const, 0)
31
32// Ein Befehl darf keinen Zug einreichen, solange sein Hook den Zug haelt;
33// der Timer reicht ihn ein, sobald der Befehl fertig ist.
34function nachDemBefehl($: Engine, prompt: { text: string }): void {
35 $.clock.after(100, () => {
36 $.prompt.submit(prompt).catch(err => $.ui.toast(`werkbank: Einreichen scheiterte: ${String(err)}`))
37 })
38}
39
40// ---- git und Prozesse ----
41type ProzessLauf = { code: number; out: string; err: string }
42
43async function run($: Engine, argv: string[], timeoutMs = 30_000, cwd?: string): Promise<ProzessLauf> {
44 try {
45 const r = await $.process.run(argv, { timeoutMs, cwd })
46 return { code: r.exitCode, out: r.stdout.trim(), err: r.stderr.trim() }
47 } catch (err) {
48 return { code: -1, out: '', err: String(err) }
49 }
50}
51
52type Ort = {
53 wurzel: string
54 zweig: string
55 istWorktree: boolean
56 istHomeserver: boolean
57}
58
59// Wo steht die Sitzung? undefined, wenn nicht in einem Git-Repo.
60async function ort($: Engine): Promise<Ort | undefined> {
61 const w = await run($, ['git', 'rev-parse', '--show-toplevel'])
62 if (w.code !== 0) return undefined
63 const zweig = (await run($, ['git', 'branch', '--show-current'])).out || '(losgeloest)'
64 const dir = (await run($, ['git', 'rev-parse', '--path-format=absolute', '--git-dir'])).out
65 const common = (await run($, ['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'])).out
66 const url = (await run($, ['git', 'remote', 'get-url', 'origin'])).out
67 return {
68 wurzel: w.out,
69 zweig,
70 istWorktree: dir !== '' && dir !== common,
71 istHomeserver: /achimcc\/homeserver(\.git)?$/.test(url),
72 }
73}
74
75// Die Commits, die auf diesem Zweig selbst entstanden sind (ohne Merges von
76// main), als Liste geaenderter Dateien. undefined, wenn der Zweig keinen
77// Anfang im Reflog hat.
78async function eigeneDateien($: Engine, zweig: string): Promise<string[] | undefined> {
79 const log = await run($, ['git', 'reflog', 'show', '--format=%H', `refs/heads/${zweig}`])
80 if (log.code !== 0 || log.out === '') return undefined
81 const anfang = log.out.split('\n').pop()!
82 const d = await run($, ['git', 'log', '--no-merges', '--first-parent', '--name-only', '--format=', `${anfang}..HEAD`])
83 if (d.code !== 0) return undefined
84 return [...new Set(d.out.split('\n').filter(Boolean))]
85}
86
87// ---- /laufend: was diese Sitzung laufen hat ----
88
89const PANE = 'werkbank-laufend'
90const TICK_MS = 15_000
91// Ab dieser Laufzeit meldet ein Toast den Lauf einmal von selbst.
92const MELDEN_AB_MS = 30 * 60_000
93// Nicht zugeordnete Starts verfallen nach dieser Zeit.
94const START_VERFALL_MS = 6 * 3600_000
95
96// Welche Aufrufe eine Hintergrundaufgabe starten, und wie sie heissen.
97function hintergrundStart(e: any): { art: string; text: string } | undefined {
98 switch (String(e.tool)) {
99 case 'Bash':
100 return e.run_in_background === true
101 ? { art: 'shell', text: e.description || e.command || 'Bash' }
102 : undefined
103 case 'Agent':
104 return e.run_in_background === true
105 ? { art: 'agent', text: e.description || e.subagent_type || 'Agent' }
106 : undefined
107 case 'Monitor':
108 return { art: 'monitor', text: e.description || e.command || 'Monitor' }
109 case 'Workflow':
110 return { art: 'workflow', text: e.name || e.description || 'Workflow' }
111 default:
112 return undefined
113 }
114}
115
116function vordergrundText(e: any): string {
117 switch (String(e.tool)) {
118 case 'Bash':
119 return e.description || e.command || 'Bash'
120 case 'Agent':
121 return `Agent: ${e.description || e.subagent_type || ''}`
122 default:
123 return String(e.tool)
124 }
125}
126
127async function lotseHolen($: Engine): Promise<void> {
128 let text: string
129 try {
130 const r = await $.process.run(['lotse', 'status'], { timeoutMs: 15_000 })
131 text = (r.stdout + r.stderr).trim() || '(keine Ausgabe)'
132 if (r.exitCode !== 0) text = `lotse status: Exit ${r.exitCode}\n${text}`
133 } catch (err) {
134 text = `lotse status nicht ausfuehrbar: ${String(err)}`
135 }
136 const now = await $.clock.now()
137 await update($, lotse, () => text)
138 await update($, lotseStand, () => now)
139}
140
141// Nur fuer diese Ladung des Moduls: welche Laeufe schon gemeldet sind.
142const gemeldet = new Set<string>()
143
144async function tick($: Engine): Promise<void> {
145 const now = await $.clock.now()
146 await update($, jetzt, () => now)
147 const liste = sichtbar(await read($, hintergrund), await read($, vordergrund), now)
148 if (liste.length === 0) {
149 $.ui.status(undefined)
150 return
151 }
152 const aeltester = liste.reduce((a, b) => (b.seit < a.seit ? b : a))
153 const rest = liste.length > 1 ? ` (+${liste.length - 1})` : ''
154 $.ui.status(`⏳ ${kurz(aeltester.text, 40)} · ${dauer(now - aeltester.seit)}${rest} — /laufend`)
155 for (const l of liste) {
156 if (now - l.seit >= MELDEN_AB_MS && !gemeldet.has(l.id)) {
157 gemeldet.add(l.id)
158 $.ui.toast(`Läuft seit ${dauer(now - l.seit)}: ${kurz(l.text, 70)}`)
159 }
160 }
161}
162
163async function laufendStart($: Engine): Promise<void> {
164 await $.command.register({
165 name: 'laufend',
166 description: 'Zeigt, was diese Sitzung gerade laufen hat, mit Laufzeit, dazu lotse status',
167 })
168 $.clock.every(TICK_MS, () => void tick($))
169}
170
171async function laufendBefehl($: Engine): Promise<CommandRunResult> {
172 await lotseHolen($)
173 await tick($)
174 await $.ui.open({ id: PANE, title: 'Laufend' })
175 const now = await $.clock.now()
176 const liste = sichtbar(await read($, hintergrund), await read($, vordergrund), now)
177 const zeilen = liste.map(l => `- ${l.art}: ${kurz(l.text, 80)} · ${dauer(now - l.seit)}`)
178 return { text: liste.length === 0 ? 'Diese Sitzung hat nichts laufen.' : zeilen.join('\n') }
179}
180
181async function laufendWerkzeug($: Engine, e: ToolCallInput, next: (e: ToolCallInput) => Promise<ToolCallResult>): Promise<ToolCallResult> {
182 // Werkzeugaufrufe in Subagenten zaehlen nicht einzeln; der Agent selbst zaehlt.
183 if (e.agentId !== undefined) return next(e)
184 const now = await $.clock.now()
185 const hg = hintergrundStart(e)
186 if (hg !== undefined) {
187 await update($, starts, list =>
188 [...list.filter(s => now - s.seit < START_VERFALL_MS), { text: hg.text, seit: now }].slice(-50),
189 )
190 return next(e)
191 }
192 const id = e.tool_use_id ?? `${e.tool}-${now}`
193 const lauf: Lauf = { id, art: String(e.tool), text: vordergrundText(e), seit: now }
194 await update($, vordergrund, list => [...list, lauf])
195 try {
196 return await next(e)
197 } finally {
198 await update($, vordergrund, list => list.filter(l => l.id !== id))
199 }
200}
201
202// Am Ende jedes Zuges nennt die Engine die Aufgaben, die noch laufen.
203// Das ist die Wahrheit; die Starts liefern nur die Startzeit dazu.
204async function laufendStop($: Engine, e: { background_tasks?: BackgroundTaskSummary[] }): Promise<void> {
205 const now = await $.clock.now()
206 const alt = await read($, hintergrund)
207 const offen = [...(await read($, starts))]
208 const neu: Lauf[] = (e.background_tasks ?? []).map(t => {
209 const bekannt = alt.find(l => l.id === t.id)
210 if (bekannt !== undefined) return bekannt
211 const text = t.description || t.command || t.name || t.agent_type || t.type
212 const i = offen.findIndex(s => s.text === t.description || s.text === t.command || s.text === t.name)
213 const seit = offen[i]?.seit ?? now
214 if (i >= 0) offen.splice(i, 1)
215 return { id: t.id, art: t.type, text, seit }
216 })
217 await update($, hintergrund, () => neu)
218 await update($, starts, () => offen.filter(s => now - s.seit < START_VERFALL_MS))
219 await tick($)
220}
221
222async function laufendPane($: Engine, e: RenderInput<'Pane'>): Promise<RenderElement> {
223 const { Box, Text, Button } = $.ui.resolve(e)
224 const now = (await read($, jetzt)) || Date.now()
225 const liste = sichtbar(await read($, hintergrund), await read($, vordergrund), now)
226 const lotseText = await read($, lotse)
227 const stand = await read($, lotseStand)
228 const breite = Math.max(20, (e.props.bodyColumns ?? 60) - 4)
229 return (
230 <Box flexDirection="column">
231 <Text bold>Diese Sitzung</Text>
232 {liste.length === 0 && <Text dimColor>nichts laufend</Text>}
233 {liste.map(l => (
234 <Text key={l.id}>
235 {dauer(now - l.seit).padStart(9)} <Text dimColor>{l.art}</Text> {kurz(l.text, breite - 20)}
236 </Text>
237 ))}
238 <Text> </Text>
239 <Box>
240 <Text bold>lotse status </Text>
241 <Text dimColor>{stand ? `vor ${dauer(now - stand)} ` : ''}</Text>
242 <Button key="neu" label="neu laden" onPress={() => void lotseHolen($)} />
243 </Box>
244 {lotseText.split('\n').slice(0, 30).map((z, i) => (
245 <Text key={`l${i}`} dimColor>
246 {kurz(z, breite)}
247 </Text>
248 ))}
249 </Box>
250 )
251}
252
253// ---- /fertig: Abschluss messen, dann Worktree weg ----
254
255type Urteil = 'ok' | 'rot' | 'gelb' | 'offen'
256type Punkt = { name: string; urteil: Urteil; text: string }
257
258const ZEICHEN: Record<Urteil, string> = { ok: '✔', rot: '✘', gelb: '!', offen: '?' }
259
260type GhLauf = { headSha: string; status: string; conclusion: string; workflowName: string }
261
262async function ciPunkt($: Engine): Promise<Punkt> {
263 const name = 'CI'
264 const r = await run($, [
265 'gh', 'run', 'list', '--branch', 'main', '--event', 'push', '--limit', '30',
266 '--json', 'headSha,status,conclusion,workflowName',
267 ])
268 if (r.code !== 0) return { name, urteil: 'offen', text: `gh run list scheiterte: ${kurz(r.err, 80)}` }
269 let laeufe: GhLauf[]
270 try {
271 laeufe = JSON.parse(r.out)
272 } catch {
273 return { name, urteil: 'offen', text: 'Antwort von gh nicht lesbar' }
274 }
275 // Der aelteste Push auf main, der HEAD enthaelt, ist der, der die Arbeit brachte.
276 const shas = [...new Set(laeufe.map(l => l.headSha))]
277 let treffer: string | undefined
278 for (const sha of shas) {
279 const a = await run($, ['git', 'merge-base', '--is-ancestor', 'HEAD', sha])
280 if (a.code === 0) treffer = sha
281 else if (treffer !== undefined) break
282 }
283 if (treffer === undefined) return { name, urteil: 'offen', text: 'kein Lauf unter den letzten 30 enthält HEAD' }
284 const eigene = laeufe.filter(l => l.headSha === treffer)
285 const rot = eigene.filter(l => l.status === 'completed' && !['success', 'skipped', 'cancelled'].includes(l.conclusion))
286 const laeuft = eigene.filter(l => l.status !== 'completed')
287 const abgebrochen = eigene.filter(l => l.conclusion === 'cancelled')
288 const kurzSha = treffer.slice(0, 8)
289 if (rot.length > 0) return { name, urteil: 'rot', text: `${kurzSha}: rot in ${rot.map(l => l.workflowName).join(', ')}` }
290 if (laeuft.length > 0) return { name, urteil: 'gelb', text: `${kurzSha}: läuft noch (${laeuft.map(l => l.workflowName).join(', ')})` }
291 if (abgebrochen.length > 0) return { name, urteil: 'gelb', text: `${kurzSha}: abgebrochen (${abgebrochen.map(l => l.workflowName).join(', ')})` }
292 return { name, urteil: 'ok', text: `${kurzSha}: grün (${eigene.map(l => l.workflowName).join(', ')})` }
293}
294
295async function deployPunkt($: Engine): Promise<Punkt> {
296 const name = 'Deploy server'
297 const r = await run(
298 $,
299 [
300 'ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', 'colmena-deploy@server',
301 'nixos-version --configuration-revision; readlink -f /run/current-system; readlink -f /nix/var/nix/profiles/system',
302 ],
303 25_000,
304 )
305 if (r.code !== 0) return { name, urteil: 'offen', text: `ssh scheiterte (Exit ${r.code}): ${kurz(r.err, 80)}` }
306 const [rev = '', laufend = '', profil = ''] = r.out.split('\n').map(s => s.trim())
307 if (!/^[0-9a-f]{40}$/.test(rev)) return { name, urteil: 'offen', text: `laufender Stand unklar: ${kurz(rev, 40)}` }
308 const kurzRev = rev.slice(0, 8)
309 if ((await run($, ['git', 'cat-file', '-e', `${rev}^{commit}`])).code !== 0) {
310 return { name, urteil: 'offen', text: `Server läuft ${kurzRev}, lokal unbekannt` }
311 }
312 if ((await run($, ['git', 'merge-base', '--is-ancestor', 'HEAD', rev])).code !== 0) {
313 return { name, urteil: 'rot', text: `Server läuft ${kurzRev}, enthält HEAD nicht` }
314 }
315 if (laufend !== profil) {
316 return { name, urteil: 'gelb', text: `${kurzRev} enthält HEAD, aber nur per deploy test (Profil ≠ /run/current-system)` }
317 }
318 return { name, urteil: 'ok', text: `Server läuft ${kurzRev}, enthält HEAD` }
319}
320
321async function fertigStart($: Engine): Promise<void> {
322 await $.command.register({
323 name: 'fertig',
324 description: 'Prüft: committed, in origin/main, CI grün, deployt, Newsletter — dann Worktree weg',
325 argumentHint: '[ohne-newsletter] [ohne-deploy]',
326 })
327}
328
329async function fertigBefehl($: Engine, e: CommandRunInput): Promise<CommandRunResult> {
330 const args = e.args.toLowerCase()
331 const wo = await ort($)
332 if (wo === undefined) return { text: 'Kein Git-Repo im Arbeitsverzeichnis dieser Sitzung.' }
333
334 const punkte: Punkt[] = []
335 const status = await run($, ['git', 'status', '--porcelain'])
336 const zeilen = status.out.split('\n').filter(Boolean)
337 const geaendert = zeilen.filter(z => !z.startsWith('??'))
338 const ungetrackt = zeilen.filter(z => z.startsWith('??'))
339 punkte.push(
340 geaendert.length > 0
341 ? { name: 'Committed', urteil: 'rot', text: `${geaendert.length} geänderte Datei(en): ${kurz(geaendert.slice(0, 3).join(', '), 80)}` }
342 : { name: 'Committed', urteil: 'ok', text: 'Arbeitsbaum ohne Änderungen' },
343 )
344 if (ungetrackt.length > 0) {
345 punkte.push({ name: 'Ungetrackt', urteil: 'gelb', text: `${ungetrackt.length}: ${kurz(ungetrackt.slice(0, 3).join(', '), 80)}` })
346 }
347
348 const fetch = await run($, ['git', 'fetch', 'origin', '--quiet'], 60_000)
349 if (fetch.code !== 0) {
350 punkte.push({ name: 'In origin/main', urteil: 'offen', text: `git fetch scheiterte: ${kurz(fetch.err, 80)}` })
351 } else {
352 const drin = (await run($, ['git', 'merge-base', '--is-ancestor', 'HEAD', 'origin/main'])).code === 0
353 const vor = (await run($, ['git', 'rev-list', '--count', 'origin/main..HEAD'])).out
354 punkte.push(
355 drin
356 ? { name: 'In origin/main', urteil: 'ok', text: 'gemergt und gepusht' }
357 : { name: 'In origin/main', urteil: 'rot', text: `${vor} Commit(s) fehlen auf origin/main` },
358 )
359 if (drin) punkte.push(await ciPunkt($))
360 if (drin && wo.istHomeserver && !args.includes('ohne-deploy')) punkte.push(await deployPunkt($))
361 }
362
363 // Auf main gibt es keinen eigenen Zweig: der Reflog von main enthaelt die
364 // Commits aller Sitzungen, Newsletter- und VPS-Urteil waeren fremde.
365 const eigene = wo.zweig === 'main' ? undefined : await eigeneDateien($, wo.zweig)
366 if (wo.istHomeserver && eigene !== undefined) {
367 const wirkt = eigene.some(d => /^(hosts|modules|lib|pkgs)\//.test(d))
368 const eintrag = eigene.some(d => d.startsWith('newsletter/'))
369 if (eintrag) punkte.push({ name: 'Newsletter', urteil: 'ok', text: 'Eintrag im Zweig' })
370 else if (wirkt && !args.includes('ohne-newsletter')) {
371 punkte.push({ name: 'Newsletter', urteil: 'gelb', text: 'kein Eintrag — bewusst? dann /fertig ohne-newsletter' })
372 }
373 if (eigene.some(d => d.startsWith('hosts/vps/'))) {
374 punkte.push({ name: 'Deploy vps', urteil: 'gelb', text: 'Zweig ändert hosts/vps — nicht geprüft, hs-deploy vps gelaufen?' })
375 }
376 }
377
378 const hg = await read($, hintergrund)
379 if (hg.length > 0) {
380 punkte.push({ name: 'Hintergrund', urteil: 'gelb', text: `läuft noch: ${kurz(hg.map(l => l.text).join(', '), 80)}` })
381 }
382
383 const alleOk = punkte.every(p => p.urteil === 'ok')
384 const kopf = `${wo.istWorktree ? 'Worktree' : 'Baum'} ${wo.wurzel} · Zweig ${wo.zweig}`
385 const tabelle = punkte.map(p => `${ZEICHEN[p.urteil]} ${p.name.padEnd(15)} ${p.text}`).join('\n')
386
387 if (alleOk && wo.istWorktree) {
388 nachDemBefehl($, {
389 text:
390 `/fertig hat alles belegt:\n${tabelle}\n\n` +
391 `Räum den Worktree ${wo.wurzel} weg (ExitWorktree mit Entfernen; ist die Sitzung nicht per ` +
392 `EnterWorktree darin, git worktree remove aus dem Hauptbaum) und lösche den gemergten Zweig ` +
393 `${wo.zweig}. Miss danach, dass Verzeichnis und Zweig weg sind, und melde es in einem Satz.`,
394 })
395 return { text: `${kopf}\n${tabelle}\n\nAlles belegt — Worktree wird weggeräumt.` }
396 }
397 const fazit = !wo.istWorktree
398 ? `${alleOk ? 'Alles belegt.' : 'Nicht fertig.'} Kein Worktree — nichts wegzuräumen.${wo.zweig === 'main' ? ' Auf main: Newsletter und VPS nicht zuordenbar, nicht geprüft.' : ''}`
399 : 'Nicht fertig — Worktree bleibt.'
400 return {
401 text: `${kopf}\n${tabelle}\n\n${fazit}`,
402 context: alleOk ? undefined : [`Ergebnis von /fertig (vom Mod gemessen, nicht behauptet):\n${tabelle}`],
403 }
404}
405
406// ---- /uebergabe: an einen frischen Agenten weiterreichen ----
407
408const WERKZEUG = 'startprompt'
409// Nach so vielen Millisekunden leert der Mod den Kontext; tippt der Nutzer
410// vorher etwas, bleibt alles stehen.
411const WECHSEL_NACH_MS = 10_000
412
413// Gilt fuer diese Ladung des Moduls; ein /clear laedt es nicht neu.
414let uebergabeAutomatisch = false
415let wartenderStart: string | undefined
416let wechselAbbrechen: Timer | undefined
417
418// Nach dem Zug, der den Startprompt abgegeben hat: /clear, dann der Startprompt
419// als erster Auftrag der frischen Sitzung.
420async function wechseln($: Engine, text: string): Promise<void> {
421 wechselAbbrechen = undefined
422 try {
423 await $.command.run({ command: 'clear' })
424 } catch (err) {
425 $.ui.toast(`/clear aus dem Mod scheiterte (${String(err)}) — selbst /clear tippen, der Startprompt liegt in der Zwischenablage.`)
426 return
427 }
428 await $.prompt.submit({ text }).catch(err =>
429 $.ui.toast(`Startprompt nicht eingereicht (${String(err)}) — er liegt in der Zwischenablage.`),
430 )
431}
432
433function wechselPlanen($: Engine): void {
434 const text = wartenderStart
435 if (text === undefined) return
436 wartenderStart = undefined
437 $.ui.toast(`Übergabe fertig — in ${WECHSEL_NACH_MS / 1000} s wird der Kontext geleert. Etwas tippen bricht ab.`)
438 wechselAbbrechen = $.clock.after(WECHSEL_NACH_MS, () => void wechseln($, text))
439}
440
441async function uebergabeStart($: Engine): Promise<void> {
442 await $.command.register({
443 name: 'uebergabe',
444 description: 'Stand ins Übergabeblatt, dann /clear und der frische Agent übernimmt (nur-blatt: ohne /clear)',
445 argumentHint: '[nur-blatt] [Hinweis für den Nachfolger, z. B. Subagent je Aufgabe]',
446 })
447 await $.tool.register({
448 name: WERKZEUG,
449 description:
450 'Legt den Startprompt fuer den Agenten, der diese Arbeit in einer frischen Sitzung uebernimmt, ' +
451 'in die Zwischenablage des Nutzers. Nur im Ablauf von /uebergabe aufrufen, als letzten Schritt.',
452 inputSchema: {
453 type: 'object',
454 properties: { text: { type: 'string', description: 'Der Startprompt, fertig zum Einfuegen' } },
455 required: ['text'],
456 },
457 isDeferred: false,
458 })
459}
460
461async function uebergabeWerkzeug($: Engine, e: ToolCallInput): Promise<ToolCallResult> {
462 const text = String((e as { text?: unknown }).text ?? '').trim()
463 if (text === '') return { deny: 'text ist leer' }
464 // Die Zwischenablage ist der Rueckfall, auch im automatischen Ablauf.
465 let kopiert = (await $.ui.copy({ text })).isCopied
466 if (!kopiert) {
467 const wl = await $.process.run(['wl-copy'], { stdin: text, timeoutMs: 5_000 }).catch(() => undefined)
468 kopiert = wl !== undefined && wl.exitCode === 0
469 }
470 if (uebergabeAutomatisch) {
471 uebergabeAutomatisch = false
472 wartenderStart = text
473 return {
474 result:
475 'Angenommen. Nach diesem Zug leert der Mod den Kontext und gibt den Startprompt dem Nachfolger. ' +
476 'Schreib nur noch einen Satz an den Nutzer und rufe kein Werkzeug mehr auf.',
477 }
478 }
479 if (kopiert) return { result: 'Startprompt liegt in der Zwischenablage. Sag dem Nutzer, dass er mit /clear oder einer neuen Sitzung weitermachen kann.' }
480 return { result: 'Zwischenablage nicht erreichbar. Gib den Startprompt im Chat als Codeblock aus.' }
481}
482
483async function uebergabeBefehl($: Engine, e: CommandRunInput): Promise<CommandRunResult> {
484 const wo = await ort($)
485 const fakten: string[] = []
486 if (wo === undefined) {
487 fakten.push('Kein Git-Repo im Arbeitsverzeichnis.')
488 } else {
489 fakten.push(`${wo.istWorktree ? 'Worktree' : 'Hauptbaum'}: ${wo.wurzel}, Zweig ${wo.zweig}`)
490 const st = await run($, ['git', 'status', '--porcelain'])
491 const n = st.out.split('\n').filter(z => z !== '' && !z.startsWith('??')).length
492 fakten.push(n > 0 ? `${n} uncommittete Änderung(en)` : 'Arbeitsbaum ohne Änderungen')
493 const vor = await run($, ['git', 'rev-list', '--count', 'origin/main..HEAD'])
494 if (vor.code === 0) fakten.push(`${vor.out} Commit(s) noch nicht auf origin/main (Stand des letzten fetch)`)
495 }
496 const hg = await read($, hintergrund)
497 const laeufe = hg.map(l => `- ${l.art}: ${kurz(l.text, 100)}`)
498
499 const nurBlatt = /(^|\s)nur-blatt(\s|$)/.test(e.args)
500 uebergabeAutomatisch = !nurBlatt
501 wartenderStart = undefined
502 const hinweis = e.args.replace(/(^|\s)nur-blatt(?=\s|$)/, ' ').trim()
503 const auftrag = [
504 'Übergabe: Diese Sitzung endet, ein frischer Agent übernimmt die Aufgabe. Arbeite nicht weiter an der Sache selbst.',
505 '',
506 'Gemessen vom Mod:',
507 ...fakten.map(f => `- ${f}`),
508 laeufe.length > 0
509 ? `Diese Hintergrundläufe sterben mit der Sitzung:\n${laeufe.join('\n')}\nHalte für jeden fest, wie der Nachfolger sein Ergebnis prüft (nicht nur, dass er lief).`
510 : '- Keine Hintergrundläufe.',
511 '',
512 'Schritte:',
513 '1. Schreib den Stand ins Übergabeblatt dieser Arbeit — das, das die Sitzung schon benutzt; gibt es keines, ' +
514 'lege UEBERGABE.md in der Wurzel des Worktrees an. Ganz oben ein Abschnitt „STAND BEI DER ÜBERGABE <Datum Uhrzeit>“: ' +
515 'was erledigt und belegt ist (mit Beleg), was offen ist als nummerierte Todo-Liste in Reihenfolge, ' +
516 'der genaue Wiederaufsetzpunkt, Fallen dieser Sitzung, und was NICHT erneut versucht werden soll.',
517 '2. Zieh die passende Stand-Memory nach, falls es eine gibt.',
518 '3. Committe das Blatt im Zweig (gezielt stagen, kein git add -A). Nicht pushen, nicht deployen, wenn das nicht ohnehin anstand.',
519 `4. Formuliere den Startprompt: Repo, Worktree samt Zweig, „Lies zuerst <Blatt>, Abschnitt STAND BEI DER ÜBERGABE“, ` +
520 'die nächste Aufgabe, die Arbeitsweise. Kurz — das Blatt trägt die Einzelheiten.' +
521 (hinweis !== '' ? ` Arbeitsweise laut Nutzer: ${hinweis}` : ''),
522 `5. Ruf mcp__werkbank__${WERKZEUG} mit dem Startprompt auf. Danach nur noch ein Satz an den Nutzer.`,
523 ].join('\n')
524
525 nachDemBefehl($, { text: auftrag })
526 const wie = nurBlatt ? 'nur Blatt und Zwischenablage' : 'danach /clear und Nachfolger automatisch'
527 return { text: `Übergabe angestoßen (${wie}).\n${fakten.join('\n')}${laeufe.length > 0 ? `\nLaufend: ${laeufe.length}` : ''}` }
528}
529
530// ---- Anmeldung ----
531
532export const register: Register = on => {
533 on('session.start', async ($, e, next) => {
534 const started = await next(e)
535 await laufendStart($)
536 await fertigStart($)
537 await uebergabeStart($)
538 return started
539 })
540
541 on('command.run', { command: 'laufend' }, $ => laufendBefehl($))
542 on('command.run', { command: 'fertig' }, ($, e) => fertigBefehl($, e))
543 on('command.run', { command: 'uebergabe' }, ($, e) => uebergabeBefehl($, e))
544
545 on('tool.call', { tool: 'mcp__werkbank__startprompt' }, ($, e) => uebergabeWerkzeug($, e))
546 on('tool.call', ($, e, next) => laufendWerkzeug($, e, next))
547
548 // Am Ende jedes Zuges nennt die Engine die Aufgaben, die noch laufen.
549 on('classic.Stop', async ($, e, next) => {
550 await laufendStop($, e)
551 wechselPlanen($)
552 return next(e)
553 })
554
555 // Tippt der Nutzer waehrend der Frist, bleibt die Sitzung, wie sie ist.
556 on('prompt.submit', ($, e, next) => {
557 if (wechselAbbrechen !== undefined && e.origin?.kind !== 'plugin') {
558 wechselAbbrechen.cancel()
559 wechselAbbrechen = undefined
560 $.ui.toast('Übergabe-Wechsel abgebrochen — der Startprompt liegt in der Zwischenablage.')
561 }
562 return next(e)
563 })
564
565 on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => laufendPane($, e))
566}
567hooks/zustand.ts 22 lines1import type { Lauf } from '../types'
2
3// Vordergrundaufrufe erst ab dieser Dauer zeigen; kurze Aufrufe sind Rauschen.
4export const VORDERGRUND_AB_MS = 20_000
5
6export function dauer(ms: number): string {
7 const min = Math.floor(ms / 60_000)
8 if (min < 1) return `${Math.max(0, Math.floor(ms / 1000))} s`
9 if (min < 60) return `${min} min`
10 return `${Math.floor(min / 60)} h ${min % 60} min`
11}
12
13export function kurz(text: string, n = 60): string {
14 const eine = text.replace(/\s+/g, ' ').trim()
15 return eine.length > n ? eine.slice(0, n - 1) + '…' : eine
16}
17
18// Alles, was gerade laeuft: Hintergrund ganz, Vordergrund ab der Schwelle.
19export function sichtbar(hg: Lauf[], vg: Lauf[], now: number): Lauf[] {
20 return [...hg, ...vg.filter(l => now - l.seit >= VORDERGRUND_AB_MS)]
21}
22types/index.d.ts 29 lines1// Ein Lauf: Hintergrundaufgabe (Bash im Hintergrund, Agent, Monitor,
2// Workflow) oder ein langer Vordergrundaufruf.
3export type Lauf = {
4 id: string
5 art: string
6 text: string
7 seit: number
8}
9
10// Ein Start aus tool.call, der noch keiner Aufgabe aus classic.Stop
11// zugeordnet ist. Zugeordnet wird ueber die Beschreibung.
12export type Start = {
13 text: string
14 seit: number
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 werkbank: {
20 hintergrund: Lauf[]
21 vordergrund: Lauf[]
22 starts: Start[]
23 lotse: string
24 lotseStand: number
25 jetzt: number
26 }
27 }
28}
29